170 lines
5.9 KiB
TypeScript
170 lines
5.9 KiB
TypeScript
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<string, unknown>, 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]);
|
|
});
|
|
});
|