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.
| 3951454 | 1 | /** |
| 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 | ||
| 8 | import { describe, it, expect } from "bun:test"; | |
| 9 | import app from "../app"; | |
| 10 | import { | |
| 11 | analyzeRawCommit, | |
| 12 | extractSignatureFromCommit, | |
| 13 | fingerprintForPublicKey, | |
| 14 | parsePgpIssuerFingerprint, | |
| 15 | parseSshSigPublicKey, | |
| 16 | unarmorPgp, | |
| 17 | unarmorSsh, | |
| 18 | verifyRawCommit, | |
| 19 | __internal, | |
| 20 | } from "../lib/signatures"; | |
| 21 | ||
| 22 | const 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 | ||
| 37 | const 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 | ||
| 48 | const 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 | ||
| 56 | describe("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 | ||
| 93 | describe("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 | ||
| 128 | describe("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 | ||
| 147 | describe("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 | ||
| 194 | describe("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 | ||
| 233 | describe("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 | ||
| 254 | describe("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 | ||
| 269 | describe("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 | ||
| 299 | describe("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 | ||
| 314 | describe("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 | }); |