import { parseSshSignature, sshFingerprint } from "./sshsig"; import { parsePgpSignature } from "./openpgp"; import type { PgpKey } from "./openpgp"; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const SIGNATURE_HEADERS = new Set(["gpgsig", "gpgsig-sha256"]); const EMAIL = /<([^>]*)>/; export type SignedCommit = { payload: Uint8Array; signature: string | null; committerEmail: string | null }; /** Splits a raw commit into the signed payload (commit minus its gpgsig header) and the signature text. */ export function splitSignedCommit(raw: Uint8Array): SignedCommit { const text = decoder.decode(raw); const headerEnd = text.indexOf("\n\n"); const headers = headerEnd === -1 ? text : text.slice(0, headerEnd); const rest = headerEnd === -1 ? "" : text.slice(headerEnd); const kept: string[] = []; const signature: string[] = []; let inSignature = false; let committerEmail: string | null = null; for (const line of headers.split("\n")) { if (line.startsWith(" ") && inSignature) { signature.push(line.slice(1)); continue; } const name = line.slice(0, line.indexOf(" ")); inSignature = SIGNATURE_HEADERS.has(name) && signature.length === 0; if (inSignature) { signature.push(line.slice(name.length + 1)); continue; } if (name === "committer") committerEmail = EMAIL.exec(line)?.[1] ?? null; kept.push(line); } return { payload: encoder.encode(`${kept.join("\n")}${rest}`), signature: signature.length > 0 ? `${signature.join("\n")}\n` : null, committerEmail, }; } export type SignatureReason = | "valid" | "unsigned" | "unknown_key" | "bad_signature" | "email_mismatch" | "unsupported" | "malformed"; export type SignatureCheck = { verified: boolean; reason: SignatureReason; kind: "ssh" | "gpg" | null; keyFingerprint: string | null; signerId: string | null; }; export type KeyOwner = { userId: string; emails: string[] }; export type KeyLookup = { ssh: (fingerprint: string) => Promise; gpg: (ids: { fingerprint: string | null; keyId: string | null }) => Promise<(KeyOwner & { key: PgpKey }) | null>; }; function result( reason: SignatureReason, kind: SignatureCheck["kind"], keyFingerprint: string | null, signerId: string | null = null, ): SignatureCheck { return { verified: reason === "valid", reason, kind, keyFingerprint, signerId }; } function emailMatches(owner: KeyOwner, email: string | null): boolean { if (!email) return false; const wanted = email.toLowerCase(); return owner.emails.some((candidate) => candidate.toLowerCase() === wanted); } /** Verifies a raw commit's SSH or OpenPGP signature against registered keys and the committer email. */ export async function checkCommitSignature(raw: Uint8Array, lookup: KeyLookup): Promise { const commit = splitSignedCommit(raw); if (!commit.signature) return result("unsigned", null, null); try { if (commit.signature.includes("BEGIN SSH SIGNATURE")) { const parsed = parseSshSignature(commit.signature, commit.payload); if (!parsed) return result("malformed", "ssh", null); const fingerprint = await sshFingerprint(parsed.publicKey); if (!(await parsed.verify())) return result("bad_signature", "ssh", fingerprint); const owner = await lookup.ssh(fingerprint); if (!owner) return result("unknown_key", "ssh", fingerprint); if (!emailMatches(owner, commit.committerEmail)) return result("email_mismatch", "ssh", fingerprint, owner.userId); return result("valid", "ssh", fingerprint, owner.userId); } if (commit.signature.includes("BEGIN PGP SIGNATURE")) { const parsed = parsePgpSignature(commit.signature, commit.payload); if (!parsed) return result("unsupported", "gpg", null); const reference = parsed.issuerFingerprint ?? parsed.issuerKeyId; const owner = await lookup.gpg({ fingerprint: parsed.issuerFingerprint, keyId: parsed.issuerKeyId }); if (!owner) return result("unknown_key", "gpg", reference); if (!(await parsed.verify(owner.key))) return result("bad_signature", "gpg", owner.key.fingerprint); if (!emailMatches(owner, commit.committerEmail)) { return result("email_mismatch", "gpg", owner.key.fingerprint, owner.userId); } return result("valid", "gpg", owner.key.fingerprint, owner.userId); } return result("unsupported", null, null); } catch { return result("malformed", null, null); } }