diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6be3f5a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +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/dist/crypto.d.ts b/dist/crypto.d.ts index 34ec493..f7828d3 100644 --- a/dist/crypto.d.ts +++ b/dist/crypto.d.ts @@ -1,12 +1,69 @@ -/** sha256 hex digest of UTF-8 text */ -export declare function sha256HexUtf8(text: string): string; /** - * Ed25519 signature over UTF-8 bytes of `message` (string). - * Node's crypto.sign/verify for Ed25519 uses `null` as the algorithm. + * @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. */ -export declare function signEd25519MessageBase64(message: string, privateKeyPem: string): string; -export declare function verifyEd25519MessageBase64(message: string, signatureB64: string, publicKeyPemOrDer: string): boolean; -/** base64url helpers for compatibility */ -export declare function base64UrlToBase64(b64url: string): string; -export declare function base64ToBase64Url(b64: string): string; +export declare const PROTOCOL_VERSION: "1.1.0"; +export declare const CANONICAL_METHOD: "json.sorted_keys.v1"; +export declare const SIGNATURE_ALG: "ed25519"; +/** The ENS text record key for the signer's public key. */ +export declare const ENS_KEY_PUB: "cl.sig.pub"; +export declare const ENS_KEY_KID: "cl.sig.kid"; +export declare const ENS_KEY_CANONICAL: "cl.sig.canonical"; +export declare const ENS_KEY_SIGNER: "cl.receipt.signer"; +/** + * 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 declare function encodePublicKey(rawBytes: Uint8Array): string; +/** + * 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 declare function parsePublicKey(ensPubValue: string): Uint8Array; +/** + * 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 declare function signCanonical(canonicalString: string, privateKeyPem: string): string; +/** + * 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 declare function verifyCanonical(canonicalString: string, signatureBase64: string, publicKeyPem: string): boolean; +/** + * Verify using a raw 32-byte public key (from ENS cl.sig.pub). + * Converts to PEM internally then delegates to verifyCanonical. + */ +export declare function verifyCanonicalWithRawKey(canonicalString: string, signatureBase64: string, rawPublicKey: Uint8Array): boolean; +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 declare function generateEd25519KeyPair(): Ed25519KeyPair; //# sourceMappingURL=crypto.d.ts.map \ No newline at end of file diff --git a/dist/crypto.d.ts.map b/dist/crypto.d.ts.map index a9708f6..fd67621 100644 --- a/dist/crypto.d.ts.map +++ b/dist/crypto.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAEA,sCAAsC;AACtC,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAGvF;AAED,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,iBAAiB,EAAE,MAAM,GAAG,OAAO,CAGpH;AAED,0CAA0C;AAC1C,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIxD;AAED,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAErD"} \ No newline at end of file +{"version":3,"file":"crypto.d.ts","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAMH,eAAO,MAAM,gBAAgB,EAAG,OAAgB,CAAC;AACjD,eAAO,MAAM,gBAAgB,EAAG,qBAA8B,CAAC;AAC/D,eAAO,MAAM,aAAa,EAAG,SAAkB,CAAC;AAEhD,2DAA2D;AAC3D,eAAO,MAAM,WAAW,EAAG,YAAqB,CAAC;AACjD,eAAO,MAAM,WAAW,EAAG,YAAqB,CAAC;AACjD,eAAO,MAAM,iBAAiB,EAAG,kBAA2B,CAAC;AAC7D,eAAO,MAAM,cAAc,EAAG,mBAA4B,CAAC;AAI3D;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,UAAU,GAAG,MAAM,CAO5D;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,WAAW,EAAE,MAAM,GAAG,UAAU,CAe9D;AAID;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,eAAe,EAAE,MAAM,EACvB,aAAa,EAAE,MAAM,GACpB,MAAM,CAWR;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAC7B,eAAe,EAAE,MAAM,EACvB,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,MAAM,GACnB,OAAO,CAeT;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CACvC,eAAe,EAAE,MAAM,EACvB,eAAe,EAAE,MAAM,EACvB,YAAY,EAAE,UAAU,GACvB,OAAO,CAeT;AAID,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,gEAAgE;IAChE,YAAY,EAAE,UAAU,CAAC;IACzB,qCAAqC;IACrC,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,wBAAgB,sBAAsB,IAAI,cAAc,CAsBvD"} \ No newline at end of file diff --git a/dist/crypto.js b/dist/crypto.js index 8c96ab9..7d187c2 100644 --- a/dist/crypto.js +++ b/dist/crypto.js @@ -1,28 +1,134 @@ -import { createHash, sign, verify } from 'node:crypto'; -/** sha256 hex digest of UTF-8 text */ -export function sha256HexUtf8(text) { - return createHash('sha256').update(text, 'utf8').digest('hex'); +/** + * @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"; +export const CANONICAL_METHOD = "json.sorted_keys.v1"; +export const SIGNATURE_ALG = "ed25519"; +/** The ENS text record key for the signer's public key. */ +export const ENS_KEY_PUB = "cl.sig.pub"; +export const ENS_KEY_KID = "cl.sig.kid"; +export const ENS_KEY_CANONICAL = "cl.sig.canonical"; +export const ENS_KEY_SIGNER = "cl.receipt.signer"; +// ── 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) { + if (rawBytes.length !== 32) { + throw new Error(`Ed25519 public key must be 32 bytes, got ${rawBytes.length}`); + } + return `ed25519:${Buffer.from(rawBytes).toString("base64")}`; } /** - * Ed25519 signature over UTF-8 bytes of `message` (string). - * Node's crypto.sign/verify for Ed25519 uses `null` as the algorithm. + * 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 signEd25519MessageBase64(message, privateKeyPem) { - const sig = sign(null, Buffer.from(message, 'utf8'), privateKeyPem); - return Buffer.from(sig).toString('base64'); +export function parsePublicKey(ensPubValue) { + 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); } -export function verifyEd25519MessageBase64(message, signatureB64, publicKeyPemOrDer) { - const sig = Buffer.from(signatureB64, 'base64'); - return verify(null, Buffer.from(message, 'utf8'), publicKeyPemOrDer, sig); +// ── 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, privateKeyPem) { + 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"); } -/** base64url helpers for compatibility */ -export function base64UrlToBase64(b64url) { - let s = b64url.replace(/-/g, '+').replace(/_/g, '/'); - while (s.length % 4 !== 0) - s += '='; - return s; +/** + * 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, signatureBase64, publicKeyPem) { + 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, signatureBase64, rawPublicKey) { + 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); } -export function base64ToBase64Url(b64) { - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +export function generateEd25519KeyPair() { + 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 + .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, + publicKeyPem: publicKey, + rawPublicKey, + ensPubValue: encodePublicKey(rawPublicKey), + }; } //# sourceMappingURL=crypto.js.map \ No newline at end of file diff --git a/dist/crypto.js.map b/dist/crypto.js.map index 65383fd..fdadb9a 100644 --- a/dist/crypto.js.map +++ b/dist/crypto.js.map @@ -1 +1 @@ -{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAEvD,sCAAsC;AACtC,MAAM,UAAU,aAAa,CAAC,IAAY;IACxC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AACjE,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,OAAe,EAAE,aAAqB;IAC7E,MAAM,GAAG,GAAG,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,aAAa,CAAC,CAAC;IACpE,OAAO,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AAC7C,CAAC;AAED,MAAM,UAAU,0BAA0B,CAAC,OAAe,EAAE,YAAoB,EAAE,iBAAyB;IACzG,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,EAAE,QAAQ,CAAC,CAAC;IAChD,OAAO,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,EAAE,iBAAiB,EAAE,GAAG,CAAC,CAAC;AAC5E,CAAC;AAED,0CAA0C;AAC1C,MAAM,UAAU,iBAAiB,CAAC,MAAc;IAC9C,IAAI,CAAC,GAAG,MAAM,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IACrD,OAAO,CAAC,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC;QAAE,CAAC,IAAI,GAAG,CAAC;IACpC,OAAO,CAAC,CAAC;AACX,CAAC;AAED,MAAM,UAAU,iBAAiB,CAAC,GAAW;IAC3C,OAAO,GAAG,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;AACzE,CAAC"} \ No newline at end of file +{"version":3,"file":"crypto.js","sourceRoot":"","sources":["../src/crypto.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAE5E,iFAAiF;AAEjF,MAAM,CAAC,MAAM,gBAAgB,GAAG,OAAgB,CAAC;AACjD,MAAM,CAAC,MAAM,gBAAgB,GAAG,qBAA8B,CAAC;AAC/D,MAAM,CAAC,MAAM,aAAa,GAAG,SAAkB,CAAC;AAEhD,2DAA2D;AAC3D,MAAM,CAAC,MAAM,WAAW,GAAG,YAAqB,CAAC;AACjD,MAAM,CAAC,MAAM,WAAW,GAAG,YAAqB,CAAC;AACjD,MAAM,CAAC,MAAM,iBAAiB,GAAG,kBAA2B,CAAC;AAC7D,MAAM,CAAC,MAAM,cAAc,GAAG,mBAA4B,CAAC;AAE3D,iFAAiF;AAEjF;;;;;;GAMG;AACH,MAAM,UAAU,eAAe,CAAC,QAAoB;IAClD,IAAI,QAAQ,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAC3B,MAAM,IAAI,KAAK,CACb,4CAA4C,QAAQ,CAAC,MAAM,EAAE,CAC9D,CAAC;IACJ,CAAC;IACD,OAAO,WAAW,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;AAC/D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,cAAc,CAAC,WAAmB;IAChD,MAAM,MAAM,GAAG,UAAU,CAAC;IAC1B,IAAI,CAAC,WAAW,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;QACpC,MAAM,IAAI,KAAK,CACb,+CAA+C,WAAW,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,CAC1E,CAAC;IACJ,CAAC;IACD,MAAM,GAAG,GAAG,WAAW,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC7C,MAAM,GAAG,GAAG,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC;IACvC,IAAI,GAAG,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QACtB,MAAM,IAAI,KAAK,CACb,yBAAyB,GAAG,CAAC,MAAM,0CAA0C,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,IAAI,UAAU,CAAC,GAAG,CAAC,CAAC;AAC7B,CAAC;AAED,iFAAiF;AAEjF;;;;;;;GAOG;AACH,MAAM,UAAU,aAAa,CAC3B,eAAuB,EACvB,aAAqB;IAErB,MAAM,IAAI,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC;IACnC,IAAI,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;IACrC,IAAI,CAAC,GAAG,EAAE,CAAC;IACX,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IAC3C,IAAI,SAAS,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CACb,2CAA2C,SAAS,CAAC,MAAM,EAAE,CAC9D,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;AACtC,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,eAAe,CAC7B,eAAuB,EACvB,eAAuB,EACvB,YAAoB;IAEpB,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,eAAe,EAAE,QAAQ,CAAC,CAAC;IACzD,IAAI,SAAS,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAC5B,MAAM,IAAI,KAAK,CACb,oDAAoD,SAAS,CAAC,MAAM,EAAE,CACvE,CAAC;IACJ,CAAC;IACD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,YAAY,CAAC,SAAS,CAAC,CAAC;QACvC,MAAM,CAAC,MAAM,CAAC,eAAe,EAAE,MAAM,CAAC,CAAC;QACvC,MAAM,CAAC,GAAG,EAAE,CAAC;QACb,OAAO,MAAM,CAAC,MAAM,CAAC,YAAY,EAAE,SAAS,CAAC,CAAC;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,yBAAyB,CACvC,eAAuB,EACvB,eAAuB,EACvB,YAAwB;IAExB,IAAI,YAAY,CAAC,MAAM,KAAK,EAAE,EAAE,CAAC;QAC/B,MAAM,IAAI,KAAK,CACb,wCAAwC,YAAY,CAAC,MAAM,EAAE,CAC9D,CAAC;IACJ,CAAC;IACD,2DAA2D;IAC3D,gDAAgD;IAChD,MAAM,UAAU,GAAG,MAAM,CAAC,IAAI,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IAClE,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,UAAU,EAAE,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IACvE,MAAM,GAAG,GAAG,+BAA+B,OAAO;SAC/C,QAAQ,CAAC,QAAQ,CAAC;SAClB,KAAK,CAAC,UAAU,CAAE;SAClB,IAAI,CAAC,IAAI,CAAC,4BAA4B,CAAC;IAC1C,OAAO,eAAe,CAAC,eAAe,EAAE,eAAe,EAAE,GAAG,CAAC,CAAC;AAChE,CAAC;AAaD,MAAM,UAAU,sBAAsB;IACpC,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,GAAG,mBAAmB,CAAC,SAAS,EAAE;QAC/D,kBAAkB,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE;QACpD,iBAAiB,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE;KACnD,CAAC,CAAC;IAEH,+CAA+C;IAC/C,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CACxB,SAAoB;SAClB,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC;SAC/B,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,EACrB,QAAQ,CACT,CAAC;IACF,oDAAoD;IACpD,MAAM,YAAY,GAAG,IAAI,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAExD,OAAO;QACL,aAAa,EAAE,UAAoB;QACnC,YAAY,EAAE,SAAmB;QACjC,YAAY;QACZ,WAAW,EAAE,eAAe,CAAC,YAAY,CAAC;KAC3C,CAAC;AACJ,CAAC"} \ No newline at end of file diff --git a/dist/ens.d.ts b/dist/ens.d.ts index 30b180d..7eee40e 100644 --- a/dist/ens.d.ts +++ b/dist/ens.d.ts @@ -1,3 +1,52 @@ -import type { EnsResolveOptions, EnsSignerInfo } from './types.js'; -export declare function resolveSignerFromENS(options: EnsResolveOptions): Promise; +/** + * @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. + */ +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; +} +/** + * 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 declare function resolveSignerFromENS(ensName: string, provider: EnsProvider): Promise; +/** + * Resolve the public key only (convenience wrapper). + * Use resolveSignerFromENS for full record access. + */ +export declare function resolvePublicKeyFromENS(ensName: string, provider: EnsProvider): Promise; //# sourceMappingURL=ens.d.ts.map \ No newline at end of file diff --git a/dist/ens.d.ts.map b/dist/ens.d.ts.map index 31d4cc2..0b44965 100644 --- a/dist/ens.d.ts.map +++ b/dist/ens.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"ens.d.ts","sourceRoot":"","sources":["../src/ens.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAiDnE,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB,GAAG,OAAO,CAAC,aAAa,CAAC,CAwB7F"} \ No newline at end of file +{"version":3,"file":"ens.d.ts","sourceRoot":"","sources":["../src/ens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAaH,MAAM,WAAW,eAAe;IAC9B,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,qCAAqC;IACrC,YAAY,EAAE,UAAU,CAAC;IACzB,2CAA2C;IAC3C,GAAG,EAAE,MAAM,CAAC;IACZ,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAAC;CACxD;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC9C;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,eAAe,CAAC,CA4D1B;AAED;;;GAGG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,WAAW,GACpB,OAAO,CAAC,UAAU,CAAC,CAGrB"} \ No newline at end of file diff --git a/dist/ens.js b/dist/ens.js index b7285b3..5b43dec 100644 --- a/dist/ens.js +++ b/dist/ens.js @@ -1,67 +1,80 @@ -import { extractEd25519Raw32FromSpkiDer, fromBase64Url, parsePemToDer, toBase64Url } from './encoding.js'; -const TXT_SIG_PUB = 'cl.sig.pub'; -const TXT_SIG_KID = 'cl.sig.kid'; -const TXT_SIG_CANONICAL = 'cl.sig.canonical'; -const TXT_RECEIPT_PEM = 'cl.receipt.pubkey.pem'; -function collapseTxt(value) { - if (typeof value === 'string') - return value; - if (Array.isArray(value)) { - const flattened = value.flat(Infinity).filter((v) => typeof v === 'string'); - return flattened.join(''); +/** + * @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, CANONICAL_METHOD, parsePublicKey, } from "./crypto.js"; +// ── 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, provider) { + let resolver; + try { + resolver = await provider.getResolver(ensName); } - return undefined; -} -async function resolveTextRecord(provider, ensName, key) { - if (!provider) { - throw new Error('ENS provider is required'); + catch (err) { + throw new Error(`ENS resolution failed for "${ensName}": ${err.message}`); } - if (typeof provider.getText === 'function') { - const val = await provider.getText(ensName, key); - return collapseTxt(val); + if (!resolver) { + throw new Error(`No ENS resolver found for "${ensName}". ` + + `Verify the name is registered and has a resolver set.`); } - if (typeof provider.getResolver === 'function') { - const resolver = await provider.getResolver(ensName); - if (resolver && typeof resolver.getText === 'function') { - const val = await resolver.getText(key); - return collapseTxt(val); - } + // Fetch all relevant text records in parallel + let pubValue; + let kidValue; + let canonicalValue; + try { + [pubValue, kidValue, canonicalValue] = await Promise.all([ + resolver.getText(ENS_KEY_PUB), + resolver.getText(ENS_KEY_KID), + resolver.getText(ENS_KEY_CANONICAL), + ]); } - throw new Error('Unsupported ENS provider interface'); -} -function parseSigPub(raw) { - const [alg, encoded] = raw.split(':', 2); - if (alg !== 'ed25519' || !encoded) { - throw new Error('Invalid cl.sig.pub format; expected ed25519:'); + catch (err) { + throw new Error(`Failed to fetch ENS text records for "${ensName}": ${err.message}`); } - const decoded = fromBase64Url(encoded); - if (decoded.length !== 32) { - throw new Error('Invalid cl.sig.pub key length, expected 32 bytes'); + if (!pubValue) { + throw new Error(`ENS name "${ensName}" has no ${ENS_KEY_PUB} text record. ` + + `Set cl.sig.pub = ed25519: on the ENS name.`); } - return decoded; -} -export async function resolveSignerFromENS(options) { - const sigPub = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_PUB); - const kid = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_KID); - const canonical = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_CANONICAL); - let raw32; - if (sigPub) { - raw32 = parseSigPub(sigPub); - } - else { - const pem = await resolveTextRecord(options.provider, options.ensName, TXT_RECEIPT_PEM); - if (!pem) { - throw new Error('No signer key TXT records found on ENS name'); - } - raw32 = extractEd25519Raw32FromSpkiDer(parsePemToDer(pem)); + // 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 { - pubkeyRaw32: raw32, - pubkeyEncoded: toBase64Url(raw32), - kid: kid || undefined, - canonical: canonical || undefined, - signer_id: options.ensName, - alg: 'ed25519' + 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, provider) { + const record = await resolveSignerFromENS(ensName, provider); + return record.rawPublicKey; +} //# sourceMappingURL=ens.js.map \ No newline at end of file diff --git a/dist/ens.js.map b/dist/ens.js.map index d23ccfb..698109e 100644 --- a/dist/ens.js.map +++ b/dist/ens.js.map @@ -1 +1 @@ -{"version":3,"file":"ens.js","sourceRoot":"","sources":["../src/ens.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"ens.js","sourceRoot":"","sources":["../src/ens.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EACL,WAAW,EACX,WAAW,EACX,iBAAiB,EAEjB,gBAAgB,EAChB,cAAc,GACf,MAAM,aAAa,CAAC;AA2BrB,iFAAiF;AAEjF;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,OAAe,EACf,QAAqB;IAErB,IAAI,QAA4B,CAAC;IACjC,IAAI,CAAC;QACH,QAAQ,GAAG,MAAM,QAAQ,CAAC,WAAW,CAAC,OAAO,CAAC,CAAC;IACjD,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,8BAA8B,OAAO,MAAO,GAAa,CAAC,OAAO,EAAE,CACpE,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CACb,8BAA8B,OAAO,KAAK;YACxC,uDAAuD,CAC1D,CAAC;IACJ,CAAC;IAED,8CAA8C;IAC9C,IAAI,QAAuB,CAAC;IAC5B,IAAI,QAAuB,CAAC;IAC5B,IAAI,cAA6B,CAAC;IAElC,IAAI,CAAC;QACH,CAAC,QAAQ,EAAE,QAAQ,EAAE,cAAc,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;YACvD,QAAQ,CAAC,OAAO,CAAC,WAAW,CAAC;YAC7B,QAAQ,CAAC,OAAO,CAAC,WAAW,CAAC;YAC7B,QAAQ,CAAC,OAAO,CAAC,iBAAiB,CAAC;SACpC,CAAC,CAAC;IACL,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,IAAI,KAAK,CACb,yCAAyC,OAAO,MAC7C,GAAa,CAAC,OACjB,EAAE,CACH,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CACb,aAAa,OAAO,YAAY,WAAW,gBAAgB;YACzD,mEAAmE,CACtE,CAAC;IACJ,CAAC;IAED,uCAAuC;IACvC,IAAI,cAAc,IAAI,cAAc,KAAK,gBAAgB,EAAE,CAAC;QAC1D,MAAM,IAAI,KAAK,CACb,aAAa,OAAO,4CAA4C;YAC9D,IAAI,cAAc,YAAY,gBAAgB,iBAAiB,CAClE,CAAC;IACJ,CAAC;IAED,mDAAmD;IACnD,MAAM,YAAY,GAAG,cAAc,CAAC,QAAQ,CAAC,CAAC;IAE9C,OAAO;QACL,IAAI,EAAE,OAAO;QACb,YAAY;QACZ,GAAG,EAAE,QAAQ,IAAI,EAAE;QACnB,SAAS,EAAE,cAAc,IAAI,gBAAgB;KAC9C,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,uBAAuB,CAC3C,OAAe,EACf,QAAqB;IAErB,MAAM,MAAM,GAAG,MAAM,oBAAoB,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;IAC7D,OAAO,MAAM,CAAC,YAAY,CAAC;AAC7B,CAAC"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index a134e3a..7d5db2b 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,11 +1,20 @@ -export * from './types.js'; -export * from './ens.js'; -export * from './schema-client.js'; -export * from './errors.js'; -export * from './normalize.js'; -export * from './receipt.js'; -export * from './canonical.js'; -export * from './crypto.js'; -export * from './receipt-v1.js'; -export * from './encoding.js'; +/** + * @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 + */ +export { PROTOCOL_VERSION, CANONICAL_METHOD, SIGNATURE_ALG, ENS_KEY_PUB, ENS_KEY_KID, ENS_KEY_CANONICAL, ENS_KEY_SIGNER, } from "./crypto.js"; +export { canonicalize, CANONICAL_TEST_VECTORS } from "./canonicalize.js"; +export { encodePublicKey, parsePublicKey, signCanonical, verifyCanonical, verifyCanonicalWithRawKey, generateEd25519KeyPair, type Ed25519KeyPair, } from "./crypto.js"; +export { resolveSignerFromENS, resolvePublicKeyFromENS, type EnsSignerRecord, type EnsProvider, type EnsResolver, } from "./ens.js"; +export { signReceipt, verifyReceipt, isSignedLayeredReceipt, type ReceiptPayload, type ReceiptProof, type SignedLayeredReceipt, type SignReceiptOptions, type VerifyReceiptResult, type VerifyReceiptOptions, } from "./receipt.js"; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 8de441b..27f9457 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,oBAAoB,CAAC;AACnC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAGH,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,cAAc,GACf,MAAM,aAAa,CAAC;AAGrB,OAAO,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAGzE,OAAO,EACL,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,yBAAyB,EACzB,sBAAsB,EACtB,KAAK,cAAc,GACpB,MAAM,aAAa,CAAC;AAGrB,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,KAAK,eAAe,EACpB,KAAK,WAAW,EAChB,KAAK,WAAW,GACjB,MAAM,UAAU,CAAC;AAGlB,OAAO,EACL,WAAW,EACX,aAAa,EACb,sBAAsB,EACtB,KAAK,cAAc,EACnB,KAAK,YAAY,EACjB,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,oBAAoB,GAC1B,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index f80b1da..4f7938c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,11 +1,25 @@ -export * from './types.js'; -export * from './ens.js'; -export * from './schema-client.js'; -export * from './errors.js'; -export * from './normalize.js'; -export * from './receipt.js'; -export * from './canonical.js'; -export * from './crypto.js'; -export * from './receipt-v1.js'; -export * from './encoding.js'; +/** + * @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, } from "./crypto.js"; +// ENS resolution +export { resolveSignerFromENS, resolvePublicKeyFromENS, } from "./ens.js"; +// Receipt v1.1.0 +export { signReceipt, verifyReceipt, isSignedLayeredReceipt, } from "./receipt.js"; //# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map index c2e168a..2cb0b19 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,oBAAoB,CAAC;AACnC,cAAc,aAAa,CAAC;AAC5B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC;AAC/B,cAAc,aAAa,CAAC;AAC5B,cAAc,iBAAiB,CAAC;AAChC,cAAc,eAAe,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,qBAAqB;AACrB,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,cAAc,GACf,MAAM,aAAa,CAAC;AAErB,mBAAmB;AACnB,OAAO,EAAE,YAAY,EAAE,sBAAsB,EAAE,MAAM,mBAAmB,CAAC;AAEzE,oBAAoB;AACpB,OAAO,EACL,eAAe,EACf,cAAc,EACd,aAAa,EACb,eAAe,EACf,yBAAyB,EACzB,sBAAsB,GAEvB,MAAM,aAAa,CAAC;AAErB,iBAAiB;AACjB,OAAO,EACL,oBAAoB,EACpB,uBAAuB,GAIxB,MAAM,UAAU,CAAC;AAElB,iBAAiB;AACjB,OAAO,EACL,WAAW,EACX,aAAa,EACb,sBAAsB,GAOvB,MAAM,cAAc,CAAC"} \ No newline at end of file diff --git a/dist/receipt.d.ts b/dist/receipt.d.ts index 5a2a6bd..70fd3eb 100644 --- a/dist/receipt.d.ts +++ b/dist/receipt.d.ts @@ -1,13 +1,85 @@ -import type { AttachProofOptions, CommandLayerReceipt, CommercialReceipt, CommonsReceipt, LayeredReceipt, LegacySignedReceiptEnvelope, ReceiptRuntimeMetadata, SignOptions, SignedLayeredReceipt, VerifyOptions } from './types.js'; -export declare function buildCommonsReceipt(input: Omit & Partial>): CommonsReceipt; -export declare function buildCommercialReceipt(input: Omit & Partial>): CommercialReceipt; -export declare function buildReceipt(input: CommandLayerReceipt): CommandLayerReceipt; -export declare function createLayeredReceipt(receipt: TReceipt, runtime?: ReceiptRuntimeMetadata): LayeredReceipt; -export declare function canonicalizeReceipt(receipt: CommandLayerReceipt): string; -export declare function hashReceiptCanonical(canonical: string): Uint8Array; -export declare function attachProof(receipt: TReceipt, options: AttachProofOptions): SignedLayeredReceipt; -export declare function signReceiptEd25519(receipt: TReceipt, options: SignOptions): SignedLayeredReceipt; -export declare function verifyReceiptSignature(receipt: SignedLayeredReceipt, options: VerifyOptions): boolean; -/** @deprecated Legacy 1.0.0 metadata.proof envelope. */ -export declare function toLegacySignedReceipt(receipt: SignedLayeredReceipt, runtimeMetadata?: Record): LegacySignedReceiptEnvelope; +/** + * @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 { CANONICAL_METHOD } from "./canonicalize.js"; +import { SIGNATURE_ALG, PROTOCOL_VERSION } from "./crypto.js"; +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; + }; +} +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 declare function signReceipt(payload: ReceiptPayload, opts: SignReceiptOptions): SignedLayeredReceipt; +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 declare function verifyReceipt(receipt: SignedLayeredReceipt, opts: VerifyReceiptOptions): VerifyReceiptResult; +/** + * Type guard: check if an unknown value is a SignedLayeredReceipt. + */ +export declare function isSignedLayeredReceipt(value: unknown): value is SignedLayeredReceipt; +export { PROTOCOL_VERSION }; //# sourceMappingURL=receipt.d.ts.map \ No newline at end of file diff --git a/dist/receipt.d.ts.map b/dist/receipt.d.ts.map index 555687a..9591dac 100644 --- a/dist/receipt.d.ts.map +++ b/dist/receipt.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"receipt.d.ts","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,iBAAiB,EACjB,cAAc,EACd,cAAc,EACd,2BAA2B,EAE3B,sBAAsB,EACtB,WAAW,EACX,oBAAoB,EACpB,aAAa,EACd,MAAM,YAAY,CAAC;AAyBpB,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,MAAM,GAAG,UAAU,CAAC,CAAC,GAAG,cAAc,CAYzJ;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,UAAU,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,iBAAiB,EAAE,MAAM,GAAG,UAAU,CAAC,CAAC,GAC1G,iBAAiB,CAgBnB;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,mBAAmB,GAAG,mBAAmB,CAE5E;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,SAAS,mBAAmB,EACvE,OAAO,EAAE,QAAQ,EACjB,OAAO,CAAC,EAAE,sBAAsB,GAC/B,cAAc,CAAC,QAAQ,CAAC,CAK1B;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,mBAAmB,GAAG,MAAM,CAExE;AAED,wBAAgB,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,CAElE;AAED,wBAAgB,WAAW,CAAC,QAAQ,SAAS,mBAAmB,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,kBAAkB,GAAG,oBAAoB,CAAC,QAAQ,CAAC,CAahJ;AAED,wBAAgB,kBAAkB,CAAC,QAAQ,SAAS,mBAAmB,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,EAAE,WAAW,GAAG,oBAAoB,CAAC,QAAQ,CAAC,CAUhJ;AAED,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,oBAAoB,EAAE,OAAO,EAAE,aAAa,GAAG,OAAO,CAWrG;AAED,wDAAwD;AACxD,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,oBAAoB,EAC7B,eAAe,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GAC5C,2BAA2B,CAe7B"} \ No newline at end of file +{"version":3,"file":"receipt.d.ts","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAgB,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACnE,OAAO,EAIL,aAAa,EACb,gBAAgB,EACjB,MAAM,aAAa,CAAC;AAIrB,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,6CAA6C;IAC7C,GAAG,EAAE,OAAO,aAAa,CAAC;IAC1B,yCAAyC;IACzC,GAAG,EAAE,MAAM,CAAC;IACZ,6BAA6B;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,SAAS,EAAE,OAAO,gBAAgB,CAAC;IACnC,kFAAkF;IAClF,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,cAAc,CAAC;IACxB,SAAS,EAAE;QACT,KAAK,EAAE,YAAY,CAAC;KACrB,CAAC;CACH;AAID,MAAM,WAAW,kBAAkB;IACjC,aAAa,EAAE,MAAM,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,cAAc,EACvB,IAAI,EAAE,kBAAkB,GACvB,oBAAoB,CAqBtB;AAID,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,OAAO,CAAC;IACf,MAAM,EAAE;QACN,cAAc,EAAE,OAAO,CAAC;QACxB,QAAQ,EAAE,OAAO,CAAC;QAClB,UAAU,EAAE,OAAO,CAAC;QACpB,aAAa,EAAE,OAAO,CAAC;QACvB,cAAc,EAAE,OAAO,CAAC;KACzB,CAAC;IACF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,mEAAmE;IACnE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,UAAU,CAAC;IAC1B,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,oBAAoB,EAC7B,IAAI,EAAE,oBAAoB,GACzB,mBAAmB,CAwGrB;AAED;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,oBAAoB,CAa/B;AAED,OAAO,EAAE,gBAAgB,EAAE,CAAC"} \ No newline at end of file diff --git a/dist/receipt.js b/dist/receipt.js index 1b2859f..05ea65b 100644 --- a/dist/receipt.js +++ b/dist/receipt.js @@ -1,119 +1,155 @@ -import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, verify as cryptoVerify } from 'node:crypto'; -import { fromBase64Url, toBase64Url } from './encoding.js'; -import { canonicalizeSortedKeysV1 } from './canonical.js'; -import { COMMAND_LAYER_CURRENT_LINE, COMMONS_CONTRACT, COMMERCIAL_CONTRACT, DEFAULT_CANONICAL_ID } from './types.js'; -function toEd25519PublicSpki(raw32) { - if (raw32.length !== 32) - throw new Error('Ed25519 public key must be 32 bytes'); - const prefix = Buffer.from('302a300506032b6570032100', 'hex'); - return Buffer.concat([prefix, Buffer.from(raw32)]); -} -function normalizePrivateKey(privateKey) { - return typeof privateKey === 'string' - ? createPrivateKey(privateKey) - : createPrivateKey({ key: Buffer.from(privateKey), format: 'der', type: 'pkcs8' }); -} -function normalizePublicKey(pubkey) { - if (typeof pubkey === 'string') { - if (pubkey.includes('BEGIN PUBLIC KEY')) - return createPublicKey(pubkey); - return createPublicKey({ key: toEd25519PublicSpki(fromBase64Url(pubkey)), format: 'der', type: 'spki' }); - } - if (pubkey.length === 32) { - return createPublicKey({ key: toEd25519PublicSpki(pubkey), format: 'der', type: 'spki' }); - } - return createPublicKey({ key: Buffer.from(pubkey), format: 'der', type: 'spki' }); -} -export function buildCommonsReceipt(input) { - return { - line: COMMAND_LAYER_CURRENT_LINE, - contract: COMMONS_CONTRACT, - verb: input.verb, - version: input.version, - payload: input.payload, - status: input.status, - ...(input.trace ? { trace: input.trace } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'result') ? { result: input.result } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'error') ? { error: input.error } : {}) - }; -} -export function buildCommercialReceipt(input) { - const commons = buildCommonsReceipt({ - verb: input.verb, - version: input.version, - trace: input.trace, - payload: input.payload, - status: input.status, - ...(Object.prototype.hasOwnProperty.call(input, 'result') ? { result: input.result } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'error') ? { error: input.error } : {}) - }); +/** + * @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"; +/** + * 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, opts) { + // 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 { - ...commons, - contract: COMMERCIAL_CONTRACT, - commercial: { ...input.commercial } + receipt: payload, + signature: { + proof: { + alg: SIGNATURE_ALG, + kid: opts.kid, + signer_id: opts.signerEns, + canonical: CANONICAL_METHOD, + signature, + }, + }, }; } -export function buildReceipt(input) { - return input.contract === COMMERCIAL_CONTRACT ? buildCommercialReceipt(input) : buildCommonsReceipt(input); -} -export function createLayeredReceipt(receipt, runtime) { - return { - receipt: buildReceipt(receipt), - ...(runtime ? { runtime: { ...runtime } } : {}) - }; -} -export function canonicalizeReceipt(receipt) { - return canonicalizeSortedKeysV1(buildReceipt(receipt)); -} -export function hashReceiptCanonical(canonical) { - return createHash('sha256').update(canonical, 'utf8').digest(); -} -export function attachProof(receipt, options) { - const proof = { - alg: options.alg, - signer_id: options.signer_id, - canonical: options.canonical, - signature: options.signature, - ...(options.kid ? { kid: options.kid } : {}) +/** + * 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, opts) { + 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; + try { + canonical = canonicalize(receipt.receipt); + } + catch (err) { + return { + valid: false, + checks, + reason: `Canonicalization failed: ${err.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.message}`, + }; + } + // ALL checks must pass for valid: true + const valid = checks.structureValid && + checks.algValid && + checks.kidMatched && + checks.signerMatched && + checks.signatureValid; return { - receipt: buildReceipt(receipt), - signature: { proof } + valid, + checks, + reason: valid + ? undefined + : Object.entries(checks) + .filter(([, v]) => !v) + .map(([k]) => `${k} failed`) + .join(", "), }; } -export function signReceiptEd25519(receipt, options) { - const canonical = options.canonical ?? DEFAULT_CANONICAL_ID; - const signature = cryptoSign(null, Buffer.from(canonicalizeReceipt(receipt), 'utf8'), normalizePrivateKey(options.privateKey)); - return attachProof(receipt, { - alg: 'ed25519', - kid: options.kid, - signer_id: options.signer_id, - canonical, - signature: toBase64Url(new Uint8Array(signature)) - }); -} -export function verifyReceiptSignature(receipt, options) { - const canonical = options.canonical ?? DEFAULT_CANONICAL_ID; - const proof = receipt.signature?.proof; - if (!proof || proof.alg !== 'ed25519' || proof.canonical !== canonical) +/** + * Type guard: check if an unknown value is a SignedLayeredReceipt. + */ +export function isSignedLayeredReceipt(value) { + if (typeof value !== "object" || value === null) return false; - return cryptoVerify(null, Buffer.from(canonicalizeReceipt(receipt.receipt), 'utf8'), normalizePublicKey(options.pubkey), Buffer.from(fromBase64Url(proof.signature))); -} -/** @deprecated Legacy 1.0.0 metadata.proof envelope. */ -export function toLegacySignedReceipt(receipt, runtimeMetadata = {}) { - const built = buildReceipt(receipt.receipt); - return { - ...(built.contract === COMMERCIAL_CONTRACT ? { x402: built.commercial } : {}), - verb: built.verb, - version: String(built.version), - ...(built.trace ? { trace: built.trace } : {}), - payload: built.payload, - status: built.status, - ...(Object.prototype.hasOwnProperty.call(built, 'result') ? { result: built.result } : {}), - metadata: { - ...runtimeMetadata, - proof: receipt.signature.proof - } - }; + const v = value; + if (typeof v.receipt !== "object" || v.receipt === null) + return false; + if (typeof v.signature !== "object" || v.signature === null) + return false; + const sig = v.signature; + if (typeof sig.proof !== "object" || sig.proof === null) + return false; + const proof = sig.proof; + return (typeof proof.alg === "string" && + typeof proof.signature === "string" && + typeof proof.signer_id === "string"); } +export { PROTOCOL_VERSION }; //# sourceMappingURL=receipt.js.map \ No newline at end of file diff --git a/dist/receipt.js.map b/dist/receipt.js.map index 05f73e1..f4aafc0 100644 --- a/dist/receipt.js.map +++ b/dist/receipt.js.map @@ -1 +1 @@ -{"version":3,"file":"receipt.js","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"receipt.js","sourceRoot":"","sources":["../src/receipt.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/src/canonicalize.ts b/src/canonicalize.ts new file mode 100644 index 0000000..f0c0808 --- /dev/null +++ b/src/canonicalize.ts @@ -0,0 +1,176 @@ +/** + * @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/src/crypto.ts b/src/crypto.ts index 87aa99e..da10aba 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -1,31 +1,183 @@ -import { createHash, sign, verify } from 'node:crypto'; +/** + * @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; -/** sha256 hex digest of UTF-8 text */ -export function sha256HexUtf8(text: string): string { - return createHash('sha256').update(text, 'utf8').digest('hex'); +// ── 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")}`; } /** - * Ed25519 signature over UTF-8 bytes of `message` (string). - * Node's crypto.sign/verify for Ed25519 uses `null` as the algorithm. + * 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 signEd25519MessageBase64(message: string, privateKeyPem: string): string { - const sig = sign(null, Buffer.from(message, 'utf8'), privateKeyPem); - return Buffer.from(sig).toString('base64'); +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); } -export function verifyEd25519MessageBase64(message: string, signatureB64: string, publicKeyPemOrDer: string): boolean { - const sig = Buffer.from(signatureB64, 'base64'); - return verify(null, Buffer.from(message, 'utf8'), publicKeyPemOrDer, sig); +// ── 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"); } -/** base64url helpers for compatibility */ -export function base64UrlToBase64(b64url: string): string { - let s = b64url.replace(/-/g, '+').replace(/_/g, '/'); - while (s.length % 4 !== 0) s += '='; - return s; +/** + * 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); } -export function base64ToBase64Url(b64: string): string { - return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); +// ── 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/src/ens.ts b/src/ens.ts index 29960c2..ab701a2 100644 --- a/src/ens.ts +++ b/src/ens.ts @@ -1,75 +1,138 @@ -import { extractEd25519Raw32FromSpkiDer, fromBase64Url, parsePemToDer, toBase64Url } from './encoding.js'; -import type { EnsResolveOptions, EnsSignerInfo } from './types.js'; +/** + * @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. + */ -const TXT_SIG_PUB = 'cl.sig.pub'; -const TXT_SIG_KID = 'cl.sig.kid'; -const TXT_SIG_CANONICAL = 'cl.sig.canonical'; -const TXT_RECEIPT_PEM = 'cl.receipt.pubkey.pem'; +import { + ENS_KEY_PUB, + ENS_KEY_KID, + ENS_KEY_CANONICAL, + ENS_KEY_SIGNER, + CANONICAL_METHOD, + parsePublicKey, +} from "./crypto.js"; -function collapseTxt(value: unknown): string | undefined { - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - const flattened = value.flat(Infinity as 1).filter((v): v is string => typeof v === 'string'); - return flattened.join(''); - } - return undefined; +// ── 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; } -async function resolveTextRecord(provider: any, ensName: string, key: string): Promise { - if (!provider) { - throw new Error('ENS provider is required'); - } +/** + * 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 ──────────────────────────────────────────────────────────────── - if (typeof provider.getText === 'function') { - const val = await provider.getText(ensName, key); - return collapseTxt(val); +/** + * 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 (typeof provider.getResolver === 'function') { - const resolver = await provider.getResolver(ensName); - if (resolver && typeof resolver.getText === 'function') { - const val = await resolver.getText(key); - return collapseTxt(val); - } + if (!resolver) { + throw new Error( + `No ENS resolver found for "${ensName}". ` + + `Verify the name is registered and has a resolver set.` + ); } - throw new Error('Unsupported ENS provider interface'); -} + // Fetch all relevant text records in parallel + let pubValue: string | null; + let kidValue: string | null; + let canonicalValue: string | null; -function parseSigPub(raw: string): Uint8Array { - const [alg, encoded] = raw.split(':', 2); - if (alg !== 'ed25519' || !encoded) { - throw new Error('Invalid cl.sig.pub format; expected ed25519:'); - } - const decoded = fromBase64Url(encoded); - if (decoded.length !== 32) { - throw new Error('Invalid cl.sig.pub key length, expected 32 bytes'); + 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 + }` + ); } - return decoded; -} -export async function resolveSignerFromENS(options: EnsResolveOptions): Promise { - const sigPub = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_PUB); - const kid = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_KID); - const canonical = await resolveTextRecord(options.provider, options.ensName, TXT_SIG_CANONICAL); + if (!pubValue) { + throw new Error( + `ENS name "${ensName}" has no ${ENS_KEY_PUB} text record. ` + + `Set cl.sig.pub = ed25519: on the ENS name.` + ); + } - let raw32: Uint8Array; - if (sigPub) { - raw32 = parseSigPub(sigPub); - } else { - const pem = await resolveTextRecord(options.provider, options.ensName, TXT_RECEIPT_PEM); - if (!pem) { - throw new Error('No signer key TXT records found on ENS name'); - } - raw32 = extractEd25519Raw32FromSpkiDer(parsePemToDer(pem)); + // 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 { - pubkeyRaw32: raw32, - pubkeyEncoded: toBase64Url(raw32), - kid: kid || undefined, - canonical: canonical || undefined, - signer_id: options.ensName, - alg: 'ed25519' + 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/src/index.ts b/src/index.ts index 2539979..7100ab4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,61 @@ -export * from './types.js'; -export * from './ens.js'; -export * from './schema-client.js'; -export * from './errors.js'; -export * from './normalize.js'; -export * from './receipt.js'; -export * from './canonical.js'; -export * from './crypto.js'; -export * from './receipt-v1.js'; -export * from './encoding.js'; +/** + * @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/src/receipt.ts b/src/receipt.ts index 13a7fe9..b8abfed 100644 --- a/src/receipt.ts +++ b/src/receipt.ts @@ -1,162 +1,253 @@ -import { createHash, createPrivateKey, createPublicKey, sign as cryptoSign, verify as cryptoVerify, type KeyLike } from 'node:crypto'; -import { fromBase64Url, toBase64Url } from './encoding.js'; -import { canonicalizeSortedKeysV1 } from './canonical.js'; +/** + * @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 { - COMMAND_LAYER_CURRENT_LINE, - COMMONS_CONTRACT, - COMMERCIAL_CONTRACT, - DEFAULT_CANONICAL_ID -} from './types.js'; -import type { - AttachProofOptions, - CommandLayerReceipt, - CommercialReceipt, - CommonsReceipt, - LayeredReceipt, - LegacySignedReceiptEnvelope, - Proof, - ReceiptRuntimeMetadata, - SignOptions, - SignedLayeredReceipt, - VerifyOptions -} from './types.js'; - -function toEd25519PublicSpki(raw32: Uint8Array): Uint8Array { - if (raw32.length !== 32) throw new Error('Ed25519 public key must be 32 bytes'); - const prefix = Buffer.from('302a300506032b6570032100', 'hex'); - return Buffer.concat([prefix, Buffer.from(raw32)]); + 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; } -function normalizePrivateKey(privateKey: Uint8Array | string): KeyLike { - return typeof privateKey === 'string' - ? createPrivateKey(privateKey) - : createPrivateKey({ key: Buffer.from(privateKey), format: 'der', type: 'pkcs8' }); +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; } -function normalizePublicKey(pubkey: Uint8Array | string): KeyLike { - if (typeof pubkey === 'string') { - if (pubkey.includes('BEGIN PUBLIC KEY')) return createPublicKey(pubkey); - return createPublicKey({ key: toEd25519PublicSpki(fromBase64Url(pubkey)), format: 'der', type: 'spki' }); - } - if (pubkey.length === 32) { - return createPublicKey({ key: toEd25519PublicSpki(pubkey), format: 'der', type: 'spki' }); - } - return createPublicKey({ key: Buffer.from(pubkey), format: 'der', type: 'spki' }); +export interface SignedLayeredReceipt { + receipt: ReceiptPayload; + signature: { + proof: ReceiptProof; + }; } -export function buildCommonsReceipt(input: Omit & Partial>): CommonsReceipt { - return { - line: COMMAND_LAYER_CURRENT_LINE, - contract: COMMONS_CONTRACT, - verb: input.verb, - version: input.version, - payload: input.payload, - status: input.status, - ...(input.trace ? { trace: input.trace } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'result') ? { result: input.result } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'error') ? { error: input.error } : {}) - }; +// ── Builders ────────────────────────────────────────────────────────────────── + +export interface SignReceiptOptions { + privateKeyPem: string; + kid: string; + signerEns: string; } -export function buildCommercialReceipt( - input: Omit & Partial> -): CommercialReceipt { - const commons = buildCommonsReceipt({ - verb: input.verb, - version: input.version, - trace: input.trace, - payload: input.payload, - status: input.status, - ...(Object.prototype.hasOwnProperty.call(input, 'result') ? { result: input.result } : {}), - ...(Object.prototype.hasOwnProperty.call(input, 'error') ? { error: input.error } : {}) - }); +/** + * 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 { - ...commons, - contract: COMMERCIAL_CONTRACT, - commercial: { ...input.commercial } + receipt: payload, + signature: { + proof: { + alg: SIGNATURE_ALG, + kid: opts.kid, + signer_id: opts.signerEns, + canonical: CANONICAL_METHOD, + signature, + }, + }, }; } -export function buildReceipt(input: CommandLayerReceipt): CommandLayerReceipt { - return input.contract === COMMERCIAL_CONTRACT ? buildCommercialReceipt(input) : buildCommonsReceipt(input); -} +// ── Verification ────────────────────────────────────────────────────────────── -export function createLayeredReceipt( - receipt: TReceipt, - runtime?: ReceiptRuntimeMetadata -): LayeredReceipt { - return { - receipt: buildReceipt(receipt) as TReceipt, - ...(runtime ? { runtime: { ...runtime } } : {}) +export interface VerifyReceiptResult { + valid: boolean; + checks: { + structureValid: boolean; + algValid: boolean; + kidMatched: boolean; + signerMatched: boolean; + signatureValid: boolean; }; + reason?: string; } -export function canonicalizeReceipt(receipt: CommandLayerReceipt): string { - return canonicalizeSortedKeysV1(buildReceipt(receipt)); +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; } -export function hashReceiptCanonical(canonical: string): Uint8Array { - return createHash('sha256').update(canonical, 'utf8').digest(); -} +/** + * 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" + ); + } -export function attachProof(receipt: TReceipt, options: AttachProofOptions): SignedLayeredReceipt { - const proof: Proof = { - alg: options.alg, - signer_id: options.signer_id, - canonical: options.canonical, - signature: options.signature, - ...(options.kid ? { kid: options.kid } : {}) + const checks = { + structureValid: false, + algValid: false, + kidMatched: false, + signerMatched: false, + signatureValid: false, }; - return { - receipt: buildReceipt(receipt) as TReceipt, - signature: { proof } - }; -} + // 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; -export function signReceiptEd25519(receipt: TReceipt, options: SignOptions): SignedLayeredReceipt { - const canonical = options.canonical ?? DEFAULT_CANONICAL_ID; - const signature = cryptoSign(null, Buffer.from(canonicalizeReceipt(receipt), 'utf8'), normalizePrivateKey(options.privateKey)); - return attachProof(receipt, { - alg: 'ed25519', - kid: options.kid, - signer_id: options.signer_id, - canonical, - signature: toBase64Url(new Uint8Array(signature)) - }); -} + const proof = receipt.signature.proof; -export function verifyReceiptSignature(receipt: SignedLayeredReceipt, options: VerifyOptions): boolean { - const canonical = options.canonical ?? DEFAULT_CANONICAL_ID; - const proof = receipt.signature?.proof; - if (!proof || proof.alg !== 'ed25519' || proof.canonical !== canonical) return false; + // 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}`, + }; + } - return cryptoVerify( - null, - Buffer.from(canonicalizeReceipt(receipt.receipt), 'utf8'), - normalizePublicKey(options.pubkey), - Buffer.from(fromBase64Url(proof.signature)) - ); -} + 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; -/** @deprecated Legacy 1.0.0 metadata.proof envelope. */ -export function toLegacySignedReceipt( - receipt: SignedLayeredReceipt, - runtimeMetadata: Record = {} -): LegacySignedReceiptEnvelope { - const built = buildReceipt(receipt.receipt); return { - ...(built.contract === COMMERCIAL_CONTRACT ? { x402: built.commercial } : {}), - verb: built.verb, - version: String(built.version), - ...(built.trace ? { trace: built.trace } : {}), - payload: built.payload, - status: built.status, - ...(Object.prototype.hasOwnProperty.call(built, 'result') ? { result: built.result } : {}), - metadata: { - ...runtimeMetadata, - proof: receipt.signature.proof - } + 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/shims.d.ts b/src/shims.d.ts deleted file mode 100644 index 4f7381a..0000000 --- a/src/shims.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -declare module 'ajv' { - export interface ErrorObject { - instancePath: string; - keyword: string; - message?: string; - params: Record; - } - - export interface ValidateFunction { - (data: unknown): boolean; - errors?: ErrorObject[] | null; - } - - export default class Ajv { - constructor(options?: Record); - compileAsync(schema: object): Promise; - } -} - -declare module 'node:crypto' { - export type KeyLike = unknown; - export function createHash(algorithm: string): { - update(data: string | Uint8Array, inputEncoding?: string): any; - digest(): Uint8Array; - }; - export function createPrivateKey(key: any): any; - export function createPublicKey(key: any): any; - export function sign(algorithm: any, data: Uint8Array, key: any): Uint8Array; - export function verify(algorithm: any, data: Uint8Array, key: any, signature: Uint8Array): boolean; - export function generateKeyPairSync(type: 'ed25519'): { privateKey: any; publicKey: any }; -} - -declare module 'node:test' { - const test: (name: string, fn: () => void | Promise) => void; - export default test; -} - -declare module 'node:assert/strict' { - const assert: { - deepEqual(actual: unknown, expected: unknown): void; - equal(actual: unknown, expected: unknown): void; - }; - export default assert; -} - -declare const Buffer: any; diff --git a/test/canonicalize.test.ts b/test/canonicalize.test.ts new file mode 100644 index 0000000..07cae23 --- /dev/null +++ b/test/canonicalize.test.ts @@ -0,0 +1,67 @@ +/** + * 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/test/crypto.test.ts b/test/crypto.test.ts new file mode 100644 index 0000000..d56d7f4 --- /dev/null +++ b/test/crypto.test.ts @@ -0,0 +1,138 @@ +/** + * 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/test/receipt.test.ts b/test/receipt.test.ts new file mode 100644 index 0000000..fcb668c --- /dev/null +++ b/test/receipt.test.ts @@ -0,0 +1,156 @@ +/** + * 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); + }); +});