import { parseSshSignature, sshFingerprint } from "./sshsig"; import { parsePgpSignature } from "./openpgp"; import type { PgpKey } from "./openpgp"; const decoder = new TextDecoder(); const SHA1_SIGNATURE_HEADER = "gpgsig"; const SHA256_SIGNATURE_HEADER = "gpgsig-sha256"; const SIGNATURE_HEADERS = new Set([SHA1_SIGNATURE_HEADER, SHA256_SIGNATURE_HEADER]); const EMAIL = /<([^>]*)>/; const LF = 0x0a; const SPACE = 0x20; export type SignedCommit = { payload: Uint8Array; signature: string | null; committerEmail: string | null; malformed: boolean; }; function headerEnd(raw: Uint8Array): number { for (let index = 0; index + 1 < raw.byteLength; index++) { if (raw[index] === LF && raw[index + 1] === LF) return index; } return raw.byteLength; } /** * Splits a raw commit into the signed payload (commit minus its signature header) and the signature text. * Works on bytes so the payload is exactly what was signed; ambiguous headers mark the commit malformed. */ export function splitSignedCommit(raw: Uint8Array): SignedCommit { const end = headerEnd(raw); const kept: Uint8Array[] = []; const signatures = new Map(); const counts = { author: 0, committer: 0, duplicateSignature: false }; let inSignature: string[] | null = null; let committerEmail: string | null = null; let start = 0; while (start <= end) { let stop = raw.indexOf(LF, start); if (stop === -1 || stop > end) stop = end; const line = raw.subarray(start, stop); start = stop + 1; if (line[0] === SPACE) { if (inSignature) inSignature.push(decoder.decode(line.subarray(1))); else kept.push(line); continue; } const space = line.indexOf(SPACE); const name = decoder.decode(space === -1 ? line : line.subarray(0, space)); inSignature = null; if (SIGNATURE_HEADERS.has(name)) { if (signatures.has(name)) counts.duplicateSignature = true; inSignature = [decoder.decode(line.subarray(name.length + 1))]; signatures.set(name, inSignature); continue; } if (name === "author") counts.author++; if (name === "committer") { counts.committer++; committerEmail = EMAIL.exec(decoder.decode(line))?.[1] ?? null; } kept.push(line); } const signature = signatures.get(SHA1_SIGNATURE_HEADER) ?? signatures.get(SHA256_SIGNATURE_HEADER); const rest = raw.subarray(end); const length = kept.reduce((total, line) => total + line.byteLength + 1, 0) - (kept.length > 0 ? 1 : 0); const payload = new Uint8Array(length + rest.byteLength); let offset = 0; kept.forEach((line, index) => { payload.set(line, offset); offset += line.byteLength; if (index < kept.length - 1) payload[offset++] = LF; }); payload.set(rest, offset); return { payload, signature: signature ? `${signature.join("\n")}\n` : null, committerEmail, malformed: counts.author !== 1 || counts.committer !== 1 || counts.duplicateSignature, }; } 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, committerEmail: string | null) => Promise; gpg: ( ids: { fingerprint: string | null; keyId: string | null }, committerEmail: string | null, ) => Promise>; }; 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's verified email. */ export async function checkCommitSignature(raw: Uint8Array, lookup: KeyLookup): Promise { const commit = splitSignedCommit(raw); if (!commit.signature) return result(commit.malformed ? "malformed" : "unsigned", null, null); if (commit.malformed) return result("malformed", 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 owners = await lookup.ssh(fingerprint, commit.committerEmail); if (owners.length === 0) return result("unknown_key", "ssh", fingerprint); const owner = owners.find((candidate) => emailMatches(candidate, commit.committerEmail)); if (!owner) return result("email_mismatch", "ssh", fingerprint); 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 owners = await lookup.gpg( { fingerprint: parsed.issuerFingerprint, keyId: parsed.issuerKeyId }, commit.committerEmail, ); if (owners.length === 0) return result("unknown_key", "gpg", reference); const verified: typeof owners = []; for (const owner of owners) if (await parsed.verify(owner.key)) verified.push(owner); if (verified.length === 0) return result("bad_signature", "gpg", owners[0].key.fingerprint); const owner = verified.find((candidate) => emailMatches(candidate, commit.committerEmail)); if (!owner) return result("email_mismatch", "gpg", verified[0].key.fingerprint); return result("valid", "gpg", owner.key.fingerprint, owner.userId); } return result("unsupported", null, null); } catch { return result("malformed", null, null); } }