summaryrefslogtreecommitdiff
path: root/src/lib
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
feat!: initial commit
Signed-off-by: João Augusto Costa Branco Marado Torres <torres.dev@disroot.org>
Diffstat (limited to 'src/lib')
-rw-r--r--src/lib/git/index.test.ts40
-rw-r--r--src/lib/git/index.ts16
-rw-r--r--src/lib/git/log.test.ts71
-rw-r--r--src/lib/git/log.ts131
-rw-r--r--src/lib/git/types.ts27
-rw-r--r--src/lib/pgp/create.test.ts130
-rw-r--r--src/lib/pgp/create.ts183
-rw-r--r--src/lib/pgp/index.ts63
-rw-r--r--src/lib/pgp/sign.test.ts121
-rw-r--r--src/lib/pgp/sign.ts82
-rw-r--r--src/lib/pgp/summary.ts232
-rw-r--r--src/lib/pgp/trust.ts19
-rw-r--r--src/lib/pgp/verify.test.ts619
-rw-r--r--src/lib/pgp/verify.ts349
14 files changed, 2083 insertions, 0 deletions
diff --git a/src/lib/git/index.test.ts b/src/lib/git/index.test.ts
new file mode 100644
index 0000000..4eedaca
--- /dev/null
+++ b/src/lib/git/index.test.ts
@@ -0,0 +1,40 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+
+// IMPORTANT: Delay the import of `gitDir` to after the stub
+let gitDir: typeof import("./index.ts").gitDir;
+
+describe("gitDir", () => {
+ it("resolves with trimmed decoded stdout", async () => {
+ const encoded = new TextEncoder().encode(
+ " /home/user/project \n",
+ ) as Uint8Array<ArrayBuffer>;
+ const fakeOutput = Promise.resolve({
+ success: true,
+ code: 0,
+ stdout: encoded,
+ stderr: new Uint8Array(),
+ signal: null,
+ });
+
+ using outputStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([fakeOutput]),
+ );
+
+ // Now import gitDir AFTER stubbing
+ ({ gitDir } = await import("./index.ts"));
+
+ const result = await gitDir();
+ assertEquals(result.pathname, "/home/user/project");
+ assertSpyCall(outputStub, 0, { args: [], returned: fakeOutput });
+ assertSpyCalls(outputStub, 1);
+ });
+});
diff --git a/src/lib/git/index.ts b/src/lib/git/index.ts
new file mode 100644
index 0000000..23a13eb
--- /dev/null
+++ b/src/lib/git/index.ts
@@ -0,0 +1,16 @@
+import { get, instanciate } from "../../utils/anonymous.ts";
+
+let cachedGitDir: Promise<URL> | undefined;
+
+export function gitDir(): Promise<URL> {
+ if (!cachedGitDir) {
+ cachedGitDir = new Deno.Command("git", {
+ args: ["rev-parse", "--show-toplevel"],
+ }).output()
+ .then(get("stdout"))
+ .then((x) => `file://${new TextDecoder().decode(x).trim()}/`)
+ .then(instanciate(URL));
+ }
+
+ return cachedGitDir;
+}
diff --git a/src/lib/git/log.test.ts b/src/lib/git/log.test.ts
new file mode 100644
index 0000000..09acb1c
--- /dev/null
+++ b/src/lib/git/log.test.ts
@@ -0,0 +1,71 @@
+import { describe, it } from "@std/testing/bdd";
+import { assertEquals, assertExists } from "@std/assert";
+import {
+ assertSpyCall,
+ assertSpyCalls,
+ returnsNext,
+ stub,
+} from "@std/testing/mock";
+import { getLastCommitForOneOfFiles } from "./log.ts";
+import {
+ emptyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitDir,
+ gitLogPrettyCommandOutput,
+ gitRevParseCommandOutput,
+} from "../../../tests/fixtures/test_data.ts";
+
+describe("getLastCommitForOneOfFiles", () => {
+ it("returns parsed commit with signature and file info", async () => {
+ const outputs = [
+ gitLogPrettyCommandOutput,
+ gitDiffTreeCommandOutput,
+ gitRevParseCommandOutput,
+ ];
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext(outputs),
+ );
+
+ const file = new URL("file.ts", gitDir);
+ const result = await getLastCommitForOneOfFiles(file);
+
+ assertExists(result);
+ assertEquals(result.hash.short, "abcdef1");
+ assertEquals(result.hash.long, "abcdef1234567890abcdef1234567890abcdef12");
+
+ assertEquals(result.author.name, "Alice");
+ assertEquals(result.committer.email, "bob@example.com");
+
+ assertEquals(result.files.length, 1);
+ assertEquals(result.files[0], {
+ path: file,
+ status: "modified",
+ });
+
+ assertEquals(result.signature?.type, "gpg");
+ assertEquals(result.signature?.signer, "bob@example.com");
+
+ for (let i = 0; i < outputs.length; i++) {
+ assertSpyCall(logStub, i, { args: [], returned: outputs[i] });
+ }
+ assertSpyCalls(logStub, outputs.length);
+ });
+
+ it("returns undefined for empty commit output", async () => {
+ using logStub = stub(
+ Deno.Command.prototype,
+ "output",
+ returnsNext([emptyCommandOutput]),
+ );
+
+ const result = await getLastCommitForOneOfFiles(
+ [new URL("nonexistent.ts", gitDir)],
+ );
+
+ assertEquals(result, undefined);
+ assertSpyCall(logStub, 0, { args: [], returned: emptyCommandOutput });
+ assertSpyCalls(logStub, 1);
+ });
+});
diff --git a/src/lib/git/log.ts b/src/lib/git/log.ts
new file mode 100644
index 0000000..86bbe7b
--- /dev/null
+++ b/src/lib/git/log.ts
@@ -0,0 +1,131 @@
+import { defined } from "../../utils/anonymous.ts";
+import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts";
+import { gitDir } from "./index.ts";
+import type { Commit, CommitFile } from "./types.ts";
+
+const format = [
+ "H",
+ "h",
+ "aI",
+ "aN",
+ "aE",
+ "cI",
+ "cN",
+ "cE",
+ // "G?",
+ "GS",
+ "GK",
+ "GF",
+ "GG",
+];
+
+export async function getLastCommitForOneOfFiles(
+ sources: MaybeIterable<URL>,
+): Promise<Commit | undefined> {
+ const files = surelyIterable(sources);
+ const gitLog = new Deno.Command("git", {
+ args: [
+ "log",
+ "-1",
+ `--pretty=format:${format.map((x) => `%${x}`).join("%n")}`,
+ "--",
+ ...Iterator.from(files).map((x) => x.pathname),
+ ],
+ });
+
+ const { stdout } = await gitLog.output();
+ const result = new TextDecoder().decode(stdout).trim();
+
+ if (result.length <= 0) {
+ return undefined;
+ }
+
+ const [
+ hash,
+ abbrHash,
+ authorDate,
+ authorName,
+ authorEmail,
+ committerDate,
+ committerName,
+ committerEmail,
+ // signatureValidation,
+ signer,
+ key,
+ keyFingerPrint,
+ ...rawLines
+ ] = result.split("\n");
+
+ const raw = rawLines.join("\n").trim();
+
+ const commit: Commit = {
+ files: await fileStatusFromCommit(hash, Iterator.from(files)),
+ hash: { long: hash, short: abbrHash },
+ author: {
+ date: new Date(authorDate),
+ name: authorName,
+ email: authorEmail,
+ },
+ committer: {
+ date: new Date(committerDate),
+ name: committerName,
+ email: committerEmail,
+ },
+ };
+
+ if (raw.length > 0) {
+ commit.signature = {
+ type: raw.startsWith("gpgsm:")
+ ? "x509"
+ : raw.startsWith("gpg:")
+ ? "gpg"
+ : "ssh",
+ signer,
+ key: { long: keyFingerPrint, short: key },
+ rawMessage: raw,
+ };
+ }
+
+ return commit;
+}
+
+async function fileStatusFromCommit(
+ hash: string,
+ files: Iterable<URL>,
+): Promise<CommitFile[]> {
+ const gitDiffTree = new Deno.Command("git", {
+ args: [
+ "diff-tree",
+ "--no-commit-id",
+ "--name-status",
+ "-r",
+ hash,
+ ],
+ });
+
+ const { stdout } = await gitDiffTree.output();
+ const result = new TextDecoder().decode(stdout).trim().split("\n").filter(
+ defined,
+ );
+
+ const dir = await gitDir();
+ return result.map((line) => {
+ const [status, path] = line.split("\t");
+ if (
+ Iterator.from(files).some((file) =>
+ file.pathname.replace(dir.pathname, "").includes(path)
+ )
+ ) {
+ return {
+ path: new URL(path, dir),
+ status: status === "A"
+ ? "added"
+ : status === "D"
+ ? "deleted"
+ : "modified",
+ } as const;
+ }
+
+ return undefined;
+ }).filter(defined);
+}
diff --git a/src/lib/git/types.ts b/src/lib/git/types.ts
new file mode 100644
index 0000000..672d242
--- /dev/null
+++ b/src/lib/git/types.ts
@@ -0,0 +1,27 @@
+export type CommitFile = {
+ path: URL;
+ status: "added" | "modified" | "deleted";
+};
+
+export type Hash = { long: string; short: string };
+
+export type Contributor = {
+ name: string;
+ email: string;
+ date: Date;
+};
+
+export type SignatureType = "ssh" | "gpg" | "x509";
+
+export type Commit = {
+ files: CommitFile[];
+ hash: Hash;
+ author: Contributor;
+ committer: Contributor;
+ signature?: {
+ type: SignatureType;
+ signer: string;
+ key: Hash;
+ rawMessage: string;
+ };
+};
diff --git a/src/lib/pgp/create.test.ts b/src/lib/pgp/create.test.ts
new file mode 100644
index 0000000..e9e9f41
--- /dev/null
+++ b/src/lib/pgp/create.test.ts
@@ -0,0 +1,130 @@
+import { beforeEach, describe, it } from "@std/testing/bdd";
+import {
+ createInMemoryFile,
+ generateKeyPair,
+ startMockFs,
+} from "../../../tests/fixtures/setup.ts";
+import {
+ armored,
+ binary,
+ createKeysFromFs,
+ DEFAULT_KEY_DISCOVERY_RULES,
+} from "./create.ts";
+import { assertEquals, assertRejects } from "@std/assert";
+import { stub } from "@std/testing/mock";
+
+startMockFs();
+
+describe("createKeysFromFs", () => {
+ let keyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+
+ beforeEach(async () => {
+ keyPair = await generateKeyPair("Alice");
+ });
+
+ it("loads a single armored key file", async () => {
+ const url = createInMemoryFile(
+ new URL("file:///mock/alice.asc"),
+ keyPair.privateKey.armor(),
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 1);
+ });
+
+ it("loads a single binary key file", async () => {
+ const binaryData = keyPair.privateKey.write();
+ const url = createInMemoryFile(
+ new URL("file:///mock/alice.gpg"),
+ binaryData as Uint8Array<ArrayBuffer>,
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 1);
+ });
+
+ it("ignores unsupported file extensions", async () => {
+ const url = createInMemoryFile(
+ new URL("file:///mock/ignored.txt"),
+ "This is not a key",
+ );
+
+ const keys = [];
+ for await (const key of createKeysFromFs(url)) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 0);
+ });
+
+ it("throws on overlapping discovery formats", async () => {
+ const rules = {
+ formats: {
+ [armored]: new Set(["asc", "gpg"]),
+ [binary]: new Set(["gpg"]),
+ },
+ };
+
+ const url = new URL("file:///mock/bogus.gpg");
+
+ await assertRejects(() => createKeysFromFs(url, rules).next());
+ });
+
+ it("handles recursive directory traversal", async () => {
+ const aliceURL = new URL("file:///mock/keys/alice.asc");
+ const bobURL = new URL("file:///mock/keys/sub/bob.asc");
+
+ createInMemoryFile(aliceURL, keyPair.privateKey.armor());
+ createInMemoryFile(bobURL, keyPair.privateKey.armor());
+
+ const mockedDirTree = {
+ "file:///mock/keys/": [
+ { name: "alice.asc", isFile: true, isDirectory: false },
+ { name: "sub", isFile: false, isDirectory: true },
+ ],
+ "file:///mock/keys/sub/": [
+ { name: "bob.asc", isFile: true, isDirectory: false },
+ ],
+ };
+
+ stub(Deno, "stat", (url: URL | string) => {
+ const href = new URL(url).href;
+ return Promise.resolve({
+ isDirectory: href.endsWith("/") || href.includes("/sub"),
+ isFile: href.endsWith(".asc"),
+ isSymlink: false,
+ } as Deno.FileInfo);
+ });
+
+ stub(Deno, "readDir", async function* (url: URL | string) {
+ const href = new URL(url).href;
+ for (
+ const entry of mockedDirTree[href as keyof typeof mockedDirTree] ?? []
+ ) {
+ yield entry as Deno.DirEntry;
+ }
+ });
+
+ const root = new URL("file:///mock/keys/");
+ const keys = [];
+
+ for await (
+ const key of createKeysFromFs(
+ root,
+ { ...DEFAULT_KEY_DISCOVERY_RULES, recursive: true },
+ )
+ ) {
+ keys.push(key);
+ }
+
+ assertEquals(keys.length, 2);
+ });
+});
diff --git a/src/lib/pgp/create.ts b/src/lib/pgp/create.ts
new file mode 100644
index 0000000..fb45954
--- /dev/null
+++ b/src/lib/pgp/create.ts
@@ -0,0 +1,183 @@
+import { readKey } from "openpgp";
+
+export const armored: unique symbol = Symbol();
+export const binary: unique symbol = Symbol();
+export type KeyFileFormat = typeof armored | typeof binary;
+
+export interface KeyDiscoveryRules {
+ formats?: Partial<Record<KeyFileFormat, Set<string> | undefined>>;
+ recursive?: boolean | number;
+}
+export const DEFAULT_KEY_DISCOVERY_RULES = {
+ formats: {
+ [armored]: new Set(["asc"]),
+ [binary]: new Set(["gpg"]),
+ },
+} satisfies KeyDiscoveryRules;
+
+export async function* createKeysFromFs(
+ key: string | URL,
+ rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES,
+ coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {},
+): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> {
+ key = new URL(key);
+
+ validateKeyDiscoveryRules(rules);
+
+ const stat = await Deno.stat(key);
+
+ if (stat.isDirectory) {
+ const generator = createKeysFromDir(key, rules, coders);
+ yield* generator;
+ } else if (stat.isFile) {
+ const period = key.pathname.lastIndexOf(".");
+ const ext = period === -1 ? "" : key.pathname.slice(period + 1);
+ if (
+ rules.formats?.[armored] !== undefined && rules.formats[armored].has(ext)
+ ) {
+ yield createKeyFromFile(
+ key,
+ armored,
+ coders?.decoder,
+ );
+ } else if (
+ rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext)
+ ) {
+ yield createKeyFromFile(
+ key,
+ binary,
+ coders?.encoder,
+ );
+ }
+ }
+}
+
+export async function* createKeysFromDir(
+ key: string | URL,
+ rules: KeyDiscoveryRules = DEFAULT_KEY_DISCOVERY_RULES,
+ coders: { decoder?: TextDecoder; encoder?: TextEncoder } = {},
+): AsyncGenerator<Awaited<ReturnType<typeof readKey>>, void, void> {
+ key = new URL(key);
+
+ validateKeyDiscoveryRules(rules);
+
+ for await (const dirEntry of Deno.readDir(key)) {
+ const filePath = new URL(dirEntry.name, key);
+ if (dirEntry.isFile) {
+ const period = filePath.pathname.lastIndexOf(".");
+ const ext = period === -1 ? "" : filePath.pathname.slice(period + 1);
+ if (
+ rules.formats?.[armored] !== undefined &&
+ rules.formats[armored].has(ext)
+ ) {
+ yield createKeyFromFile(
+ filePath,
+ armored,
+ coders?.decoder,
+ );
+ } else if (
+ rules.formats?.[binary] !== undefined && rules.formats[binary].has(ext)
+ ) {
+ yield createKeyFromFile(
+ filePath,
+ binary,
+ coders?.encoder,
+ );
+ }
+ } else if (dirEntry.isDirectory) {
+ const depth = typeof rules.recursive === "number"
+ ? rules.recursive
+ : rules.recursive
+ ? Infinity
+ : 0;
+ if (depth > 0) {
+ yield* createKeysFromDir(filePath, {
+ ...rules,
+ recursive: depth - 1,
+ }, coders);
+ }
+ }
+ }
+}
+
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof armored,
+ coder?: TextDecoder,
+): ReturnType<typeof readKey>;
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof binary,
+ coder?: TextEncoder,
+): ReturnType<typeof readKey>;
+export async function createKeyFromFile(
+ key: string | URL,
+ type: typeof armored | typeof binary,
+ coder?: TextDecoder | TextEncoder,
+): ReturnType<typeof readKey> {
+ switch (type) {
+ case armored:
+ return await Deno.readTextFile(key).then((key) =>
+ createKeyFromArmor(key, coder as TextDecoder)
+ );
+ case binary:
+ return await Deno.readFile(key).then((key) =>
+ createKeyFromBinary(key, coder as TextEncoder)
+ );
+ }
+}
+
+export function createKeyFromArmor(
+ key: string | Uint8Array,
+ decoder?: TextDecoder,
+): ReturnType<typeof readKey> {
+ return readKey({
+ armoredKey: typeof key === "string"
+ ? key
+ : (decoder ?? new TextDecoder()).decode(key),
+ });
+}
+export function createKeyFromBinary(
+ key: string | Uint8Array,
+ encoder?: TextEncoder,
+): ReturnType<typeof readKey> {
+ return readKey({
+ binaryKey: typeof key === "string"
+ ? (encoder ?? new TextEncoder()).encode(key)
+ : key,
+ });
+}
+
+function validateKeyDiscoveryRules(rules: KeyDiscoveryRules) {
+ let disjoint = true;
+ let union: Set<string> | undefined = undefined;
+ const keys = rules.formats !== undefined
+ ? Object.getOwnPropertySymbols(rules.formats) as KeyFileFormat[]
+ : [];
+
+ for (const i of keys) {
+ const set = rules.formats?.[i];
+
+ if (union === undefined) {
+ union = set;
+ continue;
+ }
+
+ if (set === undefined) {
+ continue;
+ }
+
+ disjoint &&= union.isDisjointFrom(set);
+ union = union.union(set);
+
+ if (!disjoint) {
+ break;
+ }
+ }
+
+ if (!disjoint) {
+ throw new Error(
+ `\`Set\`s from \`rules.formats\` aren't disjoint`,
+ );
+ }
+}
diff --git a/src/lib/pgp/index.ts b/src/lib/pgp/index.ts
new file mode 100644
index 0000000..8142732
--- /dev/null
+++ b/src/lib/pgp/index.ts
@@ -0,0 +1,63 @@
+import { enums, PublicKey, type Subkey } from "openpgp";
+
+export async function isKeyExpired(
+ key: PublicKey | Subkey,
+): Promise<Date | null> {
+ const keyExpiration = await key.getExpirationTime();
+
+ return typeof keyExpiration === "number"
+ ? new Date(keyExpiration)
+ : keyExpiration;
+}
+
+export type RevocationReason = { flag?: string; msg?: string };
+export type Revocation = { date: Date; reason: RevocationReason };
+export function isKeyRevoked(
+ key: PublicKey | Subkey,
+): Revocation | undefined {
+ const revokes = key.revocationSignatures.map((
+ { created, reasonForRevocationFlag, reasonForRevocationString },
+ ) => ({ created, reasonForRevocationFlag, reasonForRevocationString }));
+ let keyRevocation: Revocation | undefined = undefined;
+ for (const i of revokes) {
+ const unix = i.created?.getTime();
+ if (unix === undefined) {
+ continue;
+ }
+ const date = new Date(unix);
+ if (keyRevocation === undefined || unix < keyRevocation.date.getTime()) {
+ let flag = undefined;
+ switch (i.reasonForRevocationFlag) {
+ case enums.reasonForRevocation.noReason: {
+ flag = "No reason specified (key revocations or cert revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keySuperseded: {
+ flag = "Key is superseded (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keyCompromised: {
+ flag = "Key material has been compromised (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.keyRetired: {
+ flag = "Key is retired and no longer used (key revocations)";
+ break;
+ }
+ case enums.reasonForRevocation.userIDInvalid: {
+ flag = "User ID information is no longer valid (cert revocations)";
+ break;
+ }
+ }
+ keyRevocation = {
+ date,
+ reason: { msg: i.reasonForRevocationString ?? undefined, flag },
+ };
+ }
+ }
+
+ return keyRevocation;
+}
+
+export const toPK = (key: PublicKey | Subkey): PublicKey =>
+ key instanceof PublicKey ? key : key.mainKey;
diff --git a/src/lib/pgp/sign.test.ts b/src/lib/pgp/sign.test.ts
new file mode 100644
index 0000000..1f9c4db
--- /dev/null
+++ b/src/lib/pgp/sign.test.ts
@@ -0,0 +1,121 @@
+import {
+ assert,
+ assertAlmostEquals,
+ assertArrayIncludes,
+ assertEquals,
+ assertExists,
+} from "@std/assert";
+import { describe, it } from "@std/testing/bdd";
+import { createMessage, enums, readSignature, sign } from "openpgp";
+import { Signature } from "./sign.ts";
+import { get, instanciate } from "../../utils/anonymous.ts";
+import { bufferToBase } from "../../utils/bases.ts";
+import { generateKeyPair } from "../../../tests/fixtures/setup.ts";
+
+describe("Signature wrapper", () => {
+ const now = new Date();
+ const aliceKeyPair = generateKeyPair("Alice");
+ const signature = Promise.all([
+ aliceKeyPair.then(get("privateKey")),
+ createMessage({ text: "Hello world" }),
+ ]).then(([privateKey, message]) =>
+ sign({
+ message,
+ signingKeys: privateKey,
+ detached: true,
+ format: "object",
+ })
+ ).then((x) => readSignature({ armoredSignature: x.armor() })).then(
+ instanciate(Signature),
+ );
+
+ describe("Single signer", () => {
+ it("signingKeyIDs", async () => {
+ const { publicKey } = await aliceKeyPair;
+ const sig = await signature;
+
+ assertEquals(sig.signingKeyIDs.length, 1);
+ assert(sig.signingKeyIDs[0].equals(publicKey.getKeyID()));
+ });
+
+ it("getPackets", async () => {
+ const sig = await signature;
+
+ assertEquals(sig.getPackets().length, 1);
+ assertEquals(sig.getPackets(sig.signingKeyIDs[0]).length, 1);
+ });
+
+ describe("Packet wrapper", () => {
+ const packet = signature.then((x) => x.getPackets()[0]);
+
+ it("created", async () => {
+ const p = await packet;
+
+ assertExists(p.created);
+ assertAlmostEquals(p.created.getTime(), now.getTime());
+ });
+
+ it("issuerKeyID and issuerFingerprint", async () => {
+ const { privateKey } = await aliceKeyPair;
+ const p = await packet;
+
+ assertEquals(p.issuerKeyID, privateKey.getKeyID());
+ assertExists(p.issuerFingerprint);
+ assertEquals(
+ bufferToBase(p.issuerFingerprint, 16),
+ privateKey.getFingerprint(),
+ );
+ });
+
+ it("signatureType", async () => {
+ const p = await packet;
+
+ assertEquals(p.signatureType, enums.signature.text);
+ });
+ });
+ });
+
+ const bobKeyPair = generateKeyPair("Bob");
+ const multiSignature = Promise.all([
+ Promise.all([
+ aliceKeyPair.then(get("privateKey")),
+ bobKeyPair.then(get("privateKey")),
+ ]),
+ createMessage({ text: "Hello world" }),
+ ]).then(([signingKeys, message]) =>
+ sign({
+ message,
+ signingKeys,
+ detached: true,
+ format: "object",
+ })
+ ).then((x) => readSignature({ armoredSignature: x.armor() })).then(
+ instanciate(Signature),
+ );
+
+ describe("with multiple signers", () => {
+ it("signingKeyIDs", async () => {
+ const { publicKey: alice } = await aliceKeyPair;
+ const { publicKey: bob } = await bobKeyPair;
+ const sig = await multiSignature;
+
+ assertEquals(sig.signingKeyIDs.length, 2);
+ assertArrayIncludes(sig.signingKeyIDs, [
+ alice.getKeyID(),
+ bob.getKeyID(),
+ ]);
+ });
+
+ it("getPackets", async () => {
+ const sig = await multiSignature;
+ const { publicKey: alice } = await aliceKeyPair;
+ const { publicKey: bob } = await bobKeyPair;
+
+ assertEquals(sig.getPackets().length, 2);
+
+ assertEquals(sig.getPackets(alice.getKeyID()).length, 1);
+
+ assertEquals(sig.getPackets(bob.getKeyID()).length, 1);
+ });
+ });
+});
diff --git a/src/lib/pgp/sign.ts b/src/lib/pgp/sign.ts
new file mode 100644
index 0000000..5f7f5a8
--- /dev/null
+++ b/src/lib/pgp/sign.ts
@@ -0,0 +1,82 @@
+import type {
+ KeyID,
+ Signature as InnerSignature,
+ SignaturePacket,
+} from "openpgp";
+import { defined, identity } from "../../utils/anonymous.ts";
+import { type MaybeIterable, surelyIterable } from "../../utils/iterator.ts";
+
+export class Signature {
+ private signature!: InnerSignature;
+ #packets!: Map<string, Packet[]>;
+
+ constructor(signature: InnerSignature) {
+ this.signature = signature;
+ this.#packets = new Map();
+ for (const packet of this.signature.packets) {
+ const key = packet.issuerKeyID.bytes;
+ const keyPackets = this.#packets.get(key);
+ if (keyPackets !== undefined) {
+ keyPackets.push(new Packet(packet));
+ } else {
+ this.#packets.set(key, [new Packet(packet)]);
+ }
+ }
+ }
+
+ getPackets(key?: MaybeIterable<KeyID>): Packet[] {
+ key ??= this.signingKeyIDs;
+ const iterator = Iterator.from(surelyIterable(key));
+ return iterator.map((key) => this.#packets.get(key.bytes)).filter(defined)
+ .flatMap(identity).toArray();
+ }
+
+ get signingKeyIDs(): ReturnType<
+ InstanceType<typeof InnerSignature>["getSigningKeyIDs"]
+ > {
+ return this.signature.getSigningKeyIDs();
+ }
+
+ get inner(): InnerSignature {
+ return this.signature;
+ }
+}
+
+export class Packet {
+ private packet!: SignaturePacket;
+
+ constructor(packet: SignaturePacket) {
+ this.packet = packet;
+ }
+
+ get signersUserID(): SignaturePacket["signersUserID"] {
+ return this.packet.signersUserID;
+ }
+
+ get issuerKeyID(): SignaturePacket["issuerKeyID"] {
+ return this.packet.issuerKeyID;
+ }
+
+ get issuerFingerprint(): SignaturePacket["issuerFingerprint"] {
+ return this.packet.issuerFingerprint;
+ }
+
+ get created(): SignaturePacket["created"] {
+ return this.packet.created;
+ }
+
+ get signatureType(): SignaturePacket["signatureType"] {
+ return this.packet.signatureType;
+ }
+
+ get trustLevel(): SignaturePacket["trustLevel"] {
+ return this.packet.trustLevel;
+ }
+ get trustAmount(): SignaturePacket["trustAmount"] {
+ return this.packet.trustAmount;
+ }
+
+ get inner(): SignaturePacket {
+ return this.packet;
+ }
+}
diff --git a/src/lib/pgp/summary.ts b/src/lib/pgp/summary.ts
new file mode 100644
index 0000000..5c8a81c
--- /dev/null
+++ b/src/lib/pgp/summary.ts
@@ -0,0 +1,232 @@
+import type { Key, PublicKey, Subkey } from "openpgp";
+import type { Verification } from "./verify.ts";
+import { Level } from "../../utils/index.ts";
+import type { NonEmptyArray } from "../../utils/iterator.ts";
+import { keyTrust } from "./trust.ts";
+import { isKeyExpired, isKeyRevoked, type RevocationReason } from "./index.ts";
+
+export const enum VerificationResult {
+ NO_SIGNATURE,
+ MISSING_KEY,
+ SIGNATURE_CORRUPTED,
+ SIGNATURE_COULD_NOT_BE_CHECKED,
+ BAD_SIGNATURE,
+ UNTRUSTED_KEY,
+ TRUSTED_KEY,
+ EXPIRATION_AFTER_SIGNATURE,
+ EXPIRATION_BEFORE_SIGNATURE,
+ REVOCATION_AFTER_SIGNATURE,
+ REVOCATION_BEFORE_SIGNATURE,
+ KEY_DOES_NOT_SIGN,
+}
+
+export function logLevel(result: VerificationResult): [Level, boolean] {
+ switch (result) {
+ case VerificationResult.NO_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.MISSING_KEY:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.SIGNATURE_CORRUPTED:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.BAD_SIGNATURE:
+ return [Level.ERROR, false] as const;
+ case VerificationResult.UNTRUSTED_KEY:
+ return [Level.OK, false] as const;
+ case VerificationResult.TRUSTED_KEY:
+ return [Level.OK, true] as const;
+ case VerificationResult.EXPIRATION_AFTER_SIGNATURE:
+ return [Level.WARN, false] as const;
+ case VerificationResult.EXPIRATION_BEFORE_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.REVOCATION_AFTER_SIGNATURE:
+ return [Level.WARN, true] as const;
+ case VerificationResult.REVOCATION_BEFORE_SIGNATURE:
+ return [Level.ERROR, true] as const;
+ case VerificationResult.KEY_DOES_NOT_SIGN:
+ return [Level.ERROR, true] as const;
+ }
+
+ throw new Error("unreachable");
+}
+
+export type Summary = {
+ result: VerificationResult.NO_SIGNATURE;
+} | {
+ result: VerificationResult.MISSING_KEY;
+ reason: Error;
+ keyID: string;
+ created: Date;
+} | {
+ result:
+ | VerificationResult.SIGNATURE_CORRUPTED
+ | VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED
+ | VerificationResult.BAD_SIGNATURE;
+ reason: Error;
+} | {
+ result: VerificationResult.TRUSTED_KEY;
+ key: PublicKey | Subkey;
+ created: Date;
+} | {
+ result: VerificationResult.UNTRUSTED_KEY;
+ key: PublicKey | Subkey;
+ created: Date;
+} | {
+ result: VerificationResult.EXPIRATION_AFTER_SIGNATURE;
+ key: PublicKey | Subkey;
+ expired: Date;
+ created: Date;
+} | {
+ result: VerificationResult.REVOCATION_AFTER_SIGNATURE;
+ key: PublicKey | Subkey;
+ revoked: Date;
+ revocationReason: RevocationReason;
+ created: Date;
+} | {
+ result: VerificationResult.EXPIRATION_BEFORE_SIGNATURE;
+ key: PublicKey | Subkey;
+ expired: Date;
+ created: Date;
+} | {
+ result: VerificationResult.REVOCATION_BEFORE_SIGNATURE;
+ key: PublicKey | Subkey;
+ revoked: Date;
+ revocationReason: RevocationReason;
+ created: Date;
+} | {
+ result: VerificationResult.KEY_DOES_NOT_SIGN;
+ key: PublicKey | Subkey;
+};
+
+export async function createVerificationSummary(
+ { dataCorrupted, verifications, signature }: Verification,
+): Promise<[NonEmptyArray<Summary>, Map<string, NonEmptyArray<Summary>>]> {
+ if (signature === undefined) {
+ return [[{ result: VerificationResult.NO_SIGNATURE }], new Map()];
+ }
+
+ const corrupted = await dataCorrupted;
+ if (corrupted?.[0]) {
+ return [[{
+ result: VerificationResult.BAD_SIGNATURE,
+ reason: corrupted[1],
+ }], new Map()];
+ }
+
+ const summaries = await Promise.all<
+ Promise<[Summary[], Map<string, Summary[]>]>[]
+ >(
+ (verifications ?? []).map(
+ async ({ signatureCorrupted, verified, packet, key }) => {
+ const errors: Summary[] = [];
+ const keys: Map<string, Summary[]> = new Map();
+
+ try {
+ await verified;
+ } catch (e) {
+ if (e instanceof Error) {
+ if (
+ e.message.startsWith("Could not find signing key with key ID")
+ ) {
+ const keyID = e.message.slice(e.message.lastIndexOf(" "));
+ const key = keys.get(keyID) ?? [];
+ key.push({
+ result: VerificationResult.MISSING_KEY,
+ keyID,
+ reason: e,
+ });
+ keys.set(keyID, key);
+ } else {
+ errors.push({
+ result: VerificationResult.SIGNATURE_COULD_NOT_BE_CHECKED,
+ reason: e,
+ });
+ }
+ } else {
+ throw e;
+ }
+ }
+
+ const corrupted = await signatureCorrupted;
+ if (corrupted[0]) {
+ errors.push({
+ result: VerificationResult.SIGNATURE_CORRUPTED,
+ reason: corrupted[1],
+ });
+ }
+
+ const sig = await packet;
+ const keyID = sig.issuerKeyID;
+
+ sig.created;
+
+ const keyAwaited = await key;
+
+ if (keyAwaited === undefined) {
+ const key = keys.get(keyID.toHex()) ?? [];
+ key.push({
+ result: VerificationResult.MISSING_KEY,
+ keyID: keyID.toHex(),
+ reason: new Error(
+ `Could not find signing key with key ID ${keyID.toHex()}`,
+ ),
+ });
+ keys.set(keyID.toHex(), key);
+
+ return [errors, keys] as [Summary[], Map<string, Summary[]>];
+ }
+
+ const keySummaries = keys.get(keyAwaited.getKeyID().toHex()) ?? [];
+ const expired = await isKeyExpired(keyAwaited);
+
+ if (expired !== null && sig.created !== null) {
+ keySummaries.push({
+ result: expired <= sig.created
+ ? VerificationResult.EXPIRATION_BEFORE_SIGNATURE
+ : VerificationResult.EXPIRATION_AFTER_SIGNATURE,
+ key: keyAwaited,
+ date: expired,
+ });
+ }
+
+ const revoked = isKeyRevoked(keyAwaited);
+ if (revoked?.date !== undefined && sig.created !== null) {
+ keySummaries.push({
+ result: revoked?.date <= sig.created
+ ? VerificationResult.REVOCATION_BEFORE_SIGNATURE
+ : VerificationResult.REVOCATION_AFTER_SIGNATURE,
+ key: keyAwaited,
+ date: revoked.date,
+ revocationReason: revoked.reason,
+ });
+ }
+
+ const trust = sig.trustAmount ?? await keyTrust(keyAwaited as Key);
+
+ keySummaries.push({
+ result: trust > 0
+ ? VerificationResult.TRUSTED_KEY
+ : VerificationResult.UNTRUSTED_KEY,
+ key: keyAwaited,
+ });
+
+ keys.set(keyAwaited.getKeyID().toHex(), keySummaries);
+
+ return [errors, keys] as [Summary[], Map<string, Summary[]>];
+ },
+ ),
+ );
+
+ const errors = summaries.flatMap(([x]) => x);
+ const keys = new Map(summaries.flatMap(([, x]) => x.entries().toArray()));
+
+ if (errors.length > 0 || keys.size > 0) {
+ return [errors, keys] as [
+ NonEmptyArray<Summary>,
+ Map<string, NonEmptyArray<Summary>>,
+ ];
+ }
+
+ throw new Error("unreachable");
+}
diff --git a/src/lib/pgp/trust.ts b/src/lib/pgp/trust.ts
new file mode 100644
index 0000000..cf022b4
--- /dev/null
+++ b/src/lib/pgp/trust.ts
@@ -0,0 +1,19 @@
+import type { Key } from "npm:openpgp@^6.1.1";
+import { TRUSTED_KEYS_DIR } from "../../consts.ts";
+import { createKeysFromDir } from "./create.ts";
+import type { AsyncYieldType } from "../../utils/iterator.ts";
+import { equal, getCall } from "../../utils/anonymous.ts";
+
+let trusted:
+ | Iterable<AsyncYieldType<ReturnType<typeof createKeysFromDir>>>
+ | undefined = undefined;
+
+const fingerprints = () =>
+ Iterator.from(trusted ?? []).map(getCall("getFingerprint"));
+
+export async function keyTrust(key: Key): Promise<number> {
+ if (trusted === undefined) {
+ trusted = await Array.fromAsync(createKeysFromDir(TRUSTED_KEYS_DIR));
+ }
+ return fingerprints().some(equal(key.getFingerprint())) ? 255 : 0;
+}
diff --git a/src/lib/pgp/verify.test.ts b/src/lib/pgp/verify.test.ts
new file mode 100644
index 0000000..9c8ae9c
--- /dev/null
+++ b/src/lib/pgp/verify.test.ts
@@ -0,0 +1,619 @@
+/*
+import {
+ afterEach,
+ beforeAll,
+ beforeEach,
+ describe,
+ it,
+} from "@std/testing/bdd";
+import { type Stub, stub } from "@std/testing/mock";
+import { FakeTime } from "@std/testing/time";
+import { get } from "../../utils/anonymous.ts";
+import { SignatureVerifier } from "./verify.ts";
+import { assertEquals } from "@std/assert/equals";
+import { assert, assertExists, assertFalse, assertRejects } from "@std/assert";
+import {
+ corruptData,
+ corruptSignatureFormat,
+ createDetachedSignature,
+ createInMemoryFile,
+ generateKeyPair,
+ generateKeyPairWithSubkey,
+ startMockFs,
+} from "../../../tests/fixtures/setup.ts";
+import { emptyCommandOutput } from "../../../tests/fixtures/test_data.ts";
+
+startMockFs();
+
+describe("SignatureVerifier", () => {
+ let verifier: SignatureVerifier;
+ let aliceKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+ let bobKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+ let aliceWithSubkeyKeyPair: Awaited<ReturnType<typeof generateKeyPair>>;
+
+ beforeAll(async () => {
+ aliceKeyPair = await generateKeyPair("Alice");
+ bobKeyPair = await generateKeyPair("Bob");
+ aliceWithSubkeyKeyPair = await generateKeyPairWithSubkey("AliceWithSubkey");
+ });
+
+ beforeEach(() => {
+ verifier = new SignatureVerifier();
+ Deno.Command.prototype.output = stub(
+ Deno.Command.prototype,
+ "output",
+ () => emptyCommandOutput,
+ );
+ });
+
+ afterEach(() => {
+ (Deno.Command.prototype.output as Stub).restore();
+ });
+
+ describe("when verifying a file with a single signature", () => {
+ const originalData = new TextEncoder().encode(
+ "This is the original file content for single signature tests.",
+ ) as Uint8Array<ArrayBuffer>;
+ let originalDataUrl: URL;
+
+ beforeEach(() => {
+ // Create the data file in memory for each single signature test
+ originalDataUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt"),
+ originalData,
+ );
+ });
+
+ it("Scenario: No signature found", async () => {
+ const verification = await verifier.verify([originalDataUrl]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(
+ await verification.dataCorrupted,
+ "Data is not corrupted in the absence of a signature to check against",
+ );
+ assertEquals(
+ verification.verifications,
+ undefined,
+ "Should not find any signatures to verify",
+ );
+ // commit is stubbed, so it will be undefined
+ });
+
+ it("Scenario: Signature cannot be checked (missing key - 'E')", async () => {
+ // Create a valid signature, but don't add the signing key to the verifier
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertEquals(await verification.dataCorrupted, [false]);
+
+ assertEquals(verification.signatureCorrupted, [false]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1); // One signature found
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then(get(0)));
+
+ assertRejects(
+ () => sigVerification.verified,
+ "Verification should fail due to missing key",
+ );
+ // assertEquals(await sigVerification.status, "E", "Status should be 'E'");
+
+ // The keys promise might resolve with an empty array or throw depending on implementation
+ // assert(?) sigVerification.keys resolves as expected
+ });
+
+ it("Scenario: Signature cannot be checked (Signature corrupted/malformed - 'E')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const corruptedSignature = corruptSignatureFormat(signature);
+ const corruptedSignatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ corruptedSignature,
+ );
+
+ verifier.addKey(aliceKeyPair.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ corruptedSignatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertEquals(await verification.dataCorrupted, undefined);
+
+ assertEquals(verification.verifications, undefined);
+ // assertEquals(await sigVerification.status, "E", "Status should be 'E'");
+ });
+
+ it("Scenario: Bad signature ('B')", async () => {
+ // Create a valid signature for the original data
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+ // Create corrupted data
+ const corruptedData = corruptData(originalData);
+ const corruptedDataUrl = createInMemoryFile(
+ new URL("file:///test/corrupted_single_sig_data.txt"),
+ corruptedData,
+ );
+
+ verifier.addKey(aliceKeyPair.publicKey); // Key is available
+
+ // Verify the signature (of original data) against the corrupted data
+ const verification = await verifier.verify([
+ corruptedDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), corruptedData); // The verifier processed the corrupted data
+ assert(
+ await verification.dataCorrupted,
+ "Data should be marked as corrupted because signature does not match",
+ ); // Assuming implementation detects this
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1); // One signature found
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key); // Key should be found
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then(get(0))); // Signature data itself is not corrupted
+
+ // Expect verification to fail and report 'B'
+ assertRejects(
+ () => sigVerification.verified,
+ "Verification should fail due to data mismatch",
+ );
+ // assertEquals(await sigVerification.status, "B", "Status should be 'B'");
+ });
+
+ it("Scenario: Good signature ('G')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ // Add the key and assume it's ultimately trusted for this scenario
+ // In a real test, you might explicitly set trust levels if openpgp.js supports it easily
+ verifier.addKey(aliceKeyPair.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(
+ await verification.dataCorrupted?.then((x) => x[0]),
+ "Data should not be marked corrupted for a good signature",
+ );
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1);
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key, "Should find the signing key");
+ const signingKey = await sigVerification.key; // Assuming one key found
+ assertExists(signingKey, "Should find the signing key");
+ assertEquals(signingKey.getKeyID(), aliceKeyPair.publicKey.getKeyID());
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0]));
+
+ // Expect verification to succeed and report 'G'
+ assert(
+ await sigVerification.verified,
+ "Verification should succeed for a good signature",
+ );
+ // assertEquals(await sigVerification.status, "G", "Status should be 'G'");
+ });
+
+ it("Scenario: Good signature, unknown validity ('U')", async () => {
+ const signature = await createDetachedSignature(
+ originalData,
+ aliceKeyPair.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/single_sig_data.txt.sig"),
+ signature,
+ );
+
+ // Add the key but do *not* establish ultimate trust for this key in the verifier's context
+ // This scenario relies on your verifier or OpenPGP.js handling the 'unknown trust' case.
+ verifier.addKey(aliceKeyPair.publicKey); // Key is available, but trust level is not set
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ assertEquals(new Uint8Array(verification.data), originalData);
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications, "Should find the signature");
+ assertEquals(verification.verifications.length, 1);
+
+ const sigVerification = verification.verifications[0];
+ assertExists(sigVerification.key);
+
+ assertExists(sigVerification.packet);
+ assertFalse(await sigVerification.signatureCorrupted.then((x) => x[0]));
+
+ // Expect cryptographic verification to succeed, but status to be 'U'
+ assert(
+ await sigVerification.verified,
+ "Cryptographic verification should succeed",
+ );
+ // assertEquals(
+ // await sigVerification.status,
+ // "U",
+ // "Status should be 'U' due to unknown validity",
+ // );
+ });
+
+ // TODO(#): Add tests for Scenarios involving Key Expiration ('X', 'Y')
+ // This requires creating keys with specific expiration dates and mocking the system clock
+ it("Scenario: Good signature, key expired *after* signature time ('X')", async () => {
+ // Use fake time to control the 'now'
+ const time = new FakeTime();
+
+ const keyExpirationTime = time.now + 30 * 1000;
+ const keyPairWithExpiry = await generateKeyPair("AliceWithExpiry", {
+ keyExpirationTime,
+ });
+
+ const signature = await createDetachedSignature(
+ originalData,
+ keyPairWithExpiry.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/sig_expired_after.sig"),
+ signature,
+ );
+
+ time.tick(60 * 1000);
+
+ verifier.addKey(keyPairWithExpiry.publicKey);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ time.restore();
+
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications);
+ // const expirationDate = await verification.verifications[0].keys[0].then((
+ // x,
+ // ) => x.getExpirationTime());
+ // assertEquals(
+ // expirationDate?.valueOf(),
+ // new Date(keyExpirationTime).valueOf(),
+ // );
+ assertExists(await verification.verifications[0].packet);
+ assertFalse(
+ await verification.verifications[0].signatureCorrupted.then((x) =>
+ x[0]
+ ),
+ );
+ assert(await verification.verifications[0].verified);
+
+ // assertEquals(
+ // await verification.verifications![0].status,
+ // "X",
+ // "Status should be 'X' due to key expired after signature",
+ // );
+ });
+
+ it("Scenario: Good signature, key expired *before* signature time ('Y')", async () => {
+ // Use fake time to control the 'now' when creating the key (for expiration)
+ const time = new FakeTime();
+
+ const keyExpirationTime = time.now + 30 * 1000;
+ const keyPairExpiredBefore = await generateKeyPair("AliceExpiredBefore", {
+ keyExpirationTime,
+ });
+
+ time.tick(60 * 1000);
+
+ const signature = await createDetachedSignature(
+ originalData,
+ keyPairExpiredBefore.privateKey,
+ );
+ const signatureUrl = createInMemoryFile(
+ new URL("file:///test/sig_expired_before.sig"),
+ signature,
+ );
+
+ verifier.addKey(keyPairExpiredBefore.publicKey);
+
+ time.tick(60 * 1000);
+
+ const verification = await verifier.verify([
+ originalDataUrl,
+ signatureUrl,
+ ]);
+
+ time.restore();
+
+ assertFalse(await verification.dataCorrupted?.then((x) => x[0]));
+
+ assertFalse(verification.signatureCorrupted?.[0]);
+
+ assertExists(verification.verifications);
+ // const expirationDate = await verification.verifications[0].keys[0].then((
+ // x,
+ // ) => x.getExpirationTime());
+ // assertEquals(
+ // expirationDate?.valueOf(),
+ // new Date(keyExpirationTime).valueOf(),
+ // );
+ assertExists(await verification.verifications[0].packet);
+ assertFalse(
+ await verification.verifications[0].signatureCorrupted.then((x) =>
+ x[0]
+ ),
+ );
+ assert(await verification.verifications[0].verified);
+
+ //assertEquals(
+ // await verification.verifications![0].status,
+ // "Y",
+ // "Status should be 'Y' due to key expired before signature",
+ //);
+ });
+
+ // // TODO: Add tests for Scenarios involving Key Revocation ('R', 'Y')
+ // // This requires creating and distributing key revocation certificates. Simulating this is complex and might need mocking OpenPGP.js internal behavior or relying on its revocation handling.
+
+ // it("Scenario: Good signature, key revoked *after* signature time ('R')", async () => {
+ // // This requires creating a revocation certificate for the key *after* signing.
+ // assert(
+ // false,
+ // "Test not implemented: Simulating key revocation requires revocation certs.",
+ // );
+ // });
+
+ // it("Scenario: Good signature, key revoked *before* signature time ('Y')", async () => {
+ // // This requires creating a revocation certificate for the key *before* signing.
+ // assert(
+ // false,
+ // "Test not implemented: Simulating key revocation requires revocation certs.",
+ // );
+ // });
+
+ // it("Scenario: Signature cannot be checked (Public key available but not signing)", async () => {
+ // // Generate a key with only encryption or certification usage flags
+ // const nonSigningKeyPair = await generateKeyPair("AliceNonSigning", {
+ // usage: ["encrypt"],
+ // }); // Or ["certify"]
+
+ // const signature = await createDetachedSignature(
+ // originalData,
+ // aliceKeyPair.privateKey,
+ // ); // Signed with a signing key
+ // const signatureUrl = createInMemoryFile(
+ // new URL("file:///test/sig_non_signing_key.sig"),
+ // signature,
+ // );
+
+ // // Add the non-signing key to the verifier instead of the actual signing key
+ // await verifier.addKey(nonSigningKeyPair.publicKey);
+
+ // const verification: Verification = await verifier.verify([
+ // originalDataUrl,
+ // signatureUrl,
+ // ]);
+
+ // assertExists(verification.verifications, "Should find the signature");
+ // assertEquals(verification.verifications.length, 1);
+
+ // const sigVerification = verification.verifications[0];
+ // // Key is found, but it's the wrong type of key for verification
+ // assertExists(sigVerification.keys, "Should find a key");
+
+ // // Expect verification to fail and report 'E' or potentially 'B' depending on how openpgp.js handles this
+ // // OpenPGP.js often reports 'E' if the key's capabilities don't match the packet type.
+ // assertEquals(
+ // await sigVerification.verified,
+ // false,
+ // "Verification should fail with a non-signing key",
+ // );
+ // // We expect 'E' as the most likely status
+ // assertEquals(
+ // await sigVerification.status,
+ // "E",
+ // "Status should be 'E' with a non-signing key",
+ // );
+ // });
+
+ // TODO: Add scenarios involving signing subkeys if your verifier needs to distinguish them
+ // These would require more complex key generation and potentially inspecting the packet details.
+ });
+
+ // // --- Scenarios for multiple signatures ---
+ // describe("when verifying a file with multiple signatures", () => {
+ // const originalData = new TextEncoder().encode("This file has multiple signatures.");
+ // let originalDataUrl: URL;
+ //
+ // beforeEach(() => {
+ // originalDataUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt"), originalData);
+ // });
+ //
+ //
+ // it("Scenario: All signatures are Good ('G')", async () => {
+ // // Create signatures by Alice and Bob
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey);
+ //
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature);
+ //
+ // // Add both signing keys (assume trusted for this scenario)
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ // await verifier.addKey(bobKeyPair.publicKey);
+ //
+ // // Verify with multiple signature files
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData);
+ // assertEquals(verification.dataCorrupted, false);
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2); // Two signatures found
+ //
+ // // Check the status of each verification result
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ // assertArrayIncludes(statuses, ['G', 'G'], "Both signatures should have 'G' status");
+ //
+ // // Check the key IDs found for each verification
+ // const keyIDs = await Promise.all(verification.verifications.map(async v => (await v.keys)[0]?.getKeyID()));
+ // assertArrayIncludes(keyIDs.filter(defined), [aliceKeyPair.publicKey.getKeyID(), bobKeyPair.publicKey.getKeyID()]);
+ // });
+ //
+ // it("Scenario: Some signatures are Good ('G'), others are Bad ('B')", async () => {
+ // // Create a good signature by Alice
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ //
+ // // Create a bad signature by attempting to sign corrupted data with Bob's key
+ // const corruptedDataForBadSig = corruptData(originalData);
+ // const bobBadSignature = await createDetachedSignature(corruptedDataForBadSig, bobKeyPair.privateKey);
+ // const bobBadSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobBadSignature);
+ //
+ //
+ // // Add both signing keys
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ // await verifier.addKey(bobKeyPair.publicKey);
+ //
+ // // Verify against the original data, but provide one good and one bad signature file
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobBadSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData); // Verifier should use the original data if found and matching a good sig
+ // assertEquals(verification.dataCorrupted, false, "Data should not be marked corrupted if at least one good signature matches");
+ //
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2);
+ //
+ // // Check the status of each verification result
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ // // Expect one 'G' and one 'B' status
+ // assertEquals(statuses.filter(s => s === 'G').length, 1);
+ // assertEquals(statuses.filter(s => s === 'B').length, 1);
+ //
+ // // You would also need to check which key corresponded to the 'G' and 'B' status
+ // // This requires correlating the verification result with the key ID/fingerprint.
+ // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() })));
+ //
+ // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good");
+ // assert(verifications.some(v => v.status === 'B' && v.keyID === bobKeyPair.publicKey.getKeyID()), "Bob's signature should be Bad");
+ // });
+ //
+ // it("Scenario: Some signatures cannot be checked ('E'), others are Good ('G')", async () => {
+ // // Create a good signature by Alice
+ // const aliceSignature = await createDetachedSignature(originalData, aliceKeyPair.privateKey);
+ // const aliceSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.alice.sig"), aliceSignature);
+ //
+ // // Create a signature by Bob but don't add Bob's key to the verifier (will result in 'E')
+ // const bobSignature = await createDetachedSignature(originalData, bobKeyPair.privateKey);
+ // const bobSignatureUrl = createInMemoryFile(new URL("file:///test/multi_sig_data.txt.bob.sig"), bobSignature);
+ //
+ //
+ // // Add only Alice's key
+ // await verifier.addKey(aliceKeyPair.publicKey);
+ //
+ // const verification: Verification = await verifier.verify([originalDataUrl, aliceSignatureUrl, bobSignatureUrl]);
+ //
+ // assertEquals(new Uint8Array(verification.data), originalData);
+ // assertEquals(verification.dataCorrupted, false);
+ // assertExists(verification.verifications);
+ // assertEquals(verification.verifications.length, 2);
+ //
+ // const statuses = await Promise.all(verification.verifications.map(v => v.status));
+ //
+ // assertEquals(statuses.filter(s => s === 'G').length, 1, "One signature should be Good (Alice)");
+ // assertEquals(statuses.filter(s => s === 'E').length, 1, "One signature should be 'E' (Bob - missing key)");
+ //
+ // const verifications = await Promise.all(verification.verifications.map(async v => ({ status: await v.status, keyID: (await Promise.all(v.keys))[0]?.getKeyID() })));
+ //
+ // assert(verifications.some(v => v.status === 'G' && v.keyID === aliceKeyPair.publicKey.getKeyID()), "Alice's signature should be Good");
+ // // For the 'E' status (missing key), the keyID might be undefined or the partial KeyID from the packet.
+ // // We'll just check that one status is 'E'.
+ // assert(verifications.some(v => v.status === 'E'), "One signature should be 'E'");
+ // });
+ //
+ //
+ // // TODO: Continue adding tests for all combinations from the multiple signatures table
+ // // This requires combining different key states (expired, revoked, untrusted) for different signers
+ // // within the same verification process. This is the most complex part.
+ //
+ // it("Scenario: All signatures Unknown Validity ('U')", async () => {
+ // // Requires generating signatures with keys that are valid but not ultimately trusted for all signers.
+ // // Then verifying without establishing a trust path for any key.
+ // assert(false, "Test not implemented: Simulating unknown trust for all signatures.");
+ // });
+ //
+ // it("Scenario: At least one Good signature, with others having Key Status issues (e.g., 'X', 'Y', 'R')", async () => {
+ // // Requires creating signatures with a mix of good keys and expired/revoked keys for different signers.
+ // assert(false, "Test not implemented: Combining different key states for multiple signers.");
+ // });
+ //
+ // it("Scenario: All signatures have Key Status issues ('X', 'Y', 'R')", async () => {
+ // // Requires creating signatures with only expired or revoked keys for all signers.
+ // assert(false, "Test not implemented: Simulating all signatures with key status issues.");
+ // });
+ //
+ // it("Scenario: Combination of Bad, Unknown, and Key Status issues", async () => {
+ // // This is a very complex scenario combining multiple failure types across different signatures.
+ // assert(false, "Test not implemented: Simulating a complex mix of failure types.");
+ // });
+ //
+ // it("Scenario: At least one signature is valid, but some Public Keys not available", async () => {
+ // // Requires providing multiple signature files, but only providing some of the signing keys to the verifier.
+ // assert(false, "Test not implemented: Simulating missing keys for some signatures in a multi-signature scenario.");
+ // });
+ //
+ // it("Scenario: At least one signature is valid, but some Public Keys available but not Signing Keys", async () => {
+ // // Requires providing multiple signature files, and providing a key that is NOT a signing key for one of them.
+ // assert(false, "Test not implemented: Simulating non-signing keys for some signatures in a multi-signature scenario.");
+ // });
+ // });
+});
+*/
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]);
+ });
+ }
+ });
+}