import { fromBase64, toBase64, toHex } from "../crypto"; const ARMOR = /-----BEGIN PGP (PUBLIC KEY BLOCK|SIGNATURE)-----\r?\n([\s\S]*?)-----END PGP \1-----/; export const TAG_SIGNATURE = 2; export const TAG_PUBLIC_KEY = 6; export const TAG_PUBLIC_SUBKEY = 14; const SUBPACKET_CREATED = 2; const SUBPACKET_SIGNATURE_EXPIRES = 3; const SUBPACKET_KEY_EXPIRES = 9; const SUBPACKET_ISSUER = 16; const SUBPACKET_KEY_FLAGS = 27; const SUBPACKET_ISSUER_FINGERPRINT = 33; const SIG_BINARY = 0x00; const SIG_TEXT = 0x01; const SECOND_MS = 1000; const KEY_PREFIX = 0x99; const USER_ID_PREFIX = 0xb4; const UINT32_BYTES = 4; const MIN_SIGNATURE_BYTES = 8; 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 SHA1_ID = 2; const HASHES: Record = { 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, "")); } export 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; createdAt: number; expiresAt: number | null; 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; }; export 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 fingerprint = toHex(await crypto.subtle.digest("SHA-1", keyPrefix(body))).toUpperCase(); const createdAt = (uint32(body.subarray(1, 5)) ?? 0) * SECOND_MS; return { fingerprint, keyId: fingerprint.slice(-16), algorithm, createdAt, expiresAt: null, material, }; } function uint32(data: Uint8Array): number | null { return data.byteLength >= UINT32_BYTES ? new DataView(data.buffer, data.byteOffset, UINT32_BYTES).getUint32(0) : null; } export function keyPrefix(body: Uint8Array): Uint8Array { const out = new Uint8Array(3 + body.byteLength); out[0] = KEY_PREFIX; out[1] = (body.byteLength >> 8) & 0xff; out[2] = body.byteLength & 0xff; out.set(body, 3); return out; } export function userIdPrefix(body: Uint8Array): Uint8Array { const out = new Uint8Array(5 + body.byteLength); out[0] = USER_ID_PREFIX; new DataView(out.buffer).setUint32(1, body.byteLength); out.set(body, 5); return out; } export function concat(...parts: Uint8Array[]): Uint8Array { const out = new Uint8Array(parts.reduce((total, part) => total + part.byteLength, 0)); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.byteLength; } return out; } type Subpacket = { type: number; data: Uint8Array }; export type ParsedSignature = { type: number; algorithm: number; hashName: string | undefined; hashId: number; hashedEnd: number; body: Uint8Array; hashed: Subpacket[]; unhashed: Subpacket[]; mpis: Uint8Array; createdAt: number | null; }; export function parseSignaturePacket(body: Uint8Array): ParsedSignature | null { if (body.byteLength < MIN_SIGNATURE_BYTES || body[0] !== PGP_V4) return null; const hashedEnd = 6 + ((body[4] << 8) | body[5]); if (hashedEnd + 2 > body.byteLength) return null; const unhashedEnd = hashedEnd + 2 + ((body[hashedEnd] << 8) | body[hashedEnd + 1]); if (unhashedEnd + 2 > body.byteLength) return null; const hashed = subpackets(body.subarray(6, hashedEnd)); const created = hashed.find((item) => item.type === SUBPACKET_CREATED); const createdSeconds = created ? uint32(created.data) : null; return { type: body[1], algorithm: body[2], hashName: HASHES[body[3]], hashId: body[3], hashedEnd, body, hashed, unhashed: subpackets(body.subarray(hashedEnd + 2, unhashedEnd)), mpis: body.subarray(unhashedEnd + 2), createdAt: createdSeconds === null ? null : createdSeconds * SECOND_MS, }; } function hashedSeconds(signature: ParsedSignature, type: number): number | null { const item = signature.hashed.find((candidate) => candidate.type === type); return item ? uint32(item.data) : null; } export function keyFlags(signature: ParsedSignature): number | null { const item = signature.hashed.find((candidate) => candidate.type === SUBPACKET_KEY_FLAGS); return item && item.data.byteLength > 0 ? item.data[0] : null; } /** Earliest of the key's own expiry and the certifying signature's expiry. */ export function keyExpiry(key: PgpKey, signature: ParsedSignature): number | null { const keySeconds = hashedSeconds(signature, SUBPACKET_KEY_EXPIRES); const signatureSeconds = hashedSeconds(signature, SUBPACKET_SIGNATURE_EXPIRES); const limits = [ keySeconds ? key.createdAt + keySeconds * SECOND_MS : null, signatureSeconds && signature.createdAt !== null ? signature.createdAt + signatureSeconds * SECOND_MS : null, ].filter((limit): limit is number => limit !== null); return limits.length > 0 ? Math.min(...limits) : null; } /** SHA-1 is only honoured for revocations, where accepting a forged one can only disable a key. */ export async function verifySignature( signature: ParsedSignature, key: PgpKey, message: Uint8Array, allowSha1 = false, ): Promise { const hashName = signature.hashName ?? (allowSha1 && signature.hashId === SHA1_ID ? "SHA-1" : undefined); if (!hashName || signature.createdAt === null || key.algorithm !== signature.algorithm) return false; const trailer = new Uint8Array(6); trailer[0] = PGP_V4; trailer[1] = 0xff; new DataView(trailer.buffer).setUint32(2, signature.hashedEnd); const data = concat(message, signature.body.subarray(0, signature.hashedEnd), trailer); try { return await verifyWithKey(key, hashName, data, signature.mpis); } catch { return false; } } export async function anyVerifies( signatures: ParsedSignature[], key: PgpKey, message: Uint8Array, allowSha1 = false, ): Promise { for (const signature of signatures) if (await verifySignature(signature, key, message, allowSha1)) return true; return false; } export function latest(signatures: ParsedSignature[]): ParsedSignature | null { return signatures.reduce( (best, candidate) => (!best || (candidate.createdAt ?? 0) > (best.createdAt ?? 0) ? candidate : best), null, ); } function subpackets(bytes: Uint8Array): Subpacket[] { const out: Subpacket[] = []; 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; }; const CR = 0x0d; const LF = 0x0a; function canonicalText(payload: Uint8Array): Uint8Array { const out: number[] = []; for (let index = 0; index < payload.byteLength; index++) { const byte = payload[index]; if (byte === LF && payload[index - 1] !== CR) out.push(CR); out.push(byte); } return new Uint8Array(out); } 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 packets = readPackets(bytes).filter((candidate) => candidate.tag === TAG_SIGNATURE); if (packets.length !== 1) return null; const signature = parseSignaturePacket(packets[0].body); if (!signature) return null; const all = [...signature.hashed, ...signature.unhashed]; const issuer = all.find((item) => item.type === SUBPACKET_ISSUER); const issuerFingerprint = all.find((item) => item.type === SUBPACKET_ISSUER_FINGERPRINT); return { issuerKeyId: issuer ? toHex(issuer.data).toUpperCase() : null, issuerFingerprint: issuerFingerprint ? toHex(issuerFingerprint.data.subarray(1)).toUpperCase() : null, verify: async (key) => { if (signature.type !== SIG_BINARY && signature.type !== SIG_TEXT) return false; const createdAt = signature.createdAt; if (createdAt === null || createdAt < key.createdAt) return false; if (key.expiresAt !== null && createdAt >= key.expiresAt) return false; const lifetime = hashedSeconds(signature, SUBPACKET_SIGNATURE_EXPIRES); if (lifetime && createdAt + lifetime * SECOND_MS <= Date.now()) return false; const message = signature.type === SIG_TEXT ? canonicalText(payload) : payload; return verifySignature(signature, key, message); }, }; }