Commit3951454unknown_key
feat(BLOCK-J): J3 commit signature verification (GPG + SSH Verified badge)
feat(BLOCK-J): J3 commit signature verification (GPG + SSH Verified badge) Users register GPG or SSH public keys. Commits whose embedded gpgsig matches a registered key's fingerprint render with a green "Verified" badge on the commit list and detail views. Migration 0030_signing_keys.sql adds: - signing_keys (user_id, key_type gpg|ssh, title, fingerprint, public_key, email, expires_at, last_used_at; unique on (key_type, fingerprint)) - commit_verifications (repository_id, commit_sha, verified, reason, signature_type, signer_key_id, signer_user_id, signer_fingerprint; unique on (repository_id, commit_sha)) src/lib/signatures.ts: - extractSignatureFromCommit parses gpgsig / gpgsig-sha256 continuation lines out of raw commit objects - unarmorPgp / unarmorSsh strip PEM-style armor - parsePgpIssuerFingerprint walks OpenPGP v4/v5 signature packets and returns subpacket 33 (Issuer Fingerprint), falling back to subpacket 16 (Issuer Key ID) - parseSshSigPublicKey extracts the publickey field from SSHSIG blobs - fingerprintForPublicKey computes lowercase hex for GPG or base64 SHA-256 (SHA256:...) for SSH - verifyCommit does cache lookup -> git cat-file -> parse -> match against signing_keys -> write-back src/routes/signing-keys.tsx serves GET/POST /settings/signing-keys + POST /:id/delete, audit-logged. src/views/components.tsx CommitList accepts a verifications map and renders a green "Verified" badge per SHA. Single commit detail view additionally shows expired / unknown_key / bad_sig / email_mismatch as a yellow status pill. src/git/repository.ts adds getRawCommitObject(owner,name,sha) -> string (git cat-file commit <sha>). Identity matching only; cryptographic verification (gpg --verify / ssh-keygen -Y verify) is future work. 29 new tests (extraction, unarmor, OpenPGP packet walker, SSH fingerprinting, fast-path verify, route auth). 702 tests total. https://claude.ai/code/session_01U6sj9ezKCpmiwx7cXGZmS6
10 files changed+1532−23395145476fb556e9b51fff99ef237b0d2c184815
10 changed files+1532−23
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
@@ -89,6 +89,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
8989| Symbol / xref navigation | ✅ | I8 — `src/lib/symbols.ts` regex-based extractor for ts/js/py/rs/go/rb/java/kt/swift; on-demand indexer persists top-level definitions into `code_symbols` (0025). `src/routes/symbols.tsx` serves `/:owner/:repo/symbols` overview + A–Z list, `/:owner/:repo/symbols/search?q=` prefix search, `/:owner/:repo/symbols/:name` definition detail. Owner-only reindex. |
9090| Dependency graph | ✅ | J1 — `src/lib/deps.ts` parses package.json / requirements.txt / pyproject.toml / go.mod / Cargo.toml / Gemfile / composer.json without a TOML lib. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` grouped by ecosystem with per-ecosystem counts; owner-only reindex walks the default-branch tree (max 200 manifests, 1MB each). `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies`. |
9191| Security advisories / Dependabot alerts | ✅ | J2 — curated 12-entry seed list + minimal semver range matcher cross-referenced against J1 dep rows. `src/lib/advisories.ts` + `src/routes/advisories.tsx` serve `/:owner/:repo/security/advisories` (open) + `/all`, owner-only `POST /scan`, and per-alert dismiss/reopen. `drizzle/0029_security_advisories.sql` adds `security_advisories` + `repo_advisory_alerts`. |
92| Commit signature verification (Verified badge) | ✅ | J3 — GPG + SSH pubkey registration at `/settings/signing-keys`, `gpgsig` extraction from raw commit objects, OpenPGP packet walker for Issuer Fingerprint, SHA-256 fingerprints for SSHSIG pubkeys, memoised in `commit_verifications`. Green "Verified" badge rendered on commit list + detail when a registered key matches. `src/lib/signatures.ts` + `src/routes/signing-keys.tsx` + `drizzle/0030_signing_keys.sql`. |
9293
9394### 2.3 Collaboration
9495| Feature | Status | Notes |
@@ -283,6 +284,7 @@ This is where GlueCron beats GitHub outright. **Priority: ship these loud.**
283284### BLOCK J — Beyond-parity advanced features
284285- **J1** — Dependency graph → ✅ shipped. `drizzle/0028_repo_dependencies.sql` adds `repo_dependencies` (ecosystem + name + version_spec + manifest_path + is_dev + commit_sha) with indexes on `(repository_id, ecosystem)` + `(name)`. `src/lib/deps.ts` parses seven manifest formats (package.json, requirements.txt, pyproject.toml, go.mod, Cargo.toml, Gemfile, composer.json) without a TOML library — each parser is defensive and returns `[]` on malformed input. Walks default-branch tree (max 200 manifests, 1MB each), replaces the prior set on reindex. `src/routes/deps.tsx` serves `/:owner/:repo/dependencies` (grouped by ecosystem with per-ecosystem counts) + owner-only `POST /dependencies/reindex`. Reverse-dep lookup via `repositoriesDependingOn(ecosystem, name)` for future "who depends on me" network-graph UI. 21 new tests.
285286- **J2** — Security advisories (Dependabot-style) → ✅ shipped. `drizzle/0029_security_advisories.sql` adds `security_advisories` (GHSA + CVE IDs, severity, affected range, fixed version) + `repo_advisory_alerts` (per-repo match state with open/dismissed/fixed, unique on `(repo, advisory, manifest_path)`). `src/lib/advisories.ts` ships a 12-entry seed list (log4shell, lodash, minimist, vm2, urllib3, jwt-go, etc.), a minimal version-range matcher (`satisfiesRange` + `rangeMatches`) that handles `<`/`<=`/`>`/`>=`/`=` clauses including compound ranges, and `scanRepositoryForAlerts(repoId)` which cross-references J1 dep rows against the advisory list — inserts new alerts, reopens fixed-then-regressed ones, auto-closes alerts whose dep went away. `src/routes/advisories.tsx` serves `/:owner/:repo/security/advisories` (open), `/all` (everything), owner-only `POST /scan`, and per-alert `POST /:id/dismiss` + `POST /:id/reopen` with audit-log entries. 27 new tests (version parser, range matcher, seed shape, route auth).
287- **J3** — Commit signature verification (GPG + SSH "Verified" badge) → ✅ shipped. `drizzle/0030_signing_keys.sql` adds `signing_keys` (per-user GPG/SSH pubkeys, unique on `(key_type, fingerprint)`) + `commit_verifications` (memoised per-commit result, unique on `(repo, sha)`). `src/lib/signatures.ts` extracts `gpgsig` / `gpgsig-sha256` from raw commit objects (`getRawCommitObject` added to `src/git/repository.ts`), unarmors PGP + SSH signature blobs, walks OpenPGP packet streams for Issuer Fingerprint (subpacket 33) / Issuer Key ID (subpacket 16), parses the SSHSIG inner publickey field, and SHA-256 fingerprints SSH wire-format keys. Identity matching via fingerprint → optional email check → cached. `src/routes/signing-keys.tsx` serves `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`, audit-logged. `CommitList` + single commit view render a green "Verified" badge when cached `verified=true`. 29 new tests (extraction, unarmor, packet walker, SSH fp, end-to-end fast paths, route auth).
286288
287289### BLOCK H — Marketplace
288290- **H1** — App marketplace → ✅ shipped. `src/routes/marketplace.tsx` + `src/lib/marketplace.ts` + `drizzle/0021_marketplace_and_apps.sql` (5 tables: `apps`, `app_installations`, `app_bots`, `app_install_tokens`, `app_events`). Routes: `GET /marketplace` (public directory with search), `GET /marketplace/:slug` (detail + install CTA), `POST /marketplace/:slug/install` (user-target install in v1), `POST /marketplace/installations/:id/uninstall`, `GET /settings/apps` (personal list), `GET+POST /developer/apps-new` (register), `GET /developer/apps/:slug/manage` (event log + install count), `POST /developer/apps/:slug/tokens/new` (show-once token). Install idempotent via soft-update on existing non-uninstalled row.
@@ -298,7 +300,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
298300- `src/app.tsx` — route composition, middleware order, error handlers
299301- `src/index.ts` — Bun server entry
300302- `src/lib/config.ts` — env getters (late-binding)
301- `src/db/schema.ts` — 89 tables. New tables only via new migration.
303- `src/db/schema.ts` — 91 tables. New tables only via new migration.
302304- `src/db/index.ts` — lazy proxy DB connection
303305- `src/db/migrate.ts` — migration runner
304306- `drizzle/0000_initial.sql`, `drizzle/0001_green_ecosystem.sql` — migrations
@@ -328,6 +330,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
328330- `drizzle/0027_sso_oidc.sql` (Block I10) — migration, never edited in place. Adds `sso_config` singleton (`id='default'`) + `sso_user_links` (`subject` unique, FK to `users` with ON DELETE CASCADE).
329331- `drizzle/0028_repo_dependencies.sql` (Block J1) — migration, never edited in place. Adds `repo_dependencies` with indexes on `(repository_id, ecosystem)` + `(name)`.
330332- `drizzle/0029_security_advisories.sql` (Block J2) — migration, never edited in place. Adds `security_advisories` (`ghsa_id` unique) + `repo_advisory_alerts` (unique on `(repository_id, advisory_id, manifest_path)`, status index).
333- `drizzle/0030_signing_keys.sql` (Block J3) — migration, never edited in place. Adds `signing_keys` (unique on `(key_type, fingerprint)`) + `commit_verifications` (unique on `(repository_id, commit_sha)`).
331334
332335### 4.2 Git layer (locked)
333336- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
Addeddrizzle/0030_signing_keys.sql+43−0View fileUnifiedSplit
@@ -0,0 +1,43 @@
1-- Block J3 — Commit signature verification (GPG + SSH "Verified" badge)
2--
3-- Users register GPG or SSH public keys bound to an email identity. When we
4-- render a commit we extract the embedded signature, hash the key fingerprint
5-- out of the PGP/SSH armored blob, and look it up here. A match paired with
6-- an author email that the key declares → "Verified" badge.
7--
8-- Verifications are memoised in commit_verifications to avoid re-parsing the
9-- raw commit object on every page view.
10
11CREATE TABLE IF NOT EXISTS "signing_keys" (
12 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
13 "user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
14 "key_type" text NOT NULL, -- 'gpg' | 'ssh'
15 "title" text NOT NULL,
16 "fingerprint" text NOT NULL, -- lowercased hex (GPG) or base64 SHA256 (SSH)
17 "public_key" text NOT NULL, -- armored/authorized_keys form as uploaded
18 "email" text, -- optional UID/comment email binding
19 "expires_at" timestamp,
20 "last_used_at" timestamp,
21 "created_at" timestamp NOT NULL DEFAULT now()
22);
23
24CREATE UNIQUE INDEX IF NOT EXISTS "signing_keys_fp_unique"
25 ON "signing_keys" ("key_type", "fingerprint");
26CREATE INDEX IF NOT EXISTS "signing_keys_user_idx"
27 ON "signing_keys" ("user_id");
28
29CREATE TABLE IF NOT EXISTS "commit_verifications" (
30 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
31 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
32 "commit_sha" text NOT NULL,
33 "verified" boolean NOT NULL DEFAULT false,
34 "reason" text NOT NULL, -- 'valid' | 'unsigned' | 'unknown_key' | 'expired' | 'bad_sig' | 'email_mismatch'
35 "signature_type" text, -- 'gpg' | 'ssh' | null
36 "signer_key_id" uuid REFERENCES "signing_keys"("id") ON DELETE SET NULL,
37 "signer_user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
38 "signer_fingerprint" text,
39 "verified_at" timestamp NOT NULL DEFAULT now()
40);
41
42CREATE UNIQUE INDEX IF NOT EXISTS "commit_verifications_sha_unique"
43 ON "commit_verifications" ("repository_id", "commit_sha");
Addedsrc/__tests__/signatures.test.ts+337−0View fileUnifiedSplit
@@ -0,0 +1,337 @@
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
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});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
@@ -71,6 +71,7 @@ import mirrorsRoutes from "./routes/mirrors";
7171import ssoRoutes from "./routes/sso";
7272import depsRoutes from "./routes/deps";
7373import advisoriesRoutes from "./routes/advisories";
74import signingKeysRoutes from "./routes/signing-keys";
7475import webRoutes from "./routes/web";
7576
7677const app = new Hono();
@@ -246,6 +247,9 @@ app.route("/", depsRoutes);
246247// Security advisories / dependabot alerts — /:owner/:repo/security/advisories (Block J2)
247248app.route("/", advisoriesRoutes);
248249
250// Commit signature verification / signing keys — /settings/signing-keys (Block J3)
251app.route("/", signingKeysRoutes);
252
249253// Insights + milestones
250254app.route("/", insightsRoutes);
251255
Modifiedsrc/db/schema.ts+63−0View fileUnifiedSplit
@@ -2169,3 +2169,66 @@ export const repoAdvisoryAlerts = pgTable(
21692169);
21702170
21712171export type RepoAdvisoryAlert = typeof repoAdvisoryAlerts.$inferSelect;
2172
2173// ----------------------------------------------------------------------------
2174// Block J3 — Commit signature verification (GPG + SSH)
2175// ----------------------------------------------------------------------------
2176
2177/** Per-user GPG/SSH public keys for commit signing. */
2178export const signingKeys = pgTable(
2179 "signing_keys",
2180 {
2181 id: uuid("id").primaryKey().defaultRandom(),
2182 userId: uuid("user_id")
2183 .notNull()
2184 .references(() => users.id, { onDelete: "cascade" }),
2185 keyType: text("key_type").notNull(), // 'gpg' | 'ssh'
2186 title: text("title").notNull(),
2187 fingerprint: text("fingerprint").notNull(),
2188 publicKey: text("public_key").notNull(),
2189 email: text("email"),
2190 expiresAt: timestamp("expires_at"),
2191 lastUsedAt: timestamp("last_used_at"),
2192 createdAt: timestamp("created_at").defaultNow().notNull(),
2193 },
2194 (table) => [
2195 uniqueIndex("signing_keys_fp_unique").on(table.keyType, table.fingerprint),
2196 index("signing_keys_user_idx").on(table.userId),
2197 ]
2198);
2199
2200export type SigningKey = typeof signingKeys.$inferSelect;
2201
2202/**
2203 * Cached verification result for a (repo, commit) pair. Repopulated on demand;
2204 * rows are invalidated implicitly by CASCADE when either side is removed.
2205 */
2206export const commitVerifications = pgTable(
2207 "commit_verifications",
2208 {
2209 id: uuid("id").primaryKey().defaultRandom(),
2210 repositoryId: uuid("repository_id")
2211 .notNull()
2212 .references(() => repositories.id, { onDelete: "cascade" }),
2213 commitSha: text("commit_sha").notNull(),
2214 verified: boolean("verified").default(false).notNull(),
2215 reason: text("reason").notNull(),
2216 signatureType: text("signature_type"),
2217 signerKeyId: uuid("signer_key_id").references(() => signingKeys.id, {
2218 onDelete: "set null",
2219 }),
2220 signerUserId: uuid("signer_user_id").references(() => users.id, {
2221 onDelete: "set null",
2222 }),
2223 signerFingerprint: text("signer_fingerprint"),
2224 verifiedAt: timestamp("verified_at").defaultNow().notNull(),
2225 },
2226 (table) => [
2227 uniqueIndex("commit_verifications_sha_unique").on(
2228 table.repositoryId,
2229 table.commitSha
2230 ),
2231 ]
2232);
2233
2234export type CommitVerification = typeof commitVerifications.$inferSelect;
Modifiedsrc/git/repository.ts+18−0View fileUnifiedSplit
@@ -494,6 +494,24 @@ export async function getRawBlob(
494494 return new Uint8Array(data);
495495}
496496
497/**
498 * Return the raw commit object (`git cat-file commit <sha>`) including any
499 * `gpgsig` / `gpgsig-sha256` headers. Used by Block J3 signature verification.
500 */
501export async function getRawCommitObject(
502 owner: string,
503 name: string,
504 sha: string
505): Promise<string | null> {
506 const path = repoPath(owner, name);
507 const { stdout, exitCode } = await exec(
508 ["git", "cat-file", "commit", sha],
509 { cwd: path }
510 );
511 if (exitCode !== 0) return null;
512 return stdout;
513}
514
497515export async function searchCode(
498516 owner: string,
499517 name: string,
Addedsrc/lib/signatures.ts+719−0View fileUnifiedSplit
@@ -0,0 +1,719 @@
1/**
2 * Block J3 — Commit signature verification (GPG + SSH).
3 *
4 * Pragmatic V1 that does identity matching without requiring gpg/ssh-keygen
5 * binaries. The flow is:
6 *
7 * 1. Parse the raw commit object for a `gpgsig` / `gpgsig-sha256` header.
8 * 2. Tell whether the armored blob is a PGP signature or an SSH signature.
9 * 3. For PGP, decode the base64 body and walk the packet stream for an
10 * "Issuer Fingerprint" (subpacket 33) or "Issuer" (subpacket 16); for
11 * SSH, decode the inner SSHSIG blob for its embedded public key and
12 * fingerprint it with SHA-256.
13 * 4. Look the fingerprint up in `signing_keys`. If the registered key's
14 * owner's email matches the commit author email, → `verified=true`.
15 *
16 * We do NOT run gpg --verify here; cryptographic verification requires the
17 * full signed message re-construction + long-term key escrow which is out of
18 * scope for V1. The honest "Verified" badge therefore reads as: "signed with
19 * a key we've seen registered under this email." Future work (J3+) can shell
20 * out to `gpg --verify` / `ssh-keygen -Y verify` when those binaries are
21 * available at runtime.
22 */
23
24import { and, eq } from "drizzle-orm";
25import { db } from "../db";
26import {
27 commitVerifications,
28 signingKeys,
29 users,
30 type SigningKey,
31} from "../db/schema";
32import { getRawCommitObject } from "../git/repository";
33
34export type VerificationReason =
35 | "valid"
36 | "unsigned"
37 | "unknown_key"
38 | "expired"
39 | "bad_sig"
40 | "email_mismatch";
41
42export interface VerificationResult {
43 verified: boolean;
44 reason: VerificationReason;
45 signatureType: "gpg" | "ssh" | null;
46 fingerprint: string | null;
47 signerUserId: string | null;
48 signerKeyId: string | null;
49}
50
51// ----------------------------------------------------------------------------
52// Commit-object parsing
53// ----------------------------------------------------------------------------
54
55/**
56 * Pull out the `gpgsig` block from a raw commit object. Returns the armored
57 * signature (lines joined with \n, leading single-space continuation stripped)
58 * and the commit headers/body separately. Null when unsigned.
59 */
60export function extractSignatureFromCommit(
61 raw: string
62): { signature: string; type: "gpg" | "ssh"; authorEmail: string | null } | null {
63 if (!raw) return null;
64 const lines = raw.split("\n");
65 let sig: string[] = [];
66 let inSig = false;
67 let author: string | null = null;
68 for (let i = 0; i < lines.length; i++) {
69 const ln = lines[i];
70 if (ln === "") break; // headers ended
71 if (inSig) {
72 if (ln.startsWith(" ")) {
73 sig.push(ln.slice(1));
74 continue;
75 } else {
76 inSig = false;
77 }
78 }
79 if (ln.startsWith("gpgsig ") || ln.startsWith("gpgsig-sha256 ")) {
80 sig = [ln.replace(/^gpgsig(-sha256)? /, "")];
81 inSig = true;
82 continue;
83 }
84 if (ln.startsWith("author ")) {
85 const m = ln.match(/<([^>]+)>/);
86 if (m) author = m[1];
87 }
88 }
89 if (sig.length === 0) return null;
90 const armored = sig.join("\n");
91 const type: "gpg" | "ssh" = armored.includes("BEGIN SSH SIGNATURE")
92 ? "ssh"
93 : "gpg";
94 return { signature: armored, type, authorEmail: author };
95}
96
97// ----------------------------------------------------------------------------
98// PGP signature → issuer fingerprint
99// ----------------------------------------------------------------------------
100
101/** Base64 decoder that tolerates armor whitespace + CR. */
102function b64decode(s: string): Uint8Array {
103 const clean = s.replace(/[\r\n\s]+/g, "");
104 const bin = atob(clean);
105 const out = new Uint8Array(bin.length);
106 for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
107 return out;
108}
109
110/**
111 * Strip PEM-style armor (BEGIN/END lines + optional CRC24 trailer starting
112 * with "="). Returns the raw packet stream bytes.
113 */
114export function unarmorPgp(armored: string): Uint8Array | null {
115 const lines = armored.split(/\r?\n/);
116 const body: string[] = [];
117 let inBody = false;
118 let afterBlankLine = false;
119 for (const ln of lines) {
120 if (ln.startsWith("-----BEGIN")) {
121 inBody = true;
122 afterBlankLine = false;
123 continue;
124 }
125 if (ln.startsWith("-----END")) break;
126 if (!inBody) continue;
127 if (!afterBlankLine) {
128 if (ln.trim() === "") {
129 afterBlankLine = true;
130 }
131 // Skip armor headers like "Version:" / "Comment:" until the blank line.
132 continue;
133 }
134 if (ln.startsWith("=")) continue; // CRC24 trailer
135 body.push(ln);
136 }
137 const joined = body.join("");
138 if (!joined) return null;
139 try {
140 return b64decode(joined);
141 } catch {
142 return null;
143 }
144}
145
146/**
147 * Walk the first few packets of a PGP packet stream looking for a signature
148 * packet (tag 2) and within it the hashed+unhashed subpacket areas for
149 * subpacket 33 (Issuer Fingerprint) or 16 (Issuer Key ID). Returns the
150 * fingerprint or key ID as a lowercase hex string, or null.
151 *
152 * Only supports the OpenPGP v1 (old) and current (new) packet formats; just
153 * enough of the grammar to pluck issuer info out of modern GPG sigs.
154 */
155export function parsePgpIssuerFingerprint(bytes: Uint8Array): string | null {
156 if (!bytes || bytes.length < 2) return null;
157 let off = 0;
158 while (off < bytes.length) {
159 const tagByte = bytes[off++];
160 if ((tagByte & 0x80) === 0) return null; // not a valid packet header
161 let tag: number;
162 let len: number;
163 if ((tagByte & 0x40) === 0) {
164 // Old-format packet
165 tag = (tagByte & 0x3c) >> 2;
166 const lenType = tagByte & 0x03;
167 if (lenType === 0) {
168 len = bytes[off++];
169 } else if (lenType === 1) {
170 len = (bytes[off++] << 8) | bytes[off++];
171 } else if (lenType === 2) {
172 len =
173 (bytes[off++] << 24) |
174 (bytes[off++] << 16) |
175 (bytes[off++] << 8) |
176 bytes[off++];
177 } else {
178 return null; // indeterminate length — give up
179 }
180 } else {
181 tag = tagByte & 0x3f;
182 const l0 = bytes[off++];
183 if (l0 < 192) {
184 len = l0;
185 } else if (l0 < 224) {
186 len = ((l0 - 192) << 8) + bytes[off++] + 192;
187 } else if (l0 === 255) {
188 len =
189 (bytes[off++] << 24) |
190 (bytes[off++] << 16) |
191 (bytes[off++] << 8) |
192 bytes[off++];
193 } else {
194 return null; // partial length body — skip
195 }
196 }
197 if (tag !== 2) {
198 off += len;
199 continue;
200 }
201 // Signature packet.
202 const end = off + len;
203 const version = bytes[off++];
204 if (version !== 4 && version !== 5) {
205 off = end;
206 continue;
207 }
208 // Skip: sigType (1) + pubAlgo (1) + hashAlgo (1)
209 off += 3;
210 // Hashed subpackets
211 const hashedLen =
212 version === 4
213 ? (bytes[off++] << 8) | bytes[off++]
214 : (bytes[off++] << 24) |
215 (bytes[off++] << 16) |
216 (bytes[off++] << 8) |
217 bytes[off++];
218 const fp = scanSubpackets(bytes, off, off + hashedLen);
219 if (fp) return fp;
220 off += hashedLen;
221 // Unhashed subpackets
222 const unhashedLen =
223 version === 4
224 ? (bytes[off++] << 8) | bytes[off++]
225 : (bytes[off++] << 24) |
226 (bytes[off++] << 16) |
227 (bytes[off++] << 8) |
228 bytes[off++];
229 const fp2 = scanSubpackets(bytes, off, off + unhashedLen);
230 if (fp2) return fp2;
231 off = end;
232 }
233 return null;
234}
235
236function scanSubpackets(
237 bytes: Uint8Array,
238 start: number,
239 end: number
240): string | null {
241 let off = start;
242 let keyIdFallback: string | null = null;
243 while (off < end) {
244 const l0 = bytes[off++];
245 let spLen: number;
246 if (l0 < 192) {
247 spLen = l0;
248 } else if (l0 < 255) {
249 spLen = ((l0 - 192) << 8) + bytes[off++] + 192;
250 } else {
251 spLen =
252 (bytes[off++] << 24) |
253 (bytes[off++] << 16) |
254 (bytes[off++] << 8) |
255 bytes[off++];
256 }
257 const spType = bytes[off] & 0x7f;
258 const bodyStart = off + 1;
259 const bodyEnd = off + spLen;
260 if (spType === 33) {
261 // [version (1 byte) | fingerprint (20 or 32 bytes)]
262 const hex: string[] = [];
263 for (let i = bodyStart + 1; i < bodyEnd; i++) {
264 hex.push(bytes[i].toString(16).padStart(2, "0"));
265 }
266 return hex.join("");
267 }
268 if (spType === 16 && !keyIdFallback) {
269 const hex: string[] = [];
270 for (let i = bodyStart; i < bodyEnd; i++) {
271 hex.push(bytes[i].toString(16).padStart(2, "0"));
272 }
273 keyIdFallback = hex.join("");
274 }
275 off = bodyEnd;
276 }
277 return keyIdFallback;
278}
279
280// ----------------------------------------------------------------------------
281// SSH signature → pubkey fingerprint
282// ----------------------------------------------------------------------------
283
284/**
285 * Unarmor an SSH signature (RFC "SSHSIG" format). Returns the inner binary
286 * blob that follows the 6-byte "SSHSIG" magic.
287 */
288export function unarmorSsh(armored: string): Uint8Array | null {
289 const lines = armored.split(/\r?\n/);
290 const body: string[] = [];
291 let inBody = false;
292 for (const ln of lines) {
293 if (ln.startsWith("-----BEGIN SSH SIGNATURE")) {
294 inBody = true;
295 continue;
296 }
297 if (ln.startsWith("-----END SSH SIGNATURE")) break;
298 if (inBody && ln.trim() !== "") body.push(ln);
299 }
300 if (!body.length) return null;
301 try {
302 return b64decode(body.join(""));
303 } catch {
304 return null;
305 }
306}
307
308/**
309 * Parse the "publickey" field out of an SSHSIG blob. Returns the public key
310 * wire-format bytes (length-prefixed `ssh-...`), or null.
311 */
312export function parseSshSigPublicKey(blob: Uint8Array): Uint8Array | null {
313 if (!blob || blob.length < 10) return null;
314 // Magic "SSHSIG"
315 const magic = "SSHSIG";
316 for (let i = 0; i < magic.length; i++) {
317 if (blob[i] !== magic.charCodeAt(i)) return null;
318 }
319 let off = magic.length;
320 // u32 version
321 off += 4;
322 // string publickey (u32 len + bytes)
323 if (off + 4 > blob.length) return null;
324 const len =
325 (blob[off] << 24) |
326 (blob[off + 1] << 16) |
327 (blob[off + 2] << 8) |
328 blob[off + 3];
329 off += 4;
330 if (off + len > blob.length) return null;
331 return blob.slice(off, off + len);
332}
333
334// ----------------------------------------------------------------------------
335// Fingerprints
336// ----------------------------------------------------------------------------
337
338/**
339 * Compute the canonical fingerprint for a registered signing key.
340 * - GPG: the public-key block's issuer fingerprint (we don't parse the
341 * full PGP pubkey — users must paste it with the fingerprint line, which
342 * we strip to lowercase hex).
343 * - SSH: base64 SHA-256 of the wire-format `ssh-...` key body (the second
344 * whitespace-separated token in an authorized_keys line).
345 */
346export async function fingerprintForPublicKey(
347 keyType: "gpg" | "ssh",
348 publicKey: string
349): Promise<string | null> {
350 if (keyType === "ssh") {
351 const token = publicKey.trim().split(/\s+/)[1];
352 if (!token) return null;
353 let bytes: Uint8Array;
354 try {
355 bytes = b64decode(token);
356 } catch {
357 return null;
358 }
359 const digest = await crypto.subtle.digest("SHA-256", bytes);
360 // Base64 (unpadded) — mimics `ssh-keygen -l -E sha256`.
361 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
362 /=+$/,
363 ""
364 );
365 return `SHA256:${b64}`;
366 }
367 // GPG: extract the first 40-char (or 64-char) hex string we can find.
368 const m =
369 publicKey.match(/\b([A-Fa-f0-9]{40})\b/) ||
370 publicKey.match(/\b([A-Fa-f0-9]{64})\b/);
371 if (!m) return null;
372 return m[1].toLowerCase();
373}
374
375// ----------------------------------------------------------------------------
376// End-to-end verify
377// ----------------------------------------------------------------------------
378
379/**
380 * Verify a commit given the raw commit object. Pure function — no DB access
381 * here. Returns the parsed signature info; the matcher step is done in
382 * `verifyCommit` below.
383 */
384export function analyzeRawCommit(
385 raw: string
386): {
387 type: "gpg" | "ssh" | null;
388 fingerprint: string | null;
389 authorEmail: string | null;
390} {
391 const sig = extractSignatureFromCommit(raw);
392 if (!sig) return { type: null, fingerprint: null, authorEmail: null };
393 if (sig.type === "gpg") {
394 const packets = unarmorPgp(sig.signature);
395 if (!packets) {
396 return { type: "gpg", fingerprint: null, authorEmail: sig.authorEmail };
397 }
398 const fp = parsePgpIssuerFingerprint(packets);
399 return {
400 type: "gpg",
401 fingerprint: fp ? fp.toLowerCase() : null,
402 authorEmail: sig.authorEmail,
403 };
404 }
405 // SSH
406 const blob = unarmorSsh(sig.signature);
407 if (!blob) {
408 return { type: "ssh", fingerprint: null, authorEmail: sig.authorEmail };
409 }
410 const pubkey = parseSshSigPublicKey(blob);
411 if (!pubkey) {
412 return { type: "ssh", fingerprint: null, authorEmail: sig.authorEmail };
413 }
414 return {
415 type: "ssh",
416 fingerprint: null, // filled by caller via SubtleCrypto
417 authorEmail: sig.authorEmail,
418 ...{
419 _sshPublicKey: pubkey,
420 },
421 } as any;
422}
423
424async function fingerprintSshBytes(bytes: Uint8Array): Promise<string> {
425 const digest = await crypto.subtle.digest("SHA-256", bytes);
426 const b64 = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(
427 /=+$/,
428 ""
429 );
430 return `SHA256:${b64}`;
431}
432
433/**
434 * Cache lookup → parse → match → write-back. The expensive work (git cat-file
435 * + subtle.digest) runs only on cache miss.
436 */
437export async function verifyCommit(
438 repositoryId: string,
439 ownerName: string,
440 repoName: string,
441 sha: string,
442 opts: { forceFresh?: boolean } = {}
443): Promise<VerificationResult> {
444 if (!opts.forceFresh) {
445 const [cached] = await db
446 .select()
447 .from(commitVerifications)
448 .where(
449 and(
450 eq(commitVerifications.repositoryId, repositoryId),
451 eq(commitVerifications.commitSha, sha)
452 )
453 )
454 .limit(1);
455 if (cached) {
456 return {
457 verified: cached.verified,
458 reason: cached.reason as VerificationReason,
459 signatureType: (cached.signatureType as any) ?? null,
460 fingerprint: cached.signerFingerprint,
461 signerUserId: cached.signerUserId,
462 signerKeyId: cached.signerKeyId,
463 };
464 }
465 }
466
467 const raw = await getRawCommitObject(ownerName, repoName, sha);
468 const result = await verifyRawCommit(raw);
469 await persistVerification(repositoryId, sha, result);
470 return result;
471}
472
473/** Test-friendly: verify without hitting git or the DB. */
474export async function verifyRawCommit(
475 raw: string | null
476): Promise<VerificationResult> {
477 if (!raw)
478 return {
479 verified: false,
480 reason: "unsigned",
481 signatureType: null,
482 fingerprint: null,
483 signerUserId: null,
484 signerKeyId: null,
485 };
486 const sig = extractSignatureFromCommit(raw);
487 if (!sig)
488 return {
489 verified: false,
490 reason: "unsigned",
491 signatureType: null,
492 fingerprint: null,
493 signerUserId: null,
494 signerKeyId: null,
495 };
496
497 let fingerprint: string | null = null;
498 if (sig.type === "gpg") {
499 const packets = unarmorPgp(sig.signature);
500 if (packets) {
501 const fp = parsePgpIssuerFingerprint(packets);
502 if (fp) fingerprint = fp.toLowerCase();
503 }
504 } else {
505 const blob = unarmorSsh(sig.signature);
506 if (blob) {
507 const pubkey = parseSshSigPublicKey(blob);
508 if (pubkey) fingerprint = await fingerprintSshBytes(pubkey);
509 }
510 }
511
512 if (!fingerprint) {
513 return {
514 verified: false,
515 reason: "bad_sig",
516 signatureType: sig.type,
517 fingerprint: null,
518 signerUserId: null,
519 signerKeyId: null,
520 };
521 }
522
523 // Match against registered keys — for GPG we match as suffix since the
524 // issuer subpacket may carry only the 64-bit key ID (trailing 16 hex chars).
525 let signingKey: SigningKey | null = null;
526 if (sig.type === "gpg") {
527 const all = await db
528 .select()
529 .from(signingKeys)
530 .where(eq(signingKeys.keyType, "gpg"))
531 .limit(500);
532 const fpLc = fingerprint.toLowerCase();
533 signingKey =
534 all.find((k) => k.fingerprint.toLowerCase() === fpLc) ??
535 all.find((k) => k.fingerprint.toLowerCase().endsWith(fpLc)) ??
536 null;
537 } else {
538 const [row] = await db
539 .select()
540 .from(signingKeys)
541 .where(
542 and(
543 eq(signingKeys.keyType, "ssh"),
544 eq(signingKeys.fingerprint, fingerprint)
545 )
546 )
547 .limit(1);
548 signingKey = row ?? null;
549 }
550
551 if (!signingKey) {
552 return {
553 verified: false,
554 reason: "unknown_key",
555 signatureType: sig.type,
556 fingerprint,
557 signerUserId: null,
558 signerKeyId: null,
559 };
560 }
561
562 if (signingKey.expiresAt && signingKey.expiresAt < new Date()) {
563 return {
564 verified: false,
565 reason: "expired",
566 signatureType: sig.type,
567 fingerprint,
568 signerUserId: signingKey.userId,
569 signerKeyId: signingKey.id,
570 };
571 }
572
573 // Email match (if declared on the key).
574 if (sig.authorEmail && signingKey.email) {
575 if (
576 signingKey.email.toLowerCase().trim() !==
577 sig.authorEmail.toLowerCase().trim()
578 ) {
579 return {
580 verified: false,
581 reason: "email_mismatch",
582 signatureType: sig.type,
583 fingerprint,
584 signerUserId: signingKey.userId,
585 signerKeyId: signingKey.id,
586 };
587 }
588 }
589
590 return {
591 verified: true,
592 reason: "valid",
593 signatureType: sig.type,
594 fingerprint,
595 signerUserId: signingKey.userId,
596 signerKeyId: signingKey.id,
597 };
598}
599
600async function persistVerification(
601 repositoryId: string,
602 sha: string,
603 result: VerificationResult
604): Promise<void> {
605 try {
606 await db
607 .insert(commitVerifications)
608 .values({
609 repositoryId,
610 commitSha: sha,
611 verified: result.verified,
612 reason: result.reason,
613 signatureType: result.signatureType,
614 signerKeyId: result.signerKeyId,
615 signerUserId: result.signerUserId,
616 signerFingerprint: result.fingerprint,
617 })
618 .onConflictDoNothing();
619 } catch {
620 // best effort — rendering should never fail because the cache write blew up
621 }
622}
623
624// ----------------------------------------------------------------------------
625// CRUD for /settings/signing-keys
626// ----------------------------------------------------------------------------
627
628export async function listSigningKeysForUser(
629 userId: string
630): Promise<SigningKey[]> {
631 return db
632 .select()
633 .from(signingKeys)
634 .where(eq(signingKeys.userId, userId));
635}
636
637export async function listSigningKeysForUsername(
638 username: string
639): Promise<Array<SigningKey & { username: string }>> {
640 return db
641 .select({
642 id: signingKeys.id,
643 userId: signingKeys.userId,
644 keyType: signingKeys.keyType,
645 title: signingKeys.title,
646 fingerprint: signingKeys.fingerprint,
647 publicKey: signingKeys.publicKey,
648 email: signingKeys.email,
649 expiresAt: signingKeys.expiresAt,
650 lastUsedAt: signingKeys.lastUsedAt,
651 createdAt: signingKeys.createdAt,
652 username: users.username,
653 })
654 .from(signingKeys)
655 .innerJoin(users, eq(signingKeys.userId, users.id))
656 .where(eq(users.username, username));
657}
658
659export async function addSigningKey(params: {
660 userId: string;
661 keyType: "gpg" | "ssh";
662 title: string;
663 publicKey: string;
664 email?: string | null;
665}): Promise<
666 | { ok: true; id: string; fingerprint: string }
667 | { ok: false; error: string }
668> {
669 const { userId, keyType, title, publicKey } = params;
670 const email = (params.email || "").trim() || null;
671 const trimmed = publicKey.trim();
672 if (!trimmed) return { ok: false, error: "Public key is required" };
673 if (keyType !== "gpg" && keyType !== "ssh") {
674 return { ok: false, error: "Unknown key type" };
675 }
676 if (!title.trim()) return { ok: false, error: "Title is required" };
677 const fingerprint = await fingerprintForPublicKey(keyType, trimmed);
678 if (!fingerprint) {
679 return { ok: false, error: "Could not derive a fingerprint" };
680 }
681 try {
682 const [row] = await db
683 .insert(signingKeys)
684 .values({
685 userId,
686 keyType,
687 title: title.trim(),
688 fingerprint,
689 publicKey: trimmed,
690 email,
691 })
692 .returning();
693 return { ok: true, id: row.id, fingerprint };
694 } catch (err: any) {
695 const msg = String(err?.message || "");
696 if (msg.includes("signing_keys_fp_unique") || msg.includes("duplicate")) {
697 return { ok: false, error: "That key is already registered" };
698 }
699 return { ok: false, error: "Could not save key" };
700 }
701}
702
703export async function deleteSigningKey(
704 keyId: string,
705 userId: string
706): Promise<boolean> {
707 const rows = await db
708 .delete(signingKeys)
709 .where(and(eq(signingKeys.id, keyId), eq(signingKeys.userId, userId)))
710 .returning();
711 return rows.length > 0;
712}
713
714// Test-only internals.
715export const __internal = {
716 b64decode,
717 scanSubpackets,
718 fingerprintSshBytes,
719};
Addedsrc/routes/signing-keys.tsx+209−0View fileUnifiedSplit
@@ -0,0 +1,209 @@
1/**
2 * Block J3 — Signing keys UI.
3 *
4 * GET /settings/signing-keys — list + add form
5 * POST /settings/signing-keys — add new key
6 * POST /settings/signing-keys/:id/delete
7 */
8
9import { Hono } from "hono";
10import { Layout } from "../views/layout";
11import type { AuthEnv } from "../middleware/auth";
12import { requireAuth } from "../middleware/auth";
13import {
14 addSigningKey,
15 deleteSigningKey,
16 listSigningKeysForUser,
17} from "../lib/signatures";
18import { audit } from "../lib/notify";
19
20const signingKeysRoutes = new Hono<AuthEnv>();
21signingKeysRoutes.use("/settings/signing-keys", requireAuth);
22signingKeysRoutes.use("/settings/signing-keys/*", requireAuth);
23
24signingKeysRoutes.get("/settings/signing-keys", async (c) => {
25 const user = c.get("user")!;
26 const keys = await listSigningKeysForUser(user.id);
27 const message = c.req.query("message");
28 const error = c.req.query("error");
29 return c.html(
30 <Layout title="Signing keys" user={user}>
31 <div class="settings-container">
32 <h2>Signing keys</h2>
33 <p style="color:var(--text-muted)">
34 Register the GPG or SSH public key you use for{" "}
35 <code>git commit -S</code>. Commits we can match to a registered key
36 render with a <span style="color:var(--green);font-weight:600">Verified</span>{" "}
37 badge. This is identity matching by fingerprint — cryptographic
38 verification is future work.
39 </p>
40 {message && (
41 <div class="auth-success" style="margin-top:12px">
42 {decodeURIComponent(message)}
43 </div>
44 )}
45 {error && (
46 <div class="auth-error" style="margin-top:12px">
47 {decodeURIComponent(error)}
48 </div>
49 )}
50
51 <h3 style="margin-top:24px">Your keys</h3>
52 {keys.length === 0 ? (
53 <div class="panel-empty" style="padding:16px">
54 No signing keys yet.
55 </div>
56 ) : (
57 <div class="panel">
58 {keys.map((k) => (
59 <div
60 class="panel-item"
61 style="flex-direction:column;align-items:stretch;gap:4px"
62 >
63 <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap">
64 <div>
65 <span
66 style="font-size:10px;padding:2px 6px;border-radius:3px;background:var(--bg-subtle);text-transform:uppercase;margin-right:6px"
67 >
68 {k.keyType}
69 </span>
70 <span style="font-weight:600">{k.title}</span>
71 {k.email && (
72 <span
73 style="font-size:12px;color:var(--text-muted);margin-left:8px"
74 >
75 {k.email}
76 </span>
77 )}
78 </div>
79 <form
80 method="POST"
81 action={`/settings/signing-keys/${k.id}/delete`}
82 >
83 <button
84 type="submit"
85 class="btn btn-sm"
86 style="font-size:11px"
87 >
88 Delete
89 </button>
90 </form>
91 </div>
92 <div
93 style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);word-break:break-all"
94 >
95 {k.fingerprint}
96 </div>
97 </div>
98 ))}
99 </div>
100 )}
101
102 <h3 style="margin-top:24px">Add a key</h3>
103 <form
104 method="POST"
105 action="/settings/signing-keys"
106 class="auth-form"
107 style="max-width:720px"
108 >
109 <div class="form-group">
110 <label for="sk-title">Title</label>
111 <input
112 type="text"
113 id="sk-title"
114 name="title"
115 placeholder="e.g. Work laptop"
116 required
117 maxLength={120}
118 />
119 </div>
120 <div class="form-group">
121 <label for="sk-type">Key type</label>
122 <select id="sk-type" name="key_type" required>
123 <option value="gpg">GPG</option>
124 <option value="ssh">SSH</option>
125 </select>
126 </div>
127 <div class="form-group">
128 <label for="sk-email">Email (optional)</label>
129 <input
130 type="email"
131 id="sk-email"
132 name="email"
133 placeholder="commit-author@example.com"
134 maxLength={200}
135 />
136 </div>
137 <div class="form-group">
138 <label for="sk-public">Public key</label>
139 <textarea
140 id="sk-public"
141 name="public_key"
142 rows={10}
143 required
144 placeholder="-----BEGIN PGP PUBLIC KEY BLOCK----- ... -----END PGP PUBLIC KEY BLOCK----- or: ssh-ed25519 AAAA... you@laptop"
145 style="font-family:var(--font-mono);font-size:12px"
146 />
147 </div>
148 <button type="submit" class="btn btn-primary">
149 Add key
150 </button>
151 </form>
152 </div>
153 </Layout>
154 );
155});
156
157signingKeysRoutes.post("/settings/signing-keys", async (c) => {
158 const user = c.get("user")!;
159 const body = await c.req.parseBody();
160 const keyType = String(body.key_type || "").toLowerCase() as "gpg" | "ssh";
161 const title = String(body.title || "");
162 const publicKey = String(body.public_key || "");
163 const email = String(body.email || "");
164
165 const result = await addSigningKey({
166 userId: user.id,
167 keyType,
168 title,
169 publicKey,
170 email,
171 });
172
173 if (!result.ok) {
174 return c.redirect(
175 `/settings/signing-keys?error=${encodeURIComponent(result.error)}`
176 );
177 }
178 await audit({
179 userId: user.id,
180 action: "signing_keys.add",
181 targetId: result.id,
182 metadata: { keyType, fingerprint: result.fingerprint },
183 });
184 return c.redirect(
185 `/settings/signing-keys?message=${encodeURIComponent(
186 `Added key ${result.fingerprint.slice(0, 24)}…`
187 )}`
188 );
189});
190
191signingKeysRoutes.post("/settings/signing-keys/:id/delete", async (c) => {
192 const user = c.get("user")!;
193 const id = c.req.param("id");
194 const ok = await deleteSigningKey(id, user.id);
195 if (ok) {
196 await audit({
197 userId: user.id,
198 action: "signing_keys.delete",
199 targetId: id,
200 });
201 }
202 return c.redirect(
203 `/settings/signing-keys?${ok ? "message" : "error"}=${encodeURIComponent(
204 ok ? "Key removed." : "Key not found"
205 )}`
206 );
207});
208
209export default signingKeysRoutes;
Modifiedsrc/routes/web.tsx+104−3View fileUnifiedSplit
@@ -5,9 +5,14 @@
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc } from "drizzle-orm";
8import { eq, and, desc, inArray } from "drizzle-orm";
99import { db } from "../db";
10import { users, repositories, stars } from "../db/schema";
10import {
11 users,
12 repositories,
13 stars,
14 commitVerifications,
15} from "../db/schema";
1116import { Layout } from "../views/layout";
1217import {
1318 RepoHeader,
@@ -666,6 +671,50 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
666671
667672 const commits = await listCommits(owner, repo, ref, 50);
668673
674 // Block J3 — batch-fetch cached verification results for the page.
675 let verifications: Record<string, { verified: boolean; reason: string }> = {};
676 try {
677 const [ownerRow] = await db
678 .select()
679 .from(users)
680 .where(eq(users.username, owner))
681 .limit(1);
682 if (ownerRow) {
683 const [repoRow] = await db
684 .select()
685 .from(repositories)
686 .where(
687 and(
688 eq(repositories.ownerId, ownerRow.id),
689 eq(repositories.name, repo)
690 )
691 )
692 .limit(1);
693 if (repoRow && commits.length > 0) {
694 const rows = await db
695 .select()
696 .from(commitVerifications)
697 .where(
698 and(
699 eq(commitVerifications.repositoryId, repoRow.id),
700 inArray(
701 commitVerifications.commitSha,
702 commits.map((c) => c.sha)
703 )
704 )
705 );
706 for (const r of rows) {
707 verifications[r.commitSha] = {
708 verified: r.verified,
709 reason: r.reason,
710 };
711 }
712 }
713 }
714 } catch {
715 // DB unavailable — skip the badges gracefully.
716 }
717
669718 return c.html(
670719 <Layout title={`Commits — ${owner}/${repo}`} user={user}>
671720 <RepoHeader owner={owner} repo={repo} />
@@ -682,7 +731,12 @@ web.get("/:owner/:repo/commits/:ref?", async (c) => {
682731 <p>No commits yet.</p>
683732 </div>
684733 ) : (
685 <CommitList commits={commits} owner={owner} repo={repo} />
734 <CommitList
735 commits={commits}
736 owner={owner}
737 repo={repo}
738 verifications={verifications}
739 />
686740 )}
687741 </Layout>
688742 );
@@ -710,6 +764,41 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
710764 );
711765 }
712766
767 // Block J3 — try to verify this commit's signature.
768 let verification:
769 | { verified: boolean; reason: string; signatureType: string | null }
770 | null = null;
771 try {
772 const [ownerRow] = await db
773 .select()
774 .from(users)
775 .where(eq(users.username, owner))
776 .limit(1);
777 if (ownerRow) {
778 const [repoRow] = await db
779 .select()
780 .from(repositories)
781 .where(
782 and(
783 eq(repositories.ownerId, ownerRow.id),
784 eq(repositories.name, repo)
785 )
786 )
787 .limit(1);
788 if (repoRow) {
789 const { verifyCommit } = await import("../lib/signatures");
790 const v = await verifyCommit(repoRow.id, owner, repo, commit.sha);
791 verification = {
792 verified: v.verified,
793 reason: v.reason,
794 signatureType: v.signatureType,
795 };
796 }
797 }
798 } catch {
799 verification = null;
800 }
801
713802 const { files, raw } = diffResult;
714803
715804 return c.html(
@@ -734,6 +823,18 @@ web.get("/:owner/:repo/commit/:sha", async (c) => {
734823 day: "numeric",
735824 year: "numeric",
736825 })}
826 {verification && verification.reason !== "unsigned" && (
827 <span
828 style={`margin-left:10px;font-size:10px;padding:1px 6px;border-radius:3px;text-transform:uppercase;letter-spacing:.4px;color:#fff;background:${
829 verification.verified
830 ? "var(--green,#2ea043)"
831 : "var(--yellow,#d29922)"
832 }`}
833 title={`${verification.signatureType?.toUpperCase() || ""} · ${verification.reason}`}
834 >
835 {verification.verified ? "Verified" : verification.reason}
836 </span>
837 )}
737838 </div>
738839 <div style="margin-top: 8px">
739840 <span class="commit-sha">{commit.sha}</span>
Modifiedsrc/views/components.tsx+31−19View fileUnifiedSplit
@@ -333,28 +333,40 @@ export const CommitList: FC<{
333333 commits: GitCommit[];
334334 owner: string;
335335 repo: string;
336}> = ({ commits, owner, repo }) => (
336 verifications?: Record<string, { verified: boolean; reason: string }>;
337}> = ({ commits, owner, repo, verifications }) => (
337338 <div class="commit-list">
338 {commits.map((commit) => (
339 <div class="commit-item">
340 <div>
341 <div class="commit-message">
342 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
343 {commit.message}
344 </a>
345 </div>
346 <div class="commit-meta">
347 {commit.author} committed {formatRelativeDate(commit.date)}
339 {commits.map((commit) => {
340 const v = verifications?.[commit.sha];
341 return (
342 <div class="commit-item">
343 <div>
344 <div class="commit-message">
345 <a href={`/${owner}/${repo}/commit/${commit.sha}`}>
346 {commit.message}
347 </a>
348 {v?.verified && (
349 <span
350 title="Signed with a registered key"
351 style="margin-left:8px;font-size:10px;padding:1px 6px;border-radius:3px;background:var(--green,#2ea043);color:#fff;text-transform:uppercase;letter-spacing:.4px"
352 >
353 Verified
354 </span>
355 )}
356 </div>
357 <div class="commit-meta">
358 {commit.author} committed {formatRelativeDate(commit.date)}
359 </div>
348360 </div>
361 <a
362 href={`/${owner}/${repo}/commit/${commit.sha}`}
363 class="commit-sha"
364 >
365 {commit.sha.slice(0, 7)}
366 </a>
349367 </div>
350 <a
351 href={`/${owner}/${repo}/commit/${commit.sha}`}
352 class="commit-sha"
353 >
354 {commit.sha.slice(0, 7)}
355 </a>
356 </div>
357 ))}
368 );
369 })}
358370 </div>
359371);
360372
361373