import { fromBase64, toBase64, toHex } from "../crypto"; const ARMOR = /-----BEGIN PGP (PUBLIC KEY BLOCK|SIGNATURE)-----\r?\n([\s\S]*?)-----END PGP \1-----/; const TAG_SIGNATURE = 2; const TAG_PUBLIC_KEY = 6; const TAG_PUBLIC_SUBKEY = 14; const SUBPACKET_ISSUER = 16; const SUBPACKET_ISSUER_FINGERPRINT = 33; const SIG_BINARY = 0x00; const SIG_TEXT = 0x01; const ALGO_RSA = [1, 3]; const ALGO_ECDSA = 19; const ALGO_EDDSA_LEGACY = 22; const ALGO_ED25519 = 27; const ED25519_BYTES = 32; const PGP_V4 = 4; const TWO_OCTET_START = 192; const PARTIAL_START = 224; const FOUR_OCTET = 255; const NATIVE_POINT_PREFIX = 0x40; const UNCOMPRESSED_POINT = 4; const HASHES: Record = { 2: "SHA-1", 8: "SHA-256", 9: "SHA-384", 10: "SHA-512" }; const ED25519_OID = "2b06010401da470f01"; const CURVES: Record = { "2a8648ce3d030107": { curve: "P-256", size: 32 }, "2b81040022": { curve: "P-384", size: 48 }, "2b81040023": { curve: "P-521", size: 66 }, }; type Packet = { tag: number; body: Uint8Array }; export function dearmor(text: string): Uint8Array | null { const match = ARMOR.exec(text.replace(/\r\n/g, "\n")); if (!match) return null; const lines = match[2].split("\n"); const blank = lines.findIndex((line) => line.trim() === ""); const body = (blank === -1 ? lines : lines.slice(blank + 1)).filter((line) => !line.startsWith("=")); return fromBase64(body.join("").replace(/\s+/g, "")); } function readPackets(bytes: Uint8Array): Packet[] { const packets: Packet[] = []; let position = 0; while (position < bytes.byteLength) { const header = bytes[position++]; if (!(header & 0x80)) throw new Error("Invalid OpenPGP packet header"); let tag: number; let length: number; if (header & 0x40) { tag = header & 0x3f; const first = bytes[position++]; if (first < TWO_OCTET_START) length = first; else if (first < PARTIAL_START) length = ((first - TWO_OCTET_START) << 8) + bytes[position++] + TWO_OCTET_START; else if (first === FOUR_OCTET) { length = new DataView(bytes.buffer, bytes.byteOffset + position, 4).getUint32(0); position += 4; } else throw new Error("Partial OpenPGP packet lengths are not supported"); } else { tag = (header >> 2) & 0x0f; const lengthType = header & 0x03; const size = [1, 2, 4][lengthType]; if (!size) throw new Error("Indeterminate OpenPGP packet length"); length = 0; for (let index = 0; index < size; index++) length = length * 256 + bytes[position++]; } if (position + length > bytes.byteLength) throw new Error("Truncated OpenPGP packet"); packets.push({ tag, body: bytes.subarray(position, position + length) }); position += length; } return packets; } function readMpi(bytes: Uint8Array, position: number): { value: Uint8Array; next: number } { const bits = (bytes[position] << 8) | bytes[position + 1]; const length = Math.ceil(bits / 8); return { value: bytes.subarray(position + 2, position + 2 + length), next: position + 2 + length }; } function base64Url(bytes: Uint8Array): string { return toBase64(bytes).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, ""); } function padTo(bytes: Uint8Array, length: number): Uint8Array { let start = 0; while (start < bytes.byteLength - length && bytes[start] === 0) start++; const trimmed = bytes.subarray(start); if (trimmed.byteLength > length) throw new Error("Integer too large"); const out = new Uint8Array(length); out.set(trimmed, length - trimmed.byteLength); return out; } export type PgpKey = { fingerprint: string; keyId: string; algorithm: number; material: { kind: "rsa"; n: Uint8Array; e: Uint8Array } | { kind: "ed25519"; point: Uint8Array } | EcMaterial; }; type EcMaterial = { kind: "ecdsa"; curve: "P-256" | "P-384" | "P-521"; size: number; point: Uint8Array }; async function parseKeyPacket(body: Uint8Array): Promise { if (body[0] !== PGP_V4) return null; const algorithm = body[5]; let material: PgpKey["material"] | null = null; if (ALGO_RSA.includes(algorithm)) { const n = readMpi(body, 6); const e = readMpi(body, n.next); material = { kind: "rsa", n: n.value, e: e.value }; } else if (algorithm === ALGO_EDDSA_LEGACY || algorithm === ALGO_ECDSA) { const oidLength = body[6]; const oid = toHex(body.subarray(7, 7 + oidLength)); const point = readMpi(body, 7 + oidLength).value; if (algorithm === ALGO_EDDSA_LEGACY && oid === ED25519_OID && point[0] === NATIVE_POINT_PREFIX) { material = { kind: "ed25519", point: point.subarray(1) }; } else if (algorithm === ALGO_ECDSA && CURVES[oid] && point[0] === UNCOMPRESSED_POINT) { material = { kind: "ecdsa", ...CURVES[oid], point: point.subarray(1) }; } } else if (algorithm === ALGO_ED25519) { material = { kind: "ed25519", point: body.subarray(6, 6 + ED25519_BYTES) }; } if (!material) return null; const prefixed = new Uint8Array(3 + body.byteLength); prefixed[0] = 0x99; prefixed[1] = (body.byteLength >> 8) & 0xff; prefixed[2] = body.byteLength & 0xff; prefixed.set(body, 3); const fingerprint = toHex(await crypto.subtle.digest("SHA-1", prefixed)).toUpperCase(); return { fingerprint, keyId: fingerprint.slice(-16), algorithm, material }; } /** Parses an armored public key block into its primary key and subkeys. */ export async function parsePgpPublicKeys(armored: string): Promise { const bytes = dearmor(armored); if (!bytes) throw new Error("Expected an armored PGP public key block"); const keys: PgpKey[] = []; for (const packet of readPackets(bytes)) { if (packet.tag !== TAG_PUBLIC_KEY && packet.tag !== TAG_PUBLIC_SUBKEY) continue; const key = await parseKeyPacket(packet.body); if (key) keys.push(key); } if (keys.length === 0) throw new Error("No supported keys found (RSA, Ed25519 and NIST ECDSA are supported)"); return keys; } function subpackets(bytes: Uint8Array): Array<{ type: number; data: Uint8Array }> { const out: Array<{ type: number; data: Uint8Array }> = []; let position = 0; while (position < bytes.byteLength) { const first = bytes[position++]; let length: number; if (first < TWO_OCTET_START) length = first; else if (first < FOUR_OCTET) length = ((first - TWO_OCTET_START) << 8) + bytes[position++] + TWO_OCTET_START; else { length = new DataView(bytes.buffer, bytes.byteOffset + position, 4).getUint32(0); position += 4; } out.push({ type: bytes[position] & 0x7f, data: bytes.subarray(position + 1, position + length) }); position += length; } return out; } export type PgpSignature = { issuerKeyId: string | null; issuerFingerprint: string | null; verify: (key: PgpKey) => Promise; }; function canonicalText(payload: Uint8Array): Uint8Array { const text = new TextDecoder().decode(payload).replace(/\r?\n/g, "\r\n"); return new TextEncoder().encode(text); } async function verifyWithKey( key: PgpKey, hashName: string, data: Uint8Array, mpis: Uint8Array, ): Promise { const material = key.material; if (material.kind === "rsa") { const imported = await crypto.subtle.importKey( "jwk", { kty: "RSA", n: base64Url(material.n), e: base64Url(material.e), ext: true }, { name: "RSASSA-PKCS1-v1_5", hash: hashName }, false, ["verify"], ); const signature = padTo(readMpi(mpis, 0).value, material.n.byteLength); return crypto.subtle.verify({ name: "RSASSA-PKCS1-v1_5" }, imported, new Uint8Array(signature), data); } if (material.kind === "ed25519") { const imported = await crypto.subtle.importKey("raw", new Uint8Array(material.point), { name: "Ed25519" }, false, [ "verify", ]); const signature = key.algorithm === ALGO_ED25519 ? new Uint8Array(mpis.subarray(0, 2 * ED25519_BYTES)) : (() => { const r = readMpi(mpis, 0); const s = readMpi(mpis, r.next); const out = new Uint8Array(2 * ED25519_BYTES); out.set(padTo(r.value, ED25519_BYTES)); out.set(padTo(s.value, ED25519_BYTES), ED25519_BYTES); return out; })(); const digest = await crypto.subtle.digest(hashName, data); return crypto.subtle.verify({ name: "Ed25519" }, imported, signature, digest); } const imported = await crypto.subtle.importKey( "jwk", { kty: "EC", crv: material.curve, x: base64Url(material.point.subarray(0, material.size)), y: base64Url(material.point.subarray(material.size)), ext: true, }, { name: "ECDSA", namedCurve: material.curve }, false, ["verify"], ); const r = readMpi(mpis, 0); const s = readMpi(mpis, r.next); const signature = new Uint8Array(2 * material.size); signature.set(padTo(r.value, material.size)); signature.set(padTo(s.value, material.size), material.size); return crypto.subtle.verify({ name: "ECDSA", hash: hashName }, imported, signature, data); } /** Parses an armored detached v4 signature over `payload`. */ export function parsePgpSignature(armored: string, payload: Uint8Array): PgpSignature | null { const bytes = dearmor(armored); if (!bytes) return null; const packet = readPackets(bytes).find((candidate) => candidate.tag === TAG_SIGNATURE); if (!packet || packet.body[0] !== PGP_V4) return null; const body = packet.body; const signatureType = body[1]; const algorithm = body[2]; const hashName = HASHES[body[3]]; const hashedLength = (body[4] << 8) | body[5]; const hashedEnd = 6 + hashedLength; const unhashedLength = (body[hashedEnd] << 8) | body[hashedEnd + 1]; const unhashedEnd = hashedEnd + 2 + unhashedLength; const all = [...subpackets(body.subarray(6, hashedEnd)), ...subpackets(body.subarray(hashedEnd + 2, unhashedEnd))]; const issuer = all.find((item) => item.type === SUBPACKET_ISSUER); const issuerFingerprint = all.find((item) => item.type === SUBPACKET_ISSUER_FINGERPRINT); const mpis = body.subarray(unhashedEnd + 2); return { issuerKeyId: issuer ? toHex(issuer.data).toUpperCase() : null, issuerFingerprint: issuerFingerprint ? toHex(issuerFingerprint.data.subarray(1)).toUpperCase() : null, verify: async (key) => { if (!hashName || key.algorithm !== algorithm) return false; if (signatureType !== SIG_BINARY && signatureType !== SIG_TEXT) return false; const message = signatureType === SIG_TEXT ? canonicalText(payload) : payload; const trailer = new Uint8Array(6); trailer[0] = 4; trailer[1] = 0xff; new DataView(trailer.buffer).setUint32(2, hashedEnd); const data = new Uint8Array(message.byteLength + hashedEnd + trailer.byteLength); data.set(message); data.set(body.subarray(0, hashedEnd), message.byteLength); data.set(trailer, message.byteLength + hashedEnd); try { return await verifyWithKey(key, hashName, data, mpis); } catch { return false; } }, }; }