Skip to content

Commit 5084c69

Browse files
authored
Merge branch 'main' into claude/review-runtime-repo-oDcvb
2 parents 0e58ce1 + 5004674 commit 5084c69

26 files changed

Lines changed: 911 additions & 693 deletions

.gitignore

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,16 @@
11
node_modules/
2+
3+
# local env / logs
4+
.env
5+
.env.*
6+
*.log
7+
8+
# local test artifacts
9+
receipt*.json
10+
body.*.json
11+
analyze_receipt.json
12+
classify_receipt.json
13+
clean_receipt.json
14+
tmp.*.json
15+
payload.json
16+

README.md

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ openssl genpkey -algorithm Ed25519 -out private.pem
4444
openssl pkey -in private.pem -pubout -out public.pem
4545

4646
export RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64="$(base64 -w0 < private.pem)"
47-
export RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64="$(base64 -w0 < public.pem)"
47+
export RECEIPT_SIGNING_PUBLIC_KEY="ed25519:$(openssl pkey -in public.pem -pubin -outform DER | tail -c 32 | base64 -w0)"
4848
export RECEIPT_SIGNER_ID="runtime.local"
4949
```
5050

@@ -105,7 +105,7 @@ printf '%s' "$RECEIPT" | curl -s -X POST "http://localhost:8080/verify?ens=1" \
105105

106106
`POST /verify` supports query flags:
107107

108-
- `ens=1` — fetch verifier pubkey from ENS TXT record (`VERIFIER_ENS_NAME`, `ENS_PUBKEY_TEXT_KEY`).
108+
- `ens=1` — fetch verifier pubkey from ENS TXT records (`VERIFIER_ENS_NAME`, `cl.receipt.signer`, `cl.sig.pub`, `cl.sig.kid`).
109109
- `refresh=1` — bypass ENS cache and refresh lookup.
110110
- `schema=1` — validate receipt against verb schema.
111111

@@ -136,6 +136,3 @@ Detailed environment variable documentation lives in [`docs/CONFIGURATION.md`](d
136136

137137
See [`docs/OPERATIONS.md`](docs/OPERATIONS.md) for deployment and runbook guidance.
138138

139-
## Engineering review artifacts
140-
141-
For a focused codebase review (strengths, risks, and prioritized improvements), see [`docs/REVIEW.md`](docs/REVIEW.md).

docs/CONFIGURATION.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@ Comma-separated list of enabled handlers. Disabled verbs return `404`.
3636
|---|---|---|
3737
| `ETH_RPC_URL` | empty | Ethereum RPC endpoint for ENS resolver lookups. |
3838
| `VERIFIER_ENS_NAME` | `ENS_NAME` / `RECEIPT_SIGNER_ID` fallback | ENS name queried for TXT pubkey value. |
39-
| `ENS_PUBKEY_TEXT_KEY` | `cl.receipt.pubkey.pem` | ENS TXT key containing PEM-formatted public key. |
39+
| `ENS_SIGNER_TEXT_KEY` | `cl.receipt.signer` | ENS TXT key on verifier name that delegates to signer ENS name. |
40+
| `ENS_SIG_PUB_TEXT_KEY` | `cl.sig.pub` | ENS TXT key on signer name containing `ed25519:<base64>` public key. |
41+
| `ENS_SIG_KID_TEXT_KEY` | `cl.sig.kid` | ENS TXT key on signer name containing key identifier. |
4042

4143
## Schema fetching + validation budgets
4244

docs/OPERATIONS.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@
44

55
1. Set signing keys:
66
- `RECEIPT_SIGNING_PRIVATE_KEY_PEM_B64`
7-
- `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64`
7+
- `RECEIPT_SIGNING_PUBLIC_KEY`
88
2. Set identity metadata:
99
- `RECEIPT_SIGNER_ID`
1010
- `SERVICE_NAME`, `SERVICE_VERSION`
1111
3. If using ENS verification:
1212
- `ETH_RPC_URL`
1313
- `VERIFIER_ENS_NAME`
14-
- `ENS_PUBKEY_TEXT_KEY`
14+
- `ENS_SIGNER_TEXT_KEY`
15+
- `ENS_SIG_PUB_TEXT_KEY`
16+
- `ENS_SIG_KID_TEXT_KEY`
1517
4. Set safety limits (`FETCH_TIMEOUT_MS`, `FETCH_MAX_BYTES`, `VERIFY_MAX_MS`).
1618
5. Restrict outbound domains with `ALLOW_FETCH_HOSTS` where possible.
1719

@@ -44,10 +46,10 @@ Repeat validator polling until required verbs appear under `cached`.
4446

4547
### `no public key available`
4648

47-
- Set `RECEIPT_SIGNING_PUBLIC_KEY_PEM_B64` **or** use ENS verification with:
49+
- Set `RECEIPT_SIGNING_PUBLIC_KEY` (`ed25519:<base64>`) **or** use ENS verification with:
4850
- `ETH_RPC_URL`
4951
- `VERIFIER_ENS_NAME`
50-
- valid PEM at ENS TXT key.
52+
- valid `cl.sig.pub` and `cl.sig.kid` TXT values on signer ENS name.
5153

5254
### `validator_not_warmed_yet` with HTTP 202
5355

docs/REVIEW.md

Lines changed: 0 additions & 76 deletions
This file was deleted.

package-lock.json

Lines changed: 30 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@
1515
"scripts": {
1616
"start": "node server.mjs",
1717
"check": "node --check server.mjs",
18-
"test": "node tests/smoke.mjs",
19-
"ci": "npm run check && npm test"
18+
"test": "npm run test:unit && node tests/smoke.mjs",
19+
"ci": "npm run check && npm test",
20+
"test:unit": "node --test runtime/tests/*.test.mjs sdk/typescript-sdk/tests/*.test.mjs"
2021
},
2122
"dependencies": {
23+
"@commandlayer/runtime-core": "github:commandlayer/runtime-core#main",
2224
"ajv": "^8.17.1",
2325
"ajv-formats": "^3.0.1",
26+
"dotenv": "^17.3.1",
2427
"ethers": "^6.16.0",
2528
"express": "^4.22.1",
2629
"node-fetch": "^3.3.2"
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import crypto from "node:crypto";
2+
3+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
4+
5+
export function stableStringify(value) {
6+
const seen = new WeakSet();
7+
const helper = (current) => {
8+
if (current === null || typeof current !== "object") return current;
9+
if (seen.has(current)) return "[Circular]";
10+
seen.add(current);
11+
if (Array.isArray(current)) return current.map(helper);
12+
const output = {};
13+
for (const key of Object.keys(current).sort()) output[key] = helper(current[key]);
14+
return output;
15+
};
16+
return JSON.stringify(helper(value));
17+
}
18+
19+
export function parseEd25519PublicKey(text) {
20+
if (typeof text !== "string") throw new Error("Invalid ed25519 format");
21+
const [alg, payload] = text.trim().split(":", 2);
22+
if (alg?.toLowerCase() !== "ed25519" || !payload) throw new Error("Invalid ed25519 format");
23+
24+
const bytes = Buffer.from(payload, "base64");
25+
if (!bytes.length || bytes.toString("base64") !== payload || bytes.length !== 32) {
26+
throw new Error("Invalid ed25519 format");
27+
}
28+
return bytes;
29+
}
30+
31+
export function computeReceiptHash(receipt) {
32+
const canonicalReceipt = {
33+
issuer: receipt.issuer,
34+
verb: receipt.verb,
35+
version: receipt.version,
36+
timestamp: receipt.timestamp,
37+
payload_hash: receipt.payload_hash,
38+
alg: receipt.alg,
39+
kid: receipt.kid,
40+
};
41+
return crypto.createHash("sha256").update(stableStringify(canonicalReceipt)).digest("hex");
42+
}
43+
44+
export async function resolveSigner(agentEnsName, resolver) {
45+
const signer = await resolver.getText(agentEnsName, "cl.receipt.signer");
46+
const normalized = String(signer || "").trim();
47+
if (!normalized) throw new Error("Missing cl.receipt.signer");
48+
return normalized;
49+
}
50+
51+
export async function resolveSignatureKey(signerEnsName, resolver) {
52+
const pub = await resolver.getText(signerEnsName, "cl.sig.pub");
53+
const kid = String(await resolver.getText(signerEnsName, "cl.sig.kid") || "").trim();
54+
if (!pub) throw new Error("Missing cl.sig.pub");
55+
if (!kid) throw new Error("Missing cl.sig.kid");
56+
57+
const pubkeyBytes = parseEd25519PublicKey(pub);
58+
return { algorithm: "ed25519", kid, pubkeyBytes, rawPublicKeyBytes: pubkeyBytes };
59+
}
60+
61+
function verifySignature(receiptHash, signatureB64, pubkeyBytes) {
62+
const spki = Buffer.concat([ED25519_SPKI_PREFIX, pubkeyBytes]);
63+
const key = crypto.createPublicKey({ key: spki, format: "der", type: "spki" });
64+
return crypto.verify(null, Buffer.from(receiptHash, "utf8"), key, Buffer.from(signatureB64, "base64"));
65+
}
66+
67+
export async function verifyReceipt(receipt, { resolver, expectedIssuer } = {}) {
68+
if (!resolver) throw new Error("Resolver required");
69+
if (expectedIssuer && receipt.issuer !== expectedIssuer) throw new Error("Issuer mismatch");
70+
71+
const signerEnsName = await resolveSigner(receipt.issuer, resolver);
72+
const key = await resolveSignatureKey(signerEnsName, resolver);
73+
if (receipt.kid !== key.kid) {
74+
return { valid: false, error: "Unknown key id" };
75+
}
76+
77+
const computedHash = computeReceiptHash(receipt);
78+
if (computedHash !== receipt.receipt_hash) {
79+
return { valid: false, error: "Receipt hash mismatch" };
80+
}
81+
82+
const signatureOk = verifySignature(receipt.receipt_hash, receipt.sig, key.pubkeyBytes);
83+
if (!signatureOk) {
84+
return { valid: false, error: "Signature verification failed" };
85+
}
86+
87+
return { valid: true, signer: signerEnsName, kid: key.kid };
88+
}
89+
90+
export async function resolveSignerKey(agentEnsName, resolver) {
91+
const signer = await resolveSigner(agentEnsName, resolver);
92+
return resolveSignatureKey(signer, resolver);
93+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { resolveSigner } from "../src/receipt-verification.js";
4+
import { buildResolver } from "./helpers.mjs";
5+
6+
test("ENS Resolution: resolves cl.receipt.signer correctly", async () => {
7+
const signer = await resolveSigner("parseagent.eth", buildResolver());
8+
assert.equal(signer, "runtime.commandlayer.eth");
9+
});
10+
11+
test("ENS Resolution: fails if cl.receipt.signer missing", async () => {
12+
await assert.rejects(() => resolveSigner("invalidagent.eth", buildResolver()), /Missing cl\.receipt\.signer/);
13+
});

runtime/tests/helpers.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import fs from "node:fs";
2+
import path from "node:path";
3+
4+
export function loadFixture(name) {
5+
const fixturePath = path.join(process.cwd(), "test_vectors", name);
6+
return JSON.parse(fs.readFileSync(fixturePath, "utf8"));
7+
}
8+
9+
export function buildResolver(overrides = {}) {
10+
const pub = fs.readFileSync(path.join(process.cwd(), "test_vectors", "public_key_base64.txt"), "utf8").trim();
11+
const base = {
12+
"parseagent.eth": { "cl.receipt.signer": "runtime.commandlayer.eth" },
13+
"runtime.commandlayer.eth": { "cl.sig.pub": `ed25519:${pub}`, "cl.sig.kid": "v1" },
14+
"bad-signer.eth": { "cl.sig.kid": "v1" },
15+
"malformed.eth": { "cl.sig.pub": "ed25519:not-base64*", "cl.sig.kid": "v1" },
16+
"invalidagent.eth": {},
17+
};
18+
const records = { ...base, ...overrides };
19+
return {
20+
async getText(name, key) {
21+
return records[name]?.[key] ?? "";
22+
},
23+
};
24+
}

0 commit comments

Comments
 (0)