diff options
author | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:08:41 -0300 |
---|---|---|
committer | João Augusto Costa Branco Marado Torres <torres.dev@disroot.org> | 2025-06-24 12:50:43 -0300 |
commit | f9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch) | |
tree | d545e325ba1ae756fc2eac66fac1001b6753c40d /src/lib/pgp/verify.ts |
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src/lib/pgp/verify.ts')
-rw-r--r-- | src/lib/pgp/verify.ts | 349 |
1 files changed, 349 insertions, 0 deletions
diff --git a/src/lib/pgp/verify.ts b/src/lib/pgp/verify.ts new file mode 100644 index 0000000..da2de7f --- /dev/null +++ b/src/lib/pgp/verify.ts @@ -0,0 +1,349 @@ +import { + createMessage, + PublicKey, + readSignature, + type Subkey, + UserIDPacket, + verify, +} from "openpgp"; +import { + armored, + binary, + createKeyFromArmor, + createKeyFromBinary, + createKeyFromFile, + createKeysFromDir, + DEFAULT_KEY_DISCOVERY_RULES, + type KeyDiscoveryRules, + type KeyFileFormat, +} from "./create.ts"; +import { getLastCommitForOneOfFiles } from "../git/log.ts"; +import { defined, get, instanciate } from "../../utils/anonymous.ts"; +import { Packet, Signature } from "./sign.ts"; +import type { Commit } from "../git/types.ts"; +import { TRUSTED_KEYS_DIR } from "../../consts.ts"; +import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts"; + +type DataURL = [URL, URL?]; +type Corrupted = [false] | [true, Error]; + +export interface Verification { + data: Uint8Array<ArrayBufferLike>; + dataCorrupted?: Promise<Corrupted>; + signatureCorrupted?: Corrupted; + signature?: Signature; + verifications?: { + key: Promise<PublicKey | Subkey | undefined>; + keyID: Awaited<ReturnType<typeof verify>>["signatures"][number]["keyID"]; + userID: Promise<UserIDPacket[] | undefined>; + packet: Promise<Packet>; + signatureCorrupted: Promise<Corrupted>; + verified: Promise<boolean>; + }[]; + commit: Promise<Commit | undefined>; +} + +export class SignatureVerifier { + static #instance: SignatureVerifier; + private keys!: PublicKey[]; + #encoder!: TextEncoder; + #decoder!: TextDecoder; + + constructor() { + this.keys = []; + this.#encoder = new TextEncoder(); + this.#decoder = new TextDecoder(); + } + + /** + * Let's test all the possible outcome situations that can happened when + * verifying a signature of a file. A signature verification needs the message, + * the signature (detached) and the public keys. + * + * **Possible verification outcomes** + * + * Legend: + * + * - "X" → This condition is definitely true for the outcome. + * - "-" → This condition is not applicable or irrelevant. + * - "?" → This condition may or may not be true; the outcome doesn't guarantee it. + * + * | Outcome Description | Data Exists | Data Corrupted | Signature Exists | Signature Corrupted/Malformed | Public Key Available | Public Key is Signing Key | Public Key Expired Before Signature | Public Key Expired After Signature | Public Key Revoked Before Signature | Public Key Revoked After Signature | Public Key Ultimately Trusted | GPG/OpenPGP Status Output | Notes | + * | ------------------------------------------------------------------------------- | :---------: | :------------: | :--------------: | :---------------------------: | :------------------: | :-----------------------: | :---------------------------------: | :--------------------------------: | :---------------------------------: | :--------------------------------: | :---------------------------: | :------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | + * | **No signature found** | X | ? | | | | | - | - | - | - | | (No status) | No signature file provided or found. Data state is independent of this. | + * | **Signature cannot be checked (e.g., missing key, GPG error)** | X | ? | X | ? | | | - | - | - | - | ? | `E` | Verification failed before key or validity checks could be performed. Can be missing key, corrupted signature *format*, or GPG issue. | + * | **Bad signature** | X | X | X | | X | X | ? | ? | ? | ? | ? | `B` | The signature does not match the data, usually due to data corruption or a manipulated signature. Key status is irrelevant to the mismatch itself. | + * | **Good signature, unknown validity** | X | | X | | X | X | | | | | | `U` | Signature is cryptographically valid, key is available and is a signing key, but OpenPGP.js/GPG cannot determine the trust or validity of the key or signature attributes. | + * | **Good signature** | X | | X | | X | X | | | | | X | `G` | The signature is cryptographically valid, the key is available, is a signing key, and is ultimately trusted in the local keyring. | + * | **Good signature by an untrusted key** | X | | X | | X | X | | | | | | `G` (often with trust warning) | The signature is cryptographically valid, key is available and signing key, but not ultimately trusted. GPG might still report `G`. | + * | **Good signature, key expired *after* signature time** | X | | X | | X | X | | X | | | ? | `X` | The signature was valid at the time of signing, but the key's validity period has since passed. | + * | **Good signature, key expired *before* signature time** | X | | X | | X | X | X | | | | ? | `Y` | The signature was created *after* the key's validity period had passed. This signature is typically considered invalid. | + * | **Good signature, key revoked *after* signature time** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was valid at the time of signing, but the key has since been revoked. | + * | **Good signature, key revoked *before* signature time** | X | | X | | X | X | ? | ? | X | | ? | `Y` (often, similar to expired before) | The signature was created *after* the key had been revoked. This signature is typically considered invalid. | + * | **Signature cannot be checked (Public key available but not signing)** | X | ? | X | | X | | ? | ? | ? | ? | ? | `E` (or possibly `B`) | The key required for verification is found, but it does not have the 'sign' usage flag, making verification impossible with this key. | + * | **Good signature, made by an expired signing subkey (primary key not expired)** | X | | X | | X | X | | X | | | ? | `X` | The signature was made by a subkey that expired *after* the signature time. The primary key might still be valid. | + * | **Good signature, made by a revoked signing subkey (primary key not revoked)** | X | | X | | X | X | ? | ? | | X | ? | `R` | The signature was made by a subkey that was revoked *after* the signature time. The primary key might still be valid. | + * | **Good signature, made by a signing subkey expired *before* signature** | X | | X | | X | X | X | | | | ? | `Y` | The signature was made by a subkey that was expired *before* the signature time. | + * | **Good signature, made by a signing subkey revoked *before* signature** | X | | X | | X | X | ? | ? | X | | ? | `Y` | The signature was made by a subkey that was revoked *before* the signature time. | + * + * | Outcome Description (Combined Statuses) | Data Exists | Data Corrupted | Signature(s) Exist | At least one Signature Corrupted/Malformed | At least one Public Key Available | At least one Public Key is Signing Key | All Keys Good/Trusted? | Notes | + * |---------------------------------------------------------------------------|-------------|----------------|--------------------|--------------------------------------------|-----------------------------------|----------------------------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| + * | **No signature found** | X | ? | | | | | - | No signature file(s) provided or found. | + * | **At least one signature cannot be checked (`E`), others unknown/not checked** | X | ? | X | ? | ? | ? | ? | One or more signatures failed verification before a status could be determined (missing key, GPG issue, etc.). Other signatures' statuses might be pending or unknown. | + * | **All signatures are Bad (`B`)** | X | X | X | | X | X | ? | All provided signatures failed to match the data. Often due to data corruption or tampered signatures. | + * | **Some signatures are Good (`G`), others are Bad (`B`)** | X | ? | X | | X | X | ? | At least one valid signature found, but also invalid ones. Indicates the file was signed correctly by some, but perhaps tampered with later or signed incorrectly. | + * | **All signatures are Good (`G`)** | X | | X | | X | X | X | All provided signatures are cryptographically valid and from ultimately trusted keys. This is a strong indicator of data integrity and origin. | + * | **Some signatures are Good (`G`), others Unknown Validity (`U`)** | X | ? | X | | X | X | ? | Some valid signatures, others valid but trust/validity could not be fully determined. | + * | **All signatures Unknown Validity (`U`)** | X | ? | X | | X | X | | All provided signatures are cryptographically valid, but the validity or trust of the signing keys could not be determined for any of them. | + * | **At least one Good signature (`G`), with others having Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | ? | At least one valid and potentially trusted signature exists, but others are from expired or revoked keys. Indicates multiple signers, some with key lifecycle issues. | + * | **All signatures have Key Status issues (`X`, `Y`, `R`)** | X | ? | X | | X | X | | All provided signatures are from keys that are expired or revoked. The data integrity might be verifiable for the time of signing, but the signers' keys are compromised or outdated. | + * | **Combination of Bad (`B`), Unknown (`U`), and Key Status issues (`X`, `Y`, `R`)** | X | ? | X | ? | X | X | ? | A complex mix of verification outcomes for multiple signatures. Requires examining each individual signature's status to understand the situation fully. | + * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys not available** | X | ? | X | | ? | ? | ? | Some signatures could be verified because their keys were available, but others could not be fully checked because their corresponding keys were missing. | + * | **At least one signature is valid (`G`, `U`, `X`, `R`), but some Public Keys available but not Signing Keys** | X | ? | X | | X | ? | ? | Keys were found for some signatures, allowing some level of verification, but for others, the found key did not have the signing capability. | + */ + async verify( + data: DataURL, + type: KeyFileFormat = binary, + ): Promise<Verification> { + // will throw if the file doesn't exist, not a file, ... + // we need data. + const dataBinary = await Deno.readFile(data[0], {}); + + const signatureURL = new URL( + data[1] ?? `${data[0].href}.${type === binary ? "sig" : "asc"}`, + ); + const signatureData = + await (type === binary + ? Deno.readFile(signatureURL) + : Deno.readTextFile(signatureURL)).catch(() => undefined); + + let signature: Signature | undefined; + let signatureCorrupted: Corrupted | undefined = undefined; + if (signatureData !== undefined) { + try { + signature = new Signature( + await (typeof signatureData === "string" + ? readSignature({ armoredSignature: signatureData }) + : readSignature({ binarySignature: signatureData })), + ); + signatureCorrupted = [false]; + } catch (e) { + if ( + !(e instanceof Error && + [ + "Error during parsing", + "Packet not allowed in this context", + "Unexpected end of packet", + ].some( + (x) => e.message.startsWith(x), + )) + ) { + throw e; + } + signatureCorrupted = [true, e]; + } + } + + const commit = signature !== undefined + ? getLastCommitForOneOfFiles([data[0], signatureURL]) + : Promise.resolve(undefined); + + const verification: Verification = { + data: dataBinary, + signature, + signatureCorrupted, + commit, + }; + + if (dataBinary === undefined || signature === undefined) { + return verification; + } + + const message = await createMessage({ binary: dataBinary }); + + const verificationResult = await verify({ + message, + signature: signature?.inner, + verificationKeys: this.keys, + format: "binary", + }); + + verification.verifications = verificationResult.signatures.map( + ({ verified, keyID, signature: sig }) => { + const key = findMapAsync(this.keys, (x) => x.getSigningKey(keyID)); + const packet = sig.then((x) => x.packets[0]).then(instanciate(Packet)); + const userID = key.then((key) => + key ? getUserIDsFromKey(signature, key) : undefined + ); + const signatureCorrupted = isSignatureCorrupted(verified); + return { key, keyID, userID, packet, signatureCorrupted, verified }; + }, + ); + + verification.dataCorrupted = isDataCorrupted(verification.verifications); + + return verification; + } + + async *verifyMultiple( + data: Iterable<DataURL>, + type: KeyFileFormat = binary, + ): AsyncGenerator<Verification, void, void> { + for (const i of data) { + yield this.verify(i, type); + } + } + + addKey(key: MaybeIterable<PublicKey>): void { + if (key instanceof PublicKey) { + this.keys.push(key); + } else { + this.keys.push(...key); + } + } + + async addKeysFromDir( + key: string | URL, + rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES, + ): Promise<void> { + for await ( + const i of createKeysFromDir(key, rules, { + encoder: this.#encoder, + decoder: this.#decoder, + }) + ) { + this.keys.push(i); + } + } + + async addKeyFromFile( + key: string | URL, + type: KeyFileFormat, + ): Promise<void> { + switch (type) { + case armored: { + this.keys.push(await createKeyFromFile(key, type, this.#decoder)); + break; + } + case binary: { + this.keys.push(await createKeyFromFile(key, type, this.#encoder)); + break; + } + } + } + + async addKeyFromArmor( + key: string | Uint8Array, + ): Promise<void> { + this.keys.push( + await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()), + ); + } + + async addKeyFromBinary( + key: string | Uint8Array, + ): Promise<void> { + this.keys.push( + await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()), + ); + } + + public static async instance(): Promise<SignatureVerifier> { + if (!SignatureVerifier.#instance) { + SignatureVerifier.#instance = new SignatureVerifier(); + await SignatureVerifier.#instance.addKeysFromDir(TRUSTED_KEYS_DIR); + } + + return SignatureVerifier.#instance; + } + + public clone(): this { + const clone = new SignatureVerifier(); + + clone.keys = Object.create(this.keys); + // clone.#decoder = Object.create(this.#decoder); + // clone.#encoder = Object.create(this.#encoder); + + return clone as this; + } +} + +export const verifier = SignatureVerifier.instance(); + +function getUserIDsFromKey( + signature: Signature, + key: PublicKey | Subkey, +): UserIDPacket[] { + const packet = signature.getPackets()[0]; + const userID = packet.signersUserID; + + if (userID) { + return [UserIDPacket.fromObject(parseUserID(userID))]; + } + + key = key instanceof PublicKey ? key : key.mainKey; + return key.users.map(get("userID")).filter(defined); +} + +function parseUserID(input: string) { + const regex = /^(.*?)\s*(?:\((.*?)\))?\s*(?:<(.+?)>)?$/; + const match = input.match(regex); + + if (!match) return {}; + + const [, name, comment, email] = match; + + return { + name: name?.trim() || undefined, + comment: comment?.trim() || undefined, + email: email?.trim() || undefined, + }; +} + +async function isSignatureCorrupted( + verified: Awaited< + ReturnType<typeof verify> + >["signatures"][number]["verified"], +): Promise<Corrupted> { + return await verified.then(() => [false] as Corrupted).catch( + (e) => { + if (e instanceof Error) { + if ( + [ + "Could not find signing key with key ID", + "Signed digest did not match", + ].some((x) => e.message.startsWith(x)) + ) { + return [false]; + } + + return [true, e]; + } + throw e; + }, + ); +} + +function isDataCorrupted( + verifications: Verification["verifications"], +): Promise<Corrupted> { + return new Promise<Corrupted>((resolve) => { + if (verifications === undefined) { + resolve([false]); + } else { + Promise.all(verifications.map(get("verified"))).then( + () => resolve([false]), + ).catch((e) => { + if (e instanceof Error) { + if ( + e.message.startsWith("Signed digest did not match") + ) { + resolve([true, e]); + } + } + + resolve([false]); + }); + } + }); +} |