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.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.tsBlame719 lines · 1 contributor
3951454Claude1/**
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 }
772a24fClaude359 const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
3951454Claude360 // 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> {
772a24fClaude425 const digest = await crypto.subtle.digest("SHA-256", bytes as BufferSource);
3951454Claude426 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};