summaryrefslogtreecommitdiff
path: root/src/lib/pgp/verify.ts
blob: 026b6df99763db30501524091af215ebf36a3a1d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
import {
  createMessage,
  PublicKey,
  readSignature,
  type Subkey,
  type 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 { get, instanciate } from "../../utils/anonymous.ts";
import { Packet, Signature } from "./sign.ts";
import type { Commit } from "../git/types.ts";
import { findMapAsync, type MaybeIterable } from "../../utils/iterator.ts";
import { getUserIDsFromKey } from "./user.ts";
import { env } from "../environment.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(env.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();

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]);
      });
    }
  });
}