import { all, batch, chunks, placeholders, stmt } from "../db"; import { accountDb, stmt as scopedStmt } from "../scoped-db"; import { withStore } from "../git/store"; import { randomId } from "../crypto"; import { badRequest, conflict } from "../errors"; import { assertBelowCap } from "../limits"; import { fetchRawCommits } from "../git/upload-pack"; import { isSha } from "../git/store"; import { checkCommitSignature } from "./commit-signature"; import type { KeyLookup, SignatureCheck } from "./commit-signature"; import { parsePgpPublicKeys } from "./openpgp-keys"; import { parseSshPublicKey } from "./sshsig"; import type { RepoRow } from "~/lib/types"; const MAX_SIGNING_KEYS = 50; const MAX_PUBLIC_KEY_BYTES = 64 * 1024; export type CommitSignatureInfo = { sha: string; verified: boolean; reason: string; kind: "ssh" | "gpg" | null; keyFingerprint: string | null; signer: string | null; }; type SignatureRow = { commit_sha: string; verified: number; reason: string; kind: "ssh" | "gpg" | null; key_fingerprint: string | null; signer_name: string | null; signer_disabled_at: number | null; signer_id: string | null; }; const MAX_KEY_CANDIDATES = 10; /** * Owners with any verified address equal to the committer email sort first, so keys squatted on the same * fingerprint by other accounts cannot push the real owner past the LIMIT (AUTH-P10). */ const COMMITTER_MATCH = `((u.email = ? COLLATE NOCASE AND u.email_verified_at IS NOT NULL) OR EXISTS (SELECT 1 FROM user_emails e WHERE e.user_id = i.user_id AND e.verified_at IS NOT NULL AND e.email = ? COLLATE NOCASE))`; async function verifiedEmails(userId: string): Promise { const rows = await all<{ email: string }>( `SELECT email FROM users WHERE account_id = ?1 AND email_verified_at IS NOT NULL UNION SELECT email FROM user_emails WHERE user_id = ?1 AND verified_at IS NOT NULL`, userId, ); return rows.map((row) => row.email); } type KeyCandidate = { user_id: string; signing_key_id: string; fingerprint: string }; /** * `signing_key_ids` (D1) indexes every SSH fingerprint and GPG key/subkey to the owning account, so several * accounts may claim one fingerprint; the key material itself lives in each owner's AccountState. */ async function keyCandidates(where: string, committerEmail: string | null, ...params: string[]) { return all( `SELECT i.user_id, i.signing_key_id, i.fingerprint FROM signing_key_ids i JOIN users u ON u.account_id = i.user_id WHERE ${where} ORDER BY ${COMMITTER_MATCH} DESC, i.rowid LIMIT ${MAX_KEY_CANDIDATES}`, ...params, committerEmail ?? "", committerEmail ?? "", ); } async function storedKey(candidate: KeyCandidate, kind: "ssh" | "gpg") { return accountDb(candidate.user_id).first<{ public_key: string }>( "SELECT public_key FROM signing_keys WHERE id = ? AND user_id = ? AND kind = ?", candidate.signing_key_id, candidate.user_id, kind, ); } const lookup: KeyLookup = { async ssh(fingerprint, committerEmail) { const owners = []; const seen = new Set(); for (const row of await keyCandidates( "i.fingerprint = ? AND i.key_id = i.fingerprint", committerEmail, fingerprint, )) { if (seen.has(row.user_id) || !(await storedKey(row, "ssh"))) continue; seen.add(row.user_id); owners.push({ userId: row.user_id, emails: await verifiedEmails(row.user_id) }); } return owners; }, async gpg({ fingerprint, keyId }, committerEmail) { const candidates = await keyCandidates( "(i.fingerprint = ? OR i.key_id = ?)", committerEmail, fingerprint ?? "", keyId ?? "", ); const owners = []; for (const row of candidates) { const stored = await storedKey(row, "gpg"); if (!stored) continue; const keyring = await parsePgpPublicKeys(stored.public_key).catch(() => null); const key = keyring?.signingKeys.find((candidate) => candidate.fingerprint === row.fingerprint); if (key) owners.push({ userId: row.user_id, emails: await verifiedEmails(row.user_id), key, }); } return owners; }, }; /** Drops cached results that depended on this user's identity so they re-verify against current emails. */ export async function invalidateUserSignatures(userId: string): Promise { const keys = [ ...(await accountDb(userId).all<{ fingerprint: string }>( "SELECT fingerprint FROM signing_keys WHERE user_id = ?", userId, )), ...(await all<{ fingerprint: string }>("SELECT fingerprint FROM signing_key_ids WHERE user_id = ?", userId)), ].filter((key, index, list) => list.findIndex((other) => other.fingerprint === key.fingerprint) === index); await batch([ stmt("DELETE FROM commit_signatures WHERE signer_id = ?", userId), ...chunks(keys.map((key) => key.fingerprint)).map((part) => stmt( `DELETE FROM commit_signatures WHERE reason = 'email_mismatch' AND key_fingerprint IN (${placeholders(part.length)})`, ...part, ), ), ]); } /** Applied at read time, so a disabled signer's commits show unverified and come back when the account is re-enabled. */ export function toInfo(row: SignatureRow): CommitSignatureInfo { const disabled = row.signer_disabled_at !== null && row.signer_disabled_at !== undefined; return { sha: row.commit_sha, verified: row.verified === 1 && row.signer_id !== null && !disabled, reason: disabled && row.verified === 1 ? "signer_disabled" : row.reason, kind: row.kind, keyFingerprint: row.key_fingerprint, signer: row.signer_name, }; } /** * Recorded results for commits that exist in `repo`. Results are cached by SHA alone, so the SHAs are first * narrowed to commits present in the authorized repository; probing another repository's SHAs returns nothing. */ export async function loadSignatures( repo: Pick, shas: string[], ): Promise { return loadRecordedSignatures(await commitsInRepo(repo, shas)); } async function loadRecordedSignatures(shas: string[]): Promise { const out: CommitSignatureInfo[] = []; for (const part of chunks([...new Set(shas.filter(isSha))])) { const rows = await all( `SELECT s.commit_sha, s.verified, s.reason, s.kind, s.key_fingerprint, s.signer_id, a.name AS signer_name, a.disabled_at AS signer_disabled_at FROM commit_signatures s LEFT JOIN accounts a ON a.id = s.signer_id WHERE s.commit_sha IN (${placeholders(part.length)})`, ...part, ); out.push(...rows.map(toInfo)); } return out; } /** * Signature results are cached by commit SHA alone, so callers must first narrow requested SHAs to commits * that exist in the repository the viewer is authorized for; otherwise any reader could probe a private * repository's commits by SHA and learn their signers. */ async function commitsInRepo(repo: Pick, shas: string[]): Promise { const unique = [...new Set(shas.filter(isSha))]; if (unique.length === 0) return []; return withStore(repo.artifacts_name, async (store) => { const found = await Promise.all(unique.map(async (sha) => ((await store.commit(sha)) ? sha : null))); return found.filter((sha): sha is string => sha !== null); }); } /** Fetches raw commits through upload-pack and records verification results; callers bound `shas`. */ export async function verifyCommits(repo: Pick, shas: string[]) { const known = new Set((await loadRecordedSignatures(shas)).map((info) => info.sha)); const pending = shas.filter((sha) => isSha(sha) && !known.has(sha)); if (pending.length === 0) return 0; const raws = await fetchRawCommits(repo, pending); const now = Date.now(); const statements: D1PreparedStatement[] = []; for (const [sha, raw] of raws) { const check: SignatureCheck = await checkCommitSignature(raw, lookup); statements.push( stmt( `INSERT OR REPLACE INTO commit_signatures (commit_sha, verified, reason, kind, key_fingerprint, signer_id, verified_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, sha, check.verified ? 1 : 0, check.reason, check.kind, check.keyFingerprint, check.signerId, now, ), ); } await batch(statements); return statements.length; } export type SigningKeyInfo = { id: string; kind: "ssh" | "gpg"; title: string; fingerprint: string; createdAt: number; }; export async function listSigningKeys(userId: string): Promise { const rows = await accountDb(userId).all<{ id: string; kind: "ssh" | "gpg"; title: string; fingerprint: string; created_at: number; }>( "SELECT id, kind, title, fingerprint, created_at FROM signing_keys WHERE user_id = ? ORDER BY created_at DESC", userId, ); return rows.map((row) => ({ ...row, createdAt: row.created_at })); } /** Registers a signing key and clears cached "unknown key"/"email mismatch" results so affected commits re-verify. */ export async function addSigningKey(userId: string, kind: "ssh" | "gpg", title: string, publicKey: string) { if (publicKey.length > MAX_PUBLIC_KEY_BYTES) throw badRequest("Key is too large"); const keys = accountDb(userId); const count = await keys.first<{ count: number }>( "SELECT COUNT(*) AS count FROM signing_keys WHERE user_id = ?", userId, ); assertBelowCap(count?.count ?? 0, MAX_SIGNING_KEYS, "signing keys"); const id = randomId(); const now = Date.now(); let fingerprint: string; let ids: Array<{ fingerprint: string; keyId: string }> = []; try { if (kind === "ssh") fingerprint = (await parseSshPublicKey(publicKey)).fingerprint; else { const keyring = await parsePgpPublicKeys(publicKey); fingerprint = keyring.primary.fingerprint; ids = [keyring.primary, ...keyring.signingKeys] .filter((key, index, list) => list.findIndex((other) => other.fingerprint === key.fingerprint) === index) .map((key) => ({ fingerprint: key.fingerprint, keyId: key.keyId })); } } catch (error) { const ours = error instanceof Error && error.constructor === Error; throw badRequest( ours ? error.message : `That doesn't look like a valid ${kind === "ssh" ? "SSH" : "PGP"} public key`, ); } const existing = await keys.first<{ id: string }>( "SELECT id FROM signing_keys WHERE user_id = ? AND kind = ? AND fingerprint = ?", userId, kind, fingerprint, ); if (existing) throw conflict("You have already registered that key"); const references = kind === "ssh" ? [fingerprint] : ids.flatMap((entry) => [entry.fingerprint, entry.keyId]); await keys.run( "INSERT INTO signing_keys (id, user_id, kind, title, fingerprint, public_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", id, userId, kind, title, fingerprint, publicKey.trim(), now, ); const indexed = kind === "ssh" ? [{ fingerprint, keyId: fingerprint }] : ids; await batch([ ...indexed.map((entry) => stmt( "INSERT OR IGNORE INTO signing_key_ids (key_id, fingerprint, signing_key_id, user_id) VALUES (?, ?, ?, ?)", entry.keyId, entry.fingerprint, id, userId, ), ), ...chunks(references).map((part) => stmt( `DELETE FROM commit_signatures WHERE reason IN ('unknown_key', 'email_mismatch') AND key_fingerprint IN (${placeholders(part.length)})`, ...part, ), ), ]); return { id, fingerprint }; } /** Deleting a key revokes its verified results so badges stop vouching for it. */ export async function deleteSigningKey(userId: string, id: string): Promise { const keys = accountDb(userId); const key = await keys.first<{ fingerprint: string }>( "SELECT fingerprint FROM signing_keys WHERE id = ? AND user_id = ?", id, userId, ); if (!key) return; const subkeys = await all<{ fingerprint: string }>( "SELECT fingerprint FROM signing_key_ids WHERE signing_key_id = ?", id, ); const references = [key.fingerprint, ...subkeys.map((row) => row.fingerprint)]; await keys.batch([scopedStmt("DELETE FROM signing_keys WHERE id = ? AND user_id = ?", id, userId)]); await batch([ ...chunks(references).map((part) => stmt(`DELETE FROM commit_signatures WHERE key_fingerprint IN (${placeholders(part.length)})`, ...part), ), stmt("DELETE FROM signing_key_ids WHERE signing_key_id = ?", id), ]); }