From 99ded156c7320810495d59006c083d6333f05fac Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Tue, 12 May 2026 21:55:05 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20protocol=20v1.1.0=20compat=20=E2=80=94?= =?UTF-8?q?=20dual-mode=20verify,=20canonical=20field=20names,=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/verifyReceipt.js: accept proof.canonical || proof.canonicalization; accept sig/kid from signature.* or metadata.proof.*; add verifyCanonicalSignature for v1.1.0 raw-bytes path; dual-mode routing by hash_sha256 presence so legacy receipts continue to pass; relax schemaValid hash_sha256 requirement - api/_receipt-model.js: fix proof.alg check ed25519-sha256→ed25519; fix canonical field lookup canonical_id→canonicalization - .github/workflows/ci.yml: new CI running npm test on push/PR - .env.example: document RUNTIME_BASE_URL --- .env.example | 1 + .github/workflows/ci.yml | 16 +++++++++++++ api/_receipt-model.js | 6 ++--- lib/verifyReceipt.js | 51 ++++++++++++++++++++++++++++++---------- 4 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 .env.example create mode 100644 .github/workflows/ci.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c7e8779 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +RUNTIME_BASE_URL=https://runtime.commandlayer.org diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f9c5e21 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,16 @@ +name: CI +on: + push: + branches: [main, "claude/**"] + pull_request: + branches: [main] +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm install + - run: npm test diff --git a/api/_receipt-model.js b/api/_receipt-model.js index c2d8294..a1e727b 100644 --- a/api/_receipt-model.js +++ b/api/_receipt-model.js @@ -243,10 +243,10 @@ function validateRuntimeMetadata(runtimeMetadata, options = {}) { if (!proof) { errors.push({ message: 'runtime_metadata.proof is required.' }); } else { - if (proof.alg !== 'ed25519-sha256') { - errors.push({ message: `runtime_metadata.proof.alg must be ed25519-sha256 (got ${proof.alg || 'missing'}).` }); + if (proof.alg !== 'ed25519') { + errors.push({ message: `runtime_metadata.proof.alg must be ed25519 (got ${proof.alg || 'missing'}).` }); } - const canonicalId = proof.canonical || proof.canonical_id || null; + const canonicalId = proof.canonical || proof.canonicalization || null; if (canonicalId !== CANONICAL_PROOF_ID) { errors.push({ message: `runtime_metadata.proof canonical id must be ${CANONICAL_PROOF_ID} (got ${canonicalId || 'missing'}).` }); } diff --git a/lib/verifyReceipt.js b/lib/verifyReceipt.js index 58c1a80..510498a 100644 --- a/lib/verifyReceipt.js +++ b/lib/verifyReceipt.js @@ -60,6 +60,15 @@ async function verifyHashHexSignature(hashHex, signatureBase64, publicKey) { ); } +async function verifyCanonicalSignature(canonicalStr, signatureBase64, publicKey) { + return subtle.verify( + { name: 'Ed25519' }, + publicKey, + base64ToBytes(signatureBase64), + new TextEncoder().encode(canonicalStr), + ); +} + async function defaultTextResolver() { return null; } @@ -135,44 +144,56 @@ async function verifyReceipt(receiptInput, options = {}) { }; } + const proof = receipt?.metadata?.proof || null; + const canonicalization = proof?.canonical || proof?.canonicalization || null; + const kid = receipt?.signature?.kid || proof?.kid || null; + const sig = receipt?.signature?.sig || proof?.signature || null; + const schemaValid = Boolean( receipt && typeof receipt === 'object' && typeof receipt.signer === 'string' && typeof receipt.verb === 'string' && typeof receipt.ts === 'string' && - receipt.metadata?.proof?.canonicalization && - receipt.metadata?.proof?.hash_sha256 && - receipt.signature?.kid && - receipt.signature?.sig, + canonicalization && + kid && + sig, ); const ens = await resolveSignerFromEns(receipt?.signer, options.ens || {}); - const expectedHash = receipt?.metadata?.proof?.hash_sha256 || null; - const canonicalization = receipt?.metadata?.proof?.canonicalization || null; + const expectedHash = proof?.hash_sha256 || null; + const isLegacyMode = Boolean(expectedHash); const canonicalPayload = canonicalReceiptPayload(receipt); - const recomputedHash = await sha256Hex(canonicalize(canonicalPayload)); + const canonicalStr = canonicalize(canonicalPayload); + const recomputedHash = await sha256Hex(canonicalStr); const expectedCanonical = ens.records['cl.sig.canonical']; const canonicalizationOk = canonicalization === expectedCanonical; const hashMatched = Boolean( schemaValid && canonicalizationOk && + isLegacyMode && typeof expectedHash === 'string' && expectedHash === recomputedHash, ); - const keyIdMatched = receipt?.signature?.kid === ens.records['cl.sig.kid']; + const keyIdMatched = kid === ens.records['cl.sig.kid']; const prefixedPubkey = ens.records['cl.sig.pub']; const pubkeyBase64 = typeof prefixedPubkey === 'string' ? prefixedPubkey.replace(/^ed25519:/, '') : null; let signatureValid = false; - if (hashMatched && keyIdMatched && pubkeyBase64 && receipt?.signature?.sig) { + if (keyIdMatched && pubkeyBase64 && sig) { try { const publicKey = await importEd25519PublicKey(pubkeyBase64); - signatureValid = await verifyHashHexSignature(recomputedHash, receipt.signature.sig, publicKey); + if (isLegacyMode) { + if (hashMatched) { + signatureValid = await verifyHashHexSignature(recomputedHash, sig, publicKey); + } + } else { + signatureValid = await verifyCanonicalSignature(canonicalStr, sig, publicKey); + } } catch { signatureValid = false; } @@ -182,7 +203,13 @@ async function verifyReceipt(receiptInput, options = {}) { ens.records['cl.receipt.signer'] && receipt?.signer === ens.records['cl.receipt.signer'], ); - const ok = Boolean(schemaValid && hashMatched && signatureValid && signerMatched && ens.ensResolved); + const ok = Boolean( + schemaValid && + signatureValid && + signerMatched && + ens.ensResolved && + (isLegacyMode ? hashMatched : true), + ); return { ok, @@ -194,7 +221,7 @@ async function verifyReceipt(receiptInput, options = {}) { hash_matches: hashMatched, signature_valid: signatureValid, ens_resolved: Boolean(ens.ensResolved), - key_id: receipt?.signature?.kid || null, + key_id: kid || null, public_key_source: ens.keySource, debug: { expected_hash_sha256: expectedHash,