From f9a77c5c27aede4e5978eb55d9b7af781b680a1d Mon Sep 17 00:00:00 2001 From: João Augusto Costa Branco Marado Torres Date: Tue, 24 Jun 2025 12:08:41 -0300 Subject: feat!: initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: João Augusto Costa Branco Marado Torres --- src/lib/git/index.test.ts | 40 +++ src/lib/git/index.ts | 16 ++ src/lib/git/log.test.ts | 71 ++++++ src/lib/git/log.ts | 131 ++++++++++ src/lib/git/types.ts | 27 ++ src/lib/pgp/create.test.ts | 130 ++++++++++ src/lib/pgp/create.ts | 183 ++++++++++++++ src/lib/pgp/index.ts | 63 +++++ src/lib/pgp/sign.test.ts | 121 +++++++++ src/lib/pgp/sign.ts | 82 ++++++ src/lib/pgp/summary.ts | 232 +++++++++++++++++ src/lib/pgp/trust.ts | 19 ++ src/lib/pgp/verify.test.ts | 619 +++++++++++++++++++++++++++++++++++++++++++++ src/lib/pgp/verify.ts | 349 +++++++++++++++++++++++++ 14 files changed, 2083 insertions(+) create mode 100644 src/lib/git/index.test.ts create mode 100644 src/lib/git/index.ts create mode 100644 src/lib/git/log.test.ts create mode 100644 src/lib/git/log.ts create mode 100644 src/lib/git/types.ts create mode 100644 src/lib/pgp/create.test.ts create mode 100644 src/lib/pgp/create.ts create mode 100644 src/lib/pgp/index.ts create mode 100644 src/lib/pgp/sign.test.ts create mode 100644 src/lib/pgp/sign.ts create mode 100644 src/lib/pgp/summary.ts create mode 100644 src/lib/pgp/trust.ts create mode 100644 src/lib/pgp/verify.test.ts create mode 100644 src/lib/pgp/verify.ts (limited to 'src/lib') 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; + 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 | undefined; + +export function gitDir(): Promise { + 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, +): Promise { + 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, +): Promise { + 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>; + + 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, + ); + + 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 | 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>, 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>, 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; +export async function createKeyFromFile( + key: string | URL, + type: typeof binary, + coder?: TextEncoder, +): ReturnType; +export async function createKeyFromFile( + key: string | URL, + type: typeof armored | typeof binary, + coder?: TextDecoder | TextEncoder, +): ReturnType { + 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 { + return readKey({ + armoredKey: typeof key === "string" + ? key + : (decoder ?? new TextDecoder()).decode(key), + }); +} +export function createKeyFromBinary( + key: string | Uint8Array, + encoder?: TextEncoder, +): ReturnType { + return readKey({ + binaryKey: typeof key === "string" + ? (encoder ?? new TextEncoder()).encode(key) + : key, + }); +} + +function validateKeyDiscoveryRules(rules: KeyDiscoveryRules) { + let disjoint = true; + let union: Set | 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 { + 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; + + 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): 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["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, Map>]> { + 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]>[] + >( + (verifications ?? []).map( + async ({ signatureCorrupted, verified, packet, key }) => { + const errors: Summary[] = []; + const keys: Map = 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]; + } + + 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]; + }, + ), + ); + + 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, + Map>, + ]; + } + + 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>> + | undefined = undefined; + +const fingerprints = () => + Iterator.from(trusted ?? []).map(getCall("getFingerprint")); + +export async function keyTrust(key: Key): Promise { + 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>; + let bobKeyPair: Awaited>; + let aliceWithSubkeyKeyPair: Awaited>; + + 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; + 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; + dataCorrupted?: Promise; + signatureCorrupted?: Corrupted; + signature?: Signature; + verifications?: { + key: Promise; + keyID: Awaited>["signatures"][number]["keyID"]; + userID: Promise; + packet: Promise; + signatureCorrupted: Promise; + verified: Promise; + }[]; + commit: Promise; +} + +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 { + // 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, + type: KeyFileFormat = binary, + ): AsyncGenerator { + for (const i of data) { + yield this.verify(i, type); + } + } + + addKey(key: MaybeIterable): 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 { + 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 { + 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 { + this.keys.push( + await createKeyFromArmor(key, this.#decoder).then((x) => x.toPublic()), + ); + } + + async addKeyFromBinary( + key: string | Uint8Array, + ): Promise { + this.keys.push( + await createKeyFromBinary(key, this.#encoder).then((x) => x.toPublic()), + ); + } + + public static async instance(): Promise { + 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 + >["signatures"][number]["verified"], +): Promise { + 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 { + return new Promise((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]); + }); + } + }); +} -- cgit v1.2.3