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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
signatures.test.ts11.1 KB · 337 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
/**
 * Block J3 — Signature parsing + verification unit tests.
 *
 * Route tests only assert auth behavior; the full verify path needs a DB and
 * a real repo, which integration covers.
 */

import { describe, it, expect } from "bun:test";
import app from "../app";
import {
  analyzeRawCommit,
  extractSignatureFromCommit,
  fingerprintForPublicKey,
  parsePgpIssuerFingerprint,
  parseSshSigPublicKey,
  unarmorPgp,
  unarmorSsh,
  verifyRawCommit,
  __internal,
} from "../lib/signatures";

const SAMPLE_GPG_COMMIT = [
  "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
  "author Alice Example <alice@example.com> 1700000000 +0000",
  "committer Alice Example <alice@example.com> 1700000000 +0000",
  "gpgsig -----BEGIN PGP SIGNATURE-----",
  " ",
  " iQIzBAABCAAdFiEEABCDEFABCDEFABCDEFABCDEFABCDEFABFAmXXXXXXACgkQ",
  " ABCDEFABCDEF1234567890",
  " =ABCD",
  " -----END PGP SIGNATURE-----",
  "",
  "chore: signed commit",
  "",
].join("\n");

const SAMPLE_SSH_COMMIT = [
  "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
  "author Bob Example <bob@example.com> 1700000000 +0000",
  "committer Bob Example <bob@example.com> 1700000000 +0000",
  "gpgsig -----BEGIN SSH SIGNATURE-----",
  " U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAgC",
  " -----END SSH SIGNATURE-----",
  "",
  "chore: ssh signed",
].join("\n");

const UNSIGNED_COMMIT = [
  "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904",
  "author Nobody <nobody@example.com> 1700000000 +0000",
  "committer Nobody <nobody@example.com> 1700000000 +0000",
  "",
  "plain commit",
].join("\n");

describe("signatures — extractSignatureFromCommit", () => {
  it("returns null for unsigned commits", () => {
    expect(extractSignatureFromCommit(UNSIGNED_COMMIT)).toBeNull();
  });

  it("detects a PGP signature + author email", () => {
    const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
    expect(sig).not.toBeNull();
    expect(sig!.type).toBe("gpg");
    expect(sig!.authorEmail).toBe("alice@example.com");
    expect(sig!.signature).toContain("BEGIN PGP SIGNATURE");
  });

  it("detects an SSH signature", () => {
    const sig = extractSignatureFromCommit(SAMPLE_SSH_COMMIT);
    expect(sig).not.toBeNull();
    expect(sig!.type).toBe("ssh");
    expect(sig!.authorEmail).toBe("bob@example.com");
  });

  it("preserves continuation-line body", () => {
    const sig = extractSignatureFromCommit(SAMPLE_GPG_COMMIT);
    expect(sig!.signature.split("\n").length).toBeGreaterThan(2);
  });

  it("handles gpgsig-sha256 variant", () => {
    const raw = SAMPLE_GPG_COMMIT.replace("gpgsig ", "gpgsig-sha256 ");
    const sig = extractSignatureFromCommit(raw);
    expect(sig).not.toBeNull();
    expect(sig!.type).toBe("gpg");
  });

  it("empty input is null", () => {
    expect(extractSignatureFromCommit("")).toBeNull();
  });
});

describe("signatures — unarmorPgp", () => {
  it("decodes a minimal armored block", () => {
    const armored = [
      "-----BEGIN PGP SIGNATURE-----",
      "",
      "AAECAwQFBgcICQ==",
      "=CRC1",
      "-----END PGP SIGNATURE-----",
    ].join("\n");
    const bytes = unarmorPgp(armored);
    expect(bytes).not.toBeNull();
    expect(Array.from(bytes!)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
  });

  it("skips armor headers until blank line", () => {
    const armored = [
      "-----BEGIN PGP SIGNATURE-----",
      "Version: GnuPG v2",
      "Comment: https://example.com",
      "",
      "AAECAwQFBgcICQ==",
      "-----END PGP SIGNATURE-----",
    ].join("\n");
    const bytes = unarmorPgp(armored);
    expect(bytes).not.toBeNull();
    expect(bytes!.length).toBe(10);
  });

  it("returns null when there's no body", () => {
    expect(
      unarmorPgp("-----BEGIN PGP SIGNATURE-----\n-----END PGP SIGNATURE-----")
    ).toBeNull();
  });
});

describe("signatures — unarmorSsh", () => {
  it("decodes SSH armored bytes", () => {
    const armored = [
      "-----BEGIN SSH SIGNATURE-----",
      "U1NIU0lH",
      "-----END SSH SIGNATURE-----",
    ].join("\n");
    const bytes = unarmorSsh(armored);
    expect(bytes).not.toBeNull();
    expect(Array.from(bytes!).slice(0, 6)).toEqual([
      0x53, 0x53, 0x48, 0x53, 0x49, 0x47,
    ]);
  });

  it("returns null on garbage", () => {
    expect(unarmorSsh("")).toBeNull();
  });
});

describe("signatures — parsePgpIssuerFingerprint", () => {
  it("walks an old-format sig packet with subpacket 33", () => {
    // Build a minimal old-format v4 sig packet:
    //   tagByte = 0x88 (old format, tag=2, lenType=0)
    //   len byte
    //   version=4, sigType=0, pubAlgo=1, hashAlgo=8
    //   hashedLen u16 = 23
    //     subpacket: len=22, type=33, version=4, fp (20 bytes 0xAB)
    //   unhashedLen u16 = 0
    const fp = new Uint8Array(20).fill(0xab);
    const hashed: number[] = [];
    hashed.push(22); // subpacket length (1+type+20)
    hashed.push(33); // subpacket type
    hashed.push(4); // fp version
    for (const b of fp) hashed.push(b);
    const body: number[] = [];
    body.push(4, 0, 1, 8);
    body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
    for (const b of hashed) body.push(b);
    body.push(0, 0); // empty unhashed
    const bytes = new Uint8Array([0x88, body.length, ...body]);
    const result = parsePgpIssuerFingerprint(bytes);
    expect(result).toBe("ab".repeat(20));
  });

  it("falls back to subpacket 16 (Issuer Key ID)", () => {
    const keyId = new Uint8Array(8).fill(0xcd);
    const hashed: number[] = [];
    hashed.push(9); // len
    hashed.push(16); // type
    for (const b of keyId) hashed.push(b);
    const body: number[] = [];
    body.push(4, 0, 1, 8);
    body.push(0, 0); // empty hashed
    body.push((hashed.length >> 8) & 0xff, hashed.length & 0xff);
    for (const b of hashed) body.push(b);
    const bytes = new Uint8Array([0x88, body.length, ...body]);
    const result = parsePgpIssuerFingerprint(bytes);
    expect(result).toBe("cd".repeat(8));
  });

  it("returns null for non-signature packet streams", () => {
    expect(parsePgpIssuerFingerprint(new Uint8Array(0))).toBeNull();
    expect(parsePgpIssuerFingerprint(new Uint8Array([0, 0, 0]))).toBeNull();
  });
});

describe("signatures — fingerprintForPublicKey", () => {
  it("SHA256-fingerprints an SSH ed25519 pubkey token", async () => {
    // Synthesize an SSH wire-format pubkey: lengths in network order.
    const type = "ssh-ed25519";
    const data = new Uint8Array(32).fill(0xa0);
    const header = new Uint8Array(4 + type.length);
    new DataView(header.buffer).setUint32(0, type.length);
    for (let i = 0; i < type.length; i++) header[4 + i] = type.charCodeAt(i);
    const keyHeader = new Uint8Array(4);
    new DataView(keyHeader.buffer).setUint32(0, data.length);
    const wire = new Uint8Array(header.length + keyHeader.length + data.length);
    wire.set(header, 0);
    wire.set(keyHeader, header.length);
    wire.set(data, header.length + keyHeader.length);
    const b64 = btoa(String.fromCharCode(...wire));
    const authLine = `ssh-ed25519 ${b64} user@host`;
    const fp = await fingerprintForPublicKey("ssh", authLine);
    expect(fp).not.toBeNull();
    expect(fp!.startsWith("SHA256:")).toBe(true);
    expect(fp!.length).toBeGreaterThan(20);
  });

  it("GPG extracts first long fingerprint from armored blob", async () => {
    const pem = [
      "-----BEGIN PGP PUBLIC KEY BLOCK-----",
      "",
      "fingerprint: 1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D7E8F9A0B",
      "-----END PGP PUBLIC KEY BLOCK-----",
    ].join("\n");
    const fp = await fingerprintForPublicKey("gpg", pem);
    expect(fp).toBe("1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b");
  });

  it("returns null when no fingerprint can be derived", async () => {
    expect(await fingerprintForPublicKey("ssh", "")).toBeNull();
    expect(await fingerprintForPublicKey("gpg", "no fingerprint here")).toBeNull();
  });
});

describe("signatures — parseSshSigPublicKey", () => {
  it("extracts the wire-format pubkey from an SSHSIG blob", () => {
    // Magic + u32 version=1 + string pubkey
    const pubkey = new Uint8Array([1, 2, 3, 4, 5]);
    const blob = new Uint8Array(6 + 4 + 4 + pubkey.length);
    blob.set([0x53, 0x53, 0x48, 0x53, 0x49, 0x47], 0);
    const dv = new DataView(blob.buffer);
    dv.setUint32(6, 1); // version
    dv.setUint32(10, pubkey.length); // pubkey length
    blob.set(pubkey, 14);
    const out = parseSshSigPublicKey(blob);
    expect(out).not.toBeNull();
    expect(Array.from(out!)).toEqual([1, 2, 3, 4, 5]);
  });

  it("returns null without SSHSIG magic", () => {
    expect(parseSshSigPublicKey(new Uint8Array(0))).toBeNull();
    expect(parseSshSigPublicKey(new Uint8Array(10))).toBeNull();
  });
});

describe("signatures — analyzeRawCommit", () => {
  it("returns nulls for unsigned commit", () => {
    const r = analyzeRawCommit(UNSIGNED_COMMIT);
    expect(r.type).toBeNull();
    expect(r.fingerprint).toBeNull();
    expect(r.authorEmail).toBeNull();
  });

  it("tags type=gpg + author email for a PGP-signed commit", () => {
    const r = analyzeRawCommit(SAMPLE_GPG_COMMIT);
    expect(r.type).toBe("gpg");
    expect(r.authorEmail).toBe("alice@example.com");
  });
});

describe("signatures — verifyRawCommit (DB-free fast paths)", () => {
  it("unsigned → unsigned", async () => {
    const r = await verifyRawCommit(UNSIGNED_COMMIT);
    expect(r.verified).toBe(false);
    expect(r.reason).toBe("unsigned");
  });

  it("null commit → unsigned", async () => {
    const r = await verifyRawCommit(null);
    expect(r.verified).toBe(false);
    expect(r.reason).toBe("unsigned");
  });

  it("sig present but armor is empty → bad_sig", async () => {
    const emptySig = [
      "tree abc",
      "author X <x@example.com> 1700000000 +0000",
      "gpgsig -----BEGIN PGP SIGNATURE-----",
      " ",
      " -----END PGP SIGNATURE-----",
      "",
      "msg",
    ].join("\n");
    const r = await verifyRawCommit(emptySig);
    expect(r.verified).toBe(false);
    expect(r.reason).toBe("bad_sig");
    expect(r.signatureType).toBe("gpg");
  });
});

describe("signatures — __internal b64decode", () => {
  it("round-trips", () => {
    const s = "hello, world";
    const enc = btoa(s);
    const bytes = __internal.b64decode(enc);
    const decoded = String.fromCharCode(...bytes);
    expect(decoded).toBe(s);
  });

  it("tolerates whitespace in armor", () => {
    const bytes = __internal.b64decode("  a\nGVsbG8=  ");
    expect(String.fromCharCode(...bytes)).toBe("hello");
  });
});

describe("signatures — route auth", () => {
  it("GET /settings/signing-keys requires auth", async () => {
    const res = await app.request("/settings/signing-keys");
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });

  it("POST /settings/signing-keys requires auth", async () => {
    const res = await app.request("/settings/signing-keys", {
      method: "POST",
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });

  it("POST /settings/signing-keys/:id/delete requires auth", async () => {
    const res = await app.request(
      "/settings/signing-keys/00000000-0000-0000-0000-000000000000/delete",
      { method: "POST" }
    );
    expect(res.status).toBe(302);
    expect(res.headers.get("location") || "").toContain("/login");
  });
});