diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..98db81e --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# @commandlayer/runtime-core — environment variables +# +# Copy this file to .env and fill in real values before running local scripts. +# NEVER commit .env to version control. + +# ── ENS / RPC ────────────────────────────────────────────────────────────────── +# Ethereum JSON-RPC endpoint used by resolveSignerFromENS. +# Required when calling resolveSignerFromENS in production. +# Example: Infura, Alchemy, or a local node. +RPC_URL=https://mainnet.infura.io/v3/YOUR_PROJECT_ID + +# ── Ed25519 signing keys ─────────────────────────────────────────────────────── +# PEM-encoded Ed25519 private key for signing receipts. +# Generate with: node -e "const {generateEd25519KeyPair}=require('@commandlayer/runtime-core'); const kp=generateEd25519KeyPair(); console.log(kp.privateKeyPem);" +SIGNING_PRIVATE_KEY_PEM= + +# PEM-encoded Ed25519 public key for verifying receipts. +SIGNING_PUBLIC_KEY_PEM= + +# ENS name of the signer (must have cl.sig.pub TXT record set to the public key above). +# Example: runtime.commandlayer.eth +SIGNER_ENS_NAME= diff --git a/README.md b/README.md index 5194e0b..bb28043 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,16 @@ # @commandlayer/runtime-core -Core contract primitives for the CommandLayer v1.1.0 current line. +Core signing, verification, and canonicalization primitives for the CommandLayer v1.1.0 protocol. -This package now models the two current-line contract families explicitly: +This package is the single canonical implementation of: -- **Commons**: request/receipt payloads with no x402 or payment metadata assumptions. -- **Commercial**: the same execution contract plus an explicit `commercial` metadata object for payment-aware runtimes. +- **Canonicalization** — `json.sorted_keys.v1` deterministic JSON (keys sorted at every level) +- **Ed25519 signing and verification** — real `node:crypto` Ed25519, no mocks +- **Signed layered receipts** — v1.1.0 `SignedLayeredReceipt` with structured proof envelope +- **ENS signer resolution** — live TXT record lookup for `cl.sig.pub` +- **Legacy compat shims** — `metadata.proof` envelope bridge for runtime/server.mjs -Legacy 1.0.0 helpers are still exported, but they are isolated and labeled as legacy bridges. - -## What it provides - -- Current-line request normalization for Commons and Commercial flows -- Current-line receipt builders, canonicalization, signing, and verification helpers -- Explicit schema path helpers for the v1.1.0 split between Commons and Commercial -- ENS signer discovery from TXT records -- Legacy 1.0.0 receipt verification bridges for `metadata.proof` and `ed25519-sha256` -- Compact AJV error formatting +All other CommandLayer repos import from here. Nothing is reimplemented downstream. ## Install @@ -24,146 +18,184 @@ Legacy 1.0.0 helpers are still exported, but they are isolated and labeled as le npm install @commandlayer/runtime-core ``` -## Current-line usage +Requires Node.js >= 20. -### Normalize Commons and Commercial requests +## Usage -```ts -import { - normalizeCommonsRequest, - normalizeCommercialRequest -} from '@commandlayer/runtime-core'; +### Canonicalization -const commons = normalizeCommonsRequest({ - trace: { request_id: 'req_1' }, - payload: { message: 'hello' } -}); +```ts +import { canonicalize } from '@commandlayer/runtime-core'; -const commercial = normalizeCommercialRequest({ - trace: { request_id: 'req_2' }, - payload: { message: 'hello' }, - commercial: { plan: 'pro', settlement: 'required' } +const canonical = canonicalize({ + verb: 'chat.completions', + version: '1.1.0', + agent: 'runtime.commandlayer.eth', + timestamp: '2026-05-12T00:00:00.000Z', }); +// Keys are sorted at every level, no trailing whitespace, no undefined values ``` -### Resolve current-line schema URLs +### Sign and verify a receipt ```ts import { - buildSchemaPath, - COMMAND_LAYER_CURRENT_LINE, - COMMONS_CONTRACT, - COMMERCIAL_CONTRACT, - createSchemaClient + generateEd25519KeyPair, + signReceipt, + verifyReceipt, } from '@commandlayer/runtime-core'; -buildSchemaPath({ - contract: COMMONS_CONTRACT, - verb: 'chat.completions', - kind: 'request' -}); -// /schemas/1.1.0/commons/chat.completions/v1/request.schema.json - -const schemas = createSchemaClient({ - schemaHost: 'https://schemas.commandlayer.io', - lineVersion: COMMAND_LAYER_CURRENT_LINE +const { privateKeyPem, rawPublicKey } = generateEd25519KeyPair(); + +const signed = signReceipt( + { + verb: 'chat.completions', + version: '1.1.0', + agent: 'runtime.commandlayer.eth', + timestamp: new Date().toISOString(), + payload: { prompt: 'hello' }, + result: { output: 'world' }, + }, + { + privateKeyPem, + kid: process.env.KEY_ID!, + signerEns: 'runtime.commandlayer.eth', + } +); + +const result = verifyReceipt(signed, { + rawPublicKey, + expectedSigner: 'runtime.commandlayer.eth', }); -const validateCommercialReceipt = await schemas.getReceiptValidator({ - contract: COMMERCIAL_CONTRACT, - verb: 'chat.completions', - version: 'v1' -}); +console.assert(result.valid === true); ``` -### Build, sign, and verify current-line receipts +### Resolve signer from ENS ```ts -import { - buildCommonsReceipt, - buildCommercialReceipt, - canonicalizeReceipt, - createLayeredReceipt, - hashReceiptCanonical, - signReceiptEd25519, - verifyReceiptSignature -} from '@commandlayer/runtime-core'; +import { JsonRpcProvider } from 'ethers'; +import { resolveSignerFromENS } from '@commandlayer/runtime-core'; -const commonsReceipt = buildCommonsReceipt({ - verb: 'chat.completions', - version: 'v1', - trace: { request_id: 'req_1' }, - payload: { prompt: 'hello' }, - status: 'success', - result: { output: 'world' } -}); +// Positional form +const provider = new JsonRpcProvider(process.env.RPC_URL); +const signer = await resolveSignerFromENS('signer.commandlayer.eth', provider); -const commercialReceipt = buildCommercialReceipt({ - verb: 'chat.completions', - version: 'v1', - trace: { request_id: 'req_2' }, - payload: { prompt: 'hello' }, - commercial: { plan: 'pro', settlement: 'required' }, - status: 'success', - result: { output: 'world' } +// Options-object form (equivalent) +const signer2 = await resolveSignerFromENS({ + ensName: 'signer.commandlayer.eth', + provider, }); +``` -const canonical = canonicalizeReceipt(commercialReceipt); -const hash = hashReceiptCanonical(canonical); +Supported TXT records: -const signed = signReceiptEd25519(commercialReceipt, { - privateKey: process.env.SIGNING_PRIVATE_KEY_PEM!, - signer_id: 'signer.commandlayer.eth', - kid: '2026-01' -}); +| Key | Required | Description | +|-----|----------|-------------| +| `cl.sig.pub` | Yes | `ed25519:` | +| `cl.sig.kid` | No | Short key identifier | +| `cl.sig.canonical` | No | Defaults to `json.sorted_keys.v1` | -const layered = createLayeredReceipt(commonsReceipt, { - execution: { duration_ms: 42 } -}); +### Key encoding -const ok = verifyReceiptSignature(signed, { - pubkey: process.env.SIGNING_PUBLIC_KEY_PEM! -}); -``` +```ts +import { encodePublicKey, parsePublicKey } from '@commandlayer/runtime-core'; -`receipt` remains the canonical signed payload. Runtime metadata and signatures stay layered outside the receipt body. +// Encode raw 32-byte key for ENS TXT record +const ensValue = encodePublicKey(rawPublicKey); +// => "ed25519:hhyCuPNoMk4JtEvGEV8F6nMZ4uDO1EcyizPufmnJTOY=" -### Resolve signer from ENS +// Parse ENS TXT record back to raw bytes +const raw = parsePublicKey(ensValue); +// => Uint8Array(32) +``` + +### Low-level crypto ```ts -import { JsonRpcProvider } from 'ethers'; -import { resolveSignerFromENS } from '@commandlayer/runtime-core'; +import { + canonicalize, + signCanonical, + verifyCanonical, + verifyCanonicalWithRawKey, +} from '@commandlayer/runtime-core'; -const provider = new JsonRpcProvider(process.env.RPC_URL); -const signer = await resolveSignerFromENS({ - ensName: 'signer.commandlayer.eth', - provider -}); +const canonical = canonicalize(payload); +const signature = signCanonical(canonical, privateKeyPem); +const valid = verifyCanonical(canonical, signature, publicKeyPem); +const validFromRaw = verifyCanonicalWithRawKey(canonical, signature, rawPublicKey); ``` -Supported TXT records: +### Legacy compat shims (runtime/server.mjs bridge) + +If you need the older `metadata.proof` envelope format, import the compat helpers directly: + +```ts +import { + signReceiptEd25519Sha256, + verifyReceiptEd25519Sha256, +} from '@commandlayer/runtime-core'; +``` -- Preferred: `cl.sig.pub = ed25519:` -- Optional: `cl.sig.kid`, `cl.sig.canonical` -- Legacy fallback: `cl.receipt.pubkey.pem` +These are explicitly legacy APIs. New integrations should use `signReceipt` / `verifyReceipt`. -## Legacy 1.0.0 bridge +### Cross-repo canonicalization alignment -If you still need old `metadata.proof` envelopes or `ed25519-sha256` receipts, import the isolated compatibility helpers: +Every repo that imports `@commandlayer/runtime-core` should run the shared test vectors: + +```ts +import { canonicalize, CANONICAL_TEST_VECTORS } from '@commandlayer/runtime-core'; + +for (const { description, input, expected } of CANONICAL_TEST_VECTORS) { + const actual = canonicalize(input); + if (actual !== expected) throw new Error(`Vector failed: ${description}`); +} +``` + +## Protocol constants ```ts import { - signReceiptEd25519Sha256, - toLegacyReceiptEnvelope, - verifyReceiptEd25519Sha256 -} from '@commandlayer/runtime-core/receipt-v1'; + PROTOCOL_VERSION, // "1.1.0" + CANONICAL_METHOD, // "json.sorted_keys.v1" + SIGNATURE_ALG, // "ed25519" + ENS_KEY_PUB, // "cl.sig.pub" + ENS_KEY_KID, // "cl.sig.kid" + ENS_KEY_CANONICAL, // "cl.sig.canonical" + ENS_KEY_SIGNER, // "cl.receipt.signer" +} from '@commandlayer/runtime-core'; ``` -These are explicitly legacy APIs and should not be used for new current-line Commons or Commercial flows. +## Environment variables + +See `.env.example` for the full list. Key variables: + +| Variable | Used by | Description | +|----------|---------|-------------| +| `RPC_URL` | `resolveSignerFromENS` | Ethereum JSON-RPC endpoint | +| `SIGNING_PRIVATE_KEY_PEM` | `signReceipt`, `signCanonical` | Ed25519 private key (PEM) | +| `SIGNING_PUBLIC_KEY_PEM` | `verifyReceipt`, `verifyCanonical` | Ed25519 public key (PEM) | +| `SIGNER_ENS_NAME` | ENS | ENS name with `cl.sig.pub` set | ## Development ```bash -npm run build -npm test +npm run build # compile TypeScript +npm test # run all tests +npm run typecheck # type-check without emitting +``` + +## Signing protocol + +The signing message is **raw UTF-8 bytes** of `canonicalize(receipt)`. This is NOT `sha256(canonical)` — signatures are over the data directly. Any change to this contract requires a protocol version bump. + +``` +signature = Ed25519.sign( + privateKey, + UTF8(canonicalize(receiptPayload)) +) ``` + +## License + +Apache-2.0 diff --git a/src/compat.ts b/src/compat.ts index 966f0fd..f1b31cf 100644 --- a/src/compat.ts +++ b/src/compat.ts @@ -69,6 +69,16 @@ export function signReceiptEd25519Sha256( receipt: RuntimeReceipt, opts: SignReceiptCompatOptions ): SignedRuntimeReceipt { + if (!opts.privateKeyPem || typeof opts.privateKeyPem !== "string") { + throw new Error("signReceiptEd25519Sha256: privateKeyPem is required"); + } + if (!opts.signer_id || typeof opts.signer_id !== "string") { + throw new Error("signReceiptEd25519Sha256: signer_id is required"); + } + if (!opts.kid || typeof opts.kid !== "string") { + throw new Error("signReceiptEd25519Sha256: kid is required"); + } + // Strip any existing proof so it's not included in the signed payload const { metadata: meta = {}, ...rest } = receipt; const { proof: _proof, ...metaWithoutProof } = meta; @@ -131,12 +141,22 @@ export function verifyReceiptEd25519Sha256( ): VerifyReceiptCompatResult { const checks = { signature_valid: false, hash_matches: false }; + if (!opts.publicKeyPemOrDer || typeof opts.publicKeyPemOrDer !== "string") { + return { ok: false, checks, reason: "publicKeyPemOrDer is required" }; + } + const proof = receipt?.metadata?.proof; if (!proof) { return { ok: false, checks, reason: "Missing metadata.proof" }; } - const sig = proof.signature || proof.signature_b64; + // Accept signature from either field; require non-empty string + const sig = (proof.signature && proof.signature.length > 0) + ? proof.signature + : (proof.signature_b64 && proof.signature_b64.length > 0) + ? proof.signature_b64 + : null; + if (!sig) { return { ok: false, checks, reason: "Missing proof.signature" }; } diff --git a/src/ens.ts b/src/ens.ts index ab701a2..0e7b399 100644 --- a/src/ens.ts +++ b/src/ens.ts @@ -17,11 +17,13 @@ import { ENS_KEY_PUB, ENS_KEY_KID, ENS_KEY_CANONICAL, - ENS_KEY_SIGNER, CANONICAL_METHOD, parsePublicKey, } from "./crypto.js"; +// Re-export so callers can reference the constant without importing crypto directly +export { ENS_KEY_SIGNER } from "./crypto.js"; + // ── Types ───────────────────────────────────────────────────────────────────── export interface EnsSignerRecord { @@ -47,11 +49,28 @@ export interface EnsResolver { getText(key: string): Promise; } +/** + * Options object form of resolveSignerFromENS. + * Use this when calling from code that prefers named parameters. + */ +export interface ResolveSignerFromENSOptions { + ensName: string; + provider: EnsProvider; +} + // ── Resolution ──────────────────────────────────────────────────────────────── /** * Resolve a CommandLayer signer record from ENS. * + * Accepts either positional arguments or a single options object: + * + * // Positional (preferred in library code): + * await resolveSignerFromENS('signer.commandlayer.eth', provider); + * + * // Options object (preferred in application code): + * await resolveSignerFromENS({ ensName: 'signer.commandlayer.eth', provider }); + * * Throws on: * - No resolver found for the ENS name * - Missing cl.sig.pub record @@ -61,9 +80,30 @@ export interface EnsResolver { * Never falls back to hardcoded keys. */ export async function resolveSignerFromENS( - ensName: string, - provider: EnsProvider + ensNameOrOpts: string | ResolveSignerFromENSOptions, + providerArg?: EnsProvider ): Promise { + let ensName: string; + let provider: EnsProvider; + + if (typeof ensNameOrOpts === "string") { + if (!providerArg) { + throw new Error( + "resolveSignerFromENS: provider is required as the second argument " + + "when ensName is passed as a string." + ); + } + ensName = ensNameOrOpts; + provider = providerArg; + } else { + ensName = ensNameOrOpts.ensName; + provider = ensNameOrOpts.provider; + } + + if (!ensName || typeof ensName !== "string") { + throw new Error("resolveSignerFromENS: ensName must be a non-empty string."); + } + let resolver: EnsResolver | null; try { resolver = await provider.getResolver(ensName); @@ -75,8 +115,8 @@ export async function resolveSignerFromENS( if (!resolver) { throw new Error( - `No ENS resolver found for "${ensName}". ` + - `Verify the name is registered and has a resolver set.` + `No ENS resolver found for "${ensName}". ` + + `Verify the name is registered and has a resolver set.` ); } @@ -101,16 +141,16 @@ export async function resolveSignerFromENS( if (!pubValue) { throw new Error( - `ENS name "${ensName}" has no ${ENS_KEY_PUB} text record. ` + - `Set cl.sig.pub = ed25519: on the ENS name.` + `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.` + `ENS name "${ensName}" specifies unsupported canonical method: ` + + `"${canonicalValue}". Only "${CANONICAL_METHOD}" is supported.` ); } @@ -130,9 +170,9 @@ export async function resolveSignerFromENS( * Use resolveSignerFromENS for full record access. */ export async function resolvePublicKeyFromENS( - ensName: string, - provider: EnsProvider + ensNameOrOpts: string | ResolveSignerFromENSOptions, + providerArg?: EnsProvider ): Promise { - const record = await resolveSignerFromENS(ensName, provider); + const record = await resolveSignerFromENS(ensNameOrOpts as string, providerArg); return record.rawPublicKey; } diff --git a/src/index.ts b/src/index.ts index 713fef3..40c4cce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -46,6 +46,7 @@ export { type EnsSignerRecord, type EnsProvider, type EnsResolver, + type ResolveSignerFromENSOptions, } from "./ens.js"; // Receipt v1.1.0 diff --git a/src/receipt.ts b/src/receipt.ts index b8abfed..50968f1 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -68,10 +68,29 @@ 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"); + // Validate required fields — all four are mandated by ReceiptPayload + if (!payload.verb || typeof payload.verb !== "string") { + throw new Error("receipt.verb is required and must be a non-empty string"); + } + if (!payload.version || typeof payload.version !== "string") { + throw new Error("receipt.version is required and must be a non-empty string"); + } + if (!payload.agent || typeof payload.agent !== "string") { + throw new Error("receipt.agent is required and must be a non-empty string"); + } + if (!payload.timestamp || typeof payload.timestamp !== "string") { + throw new Error("receipt.timestamp is required and must be a non-empty string"); + } + + if (!opts.privateKeyPem || typeof opts.privateKeyPem !== "string") { + throw new Error("signReceipt: privateKeyPem is required"); + } + if (!opts.kid || typeof opts.kid !== "string") { + throw new Error("signReceipt: kid is required"); + } + if (!opts.signerEns || typeof opts.signerEns !== "string") { + throw new Error("signReceipt: signerEns is required"); + } const canonical = canonicalize(payload); const signature = signCanonical(canonical, opts.privateKeyPem); @@ -140,11 +159,14 @@ export function verifyReceipt( }; // Structure check + const proof = receipt?.signature?.proof; if ( - !receipt?.receipt || - !receipt?.signature?.proof?.signature || - !receipt?.signature?.proof?.alg || - !receipt?.signature?.proof?.signer_id + !receipt?.receipt + || !proof?.signature + || typeof proof.signature !== "string" + || proof.signature.length === 0 + || !proof?.alg + || !proof?.signer_id ) { return { valid: false, @@ -154,8 +176,6 @@ export function verifyReceipt( } checks.structureValid = true; - const proof = receipt.signature.proof; - // Algorithm check if (proof.alg !== SIGNATURE_ALG) { return { @@ -212,11 +232,11 @@ export function verifyReceipt( // ALL checks must pass for valid: true const valid = - checks.structureValid && - checks.algValid && - checks.kidMatched && - checks.signerMatched && - checks.signatureValid; + checks.structureValid + && checks.algValid + && checks.kidMatched + && checks.signerMatched + && checks.signatureValid; return { valid, @@ -244,9 +264,10 @@ export function isSignedLayeredReceipt( 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" + typeof proof.alg === "string" + && typeof proof.signature === "string" + && proof.signature.length > 0 + && typeof proof.signer_id === "string" ); } diff --git a/test/ens.test.ts b/test/ens.test.ts new file mode 100644 index 0000000..2c016ff --- /dev/null +++ b/test/ens.test.ts @@ -0,0 +1,189 @@ +/** + * ENS resolution tests — runtime-core + * + * Uses an in-process mock provider — no network calls. + * Tests both calling conventions (positional args and options object). + */ + +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { + resolveSignerFromENS, + resolvePublicKeyFromENS, + type EnsProvider, + type EnsResolver, +} from "../src/ens.js"; +import { encodePublicKey } from "../src/crypto.js"; + +// ── Mock provider factory ───────────────────────────────────────────────────── + +function makeMockProvider(records: Record): EnsProvider { + const resolver: EnsResolver = { + async getText(key: string): Promise { + return Object.prototype.hasOwnProperty.call(records, key) + ? records[key] + : null; + }, + }; + return { + async getResolver(_name: string): Promise { + return resolver; + }, + }; +} + +function makeNullProvider(): EnsProvider { + return { + async getResolver(_name: string): Promise { + return null; + }, + }; +} + +function makeThrowingProvider(): EnsProvider { + return { + async getResolver(_name: string): Promise { + throw new Error("network timeout"); + }, + }; +} + +// ── Test helpers ────────────────────────────────────────────────────────────── + +const RAW_KEY = new Uint8Array(32).fill(0xab); +const PUB_VALUE = encodePublicKey(RAW_KEY); + +const FULL_RECORDS: Record = { + "cl.sig.pub": PUB_VALUE, + "cl.sig.kid": "testKid001", + "cl.sig.canonical": "json.sorted_keys.v1", +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("resolveSignerFromENS — positional args", () => { + it("resolves a full signer record", async () => { + const provider = makeMockProvider(FULL_RECORDS); + const record = await resolveSignerFromENS("test.commandlayer.eth", provider); + + assert.strictEqual(record.name, "test.commandlayer.eth"); + assert.deepStrictEqual(record.rawPublicKey, RAW_KEY); + assert.strictEqual(record.kid, "testKid001"); + assert.strictEqual(record.canonical, "json.sorted_keys.v1"); + }); + + it("defaults kid to empty string when cl.sig.kid is absent", async () => { + const provider = makeMockProvider({ "cl.sig.pub": PUB_VALUE }); + const record = await resolveSignerFromENS("test.commandlayer.eth", provider); + assert.strictEqual(record.kid, ""); + }); + + it("defaults canonical to json.sorted_keys.v1 when absent", async () => { + const provider = makeMockProvider({ "cl.sig.pub": PUB_VALUE }); + const record = await resolveSignerFromENS("test.commandlayer.eth", provider); + assert.strictEqual(record.canonical, "json.sorted_keys.v1"); + }); + + it("throws when no resolver found", async () => { + const provider = makeNullProvider(); + await assert.rejects( + () => resolveSignerFromENS("nobody.eth", provider), + /No ENS resolver found/ + ); + }); + + it("throws when provider errors", async () => { + const provider = makeThrowingProvider(); + await assert.rejects( + () => resolveSignerFromENS("test.eth", provider), + /ENS resolution failed/ + ); + }); + + it("throws when cl.sig.pub is missing", async () => { + const provider = makeMockProvider({ "cl.sig.kid": "k1" }); + await assert.rejects( + () => resolveSignerFromENS("test.eth", provider), + /no cl.sig.pub text record/ + ); + }); + + it("throws on unsupported canonical method", async () => { + const provider = makeMockProvider({ + "cl.sig.pub": PUB_VALUE, + "cl.sig.canonical": "sha256-sorted-v2", + }); + await assert.rejects( + () => resolveSignerFromENS("test.eth", provider), + /unsupported canonical method/ + ); + }); + + it("throws on malformed cl.sig.pub", async () => { + const provider = makeMockProvider({ "cl.sig.pub": "notakey" }); + await assert.rejects( + () => resolveSignerFromENS("test.eth", provider), + /ed25519:/ + ); + }); +}); + +describe("resolveSignerFromENS — options object form", () => { + it("resolves using { ensName, provider } object", async () => { + const provider = makeMockProvider(FULL_RECORDS); + const record = await resolveSignerFromENS({ + ensName: "test.commandlayer.eth", + provider, + }); + assert.strictEqual(record.name, "test.commandlayer.eth"); + assert.deepStrictEqual(record.rawPublicKey, RAW_KEY); + }); + + it("throws when provider is missing from options", async () => { + // TypeScript would catch this, but test runtime safety too + await assert.rejects( + async () => { + const opts = { ensName: "test.eth" } as Parameters[0]; + // Force the call through any cast to test runtime guard + await (resolveSignerFromENS as (o: unknown) => Promise)(opts); + }, + /provider/ + ); + }); +}); + +describe("resolveSignerFromENS — input validation", () => { + it("throws on empty ensName string", async () => { + const provider = makeMockProvider(FULL_RECORDS); + await assert.rejects( + () => resolveSignerFromENS("", provider), + /ensName must be a non-empty string/ + ); + }); + + it("throws when positional provider is missing", async () => { + await assert.rejects( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + () => (resolveSignerFromENS as (n: string, p?: unknown) => Promise)("test.eth", undefined), + /provider is required/ + ); + }); +}); + +describe("resolvePublicKeyFromENS", () => { + it("returns the raw 32-byte public key", async () => { + const provider = makeMockProvider(FULL_RECORDS); + const key = await resolvePublicKeyFromENS("test.commandlayer.eth", provider); + assert.deepStrictEqual(key, RAW_KEY); + assert.strictEqual(key.length, 32); + }); + + it("also works with options object", async () => { + const provider = makeMockProvider(FULL_RECORDS); + const key = await resolvePublicKeyFromENS({ + ensName: "test.commandlayer.eth", + provider, + }); + assert.deepStrictEqual(key, RAW_KEY); + }); +}); diff --git a/test/receipt.test.ts b/test/receipt.test.ts index fcb668c..48bbc88 100644 --- a/test/receipt.test.ts +++ b/test/receipt.test.ts @@ -8,9 +8,9 @@ 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"; +import { signReceipt, verifyReceipt, isSignedLayeredReceipt, type ReceiptPayload } from "../src/receipt.js"; -const makePayload = () => ({ +const makePayload = (): ReceiptPayload => ({ verb: "verify", version: "1.1.0", agent: "runtime.commandlayer.eth", @@ -42,15 +42,61 @@ describe("signReceipt", () => { assert.strictEqual(sigBytes.length, 64); }); + it("preserves version field in signed receipt", () => { + const { privateKeyPem } = generateEd25519KeyPair(); + const payload = makePayload(); + payload.version = "1.1.0"; + const receipt = signReceipt(payload, { + privateKeyPem, + kid: "kid1", + signerEns: "test.eth", + }); + assert.strictEqual(receipt.receipt.version, "1.1.0"); + }); + 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", - }), + () => signReceipt( + { version: "1.1.0", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z", verb: "" } as ReceiptPayload, + { privateKeyPem, kid: "kid1", signerEns: "test.eth" } + ), /verb/ ); }); + + it("throws if version is missing", () => { + const { privateKeyPem } = generateEd25519KeyPair(); + assert.throws( + () => signReceipt( + { verb: "test", agent: "test.eth", timestamp: "2026-01-01T00:00:00Z", version: "" } as ReceiptPayload, + { privateKeyPem, kid: "kid1", signerEns: "test.eth" } + ), + /version/ + ); + }); + + it("throws if agent is missing", () => { + const { privateKeyPem } = generateEd25519KeyPair(); + assert.throws( + () => signReceipt( + { verb: "test", version: "1.1.0", timestamp: "2026-01-01T00:00:00Z", agent: "" } as ReceiptPayload, + { privateKeyPem, kid: "kid1", signerEns: "test.eth" } + ), + /agent/ + ); + }); + + it("throws if timestamp is missing", () => { + const { privateKeyPem } = generateEd25519KeyPair(); + assert.throws( + () => signReceipt( + { verb: "test", version: "1.1.0", agent: "test.eth", timestamp: "" } as ReceiptPayload, + { privateKeyPem, kid: "kid1", signerEns: "test.eth" } + ), + /timestamp/ + ); + }); }); describe("verifyReceipt — full round trip", () => { @@ -122,8 +168,8 @@ describe("verifyReceipt — full round trip", () => { const signed = signReceipt(makePayload(), { privateKeyPem, kid: "kid1", signerEns: "test.eth", }); - // Force wrong algorithm - (signed.signature.proof as any).alg = "rsa-pkcs1v15"; + // Force wrong algorithm via unknown cast through Record + (signed.signature.proof as Record).alg = "rsa-pkcs1v15"; const result = verifyReceipt(signed, { rawPublicKey }); assert.strictEqual(result.valid, false); @@ -153,4 +199,12 @@ describe("isSignedLayeredReceipt", () => { assert.strictEqual(isSignedLayeredReceipt(null), false); assert.strictEqual(isSignedLayeredReceipt("string"), false); }); + + it("returns false when signature is empty string", () => { + const obj = { + receipt: { verb: "test", version: "1.1.0", agent: "x", timestamp: "t" }, + signature: { proof: { alg: "ed25519", signature: "", signer_id: "x", kid: "k", canonical: "json.sorted_keys.v1" } }, + }; + assert.strictEqual(isSignedLayeredReceipt(obj), false); + }); });