summaryrefslogtreecommitdiff
path: root/src/lib/pgp/verify.ts
diff options
context:
space:
mode:
authorJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:08:41 -0300
committerJoão Augusto Costa Branco Marado Torres <torres.dev@disroot.org>2025-06-24 12:50:43 -0300
commitf9a77c5c27aede4e5978eb55d9b7af781b680a1d (patch)
treed545e325ba1ae756fc2eac66fac1001b6753c40d /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.ts349
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]);
+ });
+ }
+ });
+}