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,