@motebit/crypto 1.2.0 → 1.3.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.
package/dist/artifacts.js DELETED
@@ -1,1158 +0,0 @@
1
- /**
2
- * Protocol artifact signing — receipts, delegations, successions, collaborative receipts.
3
- *
4
- * These functions define the canonical signing format for all Motebit protocol
5
- * artifacts. A third party needs these to produce valid signed artifacts that
6
- * any verifier will accept.
7
- *
8
- * Moved from BSL @motebit/encryption to the permissive floor in @motebit/crypto (Apache-2.0).
9
- */
10
- import { canonicalJson, canonicalSha256, toBase64Url, fromBase64Url, bytesToHex, hexToBytes, hash, isScopeNarrowed, signBySuite, verifyBySuite, } from "./signing.js";
11
- /**
12
- * Diagnostic flag for cryptographic-artifact debugging. Reads from
13
- * `process.env.DEBUG_RECEIPT_BYTES` in Node and from
14
- * `globalThis.__motebit_debug_receipt_bytes` in browsers. When truthy,
15
- * `signExecutionReceipt` and `verifyExecutionReceipt*` log the canonical
16
- * SHA-256 and a short preview of the canonical JSON, so a verification
17
- * mismatch can be byte-diffed against the producer's intended bytes
18
- * without re-instrumenting either end. Off by default; zero overhead when
19
- * disabled.
20
- *
21
- * Pattern source: NIST SP 800-57 §5.4 — minimum observability for any
22
- * signed-artifact pipeline that crosses a process boundary.
23
- */
24
- function isReceiptDebugEnabled() {
25
- const g = globalThis;
26
- if (g.__motebit_debug_receipt_bytes === true)
27
- return true;
28
- const flag = g.process?.env?.DEBUG_RECEIPT_BYTES;
29
- return flag === "1" || flag === "true";
30
- }
31
- /** The one suite ExecutionReceipts sign under today. */
32
- export const EXECUTION_RECEIPT_SUITE = "motebit-jcs-ed25519-b64-v1";
33
- /**
34
- * Sign an execution receipt. Stamps the cryptosuite discriminator into
35
- * the receipt body, canonicalizes with JCS, dispatches the primitive
36
- * signature through `signBySuite`, and encodes as base64url per the
37
- * suite's rules.
38
- *
39
- * Callers pass a receipt *without* `signature` or `suite`; the signer
40
- * owns both. The returned object is a full `SignableReceipt` with
41
- * `suite` and `signature` set.
42
- */
43
- export async function signExecutionReceipt(receipt, privateKey, publicKey) {
44
- // Embed the public key for portable verification (no relay lookup needed)
45
- // and stamp the suite into the signed body.
46
- const withKey = publicKey ? { ...receipt, public_key: bytesToHex(publicKey) } : receipt;
47
- const body = { ...withKey, suite: EXECUTION_RECEIPT_SUITE };
48
- const canonical = canonicalJson(body);
49
- const message = new TextEncoder().encode(canonical);
50
- const sig = await signBySuite(EXECUTION_RECEIPT_SUITE, message, privateKey);
51
- const signed = { ...body, signature: toBase64Url(sig) };
52
- if (isReceiptDebugEnabled()) {
53
- const sha = await canonicalSha256(body);
54
- // eslint-disable-next-line no-console -- opt-in diagnostic, off by default
55
- console.debug(`[motebit/crypto] signExecutionReceipt canonical_sha256=${sha} chain=${Array.isArray(body.delegation_receipts)
56
- ? body.delegation_receipts.length
57
- : 0} bytes=${canonical.length}`);
58
- }
59
- // Freeze the returned signed receipt. Receipts are immutable evidence by
60
- // contract — the type system already says `readonly` is the intent. Freeze
61
- // makes the runtime enforce it: any post-sign mutation throws TypeError
62
- // at the mutation site (Node 20 strict mode, browser strict by default),
63
- // catching the bug at the producer instead of as wire-corruption noise on
64
- // the consumer five hops downstream.
65
- return Object.freeze(signed);
66
- }
67
- /**
68
- * Verify an execution receipt's signature by dispatching through the
69
- * recipe named in `receipt.suite`. Reconstructs the canonical JSON from
70
- * all fields except `signature` (the suite IS part of the signed body,
71
- * so tampering with it breaks verification).
72
- *
73
- * Fail-closed on:
74
- * - unknown suite value (dispatcher rejects)
75
- * - suite other than `EXECUTION_RECEIPT_SUITE` (until a PQ variant
76
- * lands in the registry, this narrow check rejects any other
77
- * value — widens when the union widens)
78
- * - base64url decode errors
79
- * - primitive-level verification failure
80
- */
81
- export async function verifyExecutionReceipt(receipt, publicKey) {
82
- if (receipt.suite !== EXECUTION_RECEIPT_SUITE) {
83
- if (isReceiptDebugEnabled()) {
84
- // eslint-disable-next-line no-console -- opt-in diagnostic
85
- console.debug(`[motebit/crypto] verifyExecutionReceipt EARLY_RETURN suite_mismatch actual=${JSON.stringify(receipt.suite)} expected=${JSON.stringify(EXECUTION_RECEIPT_SUITE)}`);
86
- }
87
- return false;
88
- }
89
- const { signature, ...body } = receipt;
90
- const canonical = canonicalJson(body);
91
- const message = new TextEncoder().encode(canonical);
92
- let valid = false;
93
- try {
94
- const sig = fromBase64Url(signature);
95
- valid = await verifyBySuite(receipt.suite, message, sig, publicKey);
96
- }
97
- catch {
98
- valid = false;
99
- }
100
- if (isReceiptDebugEnabled()) {
101
- const sha = await canonicalSha256(body);
102
- // eslint-disable-next-line no-console -- opt-in diagnostic, off by default
103
- console.debug(`[motebit/crypto] verifyExecutionReceipt canonical_sha256=${sha} valid=${valid} bytes=${canonical.length}`);
104
- }
105
- return valid;
106
- }
107
- export async function verifyExecutionReceiptDetailed(receipt, publicKey) {
108
- if (receipt.suite !== EXECUTION_RECEIPT_SUITE) {
109
- const { signature: _drop, ...bodyForHash } = receipt;
110
- return {
111
- valid: false,
112
- canonical_sha256: await canonicalSha256(bodyForHash),
113
- canonical_preview: canonicalJson(bodyForHash).slice(0, 256),
114
- reason: "wrong_suite",
115
- };
116
- }
117
- const { signature, ...body } = receipt;
118
- const canonical = canonicalJson(body);
119
- const message = new TextEncoder().encode(canonical);
120
- let sigBytes;
121
- try {
122
- sigBytes = fromBase64Url(signature);
123
- }
124
- catch {
125
- return {
126
- valid: false,
127
- canonical_sha256: await hash(message),
128
- canonical_preview: canonical.slice(0, 256),
129
- reason: "bad_base64",
130
- };
131
- }
132
- const valid = await verifyBySuite(receipt.suite, message, sigBytes, publicKey);
133
- return {
134
- valid,
135
- canonical_sha256: await hash(message),
136
- canonical_preview: canonical.slice(0, 256),
137
- reason: valid ? "ok" : "ed25519_mismatch",
138
- };
139
- }
140
- /** The one suite ToolInvocationReceipts sign under today. */
141
- export const TOOL_INVOCATION_RECEIPT_SUITE = "motebit-jcs-ed25519-b64-v1";
142
- /**
143
- * Compute the `args_hash` / `result_hash` for a tool-invocation receipt.
144
- * JCS-canonicalizes the value, then SHA-256s the UTF-8 bytes. Returns
145
- * hex. Use on both sides of the wire: the producer computes the hash at
146
- * sign time; a verifier with the raw value recomputes and matches.
147
- *
148
- * For `string` values (e.g., a plain result string), the canonicalization
149
- * is the value itself wrapped with JSON escaping rules; `canonicalJson`
150
- * handles both scalar and object inputs uniformly.
151
- */
152
- export async function hashToolPayload(value) {
153
- return canonicalSha256(value);
154
- }
155
- /**
156
- * Sign a tool-invocation receipt. Mirrors `signExecutionReceipt`:
157
- * stamps the cryptosuite into the body, canonicalizes with JCS,
158
- * dispatches through `signBySuite`, and encodes as base64url.
159
- *
160
- * Callers pass a receipt *without* `signature` or `suite`; the signer
161
- * owns both. Also embeds the public key (hex) so the receipt is
162
- * independently verifiable with no relay lookup.
163
- */
164
- export async function signToolInvocationReceipt(receipt, privateKey, publicKey) {
165
- const withKey = publicKey ? { ...receipt, public_key: bytesToHex(publicKey) } : receipt;
166
- const body = { ...withKey, suite: TOOL_INVOCATION_RECEIPT_SUITE };
167
- const canonical = canonicalJson(body);
168
- const message = new TextEncoder().encode(canonical);
169
- const sig = await signBySuite(TOOL_INVOCATION_RECEIPT_SUITE, message, privateKey);
170
- const signed = { ...body, signature: toBase64Url(sig) };
171
- if (isReceiptDebugEnabled()) {
172
- const sha = await canonicalSha256(body);
173
- // eslint-disable-next-line no-console -- opt-in diagnostic, off by default
174
- console.debug(`[motebit/crypto] signToolInvocationReceipt canonical_sha256=${sha} tool=${body.tool_name} bytes=${canonical.length}`);
175
- }
176
- return Object.freeze(signed);
177
- }
178
- /**
179
- * Verify a tool-invocation receipt. Fails closed on unknown suite, bad
180
- * base64, or signature mismatch — same rules as `verifyExecutionReceipt`.
181
- */
182
- export async function verifyToolInvocationReceipt(receipt, publicKey) {
183
- if (receipt.suite !== TOOL_INVOCATION_RECEIPT_SUITE) {
184
- if (isReceiptDebugEnabled()) {
185
- // eslint-disable-next-line no-console -- opt-in diagnostic
186
- console.debug(`[motebit/crypto] verifyToolInvocationReceipt EARLY_RETURN suite_mismatch actual=${JSON.stringify(receipt.suite)} expected=${JSON.stringify(TOOL_INVOCATION_RECEIPT_SUITE)}`);
187
- }
188
- return false;
189
- }
190
- const { signature, ...body } = receipt;
191
- const canonical = canonicalJson(body);
192
- const message = new TextEncoder().encode(canonical);
193
- let valid = false;
194
- try {
195
- const sig = fromBase64Url(signature);
196
- valid = await verifyBySuite(receipt.suite, message, sig, publicKey);
197
- }
198
- catch {
199
- valid = false;
200
- }
201
- if (isReceiptDebugEnabled()) {
202
- const sha = await canonicalSha256(body);
203
- // eslint-disable-next-line no-console -- opt-in diagnostic, off by default
204
- console.debug(`[motebit/crypto] verifyToolInvocationReceipt canonical_sha256=${sha} valid=${valid} bytes=${canonical.length}`);
205
- }
206
- return valid;
207
- }
208
- /**
209
- * Construct, canonicalize, and sign a sovereign payment receipt with
210
- * the payee's Ed25519 identity key. Returns a fully-formed
211
- * `ExecutionReceipt` that can be passed to any standard verifier and
212
- * fed into `bumpTrustFromReceipt` on the payer's runtime.
213
- *
214
- * No relay is contacted at any point. The resulting receipt is
215
- * self-verifiable forever from the embedded `public_key` field.
216
- */
217
- export async function signSovereignPaymentReceipt(input, privateKey, publicKey) {
218
- const receipt = {
219
- task_id: `${input.rail}:tx:${input.tx_hash}`,
220
- motebit_id: input.payee_motebit_id,
221
- device_id: input.payee_device_id,
222
- submitted_at: input.submitted_at,
223
- completed_at: input.completed_at,
224
- status: "completed",
225
- result: `${input.service_description} | paid by ${input.payer_motebit_id}: ${input.amount_micro.toString()} micro-${input.asset} via ${input.rail}`,
226
- tools_used: input.tools_used ?? [],
227
- memories_formed: 0,
228
- prompt_hash: input.prompt_hash,
229
- result_hash: input.result_hash,
230
- // relay_task_id intentionally omitted — sovereign rail, no relay binding
231
- // suite is stamped by signExecutionReceipt
232
- };
233
- return signExecutionReceipt(receipt, privateKey, publicKey);
234
- }
235
- /**
236
- * Recursively verify an execution receipt and all its delegation receipts.
237
- * Each receipt is verified against the public key found in `knownKeys` for its `motebit_id`.
238
- * Returns a tree of verification results mirroring the delegation structure.
239
- */
240
- export async function verifyReceiptChain(receipt, knownKeys) {
241
- const { task_id, motebit_id } = receipt;
242
- // Use embedded public key if available, otherwise look up from known keys.
243
- let publicKey = knownKeys.get(motebit_id);
244
- if (!publicKey && receipt.public_key) {
245
- publicKey = hexToBytes(receipt.public_key);
246
- }
247
- if (!publicKey) {
248
- const delegations = await verifyDelegations(receipt, knownKeys);
249
- return { task_id, motebit_id, verified: false, error: "unknown motebit_id", delegations };
250
- }
251
- let verified;
252
- let error;
253
- try {
254
- verified = await verifyExecutionReceipt(receipt, publicKey);
255
- }
256
- catch (err) {
257
- /* v8 ignore next 3 */
258
- verified = false;
259
- error = err instanceof Error ? err.message : String(err);
260
- }
261
- const delegations = await verifyDelegations(receipt, knownKeys);
262
- const result = { task_id, motebit_id, verified, delegations };
263
- if (error) {
264
- /* v8 ignore next */
265
- result.error = error;
266
- }
267
- return result;
268
- }
269
- async function verifyDelegations(receipt, knownKeys) {
270
- if (!receipt.delegation_receipts || receipt.delegation_receipts.length === 0) {
271
- return [];
272
- }
273
- return Promise.all(receipt.delegation_receipts.map((dr) => verifyReceiptChain(dr, knownKeys)));
274
- }
275
- /**
276
- * Verify a flat sequence of execution receipts.
277
- *
278
- * A valid sequence means:
279
- * 1. Each receipt's signature is valid against its signer's public key.
280
- * 2. Adjacent receipts are temporally ordered: receipt[i].completed_at <= receipt[i+1].submitted_at.
281
- *
282
- * An empty sequence is considered valid.
283
- * Use `verifyReceiptChain` for nested/tree-structured delegation receipts.
284
- */
285
- export async function verifyReceiptSequence(chain) {
286
- if (chain.length === 0)
287
- return { valid: true };
288
- for (let i = 0; i < chain.length; i++) {
289
- const entry = chain[i];
290
- const sigValid = await verifyExecutionReceipt(entry.receipt, entry.signer_public_key);
291
- if (!sigValid) {
292
- return { valid: false, error: `Receipt ${i} has invalid signature`, index: i };
293
- }
294
- }
295
- for (let i = 1; i < chain.length; i++) {
296
- const prev = chain[i - 1];
297
- const curr = chain[i];
298
- if (prev.receipt.completed_at > curr.receipt.submitted_at) {
299
- return {
300
- valid: false,
301
- error: `Receipt ${i} submitted_at (${curr.receipt.submitted_at}) is before receipt ${i - 1} completed_at (${prev.receipt.completed_at})`,
302
- index: i,
303
- };
304
- }
305
- }
306
- return { valid: true };
307
- }
308
- /** The one suite DelegationTokens sign under today. */
309
- export const DELEGATION_TOKEN_SUITE = "motebit-jcs-ed25519-b64-v1";
310
- /**
311
- * Sign a delegation token. The delegator authorizes the delegate to act
312
- * within the given scope. Stamps the cryptosuite into the signed body,
313
- * dispatches the primitive signature through `signBySuite`.
314
- *
315
- * Callers pass the token without `signature` or `suite`; the signer owns
316
- * both. Public keys must already be hex-encoded — this signer does not
317
- * transcode, so the input carries the same encoding the output will.
318
- */
319
- export async function signDelegation(delegation, delegatorPrivateKey) {
320
- const body = { ...delegation, suite: DELEGATION_TOKEN_SUITE };
321
- const canonical = canonicalJson(body);
322
- const message = new TextEncoder().encode(canonical);
323
- const sig = await signBySuite(DELEGATION_TOKEN_SUITE, message, delegatorPrivateKey);
324
- return { ...body, signature: toBase64Url(sig) };
325
- }
326
- /**
327
- * Verify a delegation token's signature and (optionally) expiration.
328
- *
329
- * Rejects fail-closed on:
330
- * - missing or unknown `suite` value (anything other than `DELEGATION_TOKEN_SUITE`)
331
- * - expired token (unless `options.checkExpiry === false`)
332
- * - malformed hex public key or base64url signature
333
- * - primitive-level verification failure
334
- *
335
- * @param delegation - The delegation token to verify
336
- * @param options.checkExpiry - If true (default), reject expired tokens. Pass false
337
- * only when verifying historical chains where expiration is irrelevant.
338
- * @param options.now - Current time in ms (default: Date.now()). For testing.
339
- */
340
- export async function verifyDelegation(delegation, options) {
341
- if (delegation.suite !== DELEGATION_TOKEN_SUITE)
342
- return false;
343
- const checkExpiry = options?.checkExpiry ?? true;
344
- if (checkExpiry) {
345
- const now = options?.now ?? Date.now();
346
- if (delegation.expires_at < now)
347
- return false;
348
- }
349
- const { signature, ...body } = delegation;
350
- const canonical = canonicalJson(body);
351
- const message = new TextEncoder().encode(canonical);
352
- try {
353
- const pubKey = hexToBytes(delegation.delegator_public_key);
354
- const sig = fromBase64Url(signature);
355
- return await verifyBySuite(delegation.suite, message, sig, pubKey);
356
- }
357
- catch {
358
- return false;
359
- }
360
- }
361
- /**
362
- * Verify a chain of delegation tokens.
363
- *
364
- * A valid chain means:
365
- * 1. Each delegation's signature is valid (signed by the delegator's key).
366
- * 2. Adjacent delegations are linked: delegation[i].delegate_id === delegation[i+1].delegator_id
367
- * and delegation[i].delegate_public_key === delegation[i+1].delegator_public_key.
368
- *
369
- * An empty chain is considered valid (no delegations to verify).
370
- */
371
- export async function verifyDelegationChain(chain) {
372
- if (chain.length === 0)
373
- return { valid: true };
374
- for (let i = 0; i < chain.length; i++) {
375
- const delegation = chain[i];
376
- // Chain verification is historical — don't reject expired tokens in the chain
377
- const sigValid = await verifyDelegation(delegation, { checkExpiry: false });
378
- if (!sigValid) {
379
- return { valid: false, error: `Delegation ${i} has invalid signature` };
380
- }
381
- if (i > 0) {
382
- const prev = chain[i - 1];
383
- if (prev.delegate_id !== delegation.delegator_id) {
384
- return {
385
- valid: false,
386
- error: `Chain break at ${i}: delegate_id "${prev.delegate_id}" !== delegator_id "${delegation.delegator_id}"`,
387
- };
388
- }
389
- if (prev.delegate_public_key !== delegation.delegator_public_key) {
390
- return {
391
- valid: false,
392
- error: `Chain break at ${i}: delegate_public_key mismatch`,
393
- };
394
- }
395
- // Scope narrowing: each delegation must not widen scope beyond its parent
396
- if (!isScopeNarrowed(prev.scope, delegation.scope)) {
397
- return {
398
- valid: false,
399
- error: `Delegation ${i} widens scope: parent="${prev.scope}", child="${delegation.scope}"`,
400
- };
401
- }
402
- }
403
- }
404
- return { valid: true };
405
- }
406
- /** The one suite AdjudicatorVotes sign under today — matches spec/dispute-v1.md §6.4. */
407
- export const ADJUDICATOR_VOTE_SUITE = "motebit-jcs-ed25519-b64-v1";
408
- /** The one suite DisputeResolutions sign under today — matches spec/dispute-v1.md §6.4. */
409
- export const DISPUTE_RESOLUTION_SUITE = "motebit-jcs-ed25519-b64-v1";
410
- /** The one suite DisputeRequest filings sign under today — spec/dispute-v1.md §4.2. */
411
- export const DISPUTE_REQUEST_SUITE = "motebit-jcs-ed25519-b64-v1";
412
- /** The one suite DisputeEvidence submissions sign under today — spec/dispute-v1.md §5.2. */
413
- export const DISPUTE_EVIDENCE_SUITE = "motebit-jcs-ed25519-b64-v1";
414
- /** The one suite DisputeAppeal filings sign under today — spec/dispute-v1.md §8.2. */
415
- export const DISPUTE_APPEAL_SUITE = "motebit-jcs-ed25519-b64-v1";
416
- /**
417
- * Sign a federation peer's adjudication vote. The `dispute_id` IS part
418
- * of the signed body — spec §6.5 Foundation Law: "Each AdjudicatorVote
419
- * signature MUST cover its `dispute_id`. Votes are not portable across
420
- * disputes — a malicious adjudicator collecting old votes from other
421
- * disputes cannot stuff them into a new resolution because the
422
- * dispute_id binding breaks the signature."
423
- *
424
- * Callers pass the body without `signature` or `suite`; the signer owns
425
- * both.
426
- */
427
- export async function signAdjudicatorVote(vote, peerPrivateKey) {
428
- const body = { ...vote, suite: ADJUDICATOR_VOTE_SUITE };
429
- const canonical = canonicalJson(body);
430
- const message = new TextEncoder().encode(canonical);
431
- const sig = await signBySuite(ADJUDICATOR_VOTE_SUITE, message, peerPrivateKey);
432
- return { ...body, signature: toBase64Url(sig) };
433
- }
434
- /**
435
- * Verify an adjudicator vote against the voting peer's public key.
436
- * Fail-closed on unknown suite, base64url decode error, and primitive
437
- * verification failure. Matching of `peer_id` to a legitimate federation
438
- * peer is the caller's responsibility (this function verifies the
439
- * signature; peer-membership is a trust decision).
440
- */
441
- export async function verifyAdjudicatorVote(vote, peerPublicKey) {
442
- if (vote.suite !== ADJUDICATOR_VOTE_SUITE)
443
- return false;
444
- const { signature, ...body } = vote;
445
- const canonical = canonicalJson(body);
446
- const message = new TextEncoder().encode(canonical);
447
- try {
448
- const sig = fromBase64Url(signature);
449
- return await verifyBySuite(vote.suite, message, sig, peerPublicKey);
450
- }
451
- catch {
452
- return false;
453
- }
454
- }
455
- /**
456
- * Sign a dispute resolution. For single-relay adjudication
457
- * (`adjudicator_votes: []`) the relay signs with its own identity key.
458
- * For federation resolutions, the leader collects signed
459
- * `AdjudicatorVote` entries, then signs the aggregate.
460
- *
461
- * Callers pass the body without `signature` or `suite`; the signer
462
- * owns both.
463
- *
464
- * Per spec §6.5 Foundation Law, a federation resolution MUST include
465
- * individual `AdjudicatorVote` entries — aggregated-only verdicts are
466
- * rejected. This signer does not enforce that at sign time (the
467
- * orchestrator decides whether federation is required); the verifier
468
- * re-checks every embedded vote signature when the array is non-empty.
469
- */
470
- export async function signDisputeResolution(resolution, adjudicatorPrivateKey) {
471
- const body = { ...resolution, suite: DISPUTE_RESOLUTION_SUITE };
472
- const canonical = canonicalJson(body);
473
- const message = new TextEncoder().encode(canonical);
474
- const sig = await signBySuite(DISPUTE_RESOLUTION_SUITE, message, adjudicatorPrivateKey);
475
- return { ...body, signature: toBase64Url(sig) };
476
- }
477
- /**
478
- * Verify a dispute resolution. Two layers:
479
- * 1. Outer signature verifies against `adjudicatorPublicKey`.
480
- * 2. When `adjudicator_votes.length > 0`, every embedded
481
- * AdjudicatorVote's signature is re-checked against the
482
- * corresponding `peerKeys` entry (lookup by `peer_id`). Per §6.5,
483
- * aggregated-only verdicts without individual peer signatures are
484
- * rejected — a missing peer key in the lookup is treated as a
485
- * verification failure.
486
- *
487
- * Fail-closed on unknown suite, decode errors, primitive verification
488
- * failures, any missing peer key, and any invalid embedded vote.
489
- */
490
- export async function verifyDisputeResolution(resolution, adjudicatorPublicKey, peerKeys) {
491
- if (resolution.suite !== DISPUTE_RESOLUTION_SUITE)
492
- return false;
493
- const { signature, ...body } = resolution;
494
- const canonical = canonicalJson(body);
495
- const message = new TextEncoder().encode(canonical);
496
- try {
497
- const sig = fromBase64Url(signature);
498
- const outerValid = await verifyBySuite(resolution.suite, message, sig, adjudicatorPublicKey);
499
- if (!outerValid)
500
- return false;
501
- }
502
- catch {
503
- return false;
504
- }
505
- // Federation resolutions must carry signed peer votes. Verify every
506
- // one against the caller-supplied peer-key map. Missing map or
507
- // missing peer entry is a verification failure, not a pass-through.
508
- if (resolution.adjudicator_votes.length > 0) {
509
- if (!peerKeys)
510
- return false;
511
- for (const vote of resolution.adjudicator_votes) {
512
- if (vote.dispute_id !== resolution.dispute_id)
513
- return false;
514
- const peerKey = peerKeys.get(vote.peer_id);
515
- if (!peerKey)
516
- return false;
517
- const voteValid = await verifyAdjudicatorVote(vote, peerKey);
518
- if (!voteValid)
519
- return false;
520
- }
521
- }
522
- return true;
523
- }
524
- /**
525
- * Sign a DisputeRequest. Filing party signs over canonical JSON of
526
- * every field except `signature`. The relay verifies against the
527
- * filer's registered public key before accepting the filing — without
528
- * the signature, anyone could file a dispute as anyone (foundation
529
- * law §4.4: filing party must be a direct party to the task; without
530
- * the signature binding, the relay cannot enforce that). Callers pass
531
- * the body without `signature` or `suite`; the signer owns both.
532
- */
533
- export async function signDisputeRequest(request, filerPrivateKey) {
534
- const body = { ...request, suite: DISPUTE_REQUEST_SUITE };
535
- const canonical = canonicalJson(body);
536
- const message = new TextEncoder().encode(canonical);
537
- const sig = await signBySuite(DISPUTE_REQUEST_SUITE, message, filerPrivateKey);
538
- return { ...body, signature: toBase64Url(sig) };
539
- }
540
- /**
541
- * Verify a DisputeRequest against the filing party's public key.
542
- * Fail-closed on unknown suite, base64url decode error, and primitive
543
- * verification failure. Eligibility checks (`filed_by` is a real party
544
- * to `task_id`, trust threshold, evidence_refs non-empty) are the
545
- * caller's responsibility — this verifies the signature only.
546
- */
547
- export async function verifyDisputeRequest(request, filerPublicKey) {
548
- if (request.suite !== DISPUTE_REQUEST_SUITE)
549
- return false;
550
- const { signature, ...body } = request;
551
- const canonical = canonicalJson(body);
552
- const message = new TextEncoder().encode(canonical);
553
- try {
554
- const sig = fromBase64Url(signature);
555
- return await verifyBySuite(request.suite, message, sig, filerPublicKey);
556
- }
557
- catch {
558
- return false;
559
- }
560
- }
561
- /**
562
- * Sign a DisputeEvidence submission. The submitting party — either
563
- * the dispute's filer or respondent — signs over the canonical JSON
564
- * of every field except `signature`. The relay verifies against the
565
- * submitter's registered public key (foundation law §5.4: evidence
566
- * must be cryptographically verifiable; unsigned/tampered evidence
567
- * is rejected).
568
- */
569
- export async function signDisputeEvidence(evidence, submitterPrivateKey) {
570
- const body = { ...evidence, suite: DISPUTE_EVIDENCE_SUITE };
571
- const canonical = canonicalJson(body);
572
- const message = new TextEncoder().encode(canonical);
573
- const sig = await signBySuite(DISPUTE_EVIDENCE_SUITE, message, submitterPrivateKey);
574
- return { ...body, signature: toBase64Url(sig) };
575
- }
576
- /**
577
- * Verify a DisputeEvidence submission against the submitting party's
578
- * public key. Inner `evidence_data` validation against its own per-
579
- * type schema (e.g. ExecutionReceiptSchema for `execution_receipt`)
580
- * is the adjudicator's responsibility — this verifies the outer
581
- * envelope signature only.
582
- */
583
- export async function verifyDisputeEvidence(evidence, submitterPublicKey) {
584
- if (evidence.suite !== DISPUTE_EVIDENCE_SUITE)
585
- return false;
586
- const { signature, ...body } = evidence;
587
- const canonical = canonicalJson(body);
588
- const message = new TextEncoder().encode(canonical);
589
- try {
590
- const sig = fromBase64Url(signature);
591
- return await verifyBySuite(evidence.suite, message, sig, submitterPublicKey);
592
- }
593
- catch {
594
- return false;
595
- }
596
- }
597
- /**
598
- * Sign a DisputeAppeal. The appealing party — filer or respondent —
599
- * signs over the canonical JSON of every field except `signature`.
600
- * Foundation law §8.4: one appeal per dispute; the post-appeal state
601
- * is terminal. The relay verifies against the appealer's registered
602
- * public key before transitioning the dispute to `appealed`.
603
- */
604
- export async function signDisputeAppeal(appeal, appealerPrivateKey) {
605
- const body = { ...appeal, suite: DISPUTE_APPEAL_SUITE };
606
- const canonical = canonicalJson(body);
607
- const message = new TextEncoder().encode(canonical);
608
- const sig = await signBySuite(DISPUTE_APPEAL_SUITE, message, appealerPrivateKey);
609
- return { ...body, signature: toBase64Url(sig) };
610
- }
611
- /**
612
- * Verify a DisputeAppeal against the appealing party's public key.
613
- * Fail-closed on unknown suite, base64url decode error, and primitive
614
- * verification failure.
615
- */
616
- export async function verifyDisputeAppeal(appeal, appealerPublicKey) {
617
- if (appeal.suite !== DISPUTE_APPEAL_SUITE)
618
- return false;
619
- const { signature, ...body } = appeal;
620
- const canonical = canonicalJson(body);
621
- const message = new TextEncoder().encode(canonical);
622
- try {
623
- const sig = fromBase64Url(signature);
624
- return await verifyBySuite(appeal.suite, message, sig, appealerPublicKey);
625
- }
626
- catch {
627
- return false;
628
- }
629
- }
630
- /** The one suite ConsolidationReceipts sign under today. */
631
- export const CONSOLIDATION_RECEIPT_SUITE = "motebit-jcs-ed25519-b64-v1";
632
- /**
633
- * Sign a consolidation receipt. The motebit's Ed25519 identity key
634
- * commits to the structural counts of work performed during a
635
- * consolidation cycle. Receipt is self-attesting: any holder of the
636
- * signer's public key verifies without contacting any relay.
637
- *
638
- * Callers pass the body without `signature` or `suite`; the signer
639
- * owns both. Pass `publicKey` to embed it in the receipt for portable
640
- * verification (recommended — third parties verify from the receipt
641
- * alone).
642
- *
643
- * The signed receipt is `Object.freeze`d before return so any
644
- * post-sign mutation throws synchronously at the producer instead of
645
- * surfacing as wire-corruption noise on a downstream verifier.
646
- */
647
- export async function signConsolidationReceipt(receipt, privateKey, publicKey) {
648
- const withKey = publicKey
649
- ? { ...receipt, public_key: bytesToHex(publicKey) }
650
- : receipt;
651
- const body = { ...withKey, suite: CONSOLIDATION_RECEIPT_SUITE };
652
- const canonical = canonicalJson(body);
653
- const message = new TextEncoder().encode(canonical);
654
- const sig = await signBySuite(CONSOLIDATION_RECEIPT_SUITE, message, privateKey);
655
- return Object.freeze({ ...body, signature: toBase64Url(sig) });
656
- }
657
- /**
658
- * Verify a consolidation receipt against the signer's public key.
659
- * Fail-closed on unknown `suite`, base64url decode error, primitive
660
- * verification failure. The caller is responsible for matching
661
- * `motebit_id` to whoever they expect signed; the cryptographic
662
- * property here is "this body was signed by the holder of this key."
663
- */
664
- export async function verifyConsolidationReceipt(receipt, publicKey) {
665
- if (receipt.suite !== CONSOLIDATION_RECEIPT_SUITE)
666
- return false;
667
- const { signature, ...body } = receipt;
668
- const canonical = canonicalJson(body);
669
- const message = new TextEncoder().encode(canonical);
670
- try {
671
- const sig = fromBase64Url(signature);
672
- return await verifyBySuite(receipt.suite, message, sig, publicKey);
673
- }
674
- catch {
675
- return false;
676
- }
677
- }
678
- /** The one suite BalanceWaivers sign under today — matches spec/migration-v1.md §7.2. */
679
- export const BALANCE_WAIVER_SUITE = "motebit-jcs-ed25519-b64-v1";
680
- /**
681
- * Sign a balance waiver. The agent forfeits a named micro-unit amount to
682
- * expedite departure from a relay (spec/migration-v1.md §7.2 + §7.3 — a
683
- * waiver is one of the two terminal authorizations the depart route will
684
- * accept, the other being a confirmed withdrawal).
685
- *
686
- * Callers pass the body without `signature` or `suite`; the signer owns
687
- * both. The agent's identity key signs canonical JSON of the unsigned
688
- * body (with `suite` stamped in), base64url-encoded.
689
- */
690
- export async function signBalanceWaiver(waiver, agentPrivateKey) {
691
- const body = { ...waiver, suite: BALANCE_WAIVER_SUITE };
692
- const canonical = canonicalJson(body);
693
- const message = new TextEncoder().encode(canonical);
694
- const sig = await signBySuite(BALANCE_WAIVER_SUITE, message, agentPrivateKey);
695
- return { ...body, signature: toBase64Url(sig) };
696
- }
697
- /**
698
- * Verify a balance waiver against the agent's public key. Rejects
699
- * fail-closed on unknown `suite`, base64url decode error, and primitive
700
- * verification failure. Matching of `motebit_id` to the authorizing
701
- * agent, and `waived_amount` to the actual virtual-account balance, is
702
- * the caller's responsibility (neither is a cryptographic property).
703
- */
704
- export async function verifyBalanceWaiver(waiver, agentPublicKey) {
705
- if (waiver.suite !== BALANCE_WAIVER_SUITE)
706
- return false;
707
- const { signature, ...body } = waiver;
708
- const canonical = canonicalJson(body);
709
- const message = new TextEncoder().encode(canonical);
710
- try {
711
- const sig = fromBase64Url(signature);
712
- return await verifyBySuite(waiver.suite, message, sig, agentPublicKey);
713
- }
714
- catch {
715
- return false;
716
- }
717
- }
718
- /** The one suite SettlementRecords sign under today. */
719
- export const SETTLEMENT_RECORD_SUITE = "motebit-jcs-ed25519-b64-v1";
720
- /**
721
- * Sign a settlement record. The issuing relay commits to the (amount,
722
- * fee, rate, status) tuple; a malicious relay therefore cannot issue
723
- * inconsistent records to different observers.
724
- *
725
- * Callers pass the record without `signature` or `suite`; the signer
726
- * owns both.
727
- *
728
- * Foundation Law (services/relay/CLAUDE.md rule 6): every truth the
729
- * relay asserts is independently verifiable. Per-agent settlements
730
- * deliver this through the signature; federation settlements
731
- * additionally get Merkle-batched and onchain-anchored.
732
- */
733
- export async function signSettlement(settlement, issuerPrivateKey) {
734
- const body = { ...settlement, suite: SETTLEMENT_RECORD_SUITE };
735
- const canonical = canonicalJson(body);
736
- const message = new TextEncoder().encode(canonical);
737
- const sig = await signBySuite(SETTLEMENT_RECORD_SUITE, message, issuerPrivateKey);
738
- return { ...body, signature: toBase64Url(sig) };
739
- }
740
- /**
741
- * Verify a settlement record's signature. Reconstructs canonical JSON
742
- * over all fields except `signature` and verifies Ed25519 against the
743
- * issuing relay's public key.
744
- *
745
- * The caller supplies the public key — typically resolved from the
746
- * `issuer_relay_id` via the federation peer registry or a known-keys
747
- * store. The signature alone proves the record was issued by the
748
- * holder of `issuerPublicKey`; trust in that key is a separate
749
- * concern (federation membership, key rotation chain, etc).
750
- *
751
- * Fail-closed on:
752
- * - missing or unknown `suite` value
753
- * - base64url decode errors
754
- * - primitive-level verification failure
755
- */
756
- export async function verifySettlement(settlement, issuerPublicKey) {
757
- if (settlement.suite !== SETTLEMENT_RECORD_SUITE)
758
- return false;
759
- const { signature, ...body } = settlement;
760
- const canonical = canonicalJson(body);
761
- const message = new TextEncoder().encode(canonical);
762
- try {
763
- const sig = fromBase64Url(signature);
764
- return await verifyBySuite(settlement.suite, message, sig, issuerPublicKey);
765
- }
766
- catch {
767
- return false;
768
- }
769
- }
770
- // === Key Succession (Rotation) ===
771
- /** The one suite KeySuccessionRecords sign under today. */
772
- export const KEY_SUCCESSION_SUITE = "motebit-jcs-ed25519-hex-v1";
773
- /**
774
- * Build the canonical payload for key succession signing. The `suite`
775
- * field is stamped into the signed body so verifiers dispatch the
776
- * primitive via `verifyBySuite` rather than assuming Ed25519 implicitly.
777
- */
778
- function keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, reason, recovery) {
779
- const obj = {
780
- old_public_key: oldPublicKeyHex,
781
- new_public_key: newPublicKeyHex,
782
- timestamp,
783
- suite: KEY_SUCCESSION_SUITE,
784
- };
785
- if (reason !== undefined) {
786
- obj.reason = reason;
787
- }
788
- if (recovery) {
789
- obj.recovery = true;
790
- }
791
- return canonicalJson(obj);
792
- }
793
- /**
794
- * Create a key succession record signed by both the old and new keys.
795
- * Dispatches primitive signing through `signBySuite` per the
796
- * `motebit-jcs-ed25519-hex-v1` suite.
797
- */
798
- export async function signKeySuccession(oldPrivateKey, newPrivateKey, newPublicKey, oldPublicKey, reason) {
799
- const timestamp = Date.now();
800
- const oldPublicKeyHex = bytesToHex(oldPublicKey);
801
- const newPublicKeyHex = bytesToHex(newPublicKey);
802
- const payload = keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, reason);
803
- const message = new TextEncoder().encode(payload);
804
- const oldSig = await signBySuite(KEY_SUCCESSION_SUITE, message, oldPrivateKey);
805
- const newSig = await signBySuite(KEY_SUCCESSION_SUITE, message, newPrivateKey);
806
- return {
807
- old_public_key: oldPublicKeyHex,
808
- new_public_key: newPublicKeyHex,
809
- timestamp,
810
- ...(reason !== undefined ? { reason } : {}),
811
- suite: KEY_SUCCESSION_SUITE,
812
- old_key_signature: bytesToHex(oldSig),
813
- new_key_signature: bytesToHex(newSig),
814
- };
815
- }
816
- /**
817
- * Sign a guardian recovery succession record (§3.8.3).
818
- * The guardian key signs instead of the compromised old key.
819
- * Reason MUST include "guardian_recovery".
820
- */
821
- export async function signGuardianRecoverySuccession(guardianPrivateKey, newPrivateKey, oldPublicKey, newPublicKey, reason) {
822
- const timestamp = Date.now();
823
- const oldPublicKeyHex = bytesToHex(oldPublicKey);
824
- const newPublicKeyHex = bytesToHex(newPublicKey);
825
- const effectiveReason = reason ?? "guardian_recovery";
826
- const payload = keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, effectiveReason, true);
827
- const message = new TextEncoder().encode(payload);
828
- const guardianSig = await signBySuite(KEY_SUCCESSION_SUITE, message, guardianPrivateKey);
829
- const newSig = await signBySuite(KEY_SUCCESSION_SUITE, message, newPrivateKey);
830
- return {
831
- old_public_key: oldPublicKeyHex,
832
- new_public_key: newPublicKeyHex,
833
- timestamp,
834
- reason: effectiveReason,
835
- suite: KEY_SUCCESSION_SUITE,
836
- new_key_signature: bytesToHex(newSig),
837
- recovery: true,
838
- guardian_signature: bytesToHex(guardianSig),
839
- };
840
- }
841
- /**
842
- * Verify a key succession record. For normal rotation, checks
843
- * old_key_signature + new_key_signature. For guardian recovery
844
- * (recovery: true), checks guardian_signature + new_key_signature.
845
- * Rejects records whose `suite` is missing or not the succession suite.
846
- */
847
- export async function verifyKeySuccession(record, guardianPublicKeyHex) {
848
- if (record.suite !== KEY_SUCCESSION_SUITE)
849
- return false;
850
- const payload = keySuccessionPayload(record.old_public_key, record.new_public_key, record.timestamp, record.reason, record.recovery);
851
- const message = new TextEncoder().encode(payload);
852
- try {
853
- const newPubKey = hexToBytes(record.new_public_key);
854
- const newSig = hexToBytes(record.new_key_signature);
855
- const newValid = await verifyBySuite(record.suite, message, newSig, newPubKey);
856
- if (!newValid)
857
- return false;
858
- if (record.recovery) {
859
- if (!record.guardian_signature || !guardianPublicKeyHex)
860
- return false;
861
- const guardianPubKey = hexToBytes(guardianPublicKeyHex);
862
- const guardianSig = hexToBytes(record.guardian_signature);
863
- return await verifyBySuite(record.suite, message, guardianSig, guardianPubKey);
864
- }
865
- else {
866
- if (!record.old_key_signature)
867
- return false;
868
- const oldPubKey = hexToBytes(record.old_public_key);
869
- const oldSig = hexToBytes(record.old_key_signature);
870
- return await verifyBySuite(record.suite, message, oldSig, oldPubKey);
871
- }
872
- }
873
- catch {
874
- /* v8 ignore next */
875
- return false;
876
- }
877
- }
878
- /**
879
- * Verify a full key succession chain — an ordered array of KeySuccessionRecords
880
- * representing a sequence of key rotations from a genesis key to the current active key.
881
- */
882
- export async function verifySuccessionChain(chain, guardianPublicKeyHex) {
883
- if (chain.length === 0) {
884
- return {
885
- valid: false,
886
- genesis_public_key: "",
887
- current_public_key: "",
888
- length: 0,
889
- error: { index: 0, message: "Empty succession chain" },
890
- };
891
- }
892
- const genesisKey = chain[0].old_public_key;
893
- const currentKey = chain[chain.length - 1].new_public_key;
894
- for (let i = 0; i < chain.length; i++) {
895
- const record = chain[i];
896
- if (record.recovery && !guardianPublicKeyHex) {
897
- return {
898
- valid: false,
899
- genesis_public_key: genesisKey,
900
- current_public_key: currentKey,
901
- length: chain.length,
902
- error: {
903
- index: i,
904
- message: `Record ${i} is a guardian recovery but no guardian public key provided`,
905
- },
906
- };
907
- }
908
- const sigValid = await verifyKeySuccession(record, guardianPublicKeyHex);
909
- if (!sigValid) {
910
- return {
911
- valid: false,
912
- genesis_public_key: genesisKey,
913
- current_public_key: currentKey,
914
- length: chain.length,
915
- error: { index: i, message: `Record ${i} has invalid signature` },
916
- };
917
- }
918
- if (i < chain.length - 1) {
919
- const next = chain[i + 1];
920
- if (record.new_public_key !== next.old_public_key) {
921
- return {
922
- valid: false,
923
- genesis_public_key: genesisKey,
924
- current_public_key: currentKey,
925
- length: chain.length,
926
- error: {
927
- index: i + 1,
928
- message: `Chain break at ${i + 1}: expected old_public_key "${record.new_public_key}", got "${next.old_public_key}"`,
929
- },
930
- };
931
- }
932
- }
933
- if (i < chain.length - 1) {
934
- const next = chain[i + 1];
935
- if (record.timestamp >= next.timestamp) {
936
- return {
937
- valid: false,
938
- genesis_public_key: genesisKey,
939
- current_public_key: currentKey,
940
- length: chain.length,
941
- error: {
942
- index: i + 1,
943
- message: `Temporal ordering violation at ${i + 1}: timestamp ${next.timestamp} is not after ${record.timestamp}`,
944
- },
945
- };
946
- }
947
- }
948
- }
949
- return {
950
- valid: true,
951
- genesis_public_key: genesisKey,
952
- current_public_key: currentKey,
953
- length: chain.length,
954
- };
955
- }
956
- // === Guardian Revocation (§3.3.2) ===
957
- /** Guardian revocation shares the identity-file suite (JCS + hex). */
958
- export const GUARDIAN_REVOCATION_SUITE = "motebit-jcs-ed25519-hex-v1";
959
- /**
960
- * Sign a guardian revocation payload — requires BOTH identity and guardian keys.
961
- * Neither party can unilaterally dissolve the custody relationship.
962
- * Dispatches the primitive through `signBySuite`.
963
- */
964
- export async function signGuardianRevocation(identityPrivateKey, guardianPrivateKey, timestamp) {
965
- const ts = timestamp ?? Date.now();
966
- const payload = canonicalJson({
967
- action: "guardian_revoked",
968
- timestamp: ts,
969
- suite: GUARDIAN_REVOCATION_SUITE,
970
- });
971
- const message = new TextEncoder().encode(payload);
972
- const identitySig = await signBySuite(GUARDIAN_REVOCATION_SUITE, message, identityPrivateKey);
973
- const guardianSig = await signBySuite(GUARDIAN_REVOCATION_SUITE, message, guardianPrivateKey);
974
- return {
975
- payload,
976
- identity_signature: bytesToHex(identitySig),
977
- guardian_signature: bytesToHex(guardianSig),
978
- timestamp: ts,
979
- };
980
- }
981
- /**
982
- * Verify a guardian revocation proof — both signatures must be valid.
983
- * Dispatches primitive verification through `verifyBySuite`.
984
- */
985
- export async function verifyGuardianRevocation(revocation, identityPublicKeyHex, guardianPublicKeyHex) {
986
- const payload = canonicalJson({
987
- action: "guardian_revoked",
988
- timestamp: revocation.timestamp,
989
- suite: GUARDIAN_REVOCATION_SUITE,
990
- });
991
- const message = new TextEncoder().encode(payload);
992
- try {
993
- const identityPub = hexToBytes(identityPublicKeyHex);
994
- const guardianPub = hexToBytes(guardianPublicKeyHex);
995
- const identitySig = hexToBytes(revocation.identity_signature);
996
- const guardianSig = hexToBytes(revocation.guardian_signature);
997
- const identityValid = await verifyBySuite(GUARDIAN_REVOCATION_SUITE, message, identitySig, identityPub);
998
- const guardianValid = await verifyBySuite(GUARDIAN_REVOCATION_SUITE, message, guardianSig, guardianPub);
999
- return identityValid && guardianValid;
1000
- }
1001
- catch {
1002
- return false;
1003
- }
1004
- }
1005
- // === Collaborative Receipt ===
1006
- /** The one suite CollaborativeReceipts sign under today. */
1007
- export const COLLABORATIVE_RECEIPT_SUITE = "motebit-jcs-ed25519-b64-v1";
1008
- /**
1009
- * Sign a collaborative receipt. Computes a content hash over the canonical
1010
- * JSON of all participant receipts, then signs the aggregate through
1011
- * `signBySuite` under `motebit-jcs-ed25519-b64-v1`.
1012
- */
1013
- export async function signCollaborativeReceipt(receipt, initiatorPrivateKey) {
1014
- const receiptsCanonical = canonicalJson(receipt.participant_receipts);
1015
- const receiptsBytes = new TextEncoder().encode(receiptsCanonical);
1016
- const contentHash = await hash(receiptsBytes);
1017
- const sigPayload = canonicalJson({
1018
- proposal_id: receipt.proposal_id,
1019
- plan_id: receipt.plan_id,
1020
- content_hash: contentHash,
1021
- suite: COLLABORATIVE_RECEIPT_SUITE,
1022
- });
1023
- const sigMessage = new TextEncoder().encode(sigPayload);
1024
- const sig = await signBySuite(COLLABORATIVE_RECEIPT_SUITE, sigMessage, initiatorPrivateKey);
1025
- return {
1026
- ...receipt,
1027
- content_hash: contentHash,
1028
- suite: COLLABORATIVE_RECEIPT_SUITE,
1029
- initiator_signature: toBase64Url(sig),
1030
- };
1031
- }
1032
- /**
1033
- * Verify a collaborative receipt:
1034
- * 1. Rejects any record whose `suite` is missing or not the collaborative suite.
1035
- * 2. Recomputes content hash from participant receipts and checks it matches.
1036
- * 3. Verifies the initiator's Ed25519 signature over the aggregate via `verifyBySuite`.
1037
- * 4. Optionally verifies each participant receipt against known keys.
1038
- */
1039
- export async function verifyCollaborativeReceipt(receipt, initiatorPublicKey, participantKeys) {
1040
- // 0. Suite discriminator check
1041
- if (receipt.suite !== COLLABORATIVE_RECEIPT_SUITE) {
1042
- return { valid: false, error: "Unknown or missing cryptosuite" };
1043
- }
1044
- // 1. Recompute content hash
1045
- const receiptsCanonical = canonicalJson(receipt.participant_receipts);
1046
- const receiptsBytes = new TextEncoder().encode(receiptsCanonical);
1047
- const expectedHash = await hash(receiptsBytes);
1048
- if (expectedHash !== receipt.content_hash) {
1049
- return { valid: false, error: "Content hash mismatch" };
1050
- }
1051
- // 2. Verify initiator signature (suite stamped into the signed payload)
1052
- const sigPayload = canonicalJson({
1053
- proposal_id: receipt.proposal_id,
1054
- plan_id: receipt.plan_id,
1055
- content_hash: receipt.content_hash,
1056
- suite: receipt.suite,
1057
- });
1058
- const sigMessage = new TextEncoder().encode(sigPayload);
1059
- try {
1060
- const sig = fromBase64Url(receipt.initiator_signature);
1061
- const sigValid = await verifyBySuite(receipt.suite, sigMessage, sig, initiatorPublicKey);
1062
- if (!sigValid) {
1063
- return { valid: false, error: "Initiator signature invalid" };
1064
- }
1065
- }
1066
- catch {
1067
- return { valid: false, error: "Initiator signature decode failed" };
1068
- }
1069
- // 3. Verify participant receipts if keys provided
1070
- if (participantKeys) {
1071
- for (let i = 0; i < receipt.participant_receipts.length; i++) {
1072
- const pr = receipt.participant_receipts[i];
1073
- const pubKey = participantKeys.get(pr.motebit_id);
1074
- if (!pubKey) {
1075
- return {
1076
- valid: false,
1077
- error: `Unknown participant key for receipt ${i} (${pr.motebit_id})`,
1078
- };
1079
- }
1080
- const prValid = await verifyExecutionReceipt(pr, pubKey);
1081
- if (!prValid) {
1082
- return {
1083
- valid: false,
1084
- error: `Participant receipt ${i} (${pr.motebit_id}) signature invalid`,
1085
- };
1086
- }
1087
- }
1088
- }
1089
- return { valid: true };
1090
- }
1091
- // === Device Self-Registration ===
1092
- //
1093
- // Self-attesting registration: the device proves it controls a private key
1094
- // by signing a canonical-JSON serialization of its own registration request.
1095
- // The relay verifies against the public_key carried in the same request — no
1096
- // prior trust anchor required. Wire format and verification recipe are
1097
- // foundation law in `spec/device-self-registration-v1.md`.
1098
- //
1099
- // Trust posture: a self-registered device starts at trust zero. Trust accrues
1100
- // through receipts, credentials, and onchain anchors — never through
1101
- // registration alone. See `docs/doctrine/protocol-model.md`.
1102
- /** The one suite device-registration requests sign under today. */
1103
- export const DEVICE_REGISTRATION_SUITE = "motebit-jcs-ed25519-b64-v1";
1104
- /**
1105
- * Sign a device-registration request. Stamps the cryptosuite into the body,
1106
- * canonicalizes with JCS, dispatches the primitive signature through
1107
- * `signBySuite`, and encodes as base64url per the suite's rules.
1108
- *
1109
- * Callers pass the body without `signature` and (optionally) without `suite`;
1110
- * the signer owns both. The returned object is a complete signed request
1111
- * ready to POST to a relay's self-register endpoint.
1112
- */
1113
- export async function signDeviceRegistration(body, privateKey) {
1114
- const withSuite = { ...body, suite: DEVICE_REGISTRATION_SUITE };
1115
- const canonical = canonicalJson(withSuite);
1116
- const message = new TextEncoder().encode(canonical);
1117
- const sig = await signBySuite(DEVICE_REGISTRATION_SUITE, message, privateKey);
1118
- return { ...withSuite, signature: toBase64Url(sig) };
1119
- }
1120
- /** Maximum drift between the signer's claimed timestamp and the verifier's clock. */
1121
- export const DEVICE_REGISTRATION_MAX_AGE_MS = 5 * 60 * 1000;
1122
- export async function verifyDeviceRegistration(body, now = Date.now()) {
1123
- // Step 1 — shape validation. Any missing / mistyped field is "malformed".
1124
- if (typeof body.motebit_id !== "string" ||
1125
- typeof body.device_id !== "string" ||
1126
- typeof body.public_key !== "string" ||
1127
- !/^[0-9a-f]{64}$/i.test(body.public_key) ||
1128
- typeof body.timestamp !== "number" ||
1129
- typeof body.suite !== "string" ||
1130
- typeof body.signature !== "string") {
1131
- return { valid: false, reason: "malformed" };
1132
- }
1133
- // Step 2 — replay window.
1134
- if (Math.abs(now - body.timestamp) > DEVICE_REGISTRATION_MAX_AGE_MS) {
1135
- return { valid: false, reason: "stale" };
1136
- }
1137
- // Step 3 — suite check. Only the registered suite is acceptable today;
1138
- // future suites add a dispatch arm in suite-dispatch.ts.
1139
- if (body.suite !== DEVICE_REGISTRATION_SUITE) {
1140
- return { valid: false, reason: "unsupported_suite" };
1141
- }
1142
- // Step 4–7 — canonicalize, decode, verify.
1143
- const { signature, ...bodyForSig } = body;
1144
- const canonical = canonicalJson(bodyForSig);
1145
- const message = new TextEncoder().encode(canonical);
1146
- let sigBytes;
1147
- let pkBytes;
1148
- try {
1149
- sigBytes = fromBase64Url(signature);
1150
- pkBytes = hexToBytes(body.public_key);
1151
- }
1152
- catch {
1153
- return { valid: false, reason: "malformed" };
1154
- }
1155
- const ok = await verifyBySuite(body.suite, message, sigBytes, pkBytes);
1156
- return ok ? { valid: true } : { valid: false, reason: "bad_signature" };
1157
- }
1158
- //# sourceMappingURL=artifacts.js.map