@motebit/crypto 0.8.0 → 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.
package/dist/artifacts.js CHANGED
@@ -5,38 +5,205 @@
5
5
  * artifacts. A third party needs these to produce valid signed artifacts that
6
6
  * any verifier will accept.
7
7
  *
8
- * Moved from BSL @motebit/crypto to MIT @motebit/crypto.
8
+ * Moved from BSL @motebit/encryption to the permissive floor in @motebit/crypto (Apache-2.0).
9
9
  */
10
- import { canonicalJson, ed25519Sign, ed25519Verify, toBase64Url, fromBase64Url, bytesToHex, hexToBytes, hash, isScopeNarrowed, } from "./signing.js";
10
+ import { canonicalJson, canonicalSha256, toBase64Url, fromBase64Url, bytesToHex, hexToBytes, hash, isScopeNarrowed, signBySuite, verifyBySuite, } from "./signing.js";
11
11
  /**
12
- * Sign an execution receipt. Produces a canonical JSON representation
13
- * of all fields except `signature`, signs it with Ed25519, and sets
14
- * the `signature` field to the base64url-encoded result.
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.
15
42
  */
16
43
  export async function signExecutionReceipt(receipt, privateKey, publicKey) {
17
44
  // Embed the public key for portable verification (no relay lookup needed)
18
- const body = publicKey ? { ...receipt, public_key: bytesToHex(publicKey) } : receipt;
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 };
19
48
  const canonical = canonicalJson(body);
20
49
  const message = new TextEncoder().encode(canonical);
21
- const sig = await ed25519Sign(message, privateKey);
22
- return { ...body, signature: toBase64Url(sig) };
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);
23
66
  }
24
67
  /**
25
- * Verify an execution receipt's Ed25519 signature.
26
- * Reconstructs the canonical JSON from all fields except `signature`
27
- * and verifies against the provided public key.
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
28
80
  */
29
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
+ }
30
89
  const { signature, ...body } = receipt;
31
90
  const canonical = canonicalJson(body);
32
91
  const message = new TextEncoder().encode(canonical);
92
+ let valid = false;
33
93
  try {
34
94
  const sig = fromBase64Url(signature);
35
- return await ed25519Verify(sig, message, publicKey);
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);
36
123
  }
37
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
+ }
38
188
  return false;
39
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;
40
207
  }
41
208
  /**
42
209
  * Construct, canonicalize, and sign a sovereign payment receipt with
@@ -61,6 +228,7 @@ export async function signSovereignPaymentReceipt(input, privateKey, publicKey)
61
228
  prompt_hash: input.prompt_hash,
62
229
  result_hash: input.result_hash,
63
230
  // relay_task_id intentionally omitted — sovereign rail, no relay binding
231
+ // suite is stamped by signExecutionReceipt
64
232
  };
65
233
  return signExecutionReceipt(receipt, privateKey, publicKey);
66
234
  }
@@ -137,25 +305,41 @@ export async function verifyReceiptSequence(chain) {
137
305
  }
138
306
  return { valid: true };
139
307
  }
308
+ /** The one suite DelegationTokens sign under today. */
309
+ export const DELEGATION_TOKEN_SUITE = "motebit-jcs-ed25519-b64-v1";
140
310
  /**
141
311
  * Sign a delegation token. The delegator authorizes the delegate to act
142
- * within the given scope. The signature covers all fields except `signature`.
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.
143
318
  */
144
319
  export async function signDelegation(delegation, delegatorPrivateKey) {
145
- const canonical = canonicalJson(delegation);
320
+ const body = { ...delegation, suite: DELEGATION_TOKEN_SUITE };
321
+ const canonical = canonicalJson(body);
146
322
  const message = new TextEncoder().encode(canonical);
147
- const sig = await ed25519Sign(message, delegatorPrivateKey);
148
- return { ...delegation, signature: toBase64Url(sig) };
323
+ const sig = await signBySuite(DELEGATION_TOKEN_SUITE, message, delegatorPrivateKey);
324
+ return { ...body, signature: toBase64Url(sig) };
149
325
  }
150
326
  /**
151
327
  * Verify a delegation token's signature and (optionally) expiration.
152
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
+ *
153
335
  * @param delegation - The delegation token to verify
154
336
  * @param options.checkExpiry - If true (default), reject expired tokens. Pass false
155
337
  * only when verifying historical chains where expiration is irrelevant.
156
338
  * @param options.now - Current time in ms (default: Date.now()). For testing.
157
339
  */
158
340
  export async function verifyDelegation(delegation, options) {
341
+ if (delegation.suite !== DELEGATION_TOKEN_SUITE)
342
+ return false;
159
343
  const checkExpiry = options?.checkExpiry ?? true;
160
344
  if (checkExpiry) {
161
345
  const now = options?.now ?? Date.now();
@@ -166,9 +350,9 @@ export async function verifyDelegation(delegation, options) {
166
350
  const canonical = canonicalJson(body);
167
351
  const message = new TextEncoder().encode(canonical);
168
352
  try {
169
- const pubKey = fromBase64Url(delegation.delegator_public_key);
353
+ const pubKey = hexToBytes(delegation.delegator_public_key);
170
354
  const sig = fromBase64Url(signature);
171
- return await ed25519Verify(sig, message, pubKey);
355
+ return await verifyBySuite(delegation.suite, message, sig, pubKey);
172
356
  }
173
357
  catch {
174
358
  return false;
@@ -219,14 +403,384 @@ export async function verifyDelegationChain(chain) {
219
403
  }
220
404
  return { valid: true };
221
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";
222
680
  /**
223
- * Build the canonical payload for key succession signing.
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/api/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.
224
777
  */
225
778
  function keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, reason, recovery) {
226
779
  const obj = {
227
780
  old_public_key: oldPublicKeyHex,
228
781
  new_public_key: newPublicKeyHex,
229
782
  timestamp,
783
+ suite: KEY_SUCCESSION_SUITE,
230
784
  };
231
785
  if (reason !== undefined) {
232
786
  obj.reason = reason;
@@ -238,6 +792,8 @@ function keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, reaso
238
792
  }
239
793
  /**
240
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.
241
797
  */
242
798
  export async function signKeySuccession(oldPrivateKey, newPrivateKey, newPublicKey, oldPublicKey, reason) {
243
799
  const timestamp = Date.now();
@@ -245,13 +801,14 @@ export async function signKeySuccession(oldPrivateKey, newPrivateKey, newPublicK
245
801
  const newPublicKeyHex = bytesToHex(newPublicKey);
246
802
  const payload = keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, reason);
247
803
  const message = new TextEncoder().encode(payload);
248
- const oldSig = await ed25519Sign(message, oldPrivateKey);
249
- const newSig = await ed25519Sign(message, newPrivateKey);
804
+ const oldSig = await signBySuite(KEY_SUCCESSION_SUITE, message, oldPrivateKey);
805
+ const newSig = await signBySuite(KEY_SUCCESSION_SUITE, message, newPrivateKey);
250
806
  return {
251
807
  old_public_key: oldPublicKeyHex,
252
808
  new_public_key: newPublicKeyHex,
253
809
  timestamp,
254
810
  ...(reason !== undefined ? { reason } : {}),
811
+ suite: KEY_SUCCESSION_SUITE,
255
812
  old_key_signature: bytesToHex(oldSig),
256
813
  new_key_signature: bytesToHex(newSig),
257
814
  };
@@ -268,29 +825,34 @@ export async function signGuardianRecoverySuccession(guardianPrivateKey, newPriv
268
825
  const effectiveReason = reason ?? "guardian_recovery";
269
826
  const payload = keySuccessionPayload(oldPublicKeyHex, newPublicKeyHex, timestamp, effectiveReason, true);
270
827
  const message = new TextEncoder().encode(payload);
271
- const guardianSig = await ed25519Sign(message, guardianPrivateKey);
272
- const newSig = await ed25519Sign(message, newPrivateKey);
828
+ const guardianSig = await signBySuite(KEY_SUCCESSION_SUITE, message, guardianPrivateKey);
829
+ const newSig = await signBySuite(KEY_SUCCESSION_SUITE, message, newPrivateKey);
273
830
  return {
274
831
  old_public_key: oldPublicKeyHex,
275
832
  new_public_key: newPublicKeyHex,
276
833
  timestamp,
277
834
  reason: effectiveReason,
835
+ suite: KEY_SUCCESSION_SUITE,
278
836
  new_key_signature: bytesToHex(newSig),
279
837
  recovery: true,
280
838
  guardian_signature: bytesToHex(guardianSig),
281
839
  };
282
840
  }
283
841
  /**
284
- * Verify a key succession record. For normal rotation, checks old_key_signature + new_key_signature.
285
- * For guardian recovery (recovery: true), checks guardian_signature + new_key_signature.
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.
286
846
  */
287
847
  export async function verifyKeySuccession(record, guardianPublicKeyHex) {
848
+ if (record.suite !== KEY_SUCCESSION_SUITE)
849
+ return false;
288
850
  const payload = keySuccessionPayload(record.old_public_key, record.new_public_key, record.timestamp, record.reason, record.recovery);
289
851
  const message = new TextEncoder().encode(payload);
290
852
  try {
291
853
  const newPubKey = hexToBytes(record.new_public_key);
292
854
  const newSig = hexToBytes(record.new_key_signature);
293
- const newValid = await ed25519Verify(newSig, message, newPubKey);
855
+ const newValid = await verifyBySuite(record.suite, message, newSig, newPubKey);
294
856
  if (!newValid)
295
857
  return false;
296
858
  if (record.recovery) {
@@ -298,14 +860,14 @@ export async function verifyKeySuccession(record, guardianPublicKeyHex) {
298
860
  return false;
299
861
  const guardianPubKey = hexToBytes(guardianPublicKeyHex);
300
862
  const guardianSig = hexToBytes(record.guardian_signature);
301
- return await ed25519Verify(guardianSig, message, guardianPubKey);
863
+ return await verifyBySuite(record.suite, message, guardianSig, guardianPubKey);
302
864
  }
303
865
  else {
304
866
  if (!record.old_key_signature)
305
867
  return false;
306
868
  const oldPubKey = hexToBytes(record.old_public_key);
307
869
  const oldSig = hexToBytes(record.old_key_signature);
308
- return await ed25519Verify(oldSig, message, oldPubKey);
870
+ return await verifyBySuite(record.suite, message, oldSig, oldPubKey);
309
871
  }
310
872
  }
311
873
  catch {
@@ -392,16 +954,23 @@ export async function verifySuccessionChain(chain, guardianPublicKeyHex) {
392
954
  };
393
955
  }
394
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";
395
959
  /**
396
960
  * Sign a guardian revocation payload — requires BOTH identity and guardian keys.
397
961
  * Neither party can unilaterally dissolve the custody relationship.
962
+ * Dispatches the primitive through `signBySuite`.
398
963
  */
399
964
  export async function signGuardianRevocation(identityPrivateKey, guardianPrivateKey, timestamp) {
400
965
  const ts = timestamp ?? Date.now();
401
- const payload = canonicalJson({ action: "guardian_revoked", timestamp: ts });
966
+ const payload = canonicalJson({
967
+ action: "guardian_revoked",
968
+ timestamp: ts,
969
+ suite: GUARDIAN_REVOCATION_SUITE,
970
+ });
402
971
  const message = new TextEncoder().encode(payload);
403
- const identitySig = await ed25519Sign(message, identityPrivateKey);
404
- const guardianSig = await ed25519Sign(message, guardianPrivateKey);
972
+ const identitySig = await signBySuite(GUARDIAN_REVOCATION_SUITE, message, identityPrivateKey);
973
+ const guardianSig = await signBySuite(GUARDIAN_REVOCATION_SUITE, message, guardianPrivateKey);
405
974
  return {
406
975
  payload,
407
976
  identity_signature: bytesToHex(identitySig),
@@ -411,27 +980,35 @@ export async function signGuardianRevocation(identityPrivateKey, guardianPrivate
411
980
  }
412
981
  /**
413
982
  * Verify a guardian revocation proof — both signatures must be valid.
983
+ * Dispatches primitive verification through `verifyBySuite`.
414
984
  */
415
985
  export async function verifyGuardianRevocation(revocation, identityPublicKeyHex, guardianPublicKeyHex) {
416
- const payload = canonicalJson({ action: "guardian_revoked", timestamp: revocation.timestamp });
986
+ const payload = canonicalJson({
987
+ action: "guardian_revoked",
988
+ timestamp: revocation.timestamp,
989
+ suite: GUARDIAN_REVOCATION_SUITE,
990
+ });
417
991
  const message = new TextEncoder().encode(payload);
418
992
  try {
419
993
  const identityPub = hexToBytes(identityPublicKeyHex);
420
994
  const guardianPub = hexToBytes(guardianPublicKeyHex);
421
995
  const identitySig = hexToBytes(revocation.identity_signature);
422
996
  const guardianSig = hexToBytes(revocation.guardian_signature);
423
- const identityValid = await ed25519Verify(identitySig, message, identityPub);
424
- const guardianValid = await ed25519Verify(guardianSig, message, guardianPub);
997
+ const identityValid = await verifyBySuite(GUARDIAN_REVOCATION_SUITE, message, identitySig, identityPub);
998
+ const guardianValid = await verifyBySuite(GUARDIAN_REVOCATION_SUITE, message, guardianSig, guardianPub);
425
999
  return identityValid && guardianValid;
426
1000
  }
427
1001
  catch {
428
1002
  return false;
429
1003
  }
430
1004
  }
1005
+ // === Collaborative Receipt ===
1006
+ /** The one suite CollaborativeReceipts sign under today. */
1007
+ export const COLLABORATIVE_RECEIPT_SUITE = "motebit-jcs-ed25519-b64-v1";
431
1008
  /**
432
1009
  * Sign a collaborative receipt. Computes a content hash over the canonical
433
- * JSON of all participant receipts, then signs the aggregate with the
434
- * initiator's Ed25519 private key.
1010
+ * JSON of all participant receipts, then signs the aggregate through
1011
+ * `signBySuite` under `motebit-jcs-ed25519-b64-v1`.
435
1012
  */
436
1013
  export async function signCollaborativeReceipt(receipt, initiatorPrivateKey) {
437
1014
  const receiptsCanonical = canonicalJson(receipt.participant_receipts);
@@ -441,22 +1018,29 @@ export async function signCollaborativeReceipt(receipt, initiatorPrivateKey) {
441
1018
  proposal_id: receipt.proposal_id,
442
1019
  plan_id: receipt.plan_id,
443
1020
  content_hash: contentHash,
1021
+ suite: COLLABORATIVE_RECEIPT_SUITE,
444
1022
  });
445
1023
  const sigMessage = new TextEncoder().encode(sigPayload);
446
- const sig = await ed25519Sign(sigMessage, initiatorPrivateKey);
1024
+ const sig = await signBySuite(COLLABORATIVE_RECEIPT_SUITE, sigMessage, initiatorPrivateKey);
447
1025
  return {
448
1026
  ...receipt,
449
1027
  content_hash: contentHash,
1028
+ suite: COLLABORATIVE_RECEIPT_SUITE,
450
1029
  initiator_signature: toBase64Url(sig),
451
1030
  };
452
1031
  }
453
1032
  /**
454
1033
  * Verify a collaborative receipt:
455
- * 1. Recomputes content hash from participant receipts and checks it matches.
456
- * 2. Verifies the initiator's Ed25519 signature over the aggregate.
457
- * 3. Optionally verifies each participant receipt against known keys.
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.
458
1038
  */
459
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
+ }
460
1044
  // 1. Recompute content hash
461
1045
  const receiptsCanonical = canonicalJson(receipt.participant_receipts);
462
1046
  const receiptsBytes = new TextEncoder().encode(receiptsCanonical);
@@ -464,16 +1048,17 @@ export async function verifyCollaborativeReceipt(receipt, initiatorPublicKey, pa
464
1048
  if (expectedHash !== receipt.content_hash) {
465
1049
  return { valid: false, error: "Content hash mismatch" };
466
1050
  }
467
- // 2. Verify initiator signature
1051
+ // 2. Verify initiator signature (suite stamped into the signed payload)
468
1052
  const sigPayload = canonicalJson({
469
1053
  proposal_id: receipt.proposal_id,
470
1054
  plan_id: receipt.plan_id,
471
1055
  content_hash: receipt.content_hash,
1056
+ suite: receipt.suite,
472
1057
  });
473
1058
  const sigMessage = new TextEncoder().encode(sigPayload);
474
1059
  try {
475
1060
  const sig = fromBase64Url(receipt.initiator_signature);
476
- const sigValid = await ed25519Verify(sig, sigMessage, initiatorPublicKey);
1061
+ const sigValid = await verifyBySuite(receipt.suite, sigMessage, sig, initiatorPublicKey);
477
1062
  if (!sigValid) {
478
1063
  return { valid: false, error: "Initiator signature invalid" };
479
1064
  }
@@ -503,4 +1088,71 @@ export async function verifyCollaborativeReceipt(receipt, initiatorPublicKey, pa
503
1088
  }
504
1089
  return { valid: true };
505
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
+ }
506
1158
  //# sourceMappingURL=artifacts.js.map