import { ripemd160, sha1 } from "@noble/hashes/legacy.js"; import { sha224, sha256, sha384, sha512 } from "@noble/hashes/sha2.js"; // Checks that a pasted detached signature was made over a given message, // without verifying it: every OpenPGP signature packet carries the first two // bytes of its digest, so recomputing that digest over the message tells a // signature for this message from one for another (1 in 65536 slips through). // Keycloak's GPG verifier still does the real check. const BEGIN = "-----BEGIN PGP SIGNATURE-----"; const END = "-----END PGP SIGNATURE-----"; const hashes: Record Uint8Array> = { 2: sha1, 3: ripemd160, 8: sha256, 9: sha384, 10: sha512, 11: sha224, }; /** The binary packets inside the first armored PGP SIGNATURE block of `text`. */ function dearmor(text: string): Uint8Array { const start = text.indexOf(BEGIN); const end = text.indexOf(END, start); if (start < 0 || end < 0) throw new Error("No PGP SIGNATURE block in the paste"); const lines = text .slice(start + BEGIN.length, end) .split(/\r?\n/) .map((line) => line.trim()); const blank = lines.indexOf("", 1); const hasHeaders = lines.slice(1, blank < 0 ? 0 : blank).some((line) => line.includes(": ")); const body = (hasHeaders ? lines.slice(blank) : lines).filter( (line) => line && !/^=[A-Za-z0-9+/]{4}$/.test(line), ); const binary = atob(body.join("")); return Uint8Array.from(binary, (c) => c.charCodeAt(0)); } /** Signature packet bodies (tag 2), in old or new packet format. */ function signaturePackets(bytes: Uint8Array): Uint8Array[] { const packets: Uint8Array[] = []; let at = 0; while (at < bytes.length) { const ctb = bytes[at++]; if (!(ctb & 0x80)) throw new Error("The PGP SIGNATURE block is not OpenPGP data"); let tag: number; let length: number; if (ctb & 0x40) { tag = ctb & 0x3f; const first = bytes[at++]; if (first < 192) length = first; else if (first < 224) length = ((first - 192) << 8) + bytes[at++] + 192; else if (first === 255) { length = new DataView(bytes.buffer, bytes.byteOffset + at, 4).getUint32(0); at += 4; } else throw new Error("Partial-length PGP packets are not a detached signature"); } else { tag = (ctb >> 2) & 0x0f; const size = [1, 2, 4][ctb & 0x03]; if (size === undefined) throw new Error("Indeterminate-length PGP packets are not supported"); length = 0; for (let i = 0; i < size; i++) length = length * 256 + bytes[at++]; } if (at + length > bytes.length) throw new Error("The PGP SIGNATURE block is truncated"); if (tag === 2) packets.push(bytes.subarray(at, at + length)); at += length; } return packets; } /** The first two digest bytes of a v4 signature packet recomputed over `message`. */ function quickCheck(packet: Uint8Array, message: Uint8Array): [expected: number, actual: number] { if (packet[0] !== 4) { throw new Error( `A version ${packet[0]} PGP signature cannot be checked here; sign with a v4 key`, ); } const sigType = packet[1]; if (sigType !== 0x00 && sigType !== 0x01) { throw new Error(`PGP signature type 0x${sigType.toString(16)} is not a document signature`); } const hash = hashes[packet[3]]; if (!hash) throw new Error(`PGP hash algorithm ${packet[3]} is not supported`); const hashedEnd = 6 + ((packet[4] << 8) | packet[5]); const unhashedLength = (packet[hashedEnd] << 8) | packet[hashedEnd + 1]; const left16 = hashedEnd + 2 + unhashedLength; if (left16 + 2 > packet.length) throw new Error("The PGP signature packet is truncated"); const data = sigType === 0x01 ? new TextEncoder().encode( new TextDecoder().decode(message).replace(/\r?\n/g, "\n").replace(/\n/g, "\r\n"), ) : message; const trailer = new Uint8Array(6); trailer[0] = 0x04; trailer[1] = 0xff; new DataView(trailer.buffer).setUint32(2, hashedEnd); const input = new Uint8Array(data.length + hashedEnd + trailer.length); input.set(data, 0); input.set(packet.subarray(0, hashedEnd), data.length); input.set(trailer, data.length + hashedEnd); const digest = hash(input); return [(packet[left16] << 8) | packet[left16 + 1], (digest[0] << 8) | digest[1]]; } /** * True when every signature in the pasted armored PGP SIGNATURE block was made * over `message`'s UTF-8 bytes. Throws on a block it cannot read. */ export function gpgSignatureCovers(blob: string, message: string): boolean { const packets = signaturePackets(dearmor(blob)); if (!packets.length) throw new Error("The PGP SIGNATURE block holds no signature"); const bytes = new TextEncoder().encode(message); return packets.every((packet) => { const [expected, actual] = quickCheck(packet, bytes); return expected === actual; }); }