Files
HiveGrid-Wireframes/hivegrid-cloud/packages/plugin-runtime/src/signature.test.ts
T

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