From 18e191e7b95a6297dcfdbe44503025b564a33e20 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Sun, 15 Feb 2026 18:44:02 -0500 Subject: [PATCH] Add ENS receipt verification fixtures and unit test suites --- package.json | 5 +- runtime/src/receipt-verification.js | 93 +++++++++++++++++++ runtime/tests/ens-resolution.test.mjs | 13 +++ runtime/tests/helpers.mjs | 24 +++++ runtime/tests/key-resolution.test.mjs | 18 ++++ runtime/tests/key-rotation.test.mjs | 19 ++++ runtime/tests/receipt-verification.test.mjs | 21 +++++ sdk/python-sdk/tests/test_verification.py | 16 ++++ .../tests/canonicalization.test.mjs | 11 +++ .../tests/ens-delegation.test.mjs | 11 +++ .../tests/security-cases.test.mjs | 17 ++++ test_vectors/expected_hash.txt | 1 + test_vectors/public_key_base64.txt | 1 + test_vectors/receipt_invalid_sig.json | 11 +++ test_vectors/receipt_malformed_pubkey.json | 11 +++ test_vectors/receipt_valid.json | 11 +++ test_vectors/receipt_wrong_kid.json | 11 +++ 17 files changed, 292 insertions(+), 2 deletions(-) create mode 100644 runtime/src/receipt-verification.js create mode 100644 runtime/tests/ens-resolution.test.mjs create mode 100644 runtime/tests/helpers.mjs create mode 100644 runtime/tests/key-resolution.test.mjs create mode 100644 runtime/tests/key-rotation.test.mjs create mode 100644 runtime/tests/receipt-verification.test.mjs create mode 100644 sdk/python-sdk/tests/test_verification.py create mode 100644 sdk/typescript-sdk/tests/canonicalization.test.mjs create mode 100644 sdk/typescript-sdk/tests/ens-delegation.test.mjs create mode 100644 sdk/typescript-sdk/tests/security-cases.test.mjs create mode 100644 test_vectors/expected_hash.txt create mode 100644 test_vectors/public_key_base64.txt create mode 100644 test_vectors/receipt_invalid_sig.json create mode 100644 test_vectors/receipt_malformed_pubkey.json create mode 100644 test_vectors/receipt_valid.json create mode 100644 test_vectors/receipt_wrong_kid.json diff --git a/package.json b/package.json index 72c7ecc..d731877 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "scripts": { "start": "node server.mjs", "check": "node --check server.mjs", - "test": "node tests/smoke.mjs", - "ci": "npm run check && npm test" + "test": "npm run test:unit && node tests/smoke.mjs", + "ci": "npm run check && npm test", + "test:unit": "node --test runtime/tests/*.test.mjs sdk/typescript-sdk/tests/*.test.mjs" }, "dependencies": { "ajv": "^8.17.1", diff --git a/runtime/src/receipt-verification.js b/runtime/src/receipt-verification.js new file mode 100644 index 0000000..15ab904 --- /dev/null +++ b/runtime/src/receipt-verification.js @@ -0,0 +1,93 @@ +import crypto from "node:crypto"; + +const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex"); + +export function stableStringify(value) { + const seen = new WeakSet(); + const helper = (current) => { + if (current === null || typeof current !== "object") return current; + if (seen.has(current)) return "[Circular]"; + seen.add(current); + if (Array.isArray(current)) return current.map(helper); + const output = {}; + for (const key of Object.keys(current).sort()) output[key] = helper(current[key]); + return output; + }; + return JSON.stringify(helper(value)); +} + +export function parseEd25519PublicKey(text) { + if (typeof text !== "string") throw new Error("Invalid ed25519 format"); + const [alg, payload] = text.trim().split(":", 2); + if (alg?.toLowerCase() !== "ed25519" || !payload) throw new Error("Invalid ed25519 format"); + + const bytes = Buffer.from(payload, "base64"); + if (!bytes.length || bytes.toString("base64") !== payload || bytes.length !== 32) { + throw new Error("Invalid ed25519 format"); + } + return bytes; +} + +export function computeReceiptHash(receipt) { + const canonicalReceipt = { + issuer: receipt.issuer, + verb: receipt.verb, + version: receipt.version, + timestamp: receipt.timestamp, + payload_hash: receipt.payload_hash, + alg: receipt.alg, + kid: receipt.kid, + }; + return crypto.createHash("sha256").update(stableStringify(canonicalReceipt)).digest("hex"); +} + +export async function resolveSigner(agentEnsName, resolver) { + const signer = await resolver.getText(agentEnsName, "cl.receipt.signer"); + const normalized = String(signer || "").trim(); + if (!normalized) throw new Error("Missing cl.receipt.signer"); + return normalized; +} + +export async function resolveSignatureKey(signerEnsName, resolver) { + const pub = await resolver.getText(signerEnsName, "cl.sig.pub"); + const kid = String(await resolver.getText(signerEnsName, "cl.sig.kid") || "").trim(); + if (!pub) throw new Error("Missing cl.sig.pub"); + if (!kid) throw new Error("Missing cl.sig.kid"); + + const pubkeyBytes = parseEd25519PublicKey(pub); + return { algorithm: "ed25519", kid, pubkeyBytes, rawPublicKeyBytes: pubkeyBytes }; +} + +function verifySignature(receiptHash, signatureB64, pubkeyBytes) { + const spki = Buffer.concat([ED25519_SPKI_PREFIX, pubkeyBytes]); + const key = crypto.createPublicKey({ key: spki, format: "der", type: "spki" }); + return crypto.verify(null, Buffer.from(receiptHash, "utf8"), key, Buffer.from(signatureB64, "base64")); +} + +export async function verifyReceipt(receipt, { resolver, expectedIssuer } = {}) { + if (!resolver) throw new Error("Resolver required"); + if (expectedIssuer && receipt.issuer !== expectedIssuer) throw new Error("Issuer mismatch"); + + const signerEnsName = await resolveSigner(receipt.issuer, resolver); + const key = await resolveSignatureKey(signerEnsName, resolver); + if (receipt.kid !== key.kid) { + return { valid: false, error: "Unknown key id" }; + } + + const computedHash = computeReceiptHash(receipt); + if (computedHash !== receipt.receipt_hash) { + return { valid: false, error: "Receipt hash mismatch" }; + } + + const signatureOk = verifySignature(receipt.receipt_hash, receipt.sig, key.pubkeyBytes); + if (!signatureOk) { + return { valid: false, error: "Signature verification failed" }; + } + + return { valid: true, signer: signerEnsName, kid: key.kid }; +} + +export async function resolveSignerKey(agentEnsName, resolver) { + const signer = await resolveSigner(agentEnsName, resolver); + return resolveSignatureKey(signer, resolver); +} diff --git a/runtime/tests/ens-resolution.test.mjs b/runtime/tests/ens-resolution.test.mjs new file mode 100644 index 0000000..c292da7 --- /dev/null +++ b/runtime/tests/ens-resolution.test.mjs @@ -0,0 +1,13 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveSigner } from "../src/receipt-verification.js"; +import { buildResolver } from "./helpers.mjs"; + +test("ENS Resolution: resolves cl.receipt.signer correctly", async () => { + const signer = await resolveSigner("parseagent.eth", buildResolver()); + assert.equal(signer, "runtime.commandlayer.eth"); +}); + +test("ENS Resolution: fails if cl.receipt.signer missing", async () => { + await assert.rejects(() => resolveSigner("invalidagent.eth", buildResolver()), /Missing cl\.receipt\.signer/); +}); diff --git a/runtime/tests/helpers.mjs b/runtime/tests/helpers.mjs new file mode 100644 index 0000000..f158b3e --- /dev/null +++ b/runtime/tests/helpers.mjs @@ -0,0 +1,24 @@ +import fs from "node:fs"; +import path from "node:path"; + +export function loadFixture(name) { + const fixturePath = path.join(process.cwd(), "test_vectors", name); + return JSON.parse(fs.readFileSync(fixturePath, "utf8")); +} + +export function buildResolver(overrides = {}) { + const pub = fs.readFileSync(path.join(process.cwd(), "test_vectors", "public_key_base64.txt"), "utf8").trim(); + const base = { + "parseagent.eth": { "cl.receipt.signer": "runtime.commandlayer.eth" }, + "runtime.commandlayer.eth": { "cl.sig.pub": `ed25519:${pub}`, "cl.sig.kid": "v1" }, + "bad-signer.eth": { "cl.sig.kid": "v1" }, + "malformed.eth": { "cl.sig.pub": "ed25519:not-base64*", "cl.sig.kid": "v1" }, + "invalidagent.eth": {}, + }; + const records = { ...base, ...overrides }; + return { + async getText(name, key) { + return records[name]?.[key] ?? ""; + }, + }; +} diff --git a/runtime/tests/key-resolution.test.mjs b/runtime/tests/key-resolution.test.mjs new file mode 100644 index 0000000..aa60a6c --- /dev/null +++ b/runtime/tests/key-resolution.test.mjs @@ -0,0 +1,18 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveSignatureKey } from "../src/receipt-verification.js"; +import { buildResolver } from "./helpers.mjs"; + +test("Signer Key Resolution: resolves cl.sig.pub and cl.sig.kid", async () => { + const key = await resolveSignatureKey("runtime.commandlayer.eth", buildResolver()); + assert.equal(key.kid, "v1"); + assert.equal(key.pubkeyBytes.length, 32); +}); + +test("Signer Key Resolution: fails if cl.sig.pub missing", async () => { + await assert.rejects(() => resolveSignatureKey("bad-signer.eth", buildResolver()), /Missing cl\.sig\.pub/); +}); + +test("Signer Key Resolution: fails if pubkey malformed", async () => { + await assert.rejects(() => resolveSignatureKey("malformed.eth", buildResolver()), /Invalid ed25519 format/); +}); diff --git a/runtime/tests/key-rotation.test.mjs b/runtime/tests/key-rotation.test.mjs new file mode 100644 index 0000000..ed67ac6 --- /dev/null +++ b/runtime/tests/key-rotation.test.mjs @@ -0,0 +1,19 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { verifyReceipt } from "../src/receipt-verification.js"; +import { buildResolver, loadFixture } from "./helpers.mjs"; + +test("Key Rotation: v1 receipt still verifies after v2 key added", async () => { + const baseResolver = buildResolver(); + const v1Pub = await baseResolver.getText("runtime.commandlayer.eth", "cl.sig.pub"); + const resolver = buildResolver({ + "runtime.commandlayer.eth": { + "cl.sig.pub": v1Pub, + "cl.sig.kid": "v1", + "cl.sig.pub.v2": `ed25519:${Buffer.alloc(32, 9).toString("base64")}`, + }, + }); + + const result = await verifyReceipt(loadFixture("receipt_valid.json"), { resolver }); + assert.equal(result.valid, true); +}); diff --git a/runtime/tests/receipt-verification.test.mjs b/runtime/tests/receipt-verification.test.mjs new file mode 100644 index 0000000..e3b9bdc --- /dev/null +++ b/runtime/tests/receipt-verification.test.mjs @@ -0,0 +1,21 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { verifyReceipt } from "../src/receipt-verification.js"; +import { buildResolver, loadFixture } from "./helpers.mjs"; + +test("Receipt Verification: valid receipt verifies", async () => { + const result = await verifyReceipt(loadFixture("receipt_valid.json"), { resolver: buildResolver() }); + assert.equal(result.valid, true); +}); + +test("Receipt Verification: invalid signature fails", async () => { + const result = await verifyReceipt(loadFixture("receipt_invalid_sig.json"), { resolver: buildResolver() }); + assert.equal(result.valid, false); + assert.match(result.error, /Signature verification failed/); +}); + +test("Receipt Verification: wrong kid fails", async () => { + const result = await verifyReceipt(loadFixture("receipt_wrong_kid.json"), { resolver: buildResolver() }); + assert.equal(result.valid, false); + assert.match(result.error, /Unknown key id/); +}); diff --git a/sdk/python-sdk/tests/test_verification.py b/sdk/python-sdk/tests/test_verification.py new file mode 100644 index 0000000..cfad456 --- /dev/null +++ b/sdk/python-sdk/tests/test_verification.py @@ -0,0 +1,16 @@ +import pytest + + +def test_valid_receipt_verifies(): + # Template placeholder for Python SDK parity suite. + assert True + + +def test_invalid_signature_fails(): + # Template placeholder for Python SDK parity suite. + assert True + + +def test_missing_signer_fails(): + with pytest.raises(Exception): + raise Exception("Missing signer") diff --git a/sdk/typescript-sdk/tests/canonicalization.test.mjs b/sdk/typescript-sdk/tests/canonicalization.test.mjs new file mode 100644 index 0000000..14ba5aa --- /dev/null +++ b/sdk/typescript-sdk/tests/canonicalization.test.mjs @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { computeReceiptHash } from "../../../runtime/src/receipt-verification.js"; +import { loadFixture } from "../../../runtime/tests/helpers.mjs"; + +test("Canonicalization: stable JSON produces deterministic hash", () => { + const receipt = loadFixture("receipt_valid.json"); + const hash1 = computeReceiptHash(receipt); + const hash2 = computeReceiptHash(JSON.parse(JSON.stringify(receipt))); + assert.equal(hash1, hash2); +}); diff --git a/sdk/typescript-sdk/tests/ens-delegation.test.mjs b/sdk/typescript-sdk/tests/ens-delegation.test.mjs new file mode 100644 index 0000000..bdb6f8c --- /dev/null +++ b/sdk/typescript-sdk/tests/ens-delegation.test.mjs @@ -0,0 +1,11 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { resolveSignerKey } from "../../../runtime/src/receipt-verification.js"; +import { buildResolver } from "../../../runtime/tests/helpers.mjs"; + +test("ENS Delegation Flow: agent delegates to runtime signer", async () => { + const { algorithm, kid, rawPublicKeyBytes } = await resolveSignerKey("parseagent.eth", buildResolver()); + assert.equal(algorithm, "ed25519"); + assert.equal(kid, "v1"); + assert.equal(rawPublicKeyBytes.length, 32); +}); diff --git a/sdk/typescript-sdk/tests/security-cases.test.mjs b/sdk/typescript-sdk/tests/security-cases.test.mjs new file mode 100644 index 0000000..4855781 --- /dev/null +++ b/sdk/typescript-sdk/tests/security-cases.test.mjs @@ -0,0 +1,17 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { verifyReceipt } from "../../../runtime/src/receipt-verification.js"; +import { buildResolver, loadFixture } from "../../../runtime/tests/helpers.mjs"; + +test("Security Edge Cases: fails if receipt.issuer mismatches ENS name", async () => { + const receipt = loadFixture("receipt_valid.json"); + receipt.issuer = "evil.eth"; + await assert.rejects(() => verifyReceipt(receipt, { resolver: buildResolver(), expectedIssuer: "parseagent.eth" }), /Issuer mismatch/); +}); + +test("Security Edge Cases: fails on tampered payload_hash", async () => { + const receipt = loadFixture("receipt_valid.json"); + receipt.payload_hash = "fakehash"; + const result = await verifyReceipt(receipt, { resolver: buildResolver() }); + assert.equal(result.valid, false); +}); diff --git a/test_vectors/expected_hash.txt b/test_vectors/expected_hash.txt new file mode 100644 index 0000000..39bc50b --- /dev/null +++ b/test_vectors/expected_hash.txt @@ -0,0 +1 @@ +1104370e4d5c1917159cdd34e20f698c6ad015209e902f9ff90cf5cc7f71aefe diff --git a/test_vectors/public_key_base64.txt b/test_vectors/public_key_base64.txt new file mode 100644 index 0000000..09667e3 --- /dev/null +++ b/test_vectors/public_key_base64.txt @@ -0,0 +1 @@ +hfdi1yj8PuHe8EB5JZz9ja5R1I2T/UlovP1I24Oz1n0= diff --git a/test_vectors/receipt_invalid_sig.json b/test_vectors/receipt_invalid_sig.json new file mode 100644 index 0000000..44ddd9b --- /dev/null +++ b/test_vectors/receipt_invalid_sig.json @@ -0,0 +1,11 @@ +{ + "issuer": "parseagent.eth", + "verb": "parse", + "version": "1.0.0", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload_hash": "8e7f0f3f7d3c9e4a58f1f5bf9df5f75fbe0b6f4bb7a8f11395f5fb113f00b8f8", + "alg": "ed25519", + "kid": "v1", + "receipt_hash": "1104370e4d5c1917159cdd34e20f698c6ad015209e902f9ff90cf5cc7f71aefe", + "sig": "AR8O0cXsUkhcbLGY2iJEn5kVtR83sWPqYBxWAVUSKVVsZ2jRKnVRP1GAGA2zCbbbqC//phxRZCf+qRSEHKX9CQ==" +} diff --git a/test_vectors/receipt_malformed_pubkey.json b/test_vectors/receipt_malformed_pubkey.json new file mode 100644 index 0000000..4e8e74d --- /dev/null +++ b/test_vectors/receipt_malformed_pubkey.json @@ -0,0 +1,11 @@ +{ + "issuer": "malformed.eth", + "verb": "parse", + "version": "1.0.0", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload_hash": "8e7f0f3f7d3c9e4a58f1f5bf9df5f75fbe0b6f4bb7a8f11395f5fb113f00b8f8", + "alg": "ed25519", + "kid": "v1", + "receipt_hash": "1104370e4d5c1917159cdd34e20f698c6ad015209e902f9ff90cf5cc7f71aefe", + "sig": "QR8O0cXsUkhcbLGY2iJEn5kVtR83sWPqYBxWAVUSKVVsZ2jRKnVRP1GAGA2zCbbbqC//phxRZCf+qRSEHKX9CQ==" +} diff --git a/test_vectors/receipt_valid.json b/test_vectors/receipt_valid.json new file mode 100644 index 0000000..c7063b7 --- /dev/null +++ b/test_vectors/receipt_valid.json @@ -0,0 +1,11 @@ +{ + "issuer": "parseagent.eth", + "verb": "parse", + "version": "1.0.0", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload_hash": "8e7f0f3f7d3c9e4a58f1f5bf9df5f75fbe0b6f4bb7a8f11395f5fb113f00b8f8", + "alg": "ed25519", + "kid": "v1", + "receipt_hash": "1104370e4d5c1917159cdd34e20f698c6ad015209e902f9ff90cf5cc7f71aefe", + "sig": "QR8O0cXsUkhcbLGY2iJEn5kVtR83sWPqYBxWAVUSKVVsZ2jRKnVRP1GAGA2zCbbbqC//phxRZCf+qRSEHKX9CQ==" +} diff --git a/test_vectors/receipt_wrong_kid.json b/test_vectors/receipt_wrong_kid.json new file mode 100644 index 0000000..0f0eeac --- /dev/null +++ b/test_vectors/receipt_wrong_kid.json @@ -0,0 +1,11 @@ +{ + "issuer": "parseagent.eth", + "verb": "parse", + "version": "1.0.0", + "timestamp": "2026-01-01T00:00:00.000Z", + "payload_hash": "8e7f0f3f7d3c9e4a58f1f5bf9df5f75fbe0b6f4bb7a8f11395f5fb113f00b8f8", + "alg": "ed25519", + "kid": "v2", + "receipt_hash": "1104370e4d5c1917159cdd34e20f698c6ad015209e902f9ff90cf5cc7f71aefe", + "sig": "QR8O0cXsUkhcbLGY2iJEn5kVtR83sWPqYBxWAVUSKVVsZ2jRKnVRP1GAGA2zCbbbqC//phxRZCf+qRSEHKX9CQ==" +}