@correntelabs/beeai-ashlar-bridge 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/README.md +75 -0
  2. package/STRESS_REPORT.md +71 -0
  3. package/dist/packages/beeai-ashlar-bridge/src/ashlar-tool.d.ts +84 -0
  4. package/dist/packages/beeai-ashlar-bridge/src/ashlar-tool.js +202 -0
  5. package/dist/packages/beeai-ashlar-bridge/src/beehive-swarm.d.ts +1 -0
  6. package/dist/packages/beeai-ashlar-bridge/src/beehive-swarm.js +157 -0
  7. package/dist/packages/beeai-ashlar-bridge/src/catalog-tool.d.ts +28 -0
  8. package/dist/packages/beeai-ashlar-bridge/src/catalog-tool.js +64 -0
  9. package/dist/packages/beeai-ashlar-bridge/src/compliance-tool.d.ts +34 -0
  10. package/dist/packages/beeai-ashlar-bridge/src/compliance-tool.js +73 -0
  11. package/dist/packages/beeai-ashlar-bridge/src/demo-agent.d.ts +1 -0
  12. package/dist/packages/beeai-ashlar-bridge/src/demo-agent.js +149 -0
  13. package/dist/packages/beeai-ashlar-bridge/src/index.d.ts +3 -0
  14. package/dist/packages/beeai-ashlar-bridge/src/index.js +3 -0
  15. package/dist/packages/beeai-ashlar-bridge/src/large-swarm-stress.d.ts +1 -0
  16. package/dist/packages/beeai-ashlar-bridge/src/large-swarm-stress.js +152 -0
  17. package/dist/src/x402/mandate.d.ts +449 -0
  18. package/dist/src/x402/mandate.js +1234 -0
  19. package/dist/src/x402/manifest-sig.d.ts +163 -0
  20. package/dist/src/x402/manifest-sig.js +259 -0
  21. package/dist/src/x402/merkle-transcript.d.ts +73 -0
  22. package/dist/src/x402/merkle-transcript.js +159 -0
  23. package/package.json +37 -0
  24. package/src/ashlar-tool.ts +277 -0
  25. package/src/beehive-swarm.ts +172 -0
  26. package/src/catalog-tool.ts +80 -0
  27. package/src/compliance-tool.ts +89 -0
  28. package/src/demo-agent.ts +163 -0
  29. package/src/index.ts +3 -0
  30. package/src/large-swarm-stress.ts +180 -0
  31. package/tsconfig.json +21 -0
@@ -0,0 +1,163 @@
1
+ /**
2
+ * x402sig1 — detached Ed25519 signature over the discovery manifest.
3
+ *
4
+ * Mirrors Martin Sansone's x402-signed-manifest-ref 1.1.0 construction
5
+ * EXACTLY — Ed25519 over the RFC 8785 (JCS) canonical bytes DIRECTLY,
6
+ * not over a prehash — because the verifiers that exist today
7
+ * (Melchiorre's daily sweep, Martin's kit, our own magentix verify
8
+ * scripts) all check that shape, and the point of signing is to be
9
+ * counted by checkers we did not write. Field set matches his current
10
+ * examples/well-known/x402.sig: v, canon, sig_input, alg, kid, sig,
11
+ * content_digest, signedAt. `kid` is the FULL DNS name of the key
12
+ * record (DKIM-selector style, e.g. s1._x402key.api.flareclaw.app),
13
+ * so a verifier never guesses the lookup name.
14
+ *
15
+ * Extension over the reference: our manifest is LIVE-TRUTH — the
16
+ * attestation block and freshness staple re-derive per request, so a
17
+ * static committed .sig would go stale on the next staple rotation
18
+ * (≤15 min). We therefore sign PER-SERVE: the .sig route builds the
19
+ * same manifest body the main route would serve right now and signs
20
+ * those bytes. The two fetches can still straddle a rotation; the
21
+ * digest-agreement rule (report signature-invalid ONLY when the
22
+ * verifier's own canonical sha256 of the manifest it fetched equals
23
+ * this .sig's content_digest, otherwise undetermined) is what keeps
24
+ * that race sound — content_digest says exactly which bytes were
25
+ * signed.
26
+ *
27
+ * The signer is a MANIFEST IDENTITY: it holds no funds and can move no
28
+ * money. Compromise of it can forge discovery manifests and nothing
29
+ * else — same class as the receipt identity in job-receipt.ts, which
30
+ * is why sign() is injected and this module never touches a key, a
31
+ * network, or the clock.
32
+ */
33
+ import { Buffer } from 'node:buffer';
34
+ export declare const MANIFEST_SIG_VERSION = "x402sig1";
35
+ /** DKIM-style selector — the label the DNS TXT key record lives under. */
36
+ export declare const MANIFEST_SIG_SELECTOR = "s1";
37
+ export interface ManifestSig {
38
+ v: typeof MANIFEST_SIG_VERSION;
39
+ canon: 'RFC8785-JCS';
40
+ sig_input: 'canonical-bytes';
41
+ alg: 'Ed25519';
42
+ /** Full DNS name of the TXT record carrying the verifying key. */
43
+ kid: string;
44
+ /** base64url Ed25519 signature over the canonical bytes. */
45
+ sig: string;
46
+ content_digest: {
47
+ alg: 'SHA-256';
48
+ value: string;
49
+ };
50
+ signedAt: string;
51
+ }
52
+ /**
53
+ * Guard before claiming JCS parity: our jcs() below implements the
54
+ * subset RFC 8785 needs for JSON built from strings, booleans, null,
55
+ * safe integers, arrays and plain objects. Full ES6 number formatting
56
+ * (fractions, exponents) is deliberately NOT implemented — a manifest
57
+ * that needs it must not be signed with this module, loudly.
58
+ */
59
+ export declare function assertJcsSafe(v: unknown): void;
60
+ /** RFC 8785 JCS canonical form (subset guarded by assertJcsSafe). */
61
+ export declare function jcsCanonical(v: unknown): string;
62
+ /** The DNS name a manifest served for `host` binds its key to. */
63
+ export declare function manifestSigKid(host: string): string;
64
+ export interface BuildManifestSigInput {
65
+ manifest: unknown;
66
+ kid: string;
67
+ /** base64url Ed25519 signature over the given canonical bytes. */
68
+ sign: (canonicalBytes: Buffer) => string;
69
+ now: () => number;
70
+ }
71
+ export declare function buildManifestSig({ manifest, kid, sign, now }: BuildManifestSigInput): ManifestSig;
72
+ /**
73
+ * Independent check of a ManifestSig against a raw base64url Ed25519
74
+ * public key (the exact string a verifier reads out of the DNS TXT
75
+ * record's k= tag). Returns the digest-agreement verdict a sweep
76
+ * should report: 'authentic' | 'signature-invalid' | 'undetermined'.
77
+ * 'undetermined' means the manifest bytes in hand are not the bytes
78
+ * the .sig commits to (the per-serve race, or a tampered pairing) —
79
+ * per Melchiorre's rule that is NOT an accusation of a bad signature.
80
+ *
81
+ * MS3 (Melchiorre's digest-agreement rule): `signature-invalid` is
82
+ * reported ONLY when the verifier's own canonical sha256 equals the
83
+ * artifact's content_digest, i.e. only once both sides have PROVEN
84
+ * their canonicalizations agreed. Everything else is undetermined and
85
+ * the verifier's own to sort out — you do not point at a named company
86
+ * on evidence that your JCS and theirs disagreed.
87
+ */
88
+ export declare function verifyManifestSig(manifest: unknown, sig: ManifestSig, publicKeyB64url: string): 'authentic' | 'signature-invalid' | 'undetermined';
89
+ /**
90
+ * The closed verdict vocabulary a signature sweep reports. Five values,
91
+ * not three, because two of them exist to stop a harness blaming a host
92
+ * for something that is not the host's signing:
93
+ *
94
+ * authentic key at `kid` verifies the canonical bytes
95
+ * signature-invalid digests AGREED and Ed25519 still failed (MS3)
96
+ * undetermined the bytes in hand are not the bytes signed (MS3),
97
+ * or no key could be read at `kid`
98
+ * not-a-signature the .sig path answered with something carrying no
99
+ * `sig`/`kid` — no signing was attempted at all (MS4)
100
+ * unsigned nothing served at the .sig path
101
+ *
102
+ * MS3 and MS4 are MELCHIORRE'S RULES, contributed from his daily sweep
103
+ * (#domain-discovery, 2026-08-12) and folded in here under his name.
104
+ * See conformance/x402-manifest-sig/ for the vector set that tests them.
105
+ */
106
+ export type ManifestSigVerdict = 'authentic' | 'signature-invalid' | 'undetermined' | 'not-a-signature' | 'unsigned';
107
+ export type SigArtifact = {
108
+ kind: 'signature';
109
+ sig: ManifestSig;
110
+ } | {
111
+ kind: 'not-a-signature';
112
+ reason: string;
113
+ };
114
+ /**
115
+ * MS4 — Melchiorre's rule: a 200 at the `.sig` path is not a signature.
116
+ *
117
+ * Catch-all routing (an SPA fallback, a friendly JSON 404 body, a CDN
118
+ * error page served with status 200) manufactures PHANTOM SIGNING
119
+ * ATTEMPTS: the sweep reads a body, fails to verify it, and records the
120
+ * host as having signed badly. He hit exactly this and had to correct
121
+ * two named hosts. The artifact must carry `sig` AND `kid` before it
122
+ * counts as an attempt at all — the same failure shape as a soft 404,
123
+ * one layer up.
124
+ *
125
+ * Deliberately runs BEFORE the suite check (MS1): "is this a signature?"
126
+ * is a different question from "is this a suite I know?", and answering
127
+ * them in the wrong order is what produces the phantom attempt. The
128
+ * ordering is asserted by test.
129
+ */
130
+ export declare function classifySigArtifact(body: unknown): SigArtifact;
131
+ /**
132
+ * The verdict a signature sweep should publish for one host, applying
133
+ * MS3 and MS4 in the order that keeps both honest.
134
+ *
135
+ * `sigBody` is whatever the `.sig` path returned parsed as JSON, or
136
+ * `undefined` when the path served nothing (404, connection refused).
137
+ * `publicKeyB64url` is the key read from DNS at `sig.kid`, or
138
+ * `undefined` when no key could be read — which is `undetermined`, not
139
+ * a bad signature: an unreadable key is a fact about the lookup.
140
+ */
141
+ export declare function sweepManifestSig(input: {
142
+ manifest: unknown;
143
+ sigBody: unknown | undefined;
144
+ publicKeyB64url: string | undefined;
145
+ }): {
146
+ verdict: ManifestSigVerdict;
147
+ rule: string;
148
+ reason: string;
149
+ };
150
+ export interface Ed25519Signer {
151
+ /** base64url signature over the given bytes. */
152
+ sign: (bytes: Buffer) => string;
153
+ /** Raw 32-byte public key, base64url — the DNS TXT k= value. */
154
+ publicKeyB64url: string;
155
+ }
156
+ /**
157
+ * Build a signer from a base64url 32-byte Ed25519 seed (the secret's
158
+ * stored form). The key object never leaves this closure; callers get
159
+ * a sign function and the PUBLIC key only.
160
+ */
161
+ export declare function ed25519SignerFromSeed(seedB64url: string): Ed25519Signer;
162
+ /** The TXT record value verifiers parse (Martin's records.txt format). */
163
+ export declare function manifestSigTxtValue(publicKeyB64url: string): string;
@@ -0,0 +1,259 @@
1
+ /**
2
+ * x402sig1 — detached Ed25519 signature over the discovery manifest.
3
+ *
4
+ * Mirrors Martin Sansone's x402-signed-manifest-ref 1.1.0 construction
5
+ * EXACTLY — Ed25519 over the RFC 8785 (JCS) canonical bytes DIRECTLY,
6
+ * not over a prehash — because the verifiers that exist today
7
+ * (Melchiorre's daily sweep, Martin's kit, our own magentix verify
8
+ * scripts) all check that shape, and the point of signing is to be
9
+ * counted by checkers we did not write. Field set matches his current
10
+ * examples/well-known/x402.sig: v, canon, sig_input, alg, kid, sig,
11
+ * content_digest, signedAt. `kid` is the FULL DNS name of the key
12
+ * record (DKIM-selector style, e.g. s1._x402key.api.flareclaw.app),
13
+ * so a verifier never guesses the lookup name.
14
+ *
15
+ * Extension over the reference: our manifest is LIVE-TRUTH — the
16
+ * attestation block and freshness staple re-derive per request, so a
17
+ * static committed .sig would go stale on the next staple rotation
18
+ * (≤15 min). We therefore sign PER-SERVE: the .sig route builds the
19
+ * same manifest body the main route would serve right now and signs
20
+ * those bytes. The two fetches can still straddle a rotation; the
21
+ * digest-agreement rule (report signature-invalid ONLY when the
22
+ * verifier's own canonical sha256 of the manifest it fetched equals
23
+ * this .sig's content_digest, otherwise undetermined) is what keeps
24
+ * that race sound — content_digest says exactly which bytes were
25
+ * signed.
26
+ *
27
+ * The signer is a MANIFEST IDENTITY: it holds no funds and can move no
28
+ * money. Compromise of it can forge discovery manifests and nothing
29
+ * else — same class as the receipt identity in job-receipt.ts, which
30
+ * is why sign() is injected and this module never touches a key, a
31
+ * network, or the clock.
32
+ */
33
+ import { Buffer } from 'node:buffer';
34
+ import { createHash, createPublicKey, createPrivateKey, sign as edSign, verify as edVerify } from 'node:crypto';
35
+ export const MANIFEST_SIG_VERSION = 'x402sig1';
36
+ /** DKIM-style selector — the label the DNS TXT key record lives under. */
37
+ export const MANIFEST_SIG_SELECTOR = 's1';
38
+ /**
39
+ * Guard before claiming JCS parity: our jcs() below implements the
40
+ * subset RFC 8785 needs for JSON built from strings, booleans, null,
41
+ * safe integers, arrays and plain objects. Full ES6 number formatting
42
+ * (fractions, exponents) is deliberately NOT implemented — a manifest
43
+ * that needs it must not be signed with this module, loudly.
44
+ */
45
+ export function assertJcsSafe(v) {
46
+ if (typeof v === 'string') {
47
+ // A lone (unpaired) surrogate serializes differently across JCS
48
+ // implementations — Node's JSON.stringify escapes it, some strict JCS
49
+ // libraries reject or pass it through — so two honest verifiers can
50
+ // compute DIFFERENT digests over the same logical value. Where the
51
+ // string is attacker-influenced (a census detail/record derived from a
52
+ // remote host response) that is a cross-implementation digest-
53
+ // disagreement, so reject non-well-formed strings up front.
54
+ if (typeof v.isWellFormed === 'function'
55
+ && !v.isWellFormed()) {
56
+ throw new Error('unsignable string: contains unpaired surrogate (not well-formed UTF-16)');
57
+ }
58
+ return;
59
+ }
60
+ if (v === null || typeof v === 'boolean')
61
+ return;
62
+ if (typeof v === 'number') {
63
+ if (!Number.isInteger(v) || Math.abs(v) > 2 ** 52) {
64
+ throw new Error(`non-trivial number ${v}: full JCS ES6 formatting required`);
65
+ }
66
+ return;
67
+ }
68
+ if (typeof v !== 'object')
69
+ throw new Error(`unsignable value of type ${typeof v}`);
70
+ if (Array.isArray(v)) {
71
+ v.forEach(assertJcsSafe);
72
+ return;
73
+ }
74
+ Object.values(v).forEach(assertJcsSafe);
75
+ }
76
+ /** RFC 8785 JCS canonical form (subset guarded by assertJcsSafe). */
77
+ export function jcsCanonical(v) {
78
+ if (v === null || typeof v !== 'object')
79
+ return JSON.stringify(v);
80
+ if (Array.isArray(v))
81
+ return `[${v.map(jcsCanonical).join(',')}]`;
82
+ return `{${Object.keys(v).sort()
83
+ .map(k => `${JSON.stringify(k)}:${jcsCanonical(v[k])}`)
84
+ .join(',')}}`;
85
+ }
86
+ /** The DNS name a manifest served for `host` binds its key to. */
87
+ export function manifestSigKid(host) {
88
+ return `${MANIFEST_SIG_SELECTOR}._x402key.${host}`;
89
+ }
90
+ export function buildManifestSig({ manifest, kid, sign, now }) {
91
+ assertJcsSafe(manifest);
92
+ const bytes = Buffer.from(jcsCanonical(manifest), 'utf8');
93
+ return {
94
+ v: MANIFEST_SIG_VERSION,
95
+ canon: 'RFC8785-JCS',
96
+ sig_input: 'canonical-bytes',
97
+ alg: 'Ed25519',
98
+ kid,
99
+ sig: sign(bytes),
100
+ content_digest: {
101
+ alg: 'SHA-256',
102
+ value: createHash('sha256').update(bytes).digest('hex'),
103
+ },
104
+ signedAt: new Date(now()).toISOString().replace(/\.\d{3}Z$/, 'Z'),
105
+ };
106
+ }
107
+ /**
108
+ * Independent check of a ManifestSig against a raw base64url Ed25519
109
+ * public key (the exact string a verifier reads out of the DNS TXT
110
+ * record's k= tag). Returns the digest-agreement verdict a sweep
111
+ * should report: 'authentic' | 'signature-invalid' | 'undetermined'.
112
+ * 'undetermined' means the manifest bytes in hand are not the bytes
113
+ * the .sig commits to (the per-serve race, or a tampered pairing) —
114
+ * per Melchiorre's rule that is NOT an accusation of a bad signature.
115
+ *
116
+ * MS3 (Melchiorre's digest-agreement rule): `signature-invalid` is
117
+ * reported ONLY when the verifier's own canonical sha256 equals the
118
+ * artifact's content_digest, i.e. only once both sides have PROVEN
119
+ * their canonicalizations agreed. Everything else is undetermined and
120
+ * the verifier's own to sort out — you do not point at a named company
121
+ * on evidence that your JCS and theirs disagreed.
122
+ */
123
+ export function verifyManifestSig(manifest, sig, publicKeyB64url) {
124
+ if (sig.v !== MANIFEST_SIG_VERSION || sig.canon !== 'RFC8785-JCS'
125
+ || sig.sig_input !== 'canonical-bytes' || sig.alg !== 'Ed25519') {
126
+ throw new Error('unexpected suite — refusing to guess at an unknown construction');
127
+ }
128
+ assertJcsSafe(manifest);
129
+ const bytes = Buffer.from(jcsCanonical(manifest), 'utf8');
130
+ const digest = createHash('sha256').update(bytes).digest('hex');
131
+ if (digest !== sig.content_digest.value)
132
+ return 'undetermined';
133
+ const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKeyB64url }, format: 'jwk' });
134
+ return edVerify(null, bytes, key, Buffer.from(sig.sig, 'base64url'))
135
+ ? 'authentic' : 'signature-invalid';
136
+ }
137
+ /**
138
+ * MS4 — Melchiorre's rule: a 200 at the `.sig` path is not a signature.
139
+ *
140
+ * Catch-all routing (an SPA fallback, a friendly JSON 404 body, a CDN
141
+ * error page served with status 200) manufactures PHANTOM SIGNING
142
+ * ATTEMPTS: the sweep reads a body, fails to verify it, and records the
143
+ * host as having signed badly. He hit exactly this and had to correct
144
+ * two named hosts. The artifact must carry `sig` AND `kid` before it
145
+ * counts as an attempt at all — the same failure shape as a soft 404,
146
+ * one layer up.
147
+ *
148
+ * Deliberately runs BEFORE the suite check (MS1): "is this a signature?"
149
+ * is a different question from "is this a suite I know?", and answering
150
+ * them in the wrong order is what produces the phantom attempt. The
151
+ * ordering is asserted by test.
152
+ */
153
+ export function classifySigArtifact(body) {
154
+ if (body === null || typeof body !== 'object' || Array.isArray(body)) {
155
+ return { kind: 'not-a-signature', reason: `.sig body is ${Array.isArray(body) ? 'an array' : typeof body}, not a JSON object` };
156
+ }
157
+ const b = body;
158
+ const missing = ['sig', 'kid'].filter((f) => typeof b[f] !== 'string' || b[f].length === 0);
159
+ if (missing.length > 0) {
160
+ return { kind: 'not-a-signature', reason: `.sig body carries no ${missing.join(' and no ')} — nothing was signed here` };
161
+ }
162
+ return { kind: 'signature', sig: body };
163
+ }
164
+ /**
165
+ * The verdict a signature sweep should publish for one host, applying
166
+ * MS3 and MS4 in the order that keeps both honest.
167
+ *
168
+ * `sigBody` is whatever the `.sig` path returned parsed as JSON, or
169
+ * `undefined` when the path served nothing (404, connection refused).
170
+ * `publicKeyB64url` is the key read from DNS at `sig.kid`, or
171
+ * `undefined` when no key could be read — which is `undetermined`, not
172
+ * a bad signature: an unreadable key is a fact about the lookup.
173
+ */
174
+ export function sweepManifestSig(input) {
175
+ if (input.sigBody === undefined) {
176
+ return { verdict: 'unsigned', rule: 'MS6', reason: 'nothing served at the .sig path' };
177
+ }
178
+ const artifact = classifySigArtifact(input.sigBody);
179
+ if (artifact.kind === 'not-a-signature') {
180
+ return { verdict: 'not-a-signature', rule: 'MS4', reason: artifact.reason };
181
+ }
182
+ const sig = artifact.sig;
183
+ // MS1 — an unknown suite is refused, never guessed at, and refusing
184
+ // is not an accusation: we cannot check what we cannot parse. Runs
185
+ // before the key lookup so the rule attribution matches the
186
+ // standalone checker in conformance/x402-manifest-sig/verify.mjs on
187
+ // EVERY input, not just the ones in the vector set — the two are
188
+ // published as one answer and a reader must not be able to make
189
+ // them disagree.
190
+ const suite = {
191
+ v: MANIFEST_SIG_VERSION, canon: 'RFC8785-JCS',
192
+ sig_input: 'canonical-bytes', alg: 'Ed25519',
193
+ };
194
+ for (const [k, want] of Object.entries(suite)) {
195
+ if (sig[k] !== want) {
196
+ return {
197
+ verdict: 'undetermined', rule: 'MS1',
198
+ reason: `unexpected ${k}=${JSON.stringify(sig[k] ?? null)} (want ${JSON.stringify(want)}) — refusing to guess at an unknown construction`,
199
+ };
200
+ }
201
+ }
202
+ if (input.publicKeyB64url === undefined) {
203
+ return { verdict: 'undetermined', rule: 'MS5', reason: `no verifying key readable at ${artifact.sig.kid}` };
204
+ }
205
+ let bytes;
206
+ try {
207
+ assertJcsSafe(input.manifest);
208
+ bytes = Buffer.from(jcsCanonical(input.manifest), 'utf8');
209
+ }
210
+ catch (e) {
211
+ return { verdict: 'undetermined', rule: 'MS1', reason: e.message };
212
+ }
213
+ const digest = createHash('sha256').update(bytes).digest('hex');
214
+ if (digest !== artifact.sig.content_digest?.value) {
215
+ return {
216
+ verdict: 'undetermined', rule: 'MS3',
217
+ reason: 'canonical digest of the manifest in hand differs from the .sig content_digest — these are not the bytes that were signed',
218
+ };
219
+ }
220
+ let ok;
221
+ try {
222
+ const key = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: input.publicKeyB64url }, format: 'jwk' });
223
+ ok = edVerify(null, bytes, key, Buffer.from(artifact.sig.sig, 'base64url'));
224
+ }
225
+ catch (e) {
226
+ return { verdict: 'undetermined', rule: 'MS5', reason: `unusable key material at ${artifact.sig.kid}: ${e.message}` };
227
+ }
228
+ return ok
229
+ ? { verdict: 'authentic', rule: 'MS2', reason: 'key at kid verifies the canonical bytes' }
230
+ : { verdict: 'signature-invalid', rule: 'MS3', reason: 'digests agreed and Ed25519 verification still failed' };
231
+ }
232
+ /** PKCS#8 DER prefix for a raw 32-byte Ed25519 seed (RFC 8410 shape). */
233
+ const ED25519_PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex');
234
+ /**
235
+ * Build a signer from a base64url 32-byte Ed25519 seed (the secret's
236
+ * stored form). The key object never leaves this closure; callers get
237
+ * a sign function and the PUBLIC key only.
238
+ */
239
+ export function ed25519SignerFromSeed(seedB64url) {
240
+ const seed = Buffer.from(seedB64url.trim(), 'base64url');
241
+ if (seed.length !== 32)
242
+ throw new Error(`manifest signing seed must be 32 bytes, got ${seed.length}`);
243
+ const privateKey = createPrivateKey({
244
+ key: Buffer.concat([ED25519_PKCS8_PREFIX, seed]),
245
+ format: 'der',
246
+ type: 'pkcs8',
247
+ });
248
+ const jwk = createPublicKey(privateKey).export({ format: 'jwk' });
249
+ if (!jwk.x)
250
+ throw new Error('failed to derive public key from seed');
251
+ return {
252
+ sign: (bytes) => edSign(null, bytes, privateKey).toString('base64url'),
253
+ publicKeyB64url: jwk.x,
254
+ };
255
+ }
256
+ /** The TXT record value verifiers parse (Martin's records.txt format). */
257
+ export function manifestSigTxtValue(publicKeyB64url) {
258
+ return `v=x402key1; alg=Ed25519; k=${publicKeyB64url}`;
259
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * merkle-transcript — RFC 6962 Merkle commitments over a room's draw
3
+ * transcript, so one draw can be proven without revealing the rest.
4
+ *
5
+ * WHY. A room's signed summary currently commits to its transcript FLAT:
6
+ * sha256(JCS(all nonces)) and sha256(JCS(all seeds)). To prove that a single
7
+ * draw was in the session you must therefore disclose EVERY draw. For a tenant
8
+ * showing a third party "draw 7 happened, here is its seed", that is a
9
+ * full-transcript disclosure to establish one fact.
10
+ *
11
+ * A Merkle root replaces that with an inclusion proof of ceil(log2(n)) hashes
12
+ * that reveals nothing else, while the summary still commits to exactly one
13
+ * value the enclave signs.
14
+ *
15
+ * WHY RFC 6962 RATHER THAN A TREE OF OUR OWN. Certificate Transparency's
16
+ * Merkle Tree Hash is the transparency-log precedent this audience already
17
+ * implements, it has published test vectors, and its two hardest details are
18
+ * already decided in a way that has survived a decade of attack:
19
+ *
20
+ * - DOMAIN SEPARATION. Leaves are hashed with a 0x00 prefix and interior
21
+ * nodes with 0x01. Without it an interior node's preimage can be presented
22
+ * as a leaf, so a proof for a value that was never in the tree verifies.
23
+ * - ODD NODES ARE PROMOTED, NEVER DUPLICATED. The split is at the largest
24
+ * power of two BELOW n, so a lone right-hand node carries up unchanged.
25
+ * Duplicating it instead is CVE-2012-2459: two distinct transcripts
26
+ * collapse to the same root, and a verifier cannot tell which it was
27
+ * shown.
28
+ *
29
+ * Both are exactly the class of underdetermined value that gets a profile
30
+ * picked apart in review, so both are pinned by tests below rather than left
31
+ * to the reader.
32
+ *
33
+ * LEAF CONTENT. A leaf commits to {drawId, nonce, seed} in JCS canonical form.
34
+ * drawId is included deliberately: it is the payment-intent identity, and a
35
+ * transcript whose leaves omit it cannot distinguish two draws that differ
36
+ * only by intent — the same gap Tiago Pinto found in the SCITT draft's
37
+ * execution digest (W3, 2026-08-18), where two distinct payments produced
38
+ * identical digest inputs.
39
+ */
40
+ import { Buffer } from 'node:buffer';
41
+ export declare const MERKLE_PROFILE = "x402room-transcript-merkle/1";
42
+ /** RFC 6962 §2.1: MTH({}) = SHA-256 of the empty string. */
43
+ export declare const EMPTY_ROOT: string;
44
+ /** One transcript entry. drawId is part of the commitment, not metadata. */
45
+ export interface TranscriptEntry {
46
+ drawId: string;
47
+ nonce: string;
48
+ seed: string;
49
+ }
50
+ /** RFC 6962 leaf hash: SHA-256(0x00 || entry). */
51
+ export declare function leafHash(entry: TranscriptEntry): Buffer;
52
+ /** RFC 6962 interior hash: SHA-256(0x01 || left || right). */
53
+ export declare function nodeHash(left: Buffer, right: Buffer): Buffer;
54
+ /** The transcript root a signed summary commits to. */
55
+ export declare function transcriptRoot(entries: TranscriptEntry[]): string;
56
+ /**
57
+ * Merkle Tree Hash over ALREADY-hashed leaves — exposed so the RFC 6962
58
+ * published vectors can be run through the MODULE's own tree (splitPoint + mth),
59
+ * not a test-local lookalike. A regression in splitPoint would otherwise pass a
60
+ * vector suite that only exercises a copy. (Crypto review M1.)
61
+ */
62
+ export declare function mthOfLeafHashes(leafHashes: Buffer[]): string;
63
+ /**
64
+ * Inclusion proof for entry `index` — the sibling hashes needed to recompute
65
+ * the root, bottom-up. RFC 6962 §2.1.1 PATH(m, D[n]).
66
+ */
67
+ export declare function inclusionProof(entries: TranscriptEntry[], index: number): string[];
68
+ /**
69
+ * Verify one entry against a root WITHOUT the rest of the transcript. This is
70
+ * the whole point: the verifier holds the entry, the proof, its index, the
71
+ * transcript size, and the signed root — and nothing else.
72
+ */
73
+ export declare function verifyInclusion(entry: TranscriptEntry, index: number, size: number, proof: string[], root: string): boolean;
@@ -0,0 +1,159 @@
1
+ /**
2
+ * merkle-transcript — RFC 6962 Merkle commitments over a room's draw
3
+ * transcript, so one draw can be proven without revealing the rest.
4
+ *
5
+ * WHY. A room's signed summary currently commits to its transcript FLAT:
6
+ * sha256(JCS(all nonces)) and sha256(JCS(all seeds)). To prove that a single
7
+ * draw was in the session you must therefore disclose EVERY draw. For a tenant
8
+ * showing a third party "draw 7 happened, here is its seed", that is a
9
+ * full-transcript disclosure to establish one fact.
10
+ *
11
+ * A Merkle root replaces that with an inclusion proof of ceil(log2(n)) hashes
12
+ * that reveals nothing else, while the summary still commits to exactly one
13
+ * value the enclave signs.
14
+ *
15
+ * WHY RFC 6962 RATHER THAN A TREE OF OUR OWN. Certificate Transparency's
16
+ * Merkle Tree Hash is the transparency-log precedent this audience already
17
+ * implements, it has published test vectors, and its two hardest details are
18
+ * already decided in a way that has survived a decade of attack:
19
+ *
20
+ * - DOMAIN SEPARATION. Leaves are hashed with a 0x00 prefix and interior
21
+ * nodes with 0x01. Without it an interior node's preimage can be presented
22
+ * as a leaf, so a proof for a value that was never in the tree verifies.
23
+ * - ODD NODES ARE PROMOTED, NEVER DUPLICATED. The split is at the largest
24
+ * power of two BELOW n, so a lone right-hand node carries up unchanged.
25
+ * Duplicating it instead is CVE-2012-2459: two distinct transcripts
26
+ * collapse to the same root, and a verifier cannot tell which it was
27
+ * shown.
28
+ *
29
+ * Both are exactly the class of underdetermined value that gets a profile
30
+ * picked apart in review, so both are pinned by tests below rather than left
31
+ * to the reader.
32
+ *
33
+ * LEAF CONTENT. A leaf commits to {drawId, nonce, seed} in JCS canonical form.
34
+ * drawId is included deliberately: it is the payment-intent identity, and a
35
+ * transcript whose leaves omit it cannot distinguish two draws that differ
36
+ * only by intent — the same gap Tiago Pinto found in the SCITT draft's
37
+ * execution digest (W3, 2026-08-18), where two distinct payments produced
38
+ * identical digest inputs.
39
+ */
40
+ import { Buffer } from 'node:buffer';
41
+ import { createHash } from 'node:crypto';
42
+ import { jcsCanonical, assertJcsSafe } from './manifest-sig.js';
43
+ export const MERKLE_PROFILE = 'x402room-transcript-merkle/1';
44
+ const sha256 = (b) => createHash('sha256').update(b).digest();
45
+ /** RFC 6962 §2.1: MTH({}) = SHA-256 of the empty string. */
46
+ export const EMPTY_ROOT = sha256(Buffer.alloc(0)).toString('hex');
47
+ /** RFC 6962 leaf hash: SHA-256(0x00 || entry). */
48
+ export function leafHash(entry) {
49
+ const canonical = { drawId: entry.drawId, nonce: entry.nonce, seed: entry.seed };
50
+ assertJcsSafe(canonical);
51
+ return sha256(Buffer.concat([Buffer.from([0x00]), Buffer.from(jcsCanonical(canonical), 'utf8')]));
52
+ }
53
+ /** RFC 6962 interior hash: SHA-256(0x01 || left || right). */
54
+ export function nodeHash(left, right) {
55
+ return sha256(Buffer.concat([Buffer.from([0x01]), left, right]));
56
+ }
57
+ /** Largest power of two STRICTLY less than n — RFC 6962's split point. */
58
+ function splitPoint(n) {
59
+ let k = 1;
60
+ while (k * 2 < n)
61
+ k *= 2;
62
+ return k;
63
+ }
64
+ /** Merkle Tree Hash over leaf hashes (RFC 6962 §2.1). */
65
+ function mth(leaves) {
66
+ if (leaves.length === 0)
67
+ return sha256(Buffer.alloc(0));
68
+ if (leaves.length === 1)
69
+ return leaves[0];
70
+ const k = splitPoint(leaves.length);
71
+ return nodeHash(mth(leaves.slice(0, k)), mth(leaves.slice(k)));
72
+ }
73
+ /** The transcript root a signed summary commits to. */
74
+ export function transcriptRoot(entries) {
75
+ return mth(entries.map(leafHash)).toString('hex');
76
+ }
77
+ /**
78
+ * Merkle Tree Hash over ALREADY-hashed leaves — exposed so the RFC 6962
79
+ * published vectors can be run through the MODULE's own tree (splitPoint + mth),
80
+ * not a test-local lookalike. A regression in splitPoint would otherwise pass a
81
+ * vector suite that only exercises a copy. (Crypto review M1.)
82
+ */
83
+ export function mthOfLeafHashes(leafHashes) {
84
+ return mth(leafHashes).toString('hex');
85
+ }
86
+ /**
87
+ * Inclusion proof for entry `index` — the sibling hashes needed to recompute
88
+ * the root, bottom-up. RFC 6962 §2.1.1 PATH(m, D[n]).
89
+ */
90
+ export function inclusionProof(entries, index) {
91
+ if (index < 0 || index >= entries.length)
92
+ throw new Error(`index ${index} outside transcript of ${entries.length}`);
93
+ const path = [];
94
+ // RFC 6962 §2.1.1 appends the sibling AFTER the recursive call:
95
+ // PATH(m, D[n]) = PATH(m, D[0:k]) : MTH(D[k:n])
96
+ // so the path is ordered BOTTOM-UP, deepest sibling first — which is the
97
+ // order verification consumes it in. Pushing the sibling before recursing
98
+ // yields a top-down path that verifies for no tree at all.
99
+ const walk = (leaves, m) => {
100
+ if (leaves.length <= 1)
101
+ return;
102
+ const k = splitPoint(leaves.length);
103
+ if (m < k) {
104
+ walk(leaves.slice(0, k), m);
105
+ path.push(mth(leaves.slice(k)));
106
+ }
107
+ else {
108
+ walk(leaves.slice(k), m - k);
109
+ path.push(mth(leaves.slice(0, k)));
110
+ }
111
+ };
112
+ walk(entries.map(leafHash), index);
113
+ return path.map((b) => b.toString('hex'));
114
+ }
115
+ /**
116
+ * Verify one entry against a root WITHOUT the rest of the transcript. This is
117
+ * the whole point: the verifier holds the entry, the proof, its index, the
118
+ * transcript size, and the signed root — and nothing else.
119
+ */
120
+ export function verifyInclusion(entry, index, size, proof, root) {
121
+ if (index < 0 || index >= size)
122
+ return false;
123
+ // RFC 6962 §2.1.1's OWN verification algorithm. An earlier version
124
+ // re-split the tree top-down while consuming a bottom-up path, which
125
+ // verifies for no tree at all: RFC 6962 trees are LEFT-FULL, not balanced,
126
+ // so a node's side at each level is not recoverable by halving an index.
127
+ // fn/sn track the leaf's index and the last index at each level, and it is
128
+ // their parity — not arithmetic on the size — that says which side we are.
129
+ let fn = index, sn = size - 1;
130
+ let r = leafHash(entry);
131
+ for (const p of proof) {
132
+ // Canonical 64-lowercase-hex only. Buffer.from('hex') silently
133
+ // truncates at the first invalid pair, so a 65-char string or 64 hex
134
+ // chars followed by garbage would still decode to 32 bytes and verify —
135
+ // proof-string malleability. (Crypto review M2.)
136
+ if (!/^[0-9a-f]{64}$/.test(p))
137
+ return false;
138
+ const sibling = Buffer.from(p, 'hex');
139
+ if (sibling.length !== 32)
140
+ return false;
141
+ if (sn === 0)
142
+ return false; // path longer than the tree
143
+ if (fn % 2 === 1 || fn === sn) {
144
+ r = nodeHash(sibling, r);
145
+ while (fn % 2 === 0 && fn !== 0) {
146
+ fn >>= 1;
147
+ sn >>= 1;
148
+ }
149
+ }
150
+ else {
151
+ r = nodeHash(r, sibling);
152
+ }
153
+ fn >>= 1;
154
+ sn >>= 1;
155
+ }
156
+ // sn must be exhausted: a SHORT path leaves tree levels unaccounted for,
157
+ // and a padded one is rejected above by running out of levels first.
158
+ return sn === 0 && r.toString('hex') === root;
159
+ }