From db5f669c04d22426890a58d76ae03aec5b668f87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 12 May 2026 20:37:38 +0000 Subject: [PATCH] feat: clean up runtime-core to single authoritative protocol implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all legacy/duplicate src files: canonical.ts, encoding.ts, errors.ts, normalize.ts, schema-client.ts, types.ts (all used wrong protocol or imported ajv which is not a dep) - Remove root-level duplicate TS files (old pre-src layout) - Remove stale tests/runtime-core.test.mjs (imported removed API) - Remove instructional APPLY.md and DELETIONS.sh (already applied) - Fix crypto.ts: use sign(null,...)/verify(null,...) for Ed25519 — createSign("Ed25519") is invalid in Node.js crypto API - Fix package.json test script: --require tsx/esm → --import tsx/esm (ESM packages require --import, not --require) - Fix test/crypto.test.ts: static import of createHash, async callback All 41 tests pass (canonicalize, crypto, receipt round-trip). https://claude.ai/code/session_01GJsQ9fnXzuYDuhtBuByKeW --- APPLY.md | 54 ---- DELETIONS.sh | 18 -- canonicalize.test.ts | 67 ---- canonicalize.ts | 176 ----------- ci.yml | 64 ---- crypto.test.ts | 138 --------- crypto.ts | 183 ----------- ens.ts | 138 --------- index.ts | 61 ---- package-lock.json | 594 ++++++++++++++++++++++++++++++++---- package.json | 2 +- receipt.test.ts | 156 ---------- receipt.ts | 253 --------------- src/canonical.ts | 52 ---- src/crypto.ts | 12 +- src/encoding.ts | 39 --- src/errors.ts | 25 -- src/normalize.ts | 49 --- src/schema-client.ts | 144 --------- src/types.ts | 141 --------- test/crypto.test.ts | 4 +- tests/runtime-core.test.mjs | 39 --- 22 files changed, 549 insertions(+), 1860 deletions(-) delete mode 100644 APPLY.md delete mode 100644 DELETIONS.sh delete mode 100644 canonicalize.test.ts delete mode 100644 canonicalize.ts delete mode 100644 ci.yml delete mode 100644 crypto.test.ts delete mode 100644 crypto.ts delete mode 100644 ens.ts delete mode 100644 index.ts delete mode 100644 receipt.test.ts delete mode 100644 receipt.ts delete mode 100644 src/canonical.ts delete mode 100644 src/encoding.ts delete mode 100644 src/errors.ts delete mode 100644 src/normalize.ts delete mode 100644 src/schema-client.ts delete mode 100644 src/types.ts delete mode 100644 tests/runtime-core.test.mjs diff --git a/APPLY.md b/APPLY.md deleted file mode 100644 index cd51b7e..0000000 --- a/APPLY.md +++ /dev/null @@ -1,54 +0,0 @@ -# runtime-core — Apply Instructions -# Apply in this exact order - -# ── Step 1: Deletions ──────────────────────────────────────────────────────── -bash DELETIONS.sh - -# ── Step 2: Replace source files ──────────────────────────────────────────── -# Copy these files into the repo (replacing existing): -# src/crypto.ts ← new: protocol constants, standard base64, raw-byte signing -# src/ens.ts ← new: hard-fail ENS, standard base64 key parsing -# src/canonicalize.ts ← new: single implementation + CANONICAL_TEST_VECTORS -# src/receipt.ts ← new: correct proof field names (alg/kid/signer_id) -# src/index.ts ← new: clean public API export - -# ── Step 3: New files ──────────────────────────────────────────────────────── -# test/canonicalize.test.ts -# test/crypto.test.ts -# test/receipt.test.ts -# .github/workflows/ci.yml -# .gitignore (replaces or creates) -# .env.example -# CHANGELOG.md -# SECURITY.md -# CONTRIBUTING.md -# package.json (replace: pins ethers ^6.13.0, adds publishConfig, exports map) - -# ── Step 4: Install & verify ───────────────────────────────────────────────── -npm install -npm run build -npm test - -# Expected output: all tests pass, no type errors - -# ── Step 5: Commit ─────────────────────────────────────────────────────────── -git add -A -git commit -m "feat: v1.1.0 protocol alignment - -- Fix signing message: raw canonical UTF-8 bytes (not sha256 hex) -- Fix ENS key encoding: standard base64 with = padding -- Fix proof field names: alg/kid/signer_id (not signature_alg/key_id/signer) -- Remove dist/ from git, add to .gitignore -- Add publishConfig for npm publication -- Add CI workflow -- Add CANONICAL_TEST_VECTORS for cross-repo alignment -- Add CHANGELOG.md, SECURITY.md, CONTRIBUTING.md -- ENS resolution now hard-fails (no silent fallback)" - -git tag v1.1.0 -git push origin main --follow-tags - -# ── Step 6: Publish to npm ─────────────────────────────────────────────────── -npm publish -# After this, all other repos switch from github:commandlayer/runtime-core#main -# to @commandlayer/runtime-core@1.1.0 diff --git a/DELETIONS.sh b/DELETIONS.sh deleted file mode 100644 index a4cfe4a..0000000 --- a/DELETIONS.sh +++ /dev/null @@ -1,18 +0,0 @@ -# DELETIONS — runtime-core -# Run these commands in the repo root before applying the new files - -# 1. Remove dist/ from git tracking entirely -git rm -r --cached dist/ -# (The new .gitignore will prevent it from being re-added) - -# 2. Remove shims if present (not needed on Node 20+) -git rm -f src/shims.d.ts 2>/dev/null || true - -# 3. Confirm .gitignore now has dist/ entry (added in new .gitignore file) -grep "dist/" .gitignore && echo "✓ dist/ is gitignored" - -# 4. Stage the deletions -git add .gitignore -git add -A - -echo "Deletion step complete. Apply new files next, then: npm install && npm run build && npm test" diff --git a/canonicalize.test.ts b/canonicalize.test.ts deleted file mode 100644 index 07cae23..0000000 --- a/canonicalize.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Canonicalization tests — runtime-core - * - * These tests run the CANONICAL_TEST_VECTORS that all downstream repos - * import and run in their own test suites. If these pass here and pass - * in agent-sdk, verifyagent, mcp-server etc., canonicalization is aligned. - */ - -import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; -import { canonicalize, CANONICAL_TEST_VECTORS } from "../src/canonicalize.js"; - -describe("canonicalize — test vectors", () => { - for (const vector of CANONICAL_TEST_VECTORS) { - it(vector.description, () => { - const result = canonicalize(vector.input); - assert.strictEqual(result, vector.expected); - }); - } -}); - -describe("canonicalize — error cases", () => { - it("throws on Infinity", () => { - assert.throws(() => canonicalize({ x: Infinity }), /non-finite number/); - }); - - it("throws on NaN", () => { - assert.throws(() => canonicalize({ x: NaN }), /non-finite number/); - }); - - it("throws on Date objects", () => { - assert.throws(() => canonicalize({ d: new Date() }), /Date objects/); - }); - - it("throws on BigInt", () => { - assert.throws(() => canonicalize({ n: BigInt(1) }), /BigInt/); - }); - - it("throws on circular reference", () => { - const obj: Record = {}; - obj.self = obj; - assert.throws(() => canonicalize(obj), /circular reference/); - }); - - it("skips undefined values", () => { - const result = canonicalize({ a: 1, b: undefined, c: 3 }); - assert.strictEqual(result, '{"a":1,"c":3}'); - }); - - it("undefined in array becomes null", () => { - const result = canonicalize([1, undefined, 3]); - assert.strictEqual(result, "[1,null,3]"); - }); -}); - -describe("canonicalize — determinism", () => { - it("same output for same input regardless of insertion order", () => { - const a = canonicalize({ z: 1, a: 2 }); - const b = canonicalize({ a: 2, z: 1 }); - assert.strictEqual(a, b); - }); - - it("nested keys are also sorted", () => { - const result = canonicalize({ outer: { z: 9, a: 1 } }); - assert.strictEqual(result, '{"outer":{"a":1,"z":9}}'); - }); -}); diff --git a/canonicalize.ts b/canonicalize.ts deleted file mode 100644 index f0c0808..0000000 --- a/canonicalize.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * @commandlayer/runtime-core — canonicalize.ts - * - * Canonical JSON serialization for CommandLayer receipt signing. - * - * Method ID: json.sorted_keys.v1 (matches ENS cl.sig.canonical production record) - * - * Rules: - * - Keys sorted lexicographically at every level (recursive) - * - No undefined values (skipped, same as JSON.stringify default) - * - No Date objects (must be pre-serialized to ISO string by caller) - * - No circular references (throws) - * - Numbers: standard JSON (no Infinity, no NaN — both throw) - * - Arrays: order preserved (not sorted) - * - Unicode: standard JSON escaping (no additional escaping) - * - * This is the single canonical implementation for the CommandLayer protocol. - * All other repos import from here — do not copy or reimplement. - */ - -import { CANONICAL_METHOD } from "./crypto.js"; - -export { CANONICAL_METHOD }; - -/** - * Canonicalize a value using json.sorted_keys.v1. - * - * Returns a stable JSON string suitable for Ed25519 signing. - * The returned string is UTF-8 safe and deterministic across all - * compliant implementations. - * - * @throws if value contains Date objects, Infinity, NaN, or circular refs - */ -export function canonicalize(value: unknown): string { - return canonicalizeValue(value, new Set()); -} - -function canonicalizeValue(value: unknown, seen: Set): string { - if (value === null) return "null"; - if (value === undefined) return ""; // caller should filter undefined - - const type = typeof value; - - if (type === "boolean") return value ? "true" : "false"; - - if (type === "number") { - if (!isFinite(value as number)) { - throw new Error( - `canonicalize: non-finite number (${value}) is not valid JSON. ` + - `Convert Infinity/NaN to null or a string before canonicalizing.` - ); - } - return JSON.stringify(value); - } - - if (type === "string") return JSON.stringify(value); - - if (type === "bigint") { - throw new Error( - `canonicalize: BigInt (${value}n) is not valid JSON. ` + - `Convert to string or number before canonicalizing.` - ); - } - - if (value instanceof Date) { - throw new Error( - `canonicalize: Date objects must be pre-serialized to ISO strings. ` + - `Use value.toISOString() before canonicalizing.` - ); - } - - if (Array.isArray(value)) { - if (seen.has(value)) throw new Error("canonicalize: circular reference"); - seen.add(value); - const items = value.map((item) => - item === undefined ? "null" : canonicalizeValue(item, seen) - ); - seen.delete(value); - return `[${items.join(",")}]`; - } - - if (type === "object") { - if (seen.has(value as object)) - throw new Error("canonicalize: circular reference"); - seen.add(value as object); - - const obj = value as Record; - const sortedKeys = Object.keys(obj).sort(); - const pairs: string[] = []; - - for (const key of sortedKeys) { - const v = obj[key]; - if (v === undefined) continue; // skip undefined values - pairs.push(`${JSON.stringify(key)}:${canonicalizeValue(v, seen)}`); - } - - seen.delete(value as object); - return `{${pairs.join(",")}}`; - } - - // functions, symbols — not serializable - throw new Error(`canonicalize: unsupported type "${type}"`); -} - -/** - * Canonical test vectors for cross-repo validation. - * - * Every repo that imports @commandlayer/runtime-core should run these - * in their test suite to confirm canonicalization behaves identically. - * - * Import: import { CANONICAL_TEST_VECTORS } from '@commandlayer/runtime-core/canonicalize' - */ -export const CANONICAL_TEST_VECTORS = [ - { - description: "empty object", - input: {}, - expected: "{}", - }, - { - description: "single key", - input: { a: 1 }, - expected: '{"a":1}', - }, - { - description: "keys sorted lexicographically", - input: { z: 1, a: 2, m: 3 }, - expected: '{"a":2,"m":3,"z":1}', - }, - { - description: "nested object keys sorted", - input: { b: { z: 1, a: 2 }, a: 1 }, - expected: '{"a":1,"b":{"a":2,"z":1}}', - }, - { - description: "array order preserved", - input: { arr: [3, 1, 2] }, - expected: '{"arr":[3,1,2]}', - }, - { - description: "null value", - input: { x: null }, - expected: '{"x":null}', - }, - { - description: "undefined value skipped", - input: { a: 1, b: undefined, c: 3 }, - expected: '{"a":1,"c":3}', - }, - { - description: "boolean values", - input: { t: true, f: false }, - expected: '{"f":false,"t":true}', - }, - { - description: "unicode string", - input: { msg: "hello \u4e16\u754c" }, - expected: '{"msg":"hello \u4e16\u754c"}', - }, - { - description: "string with quotes and backslash", - input: { s: 'say "hi" \\here' }, - expected: '{"s":"say \\"hi\\" \\\\here"}', - }, - { - description: "full receipt-like object", - input: { - verb: "verify", - version: "1.1.0", - agent: "runtime.commandlayer.eth", - payload: { input: "test", result: "ok" }, - timestamp: "2026-05-12T00:00:00.000Z", - }, - expected: - '{"agent":"runtime.commandlayer.eth","payload":{"input":"test","result":"ok"},"timestamp":"2026-05-12T00:00:00.000Z","verb":"verify","version":"1.1.0"}', - }, -] as const; diff --git a/ci.yml b/ci.yml deleted file mode 100644 index 6be3f5a..0000000 --- a/ci.yml +++ /dev/null @@ -1,64 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build-and-test: - name: Build & Test - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [20.x, 22.x] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v4 - with: - node-version: ${{ matrix.node-version }} - cache: "npm" - - - name: Install dependencies - run: npm ci - - - name: Type check - run: npx tsc --noEmit - - - name: Build - run: npm run build - - - name: Test - run: npm test - - publish-check: - name: Publish Dry Run - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' - needs: build-and-test - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "22.x" - cache: "npm" - registry-url: "https://registry.npmjs.org" - - - name: Install dependencies - run: npm ci - - - name: Build - run: npm run build - - - name: Publish dry run - run: npm publish --dry-run diff --git a/crypto.test.ts b/crypto.test.ts deleted file mode 100644 index d56d7f4..0000000 --- a/crypto.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Crypto tests — runtime-core - * - * Tests the signing contract: - * message = raw UTF-8 bytes of canonicalize(payload) - * signature = Ed25519(message) - * encoding = standard base64 - */ - -import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; -import { - generateEd25519KeyPair, - signCanonical, - verifyCanonical, - verifyCanonicalWithRawKey, - encodePublicKey, - parsePublicKey, -} from "../src/crypto.js"; -import { canonicalize } from "../src/canonicalize.js"; - -describe("key encoding", () => { - it("encodes 32-byte key with standard base64 (= padding)", () => { - const raw = new Uint8Array(32).fill(1); - const encoded = encodePublicKey(raw); - assert.ok(encoded.startsWith("ed25519:")); - // Standard base64 may have = padding - const b64 = encoded.slice("ed25519:".length); - assert.ok(/^[A-Za-z0-9+/]+=*$/.test(b64), "should be standard base64"); - }); - - it("throws on wrong key length", () => { - assert.throws(() => encodePublicKey(new Uint8Array(31)), /32 bytes/); - assert.throws(() => encodePublicKey(new Uint8Array(33)), /32 bytes/); - }); - - it("parsePublicKey round-trips encodePublicKey", () => { - const raw = new Uint8Array(32); - crypto.getRandomValues(raw); - const encoded = encodePublicKey(raw); - const decoded = parsePublicKey(encoded); - assert.deepStrictEqual(decoded, raw); - }); - - it("parses production ENS record format", () => { - // Live ENS record: ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY= - const prod = "ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY="; - const raw = parsePublicKey(prod); - assert.strictEqual(raw.length, 32); - }); - - it("rejects missing ed25519: prefix", () => { - assert.throws( - () => parsePublicKey("hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY="), - /ed25519:/ - ); - }); -}); - -describe("sign and verify — round trip", () => { - it("signs and verifies a canonical string with PEM keys", () => { - const { privateKeyPem, publicKeyPem } = generateEd25519KeyPair(); - const payload = { verb: "verify", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z" }; - const canonical = canonicalize(payload); - - const sig = signCanonical(canonical, privateKeyPem); - - // Signature is standard base64 - assert.ok(/^[A-Za-z0-9+/]+=*$/.test(sig), "signature should be standard base64"); - // Ed25519 signatures are always 64 bytes - assert.strictEqual(Buffer.from(sig, "base64").length, 64); - - const valid = verifyCanonical(canonical, sig, publicKeyPem); - assert.strictEqual(valid, true); - }); - - it("verifies with raw public key", () => { - const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); - const payload = { verb: "sign", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z" }; - const canonical = canonicalize(payload); - const sig = signCanonical(canonical, privateKeyPem); - - const valid = verifyCanonicalWithRawKey(canonical, sig, rawPublicKey); - assert.strictEqual(valid, true); - }); - - it("returns false for tampered payload", () => { - const { privateKeyPem, publicKeyPem } = generateEd25519KeyPair(); - const payload = { verb: "verify", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z" }; - const canonical = canonicalize(payload); - const sig = signCanonical(canonical, privateKeyPem); - - const tampered = canonicalize({ ...payload, agent: "evil.eth" }); - const valid = verifyCanonical(tampered, sig, publicKeyPem); - assert.strictEqual(valid, false); - }); - - it("returns false for wrong key", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - const { publicKeyPem: wrongPub } = generateEd25519KeyPair(); - const canonical = canonicalize({ verb: "test" }); - const sig = signCanonical(canonical, privateKeyPem); - - const valid = verifyCanonical(canonical, sig, wrongPub); - assert.strictEqual(valid, false); - }); - - it("throws on signature wrong length", () => { - const { publicKeyPem } = generateEd25519KeyPair(); - assert.throws( - () => verifyCanonical("test", Buffer.from("tooshort").toString("base64"), publicKeyPem), - /64 bytes/ - ); - }); -}); - -describe("sign and verify — signing message is raw bytes", () => { - it("signing message is raw canonical UTF-8, not sha256 hex", () => { - // This test documents and enforces the protocol decision: - // we sign raw bytes, not sha256(canonical). - // If this ever needs to change, it requires a protocol version bump. - const { privateKeyPem, publicKeyPem } = generateEd25519KeyPair(); - const payload = { verb: "test", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z" }; - const canonical = canonicalize(payload); - - // Sign raw canonical - const sig = signCanonical(canonical, privateKeyPem); - - // Verification with raw canonical should succeed - assert.strictEqual(verifyCanonical(canonical, sig, publicKeyPem), true); - - // Verification with sha256(canonical) would fail — different message - const { createHash } = await import("node:crypto"); - const sha256hex = createHash("sha256").update(canonical, "utf8").digest("hex"); - // sha256hex is a different string — if someone signs this instead, verify fails - assert.strictEqual(verifyCanonical(sha256hex, sig, publicKeyPem), false); - }); -}); diff --git a/crypto.ts b/crypto.ts deleted file mode 100644 index da10aba..0000000 --- a/crypto.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * @commandlayer/runtime-core — crypto.ts - * - * PROTOCOL SIGNING CONTRACT (canonical, locked to ENS production record): - * cl.sig.canonical = json.sorted_keys.v1 - * cl.sig.pub = ed25519: (= padding, +/ charset) - * signing message = Ed25519.sign(raw_canonical_utf8_bytes) - * where canonical = canonicalizeSortedKeysV1(payload) - * - * DO NOT change the signing message without a protocol version bump and - * coordinated migration across all repos. - */ - -import { createSign, createVerify, generateKeyPairSync } from "node:crypto"; - -// ── Protocol constants ──────────────────────────────────────────────────────── - -export const PROTOCOL_VERSION = "1.1.0" as const; -export const CANONICAL_METHOD = "json.sorted_keys.v1" as const; -export const SIGNATURE_ALG = "ed25519" as const; - -/** The ENS text record key for the signer's public key. */ -export const ENS_KEY_PUB = "cl.sig.pub" as const; -export const ENS_KEY_KID = "cl.sig.kid" as const; -export const ENS_KEY_CANONICAL = "cl.sig.canonical" as const; -export const ENS_KEY_SIGNER = "cl.receipt.signer" as const; - -// ── Key encoding (standard base64, matches production ENS records) ──────────── - -/** - * Encode a raw 32-byte Ed25519 public key to the ENS cl.sig.pub format: - * ed25519: - * - * Standard base64: uses A-Z a-z 0-9 +/ with = padding. - * This matches the live ENS record for runtime.commandlayer.eth. - */ -export function encodePublicKey(rawBytes: Uint8Array): string { - if (rawBytes.length !== 32) { - throw new Error( - `Ed25519 public key must be 32 bytes, got ${rawBytes.length}` - ); - } - return `ed25519:${Buffer.from(rawBytes).toString("base64")}`; -} - -/** - * Parse an ENS cl.sig.pub value to raw 32-byte public key. - * Accepts: ed25519: - * Rejects anything that isn't 32 bytes after decode. - */ -export function parsePublicKey(ensPubValue: string): Uint8Array { - const prefix = "ed25519:"; - if (!ensPubValue.startsWith(prefix)) { - throw new Error( - `cl.sig.pub must start with "ed25519:", got: ${ensPubValue.slice(0, 20)}` - ); - } - const b64 = ensPubValue.slice(prefix.length); - const raw = Buffer.from(b64, "base64"); - if (raw.length !== 32) { - throw new Error( - `cl.sig.pub decoded to ${raw.length} bytes; expected 32 (Ed25519 public key)` - ); - } - return new Uint8Array(raw); -} - -// ── Signing ─────────────────────────────────────────────────────────────────── - -/** - * Sign a canonical string using an Ed25519 private key (PEM or DER). - * - * Signing message: raw UTF-8 bytes of the canonical string. - * NOT sha256(canonical) — signs the data directly. - * - * Returns: standard base64-encoded 64-byte signature. - */ -export function signCanonical( - canonicalString: string, - privateKeyPem: string -): string { - const sign = createSign("Ed25519"); - sign.update(canonicalString, "utf8"); - sign.end(); - const sigBuffer = sign.sign(privateKeyPem); - if (sigBuffer.length !== 64) { - throw new Error( - `Ed25519 signature must be 64 bytes, got ${sigBuffer.length}` - ); - } - return sigBuffer.toString("base64"); -} - -/** - * Verify an Ed25519 signature over a canonical string. - * - * @param canonicalString The canonical JSON string that was signed - * @param signatureBase64 Standard base64-encoded signature (64 bytes) - * @param publicKeyPem PEM-encoded Ed25519 public key - * - * Returns true if signature is valid, false otherwise. - * Never throws on invalid signature — only throws on malformed inputs. - */ -export function verifyCanonical( - canonicalString: string, - signatureBase64: string, - publicKeyPem: string -): boolean { - const sigBuffer = Buffer.from(signatureBase64, "base64"); - if (sigBuffer.length !== 64) { - throw new Error( - `Signature must decode to 64 bytes (Ed25519), got ${sigBuffer.length}` - ); - } - try { - const verify = createVerify("Ed25519"); - verify.update(canonicalString, "utf8"); - verify.end(); - return verify.verify(publicKeyPem, sigBuffer); - } catch { - return false; - } -} - -/** - * Verify using a raw 32-byte public key (from ENS cl.sig.pub). - * Converts to PEM internally then delegates to verifyCanonical. - */ -export function verifyCanonicalWithRawKey( - canonicalString: string, - signatureBase64: string, - rawPublicKey: Uint8Array -): boolean { - if (rawPublicKey.length !== 32) { - throw new Error( - `Raw public key must be 32 bytes, got ${rawPublicKey.length}` - ); - } - // Wrap raw key in SubjectPublicKeyInfo DER for node:crypto - // Ed25519 SPKI prefix: 302a300506032b6570032100 - const spkiPrefix = Buffer.from("302a300506032b6570032100", "hex"); - const spkiDer = Buffer.concat([spkiPrefix, Buffer.from(rawPublicKey)]); - const pem = `-----BEGIN PUBLIC KEY-----\n${spkiDer - .toString("base64") - .match(/.{1,64}/g)! - .join("\n")}\n-----END PUBLIC KEY-----`; - return verifyCanonical(canonicalString, signatureBase64, pem); -} - -// ── Key generation (for testing / provisioning) ─────────────────────────────── - -export interface Ed25519KeyPair { - privateKeyPem: string; - publicKeyPem: string; - /** Raw 32-byte public key, ready for ENS cl.sig.pub encoding */ - rawPublicKey: Uint8Array; - /** Formatted ENS cl.sig.pub value */ - ensPubValue: string; -} - -export function generateEd25519KeyPair(): Ed25519KeyPair { - const { privateKey, publicKey } = generateKeyPairSync("ed25519", { - privateKeyEncoding: { type: "pkcs8", format: "pem" }, - publicKeyEncoding: { type: "spki", format: "pem" }, - }); - - // Extract raw 32-byte public key from SPKI PEM - const spkiDer = Buffer.from( - (publicKey as string) - .replace(/-----[^-]+-----/g, "") - .replace(/\s/g, ""), - "base64" - ); - // Last 32 bytes of SPKI DER are the raw Ed25519 key - const rawPublicKey = new Uint8Array(spkiDer.slice(-32)); - - return { - privateKeyPem: privateKey as string, - publicKeyPem: publicKey as string, - rawPublicKey, - ensPubValue: encodePublicKey(rawPublicKey), - }; -} diff --git a/ens.ts b/ens.ts deleted file mode 100644 index ab701a2..0000000 --- a/ens.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * @commandlayer/runtime-core — ens.ts - * - * ENS text record resolution for CommandLayer signer keys. - * - * ENS record format (production, locked): - * cl.sig.pub = ed25519: - * cl.sig.kid = - * cl.sig.canonical = json.sorted_keys.v1 - * cl.receipt.signer = - * - * NO hardcoded fallback keys. ENS resolution failure is a hard error. - * If you need test fixtures, use test/fixtures/ens-mock.ts. - */ - -import { - ENS_KEY_PUB, - ENS_KEY_KID, - ENS_KEY_CANONICAL, - ENS_KEY_SIGNER, - CANONICAL_METHOD, - parsePublicKey, -} from "./crypto.js"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface EnsSignerRecord { - /** ENS name, e.g. runtime.commandlayer.eth */ - name: string; - /** Raw 32-byte Ed25519 public key */ - rawPublicKey: Uint8Array; - /** Short key identifier from cl.sig.kid */ - kid: string; - /** Canonicalization method from cl.sig.canonical */ - canonical: string; -} - -/** - * Minimal ENS provider interface. - * Compatible with ethers v6 EnsResolver and any custom resolver. - */ -export interface EnsProvider { - getResolver(name: string): Promise; -} - -export interface EnsResolver { - getText(key: string): Promise; -} - -// ── Resolution ──────────────────────────────────────────────────────────────── - -/** - * Resolve a CommandLayer signer record from ENS. - * - * Throws on: - * - No resolver found for the ENS name - * - Missing cl.sig.pub record - * - Malformed cl.sig.pub (not ed25519: prefix or wrong key length) - * - cl.sig.canonical mismatch (if present and not json.sorted_keys.v1) - * - * Never falls back to hardcoded keys. - */ -export async function resolveSignerFromENS( - ensName: string, - provider: EnsProvider -): Promise { - let resolver: EnsResolver | null; - try { - resolver = await provider.getResolver(ensName); - } catch (err) { - throw new Error( - `ENS resolution failed for "${ensName}": ${(err as Error).message}` - ); - } - - if (!resolver) { - throw new Error( - `No ENS resolver found for "${ensName}". ` + - `Verify the name is registered and has a resolver set.` - ); - } - - // Fetch all relevant text records in parallel - let pubValue: string | null; - let kidValue: string | null; - let canonicalValue: string | null; - - try { - [pubValue, kidValue, canonicalValue] = await Promise.all([ - resolver.getText(ENS_KEY_PUB), - resolver.getText(ENS_KEY_KID), - resolver.getText(ENS_KEY_CANONICAL), - ]); - } catch (err) { - throw new Error( - `Failed to fetch ENS text records for "${ensName}": ${ - (err as Error).message - }` - ); - } - - if (!pubValue) { - throw new Error( - `ENS name "${ensName}" has no ${ENS_KEY_PUB} text record. ` + - `Set cl.sig.pub = ed25519: on the ENS name.` - ); - } - - // Validate canonical method if present - if (canonicalValue && canonicalValue !== CANONICAL_METHOD) { - throw new Error( - `ENS name "${ensName}" specifies unsupported canonical method: ` + - `"${canonicalValue}". Only "${CANONICAL_METHOD}" is supported.` - ); - } - - // Parse the public key — throws on malformed input - const rawPublicKey = parsePublicKey(pubValue); - - return { - name: ensName, - rawPublicKey, - kid: kidValue ?? "", - canonical: canonicalValue ?? CANONICAL_METHOD, - }; -} - -/** - * Resolve the public key only (convenience wrapper). - * Use resolveSignerFromENS for full record access. - */ -export async function resolvePublicKeyFromENS( - ensName: string, - provider: EnsProvider -): Promise { - const record = await resolveSignerFromENS(ensName, provider); - return record.rawPublicKey; -} diff --git a/index.ts b/index.ts deleted file mode 100644 index 7100ab4..0000000 --- a/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @commandlayer/runtime-core - * - * The single protocol implementation artifact for the CommandLayer ecosystem. - * All other repos import from here — nothing is reimplemented downstream. - * - * Public API surface: - * - Protocol constants - * - Canonicalization (json.sorted_keys.v1) - * - Ed25519 crypto (sign, verify, key encoding) - * - ENS resolution - * - Receipt building and verification (v1.1.0) - * - Test vectors - */ - -// Protocol constants -export { - PROTOCOL_VERSION, - CANONICAL_METHOD, - SIGNATURE_ALG, - ENS_KEY_PUB, - ENS_KEY_KID, - ENS_KEY_CANONICAL, - ENS_KEY_SIGNER, -} from "./crypto.js"; - -// Canonicalization -export { canonicalize, CANONICAL_TEST_VECTORS } from "./canonicalize.js"; - -// Crypto primitives -export { - encodePublicKey, - parsePublicKey, - signCanonical, - verifyCanonical, - verifyCanonicalWithRawKey, - generateEd25519KeyPair, - type Ed25519KeyPair, -} from "./crypto.js"; - -// ENS resolution -export { - resolveSignerFromENS, - resolvePublicKeyFromENS, - type EnsSignerRecord, - type EnsProvider, - type EnsResolver, -} from "./ens.js"; - -// Receipt v1.1.0 -export { - signReceipt, - verifyReceipt, - isSignedLayeredReceipt, - type ReceiptPayload, - type ReceiptProof, - type SignedLayeredReceipt, - type SignReceiptOptions, - type VerifyReceiptResult, - type VerifyReceiptOptions, -} from "./receipt.js"; diff --git a/package-lock.json b/package-lock.json index cf05f91..82e0c64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,14 +7,17 @@ "": { "name": "@commandlayer/runtime-core", "version": "1.1.0", - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "ajv": "^8.17.1", - "ethers": "^6.0.0" + "ethers": "^6.13.0" }, "devDependencies": { - "@types/node": "^22.13.10", - "typescript": "^5.8.2" + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -23,6 +26,448 @@ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -48,9 +493,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.18", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.18.tgz", - "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -63,20 +508,46 @@ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", "license": "MIT" }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "bin": { + "esbuild": "bin/esbuild" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/ethers": { @@ -122,41 +593,42 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" ], - "license": "BSD-3-Clause" + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, "node_modules/tslib": { @@ -165,6 +637,26 @@ "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index 361174f..2d011c1 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "scripts": { "build": "tsc", "build:watch": "tsc --watch", - "test": "node --test --require tsx/esm test/**/*.test.ts", + "test": "node --test --import tsx/esm test/**/*.test.ts", "test:build": "npm run build && node --test dist/test/**/*.test.js", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build && npm test" diff --git a/receipt.test.ts b/receipt.test.ts deleted file mode 100644 index fcb668c..0000000 --- a/receipt.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Receipt tests — runtime-core - * - * Full round-trip: signReceipt → verifyReceipt - * Tests proof field names, signer matching, and tamper detection. - */ - -import { strict as assert } from "node:assert"; -import { describe, it } from "node:test"; -import { generateEd25519KeyPair } from "../src/crypto.js"; -import { signReceipt, verifyReceipt, isSignedLayeredReceipt } from "../src/receipt.js"; - -const makePayload = () => ({ - verb: "verify", - version: "1.1.0", - agent: "runtime.commandlayer.eth", - timestamp: new Date().toISOString(), - payload: { input: "test-value" }, -}); - -describe("signReceipt", () => { - it("produces a SignedLayeredReceipt with correct proof fields", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - const receipt = signReceipt(makePayload(), { - privateKeyPem, - kid: "vC4WbcNoq2znSCiQ", - signerEns: "runtime.commandlayer.eth", - }); - - assert.ok(receipt.receipt); - assert.ok(receipt.signature?.proof); - - const proof = receipt.signature.proof; - // Field names must match protocol spec - assert.strictEqual(proof.alg, "ed25519"); // not signature_alg - assert.strictEqual(proof.kid, "vC4WbcNoq2znSCiQ"); // not key_id - assert.strictEqual(proof.signer_id, "runtime.commandlayer.eth"); // not signer - assert.strictEqual(proof.canonical, "json.sorted_keys.v1"); - - // Signature is standard base64, 64 bytes - const sigBytes = Buffer.from(proof.signature, "base64"); - assert.strictEqual(sigBytes.length, 64); - }); - - it("throws if verb is missing", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - assert.throws( - () => signReceipt({ version: "1.1.0", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z" } as any, { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }), - /verb/ - ); - }); -}); - -describe("verifyReceipt — full round trip", () => { - it("returns valid: true for a correctly signed receipt", () => { - const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, - kid: "vC4WbcNoq2znSCiQ", - signerEns: "runtime.commandlayer.eth", - }); - - const result = verifyReceipt(signed, { - rawPublicKey, - expectedSigner: "runtime.commandlayer.eth", - expectedKid: "vC4WbcNoq2znSCiQ", - }); - - assert.strictEqual(result.valid, true); - assert.strictEqual(result.checks.signatureValid, true); - assert.strictEqual(result.checks.signerMatched, true); - assert.strictEqual(result.checks.kidMatched, true); - assert.strictEqual(result.checks.algValid, true); - }); - - it("returns valid: false when payload is tampered", () => { - const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }); - - // Tamper with the receipt payload after signing - signed.receipt.payload = { input: "tampered" }; - - const result = verifyReceipt(signed, { rawPublicKey }); - assert.strictEqual(result.valid, false); - assert.strictEqual(result.checks.signatureValid, false); - }); - - it("returns valid: false when signer doesn't match expectedSigner", () => { - const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "alice.eth", - }); - - const result = verifyReceipt(signed, { - rawPublicKey, - expectedSigner: "bob.eth", // different from alice.eth - }); - - assert.strictEqual(result.valid, false); - assert.strictEqual(result.checks.signerMatched, false); - // signerMatched MUST be part of validity — this tests the critical bug from audit #2 - }); - - it("returns valid: false for wrong public key", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - const { rawPublicKey: wrongKey } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }); - - const result = verifyReceipt(signed, { rawPublicKey: wrongKey }); - assert.strictEqual(result.valid, false); - assert.strictEqual(result.checks.signatureValid, false); - }); - - it("rejects unknown algorithm", () => { - const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }); - // Force wrong algorithm - (signed.signature.proof as any).alg = "rsa-pkcs1v15"; - - const result = verifyReceipt(signed, { rawPublicKey }); - assert.strictEqual(result.valid, false); - assert.ok(result.reason?.includes("Unsupported algorithm")); - }); - - it("throws if no public key provided", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }); - assert.throws(() => verifyReceipt(signed, {}), /rawPublicKey or publicKeyPem/); - }); -}); - -describe("isSignedLayeredReceipt", () => { - it("returns true for valid receipt", () => { - const { privateKeyPem } = generateEd25519KeyPair(); - const signed = signReceipt(makePayload(), { - privateKeyPem, kid: "kid1", signerEns: "test.eth", - }); - assert.strictEqual(isSignedLayeredReceipt(signed), true); - }); - - it("returns false for flat/legacy receipt", () => { - assert.strictEqual(isSignedLayeredReceipt({ proof: { signature: "abc" } }), false); - assert.strictEqual(isSignedLayeredReceipt(null), false); - assert.strictEqual(isSignedLayeredReceipt("string"), false); - }); -}); diff --git a/receipt.ts b/receipt.ts deleted file mode 100644 index b8abfed..0000000 --- a/receipt.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * @commandlayer/runtime-core — receipt.ts - * - * v1.1.0 signed layered receipt builder and verifier. - * - * Proof field names (canonical, matches clas schema): - * proof.alg — signature algorithm ("ed25519") - * proof.kid — key identifier (from ENS cl.sig.kid) - * proof.signer_id — ENS name of signer (e.g. runtime.commandlayer.eth) - * proof.canonical — canonicalization method ("json.sorted_keys.v1") - * proof.signature — standard base64-encoded Ed25519 signature (64 bytes) - */ - -import { canonicalize, CANONICAL_METHOD } from "./canonicalize.js"; -import { - signCanonical, - verifyCanonical, - verifyCanonicalWithRawKey, - SIGNATURE_ALG, - PROTOCOL_VERSION, -} from "./crypto.js"; - -// ── Types ───────────────────────────────────────────────────────────────────── - -export interface ReceiptPayload { - verb: string; - version: string; - agent: string; - timestamp: string; - [key: string]: unknown; -} - -export interface ReceiptProof { - /** Signature algorithm. Always "ed25519". */ - alg: typeof SIGNATURE_ALG; - /** Key identifier from ENS cl.sig.kid */ - kid: string; - /** ENS name of the signer */ - signer_id: string; - /** Canonicalization method. Always "json.sorted_keys.v1". */ - canonical: typeof CANONICAL_METHOD; - /** Standard base64-encoded Ed25519 signature over the canonical receipt string */ - signature: string; -} - -export interface SignedLayeredReceipt { - receipt: ReceiptPayload; - signature: { - proof: ReceiptProof; - }; -} - -// ── Builders ────────────────────────────────────────────────────────────────── - -export interface SignReceiptOptions { - privateKeyPem: string; - kid: string; - signerEns: string; -} - -/** - * Build and sign a v1.1.0 layered receipt. - * - * Signing message: raw UTF-8 bytes of canonicalize(receipt) - * Output signature: standard base64 (64 bytes) - */ -export function signReceipt( - payload: ReceiptPayload, - opts: SignReceiptOptions -): SignedLayeredReceipt { - // Validate required fields - if (!payload.verb) throw new Error("receipt.verb is required"); - if (!payload.agent) throw new Error("receipt.agent is required"); - if (!payload.timestamp) throw new Error("receipt.timestamp is required"); - - const canonical = canonicalize(payload); - const signature = signCanonical(canonical, opts.privateKeyPem); - - return { - receipt: payload, - signature: { - proof: { - alg: SIGNATURE_ALG, - kid: opts.kid, - signer_id: opts.signerEns, - canonical: CANONICAL_METHOD, - signature, - }, - }, - }; -} - -// ── Verification ────────────────────────────────────────────────────────────── - -export interface VerifyReceiptResult { - valid: boolean; - checks: { - structureValid: boolean; - algValid: boolean; - kidMatched: boolean; - signerMatched: boolean; - signatureValid: boolean; - }; - reason?: string; -} - -export interface VerifyReceiptOptions { - /** Expected signer ENS name. If provided, signer_id must match. */ - expectedSigner?: string; - /** Raw 32-byte public key. If provided, used for verification directly. */ - rawPublicKey?: Uint8Array; - /** PEM public key. If provided, used for verification directly. */ - publicKeyPem?: string; - /** Expected kid. If provided, proof.kid must match. */ - expectedKid?: string; -} - -/** - * Verify a v1.1.0 signed layered receipt. - * - * Returns a detailed result with per-check breakdown. - * Never throws on invalid signature — only throws on missing required options. - */ -export function verifyReceipt( - receipt: SignedLayeredReceipt, - opts: VerifyReceiptOptions -): VerifyReceiptResult { - if (!opts.rawPublicKey && !opts.publicKeyPem) { - throw new Error( - "verifyReceipt requires either rawPublicKey or publicKeyPem" - ); - } - - const checks = { - structureValid: false, - algValid: false, - kidMatched: false, - signerMatched: false, - signatureValid: false, - }; - - // Structure check - if ( - !receipt?.receipt || - !receipt?.signature?.proof?.signature || - !receipt?.signature?.proof?.alg || - !receipt?.signature?.proof?.signer_id - ) { - return { - valid: false, - checks, - reason: "Receipt is missing required structure fields", - }; - } - checks.structureValid = true; - - const proof = receipt.signature.proof; - - // Algorithm check - if (proof.alg !== SIGNATURE_ALG) { - return { - valid: false, - checks, - reason: `Unsupported algorithm "${proof.alg}". Only "${SIGNATURE_ALG}" is supported.`, - }; - } - checks.algValid = true; - - // Kid check (if expected) - checks.kidMatched = opts.expectedKid - ? proof.kid === opts.expectedKid - : true; - - // Signer check (if expected) - checks.signerMatched = opts.expectedSigner - ? proof.signer_id === opts.expectedSigner - : true; - - // Signature verification - let canonical: string; - try { - canonical = canonicalize(receipt.receipt); - } catch (err) { - return { - valid: false, - checks, - reason: `Canonicalization failed: ${(err as Error).message}`, - }; - } - - try { - if (opts.rawPublicKey) { - checks.signatureValid = verifyCanonicalWithRawKey( - canonical, - proof.signature, - opts.rawPublicKey - ); - } else { - checks.signatureValid = verifyCanonical( - canonical, - proof.signature, - opts.publicKeyPem! - ); - } - } catch (err) { - return { - valid: false, - checks, - reason: `Signature verification error: ${(err as Error).message}`, - }; - } - - // ALL checks must pass for valid: true - const valid = - checks.structureValid && - checks.algValid && - checks.kidMatched && - checks.signerMatched && - checks.signatureValid; - - return { - valid, - checks, - reason: valid - ? undefined - : Object.entries(checks) - .filter(([, v]) => !v) - .map(([k]) => `${k} failed`) - .join(", "), - }; -} - -/** - * Type guard: check if an unknown value is a SignedLayeredReceipt. - */ -export function isSignedLayeredReceipt( - value: unknown -): value is SignedLayeredReceipt { - if (typeof value !== "object" || value === null) return false; - const v = value as Record; - if (typeof v.receipt !== "object" || v.receipt === null) return false; - if (typeof v.signature !== "object" || v.signature === null) return false; - const sig = v.signature as Record; - if (typeof sig.proof !== "object" || sig.proof === null) return false; - const proof = sig.proof as Record; - return ( - typeof proof.alg === "string" && - typeof proof.signature === "string" && - typeof proof.signer_id === "string" - ); -} - -export { PROTOCOL_VERSION }; diff --git a/src/canonical.ts b/src/canonical.ts deleted file mode 100644 index a335722..0000000 --- a/src/canonical.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Canonicalization: json.sorted_keys.v1 - * - Deterministic, recursive key ordering - * - JSON.stringify on the normalized structure - * - * This is intentionally simple and stable; we treat the canonical string as the - * signing input after hashing (sha256 hex), consistent across runtimes. - */ - -export const CANONICAL_ID_SORTED_KEYS_V1 = "json.sorted_keys.v1"; - -type Json = - | null - | boolean - | number - | string - | Json[] - | { [k: string]: Json }; - -function isPlainObject(v: any): v is Record { - return !!v && typeof v === "object" && !Array.isArray(v); -} - -function normalize(v: any): Json { - if (v === null) return null; - const t = typeof v; - - if (t === "string" || t === "boolean") return v; - if (t === "number") { - // JSON doesn't support NaN/Infinity; encode as null to avoid nondeterminism - return Number.isFinite(v) ? v : null; - } - - if (Array.isArray(v)) return v.map(normalize); - - if (isPlainObject(v)) { - const out: Record = {}; - const keys = Object.keys(v).sort(); - for (const k of keys) out[k] = normalize(v[k]); - return out; - } - - // Unsupported types: map to string to avoid throwing during canonicalization - // (runtimes should validate before signing anyway) - return String(v) as any; -} - -/** Return canonical JSON string per json.sorted_keys.v1 */ -export function canonicalizeSortedKeysV1(value: any): string { - const normalized = normalize(value); - return JSON.stringify(normalized); -} diff --git a/src/crypto.ts b/src/crypto.ts index da10aba..55a9f69 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -11,7 +11,7 @@ * coordinated migration across all repos. */ -import { createSign, createVerify, generateKeyPairSync } from "node:crypto"; +import { sign, verify, generateKeyPairSync } from "node:crypto"; // ── Protocol constants ──────────────────────────────────────────────────────── @@ -79,10 +79,7 @@ export function signCanonical( canonicalString: string, privateKeyPem: string ): string { - const sign = createSign("Ed25519"); - sign.update(canonicalString, "utf8"); - sign.end(); - const sigBuffer = sign.sign(privateKeyPem); + const sigBuffer = sign(null, Buffer.from(canonicalString, "utf8"), privateKeyPem); if (sigBuffer.length !== 64) { throw new Error( `Ed25519 signature must be 64 bytes, got ${sigBuffer.length}` @@ -113,10 +110,7 @@ export function verifyCanonical( ); } try { - const verify = createVerify("Ed25519"); - verify.update(canonicalString, "utf8"); - verify.end(); - return verify.verify(publicKeyPem, sigBuffer); + return verify(null, Buffer.from(canonicalString, "utf8"), publicKeyPem, sigBuffer); } catch { return false; } diff --git a/src/encoding.ts b/src/encoding.ts deleted file mode 100644 index bda5c32..0000000 --- a/src/encoding.ts +++ /dev/null @@ -1,39 +0,0 @@ -export function toBase64Url(input: Uint8Array): string { - return Buffer.from(input) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/g, ''); -} - -export function fromBase64Url(input: string): Uint8Array { - const normalized = input.replace(/-/g, '+').replace(/_/g, '/'); - const padding = normalized.length % 4 === 0 ? '' : '='.repeat(4 - (normalized.length % 4)); - return new Uint8Array(Buffer.from(normalized + padding, 'base64')); -} - -export function parsePemToDer(pem: string): Uint8Array { - const body = pem - .replace(/-----BEGIN PUBLIC KEY-----/g, '') - .replace(/-----END PUBLIC KEY-----/g, '') - .replace(/\s+/g, ''); - return new Uint8Array(Buffer.from(body, 'base64')); -} - -export function extractEd25519Raw32FromSpkiDer(der: Uint8Array): Uint8Array { - if (der.length < 32) { - throw new Error('Invalid SPKI DER length'); - } - - // Ed25519 SPKI often ends with BIT STRING containing 0x00 + 32-byte key. - // We validate by reading the last 33 bytes and confirming leading 0x00. - const tail = der.slice(-33); - if (tail[0] !== 0x00) { - throw new Error('Invalid Ed25519 SPKI: missing zero bit padding octet'); - } - const key = tail.slice(1); - if (key.length !== 32) { - throw new Error('Invalid Ed25519 key length in SPKI'); - } - return key; -} diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index 7ce3db4..0000000 --- a/src/errors.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { ErrorObject } from 'ajv'; -import type { CompactAjvError } from './types.js'; - -export class RuntimeCoreError extends Error { - code: string; - - constructor(code: string, message: string) { - super(message); - this.name = 'RuntimeCoreError'; - this.code = code; - } -} - -export function formatAjvErrors(errors: ErrorObject[] | null | undefined): CompactAjvError[] { - if (!errors?.length) { - return []; - } - - return errors.map((error) => ({ - instancePath: error.instancePath, - keyword: error.keyword, - message: error.message, - params: error.params - })); -} diff --git a/src/normalize.ts b/src/normalize.ts deleted file mode 100644 index 9447b00..0000000 --- a/src/normalize.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { - CommercialRequest, - CommercialTerms, - CommonsRequest, - TraceContext -} from './types.js'; - -function normalizeTrace(trace: unknown): TraceContext | undefined { - if (trace && typeof trace === 'object' && !Array.isArray(trace)) { - return { ...(trace as TraceContext) }; - } - return undefined; -} - -function normalizePayload(body: Record): unknown { - if (Object.prototype.hasOwnProperty.call(body, 'payload')) { - return body.payload; - } - return body.input; -} - -export function normalizeCommonsRequest(body: Record | null | undefined): CommonsRequest { - const safeBody = body ?? {}; - return { - payload: normalizePayload(safeBody), - ...(normalizeTrace(safeBody.trace) ? { trace: normalizeTrace(safeBody.trace) } : {}) - }; -} - -export function normalizeCommercialRequest(body: Record | null | undefined): CommercialRequest { - const safeBody = body ?? {}; - const commercial = (safeBody.commercial ?? safeBody.payment) as CommercialTerms | undefined; - if (!commercial || typeof commercial !== 'object' || Array.isArray(commercial)) { - throw new Error('Commercial requests require a commercial metadata object'); - } - - return { - ...normalizeCommonsRequest(safeBody), - commercial: { ...commercial } - }; -} - -/** - * Current-line default normalization targets Commons and intentionally ignores legacy x402 metadata. - * @deprecated Prefer normalizeCommonsRequest or normalizeCommercialRequest. - */ -export function normalizeRequest(body: Record | null | undefined): CommonsRequest { - return normalizeCommonsRequest(body); -} diff --git a/src/schema-client.ts b/src/schema-client.ts deleted file mode 100644 index 770b07c..0000000 --- a/src/schema-client.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { ValidateFunction } from 'ajv'; -import type { - AsyncValidator, - CommandLayerLineVersion, - ContractTier, - SchemaClientOptions, - SchemaVersion, - ValidatorRequest -} from './types.js'; -import { COMMAND_LAYER_CURRENT_LINE, DEFAULT_SCHEMA_VERSION } from './types.js'; - -interface SchemaClient { - fetchJson: (url: string) => Promise; - getRequestValidator: (params: ValidatorRequest) => Promise; - getReceiptValidator: (params: ValidatorRequest) => Promise; - buildSchemaUrl: (kind: 'request' | 'receipt', params: ValidatorRequest) => string; -} - -interface AjvLike { - compileAsync(schema: object): Promise; -} - -function swapWww(hostname: string): string { - return hostname.startsWith('www.') ? hostname.slice(4) : `www.${hostname}`; -} - -export function buildSchemaPath(params: { - contract: ContractTier; - verb: string; - version?: SchemaVersion; - lineVersion?: CommandLayerLineVersion; - kind: 'request' | 'receipt'; -}): string { - const schemaVersion = params.version ?? DEFAULT_SCHEMA_VERSION; - const lineVersion = params.lineVersion ?? COMMAND_LAYER_CURRENT_LINE; - return `/schemas/${lineVersion}/${params.contract}/${params.verb}/${schemaVersion}/${params.kind}.schema.json`; -} - -/** @deprecated Legacy pre-1.1.0 schema path format. */ -export function buildLegacySchemaPath(kind: 'request' | 'receipt', params: { tier: string; verb: string; version: string }): string { - return `/schemas/${params.tier}/${params.verb}/${params.version}/${kind}.schema.json`; -} - -export function createSchemaClient(options: SchemaClientOptions): SchemaClient { - const timeoutMs = options.timeoutMs ?? 5000; - const baseUrl = new URL(options.schemaHost); - const defaultLineVersion = options.lineVersion ?? COMMAND_LAYER_CURRENT_LINE; - const fetchCache = new Map>(); - const validatorCache = new Map>(); - let ajvPromise: Promise | undefined; - - async function getAjv(): Promise { - if (!ajvPromise) { - ajvPromise = (async () => { - const ajvModule = await import('ajv'); - const AjvCtor = ajvModule.default as unknown as new (options: { strict: boolean; allErrors: boolean; loadSchema: (uri: string) => Promise; }) => AjvLike; - return new AjvCtor({ - strict: true, - allErrors: true, - loadSchema: async (uri: string) => (await fetchJson(uri)) as object - }) as AjvLike; - })(); - } - return ajvPromise; - } - - async function fetchWithTimeout(url: string): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(url, { signal: controller.signal }); - } finally { - clearTimeout(timer); - } - } - - async function fetchJson(url: string): Promise { - const cached = fetchCache.get(url); - if (cached) return cached; - - const request = (async () => { - const parsed = new URL(url); - const tryUrls = [url, new URL(`${parsed.protocol}//${swapWww(parsed.hostname)}${parsed.pathname}${parsed.search}`).toString()]; - let lastError: unknown; - - for (const candidate of tryUrls) { - try { - const response = await fetchWithTimeout(candidate); - if (!response.ok) throw new Error(`Schema fetch failed (${response.status}) for ${candidate}`); - - const contentType = response.headers.get('content-type') ?? ''; - if (!contentType.toLowerCase().includes('application/json')) { - throw new Error(`Unexpected content-type for schema: ${contentType || 'unknown'}`); - } - - return await response.json(); - } catch (error) { - lastError = error; - } - } - - throw lastError instanceof Error ? lastError : new Error('Schema fetch failed'); - })(); - - fetchCache.set(url, request); - return request; - } - - function buildSchemaUrl(kind: 'request' | 'receipt', params: ValidatorRequest): string { - const path = buildSchemaPath({ - contract: params.contract, - verb: params.verb, - version: params.version, - lineVersion: params.lineVersion ?? defaultLineVersion, - kind - }); - return new URL(path, baseUrl).toString(); - } - - async function getValidator(kind: 'request' | 'receipt', params: ValidatorRequest): Promise { - const contract = params.contract; - const version = params.version ?? DEFAULT_SCHEMA_VERSION; - const lineVersion = params.lineVersion ?? defaultLineVersion; - const key = `${kind}:${lineVersion}:${contract}:${params.verb}:${version}`; - const existing = validatorCache.get(key); - if (existing) return existing; - - const promise = (async () => { - const schema = await fetchJson(buildSchemaUrl(kind, params)); - const ajv = await getAjv(); - return ajv.compileAsync(schema as object); - })(); - - validatorCache.set(key, promise); - return promise; - } - - return { - fetchJson, - buildSchemaUrl, - getRequestValidator: (params) => getValidator('request', params), - getReceiptValidator: (params) => getValidator('receipt', params) - }; -} diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 6adaeeb..0000000 --- a/src/types.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { ErrorObject, ValidateFunction } from 'ajv'; - -export const COMMAND_LAYER_CURRENT_LINE = '1.1.0' as const; -export const DEFAULT_SCHEMA_VERSION = 'v1' as const; -export const COMMONS_CONTRACT = 'commons' as const; -export const COMMERCIAL_CONTRACT = 'commercial' as const; -export const DEFAULT_CANONICAL_ID = 'json.sorted_keys.v1' as const; - -export type CommandLayerLineVersion = typeof COMMAND_LAYER_CURRENT_LINE; -export type SchemaVersion = typeof DEFAULT_SCHEMA_VERSION | string; -export type ContractTier = typeof COMMONS_CONTRACT | typeof COMMERCIAL_CONTRACT; -export type ReceiptStatus = 'success' | 'error'; - -export interface TraceContext { - [key: string]: unknown; -} - -export interface CommonsRequest { - payload: unknown; - trace?: TraceContext; -} - -export interface CommercialTerms { - [key: string]: unknown; -} - -export interface CommercialRequest extends CommonsRequest { - commercial: CommercialTerms; -} - -export type NormalizedRequest = CommonsRequest; -export type NormalizedCommercialRequest = CommercialRequest; - -export interface SchemaClientOptions { - schemaHost: string; - timeoutMs?: number; - lineVersion?: CommandLayerLineVersion; -} - -export interface ValidatorRequest { - contract: ContractTier; - verb: string; - version?: SchemaVersion; - lineVersion?: CommandLayerLineVersion; -} - -export type AsyncValidator = ValidateFunction; - -export interface CommonsReceipt { - line: CommandLayerLineVersion; - contract: typeof COMMONS_CONTRACT; - verb: string; - version: SchemaVersion; - trace?: TraceContext; - payload: unknown; - status: ReceiptStatus; - result?: unknown; - error?: unknown; -} - -export interface CommercialReceipt extends Omit { - contract: typeof COMMERCIAL_CONTRACT; - commercial: CommercialTerms; -} - -export type CommandLayerReceipt = CommonsReceipt | CommercialReceipt; - -export interface ReceiptRuntimeMetadata { - [key: string]: unknown; -} - -export interface Proof { - alg: 'ed25519' | string; - kid?: string; - signer_id: string; - canonical: string; - signature: string; -} - -export interface SignedReceiptLayer { - proof: Proof; -} - -export interface LayeredReceipt { - receipt: TReceipt; - runtime?: ReceiptRuntimeMetadata; -} - -export interface SignedLayeredReceipt extends LayeredReceipt { - signature: SignedReceiptLayer; -} - -/** @deprecated Legacy 1.0.0 envelope; use CommandLayerReceipt. */ -export interface LegacySignedReceiptEnvelope { - verb?: string; - version?: string; - x402?: unknown; - trace?: unknown; - payload?: unknown; - status?: string; - result?: unknown; - metadata: Record & { - proof: Proof; - }; -} - -export interface SignOptions { - privateKey: Uint8Array | string; - signer_id: string; - kid?: string; - canonical?: string; -} - -export interface VerifyOptions { - pubkey: Uint8Array | string; - canonical?: string; -} - -export interface AttachProofOptions { - alg: string; - kid?: string; - signer_id: string; - canonical: string; - signature: string; -} - -export interface EnsResolveOptions { - ensName: string; - provider?: unknown; -} - -export interface EnsSignerInfo { - pubkeyRaw32: Uint8Array; - pubkeyEncoded: string; - kid?: string; - canonical?: string; - signer_id: string; - alg: 'ed25519'; -} - -export type CompactAjvError = Pick; diff --git a/test/crypto.test.ts b/test/crypto.test.ts index d56d7f4..fb3417e 100644 --- a/test/crypto.test.ts +++ b/test/crypto.test.ts @@ -8,6 +8,7 @@ */ import { strict as assert } from "node:assert"; +import { createHash } from "node:crypto"; import { describe, it } from "node:test"; import { generateEd25519KeyPair, @@ -115,7 +116,7 @@ describe("sign and verify — round trip", () => { }); describe("sign and verify — signing message is raw bytes", () => { - it("signing message is raw canonical UTF-8, not sha256 hex", () => { + it("signing message is raw canonical UTF-8, not sha256 hex", async () => { // This test documents and enforces the protocol decision: // we sign raw bytes, not sha256(canonical). // If this ever needs to change, it requires a protocol version bump. @@ -130,7 +131,6 @@ describe("sign and verify — signing message is raw bytes", () => { assert.strictEqual(verifyCanonical(canonical, sig, publicKeyPem), true); // Verification with sha256(canonical) would fail — different message - const { createHash } = await import("node:crypto"); const sha256hex = createHash("sha256").update(canonical, "utf8").digest("hex"); // sha256hex is a different string — if someone signs this instead, verify fails assert.strictEqual(verifyCanonical(sha256hex, sig, publicKeyPem), false); diff --git a/tests/runtime-core.test.mjs b/tests/runtime-core.test.mjs deleted file mode 100644 index 8d6c487..0000000 --- a/tests/runtime-core.test.mjs +++ /dev/null @@ -1,39 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { generateKeyPairSync } from "node:crypto"; - -import { - signReceiptEd25519Sha256, - verifyReceiptEd25519Sha256, -} from "../dist/index.js"; - -test("runtime-core: sign → verify roundtrip works", async () => { - const { publicKey, privateKey } = generateKeyPairSync("ed25519"); - - const privatePem = privateKey.export({ type: "pkcs8", format: "pem" }); - const publicPem = publicKey.export({ type: "spki", format: "pem" }); - - const receipt = { - x402: { verb: "test", version: "1.1.0" }, - trace: { trace_id: "trace_test_1" }, - status: "success", - result: { ok: true }, - }; - - const layeredReceipt = signReceiptEd25519Sha256(receipt, { - signer_id: "runtime.commandlayer.eth", - kid: "testkid", - canonical: "json.sorted_keys.v1", - privateKeyPem: privatePem, - }); - - assert.ok(layeredReceipt.signature?.proof.hash_sha256); - assert.ok(layeredReceipt.signature?.proof.signature_b64); - - const result = verifyReceiptEd25519Sha256(layeredReceipt, { - publicKeyPemOrDer: publicPem, - allowedCanonicals: ["json.sorted_keys.v1"], - }); - - assert.equal(result.ok, true); -});