import { createHash, generateKeyPairSync, KeyObject, sign } from "node:crypto"; import { describe, expect, test } from "vitest"; import { canonicalPluginSignaturePayload, parseTrustedPublicKeys, verifyPluginSignature } from "./signature.js"; function ed25519Keypair(): { privateKey: KeyObject; publicKeyHex: string } { const { privateKey, publicKey } = generateKeyPairSync("ed25519"); const spki = publicKey.export({ format: "der", type: "spki" }) as Buffer; // SPKI prefix is 12 bytes; raw key is the last 32 bytes. const rawKey = spki.subarray(spki.length - 32); return { privateKey, publicKeyHex: rawKey.toString("hex") }; } function signBuffer(privateKey: KeyObject, wasm: Uint8Array): string { return sign(null, Buffer.from(wasm), privateKey).toString("hex"); } function signManifestPayload(privateKey: KeyObject, manifest: Record, wasmSha256: string): string { return sign(null, Buffer.from(canonicalPluginSignaturePayload(manifest, wasmSha256), "utf8"), privateKey).toString("hex"); } describe("verifyPluginSignature", () => { const wasm = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); const wasmHash = createHash("sha256").update(wasm).digest("hex"); test("returns ok when signature, hash and trust all match", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const signatureHex = signBuffer(privateKey, wasm); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex, publicKeyHex, trustedPublicKeysHex: [publicKeyHex], }); expect(result.ok).toBe(true); }); test("returns ok for the canonical manifest-plus-wasm hash payload signed by the CLI", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const manifest = { schemaVersion: 1, id: "de.toolctrl.test", version: "1.0.0", name: "Test", entry: "plugin.wasm", wasmSha256: wasmHash, signature: "0".repeat(128), signedBy: publicKeyHex, capabilities: [{ name: "log" }], hooks: [{ name: "workflow.executed", export: "onWorkflowExecuted" }], }; const signatureHex = signManifestPayload(privateKey, manifest, wasmHash); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex, publicKeyHex, trustedPublicKeysHex: [publicKeyHex], manifest, }); expect(result.ok).toBe(true); }); test("fails when manifest content changes after signing", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const manifest = { id: "de.toolctrl.test", version: "1.0.0", capabilities: [{ name: "log" }] }; const signatureHex = signManifestPayload(privateKey, manifest, wasmHash); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex, publicKeyHex, trustedPublicKeysHex: [publicKeyHex], manifest: { ...manifest, capabilities: [{ name: "audit" }] }, }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("signature_invalid"); }); test("fails with binary_hash_mismatch when wasm bytes differ from manifest hash", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const signatureHex = signBuffer(privateKey, wasm); const tampered = new Uint8Array([...wasm, 0xff]); const result = verifyPluginSignature({ wasmBuffer: tampered, expectedWasmSha256: wasmHash, signatureHex, publicKeyHex, trustedPublicKeysHex: [publicKeyHex], }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("binary_hash_mismatch"); }); test("fails with public_key_not_trusted when the signing key is not in the allowlist", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const { publicKeyHex: otherKey } = ed25519Keypair(); const signatureHex = signBuffer(privateKey, wasm); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex, publicKeyHex, trustedPublicKeysHex: [otherKey], }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("public_key_not_trusted"); }); test("fails with signature_invalid when the signature is tampered", () => { const { privateKey, publicKeyHex } = ed25519Keypair(); const valid = signBuffer(privateKey, wasm); // Flip a hex char while staying in [0-9a-f] to keep the format-check happy. const tampered = (valid[0] === "0" ? "1" : "0") + valid.slice(1); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex: tampered, publicKeyHex, trustedPublicKeysHex: [publicKeyHex], }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("signature_invalid"); }); test("fails with malformed_input on non-hex signature", () => { const { publicKeyHex } = ed25519Keypair(); const result = verifyPluginSignature({ wasmBuffer: wasm, expectedWasmSha256: wasmHash, signatureHex: "not-hex", publicKeyHex, trustedPublicKeysHex: [publicKeyHex], }); expect(result.ok).toBe(false); if (!result.ok) expect(result.reason).toBe("malformed_input"); }); }); describe("parseTrustedPublicKeys", () => { test("returns an empty list for missing / empty env", () => { expect(parseTrustedPublicKeys(undefined)).toEqual([]); expect(parseTrustedPublicKeys("")).toEqual([]); }); test("splits a CSV and lowercases hex entries", () => { const a = "aa".repeat(32); const b = "BB".repeat(32); expect(parseTrustedPublicKeys(`${a},${b}`)).toEqual([a, b.toLowerCase()]); }); test("silently drops non-hex / wrong-length entries", () => { const valid = "cc".repeat(32); expect(parseTrustedPublicKeys(`junk,${valid},also-bad`)).toEqual([valid]); }); });