Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

signatures.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

signatures.test.tsBlame337 lines · 1 contributor
3951454Claude1/**
2 * Block J3 — Signature parsing + verification unit tests.
3 *
4 * Route tests only assert auth behavior; the full verify path needs a DB and
5 * a real repo, which integration covers.
6 */
7
8import { describe, it, expect } from "bun:test";
9import app from "../app";
10import {
11 analyzeRawCommit,
12 extractSignatureFromCommit,
13 fingerprintForPublicKey,
14 parsePgpIssuerFingerprint,
15 parseSshSigPublicKey,
16 unarmorPgp,
17 unarmorSsh,
18 verifyRawCommit,
19 __internal,
20} from "../lib/signatures";
21
22const SAMPLE_GPG_COMMIT = [
23 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
24 "author Alice Example <alice@example.com> 1700000000 +0000",
25 "committer Alice Example <alice@example.com> 1700000000 +0000",
26 "gpgsig -----BEGIN PGP SIGNATURE-----",
27 " ",
28 " iQIzBAABCAAdFiEEABCDEFABCDEFABCDEFABCDEFABCDEFABFAmXXXXXXACgkQ",
29 " ABCDEFABCDEF1234567890",
30 " =ABCD",
31 " -----END PGP SIGNATURE-----",
32 "",
33 "chore: signed commit",
34 "",
35].join("\n");
36
37const SAMPLE_SSH_COMMIT = [
38 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
39 "author Bob Example <bob@example.com> 1700000000 +0000",
40 "committer Bob Example <bob@example.com> 1700000000 +0000",
41 "gpgsig -----BEGIN SSH SIGNATURE-----",
42 " U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgC",
43 " -----END SSH SIGNATURE-----",
44 "",
45 "chore: ssh signed",
46].join("\n");
47
48const UNSIGNED_COMMIT = [
49 "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
50 "author Nobody <nobody@example.com> 1700000000 +0000",
51 "committer Nobody <nobody@example.com> 1700000000 +0000",
52 "",
53 "plain commit",
54].join("\n");
55
56describe("signatures — extractSignatureFromCommit", () => {
57 it("returns null for unsigned commits", () => {
58 expect(extractSignatureFromCommit(UNSIGNED_COMMIT)).toBeNull();
59 });
60
61 it("detects a PGP signature + author email", () => {
62 const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
63 expect(sig).not.toBeNull();
64 expect(sig!.type).toBe("gpg");
65 expect(sig!.authorEmail).toBe("alice@example.com");
66 expect(sig!.signature).toContain("BEGIN PGP SIGNATURE");
67 });
68
69 it("detects an SSH signature", () => {
70 const sig = extractSignatureFromCommit(SAMPLE_SSH_COMMIT);
71 expect(sig).not.toBeNull();
72 expect(sig!.type).toBe("ssh");
73 expect(sig!.authorEmail).toBe("bob@example.com");
74 });
75
76 it("preserves continuation-line body", () => {
77 const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
78 expect(sig!.signature.split("\n").length).toBeGreaterThan(2);
79 });
80
81 it("handles gpgsig-sha256 variant", () => {
82 const raw = SAMPLE_GPG_COMMIT.replace("gpgsig ", "gpgsig-sha256 ");
83 const sig = extractSignatureFromCommit(raw);
84 expect(sig).not.toBeNull();
85 expect(sig!.type).toBe("gpg");
86 });
87
88 it("empty input is null", () => {
89 expect(extractSignatureFromCommit("")).toBeNull();
90 });
91});
92
93describe("signatures — unarmorPgp", () => {
94 it("decodes a minimal armored block", () => {
95 const armored = [
96 "-----BEGIN PGP SIGNATURE-----",
97 "",
98 "AAECAwQFBgcICQ==",
99 "=CRC1",
100 "-----END PGP SIGNATURE-----",
101 ].join("\n");
102 const bytes = unarmorPgp(armored);
103 expect(bytes).not.toBeNull();
104 expect(Array.from(bytes!)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
105 });
106
107 it("skips armor headers until blank line", () => {
108 const armored = [
109 "-----BEGIN PGP SIGNATURE-----",
110 "Version: GnuPG v2",
111 "Comment: https://example.com",
112 "",
113 "AAECAwQFBgcICQ==",
114 "-----END PGP SIGNATURE-----",
115 ].join("\n");
116 const bytes = unarmorPgp(armored);
117 expect(bytes).not.toBeNull();
118 expect(bytes!.length).toBe(10);
119 });
120
121 it("returns null when there's no body", () => {
122 expect(
123 unarmorPgp("-----BEGIN PGP SIGNATURE-----\n-----END PGP SIGNATURE-----")
124 ).toBeNull();
125 });
126});
127
128describe("signatures — unarmorSsh", () => {
129 it("decodes SSH armored bytes", () => {
130 const armored = [
131 "-----BEGIN SSH SIGNATURE-----",
132 "U1NIU0lH",
133 "-----END SSH SIGNATURE-----",
134 ].join("\n");
135 const bytes = unarmorSsh(armored);
136 expect(bytes).not.toBeNull();
137 expect(Array.from(bytes!).slice(0, 6)).toEqual([
138 0x53, 0x53, 0x48, 0x53, 0x49, 0x47,
139 ]);
140 });
141
142 it("returns null on garbage", () => {
143 expect(unarmorSsh("")).toBeNull();
144 });
145});
146
147describe("signatures — parsePgpIssuerFingerprint", () => {
148 it("walks an old-format sig packet with subpacket 33", () => {
149 // Build a minimal old-format v4 sig packet:
150 // tagByte = 0x88 (old format, tag=2, lenType=0)
151 // len byte
152 // version=4, sigType=0, pubAlgo=1, hashAlgo=8
153 // hashedLen u16 = 23
154 // subpacket: len=22, type=33, version=4, fp (20 bytes 0xAB)
155 // unhashedLen u16 = 0
156 const fp = new Uint8Array(20).fill(0xab);
157 const hashed: number[] = [];
158 hashed.push(22); // subpacket length (1+type+20)
159 hashed.push(33); // subpacket type
160 hashed.push(4); // fp version
161 for (const b of fp) hashed.push(b);
162 const body: number[] = [];
163 body.push(4, 0, 1, 8);
164 body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
165 for (const b of hashed) body.push(b);
166 body.push(0, 0); // empty unhashed
167 const bytes = new Uint8Array([0x88, body.length, ...body]);
168 const result = parsePgpIssuerFingerprint(bytes);
169 expect(result).toBe("ab".repeat(20));
170 });
171
172 it("falls back to subpacket 16 (Issuer Key ID)", () => {
173 const keyId = new Uint8Array(8).fill(0xcd);
174 const hashed: number[] = [];
175 hashed.push(9); // len
176 hashed.push(16); // type
177 for (const b of keyId) hashed.push(b);
178 const body: number[] = [];
179 body.push(4, 0, 1, 8);
180 body.push(0, 0); // empty hashed
181 body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
182 for (const b of hashed) body.push(b);
183 const bytes = new Uint8Array([0x88, body.length, ...body]);
184 const result = parsePgpIssuerFingerprint(bytes);
185 expect(result).toBe("cd".repeat(8));
186 });
187
188 it("returns null for non-signature packet streams", () => {
189 expect(parsePgpIssuerFingerprint(new Uint8Array(0))).toBeNull();
190 expect(parsePgpIssuerFingerprint(new Uint8Array([0, 0, 0]))).toBeNull();
191 });
192});
193
194describe("signatures — fingerprintForPublicKey", () => {
195 it("SHA256-fingerprints an SSH ed25519 pubkey token", async () => {
196 // Synthesize an SSH wire-format pubkey: lengths in network order.
197 const type = "ssh-ed25519";
198 const data = new Uint8Array(32).fill(0xa0);
199 const header = new Uint8Array(4 + type.length);
200 new DataView(header.buffer).setUint32(0, type.length);
201 for (let i = 0; i < type.length; i++) header[4 + i] = type.charCodeAt(i);
202 const keyHeader = new Uint8Array(4);
203 new DataView(keyHeader.buffer).setUint32(0, data.length);
204 const wire = new Uint8Array(header.length + keyHeader.length + data.length);
205 wire.set(header, 0);
206 wire.set(keyHeader, header.length);
207 wire.set(data, header.length + keyHeader.length);
208 const b64 = btoa(String.fromCharCode(...wire));
209 const authLine = `ssh-ed25519 ${b64} user@host`;
210 const fp = await fingerprintForPublicKey("ssh", authLine);
211 expect(fp).not.toBeNull();
212 expect(fp!.startsWith("SHA256:")).toBe(true);
213 expect(fp!.length).toBeGreaterThan(20);
214 });
215
216 it("GPG extracts first long fingerprint from armored blob", async () => {
217 const pem = [
218 "-----BEGIN PGP PUBLIC KEY BLOCK-----",
219 "",
220 "fingerprint: 1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0B",
221 "-----END PGP PUBLIC KEY BLOCK-----",
222 ].join("\n");
223 const fp = await fingerprintForPublicKey("gpg", pem);
224 expect(fp).toBe("1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b");
225 });
226
227 it("returns null when no fingerprint can be derived", async () => {
228 expect(await fingerprintForPublicKey("ssh", "")).toBeNull();
229 expect(await fingerprintForPublicKey("gpg", "no fingerprint here")).toBeNull();
230 });
231});
232
233describe("signatures — parseSshSigPublicKey", () => {
234 it("extracts the wire-format pubkey from an SSHSIG blob", () => {
235 // Magic + u32 version=1 + string pubkey
236 const pubkey = new Uint8Array([1, 2, 3, 4, 5]);
237 const blob = new Uint8Array(6 + 4 + 4 + pubkey.length);
238 blob.set([0x53, 0x53, 0x48, 0x53, 0x49, 0x47], 0);
239 const dv = new DataView(blob.buffer);
240 dv.setUint32(6, 1); // version
241 dv.setUint32(10, pubkey.length); // pubkey length
242 blob.set(pubkey, 14);
243 const out = parseSshSigPublicKey(blob);
244 expect(out).not.toBeNull();
245 expect(Array.from(out!)).toEqual([1, 2, 3, 4, 5]);
246 });
247
248 it("returns null without SSHSIG magic", () => {
249 expect(parseSshSigPublicKey(new Uint8Array(0))).toBeNull();
250 expect(parseSshSigPublicKey(new Uint8Array(10))).toBeNull();
251 });
252});
253
254describe("signatures — analyzeRawCommit", () => {
255 it("returns nulls for unsigned commit", () => {
256 const r = analyzeRawCommit(UNSIGNED_COMMIT);
257 expect(r.type).toBeNull();
258 expect(r.fingerprint).toBeNull();
259 expect(r.authorEmail).toBeNull();
260 });
261
262 it("tags type=gpg + author email for a PGP-signed commit", () => {
263 const r = analyzeRawCommit(SAMPLE_GPG_COMMIT);
264 expect(r.type).toBe("gpg");
265 expect(r.authorEmail).toBe("alice@example.com");
266 });
267});
268
269describe("signatures — verifyRawCommit (DB-free fast paths)", () => {
270 it("unsigned → unsigned", async () => {
271 const r = await verifyRawCommit(UNSIGNED_COMMIT);
272 expect(r.verified).toBe(false);
273 expect(r.reason).toBe("unsigned");
274 });
275
276 it("null commit → unsigned", async () => {
277 const r = await verifyRawCommit(null);
278 expect(r.verified).toBe(false);
279 expect(r.reason).toBe("unsigned");
280 });
281
282 it("sig present but armor is empty → bad_sig", async () => {
283 const emptySig = [
284 "tree abc",
285 "author X <x@example.com> 1700000000 +0000",
286 "gpgsig -----BEGIN PGP SIGNATURE-----",
287 " ",
288 " -----END PGP SIGNATURE-----",
289 "",
290 "msg",
291 ].join("\n");
292 const r = await verifyRawCommit(emptySig);
293 expect(r.verified).toBe(false);
294 expect(r.reason).toBe("bad_sig");
295 expect(r.signatureType).toBe("gpg");
296 });
297});
298
299describe("signatures — __internal b64decode", () => {
300 it("round-trips", () => {
301 const s = "hello, world";
302 const enc = btoa(s);
303 const bytes = __internal.b64decode(enc);
304 const decoded = String.fromCharCode(...bytes);
305 expect(decoded).toBe(s);
306 });
307
308 it("tolerates whitespace in armor", () => {
309 const bytes = __internal.b64decode(" a\nGVsbG8= ");
310 expect(String.fromCharCode(...bytes)).toBe("hello");
311 });
312});
313
314describe("signatures — route auth", () => {
315 it("GET /settings/signing-keys requires auth", async () => {
316 const res = await app.request("/settings/signing-keys");
317 expect(res.status).toBe(302);
318 expect(res.headers.get("location") || "").toContain("/login");
319 });
320
321 it("POST /settings/signing-keys requires auth", async () => {
322 const res = await app.request("/settings/signing-keys", {
323 method: "POST",
324 });
325 expect(res.status).toBe(302);
326 expect(res.headers.get("location") || "").toContain("/login");
327 });
328
329 it("POST /settings/signing-keys/:id/delete requires auth", async () => {
330 const res = await app.request(
331 "/settings/signing-keys/00000000-0000-0000-0000-000000000000/delete",
332 { method: "POST" }
333 );
334 expect(res.status).toBe(302);
335 expect(res.headers.get("location") || "").toContain("/login");
336 });
337});