Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
RUNTIME_BASE_URL=https://runtime.commandlayer.org
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions api/_receipt-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'}).` });
}
Expand Down
51 changes: 39 additions & 12 deletions lib/verifyReceipt.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading