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