@calimero-network/mero-js 22.6.0 → 22.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/account/account.d.ts +99 -4
  2. package/dist/account/account.js +0 -0
  3. package/dist/account/index.d.ts +6 -2
  4. package/dist/account/index.js +3 -1
  5. package/dist/admin-api/admin-client.d.ts +138 -2
  6. package/dist/admin-api/admin-client.js +237 -1
  7. package/dist/admin-api/admin-types.d.ts +159 -5
  8. package/dist/capabilities.d.ts +42 -0
  9. package/dist/capabilities.js +64 -0
  10. package/dist/cloud/cloud-client.d.ts +38 -8
  11. package/dist/cloud/cloud-client.js +38 -21
  12. package/dist/cloud/connect.d.ts +101 -2
  13. package/dist/cloud/connect.js +84 -8
  14. package/dist/cloud/enrol-redirect.d.ts +195 -0
  15. package/dist/cloud/enrol-redirect.js +236 -0
  16. package/dist/cloud/index.d.ts +6 -2
  17. package/dist/cloud/index.js +7 -1
  18. package/dist/cloud/link-redirect.d.ts +85 -0
  19. package/dist/cloud/link-redirect.js +105 -0
  20. package/dist/crypto/internal.d.ts +15 -0
  21. package/dist/crypto/internal.js +18 -0
  22. package/dist/device-cert/device-cert.d.ts +85 -2
  23. package/dist/device-cert/device-cert.js +100 -9
  24. package/dist/device-cert/index.d.ts +2 -2
  25. package/dist/device-cert/index.js +1 -1
  26. package/dist/events/sse.d.ts +22 -2
  27. package/dist/events/sse.js +20 -11
  28. package/dist/http-client/http-types.d.ts +18 -0
  29. package/dist/http-client/web-client.js +21 -1
  30. package/dist/index.browser.mjs +2 -2
  31. package/dist/index.browser.mjs.map +4 -4
  32. package/dist/index.cjs +1999 -536
  33. package/dist/index.cjs.map +4 -4
  34. package/dist/index.d.ts +11 -6
  35. package/dist/index.js +17 -3
  36. package/dist/index.mjs +1999 -536
  37. package/dist/index.mjs.map +4 -4
  38. package/dist/login/login.d.ts +11 -1
  39. package/dist/login/login.js +5 -4
  40. package/dist/login/session.d.ts +21 -5
  41. package/dist/login/session.js +7 -15
  42. package/dist/namespace-op/namespace-op.d.ts +14 -2
  43. package/dist/namespace-op/namespace-op.js +9 -5
  44. package/dist/relay/index.d.ts +3 -3
  45. package/dist/relay/index.js +1 -1
  46. package/dist/relay/nonce-source.d.ts +135 -0
  47. package/dist/relay/nonce-source.js +187 -19
  48. package/dist/relay/relay-client.d.ts +148 -5
  49. package/dist/relay/relay-client.js +187 -7
  50. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  51. package/dist/relay-attestation/attest-node-key.js +110 -0
  52. package/dist/relay-attestation/index.d.ts +1 -0
  53. package/dist/relay-attestation/index.js +1 -0
  54. package/dist/request-proof/authorizer.d.ts +48 -0
  55. package/dist/request-proof/authorizer.js +65 -0
  56. package/dist/request-proof/index.d.ts +4 -0
  57. package/dist/request-proof/index.js +2 -0
  58. package/dist/request-proof/request-proof.d.ts +56 -0
  59. package/dist/request-proof/request-proof.js +111 -0
  60. package/dist/rpc/index.d.ts +21 -0
  61. package/dist/rpc/index.js +20 -0
  62. package/dist/transport/index.d.ts +6 -0
  63. package/dist/transport/index.js +3 -0
  64. package/dist/transport/mero-client.d.ts +286 -0
  65. package/dist/transport/mero-client.js +379 -0
  66. package/dist/transport/relay-observer.d.ts +152 -0
  67. package/dist/transport/relay-observer.js +173 -0
  68. package/dist/transport/relay-transport.d.ts +73 -0
  69. package/dist/transport/relay-transport.js +94 -0
  70. package/dist/transport/types.d.ts +100 -0
  71. package/dist/transport/types.js +32 -0
  72. package/dist/warrant/governance-op.d.ts +47 -1
  73. package/dist/warrant/governance-op.js +62 -2
  74. package/dist/warrant/index.d.ts +2 -2
  75. package/dist/warrant/index.js +1 -1
  76. package/package.json +1 -1
@@ -27,8 +27,8 @@
27
27
  * monotonic per-device sequence, and one minted against the wrong executor is
28
28
  * unspendable: the number is gone and the write never happened.
29
29
  */
30
- import type { GovernanceOp } from '../warrant/governance-op.js';
31
30
  import { type Signer } from '../signer/signer.js';
31
+ import { type GovernanceOp } from '../warrant/governance-op.js';
32
32
  import type { NonceSource } from './nonce-source.js';
33
33
  /** Where and as whom to write, plus the key that consents. */
34
34
  export interface RelayClientConfig {
@@ -73,7 +73,13 @@ export interface RelayClientConfig {
73
73
  * (a non-extractable WebCrypto key, a passkey, a hardware or remote signer).
74
74
  */
75
75
  signer?: Signer;
76
- /** Where nonces come from. See {@link NonceSource} — a reset replays. */
76
+ /**
77
+ * Where nonces come from. See {@link NonceSource} — a reset replays.
78
+ *
79
+ * A client whose storage may be cleared between sessions should wrap its
80
+ * counter in `createRecoveringNonceSource`, which asks the node where the
81
+ * sequence actually stands before the first mint instead of restarting at 1.
82
+ */
77
83
  nonces: NonceSource;
78
84
  /**
79
85
  * Seconds a minted warrant stays valid. Defaults to 300.
@@ -86,6 +92,14 @@ export interface RelayClientConfig {
86
92
  /** Injected for tests and non-browser runtimes. Defaults to global `fetch`. */
87
93
  fetch?: typeof fetch;
88
94
  timeoutMs?: number;
95
+ /**
96
+ * How many times a `429` is waited out before it reaches the caller.
97
+ * Defaults to 3; `0` surfaces rate limits immediately.
98
+ *
99
+ * Safe to retry because the relay's ingress limiter runs before the node sees
100
+ * the request, so a throttled warrant's nonce is not spent.
101
+ */
102
+ rateLimitRetries?: number;
89
103
  }
90
104
  /** What a relay says about its ability to run intents in one context. */
91
105
  export interface RelayDescription {
@@ -169,6 +183,61 @@ export interface GovernResult {
169
183
  /** The group the op acted on, hex: the new subgroup, for a creation. */
170
184
  groupId: string;
171
185
  }
186
+ /** What {@link RelayClient.foundNamespace} founds with. */
187
+ export interface FoundNamespaceInput {
188
+ /**
189
+ * The salt the namespace id is derived from, hex (32 bytes). Random when
190
+ * absent, which is what almost every caller wants: one account founds many
191
+ * namespaces by varying it.
192
+ */
193
+ salt?: string;
194
+ /**
195
+ * The relay's account, hex: the warrant's `executor`. The namespace does not
196
+ * exist yet, so there is nothing to ask `describeGovernance` about. Falls back
197
+ * to the client's configured or already-learned `executorAccount`.
198
+ */
199
+ executorAccount?: string;
200
+ /**
201
+ * The namespace root's default capability mask (`MemberCapabilities` bits),
202
+ * set right after founding. A namespace is founded with core's minimal
203
+ * default (`CAN_JOIN_OPEN_SUBGROUPS` only), so an app that wants members to
204
+ * create contexts or invite, for example, names its own mask here.
205
+ *
206
+ * Set through the relay with a delegated `DefaultCapabilitiesSet`, applied as
207
+ * the founder, who is the namespace's admin. It cannot include
208
+ * `CAN_AUTHOR_ON_BEHALF` (bit 9): which relays may write for members is never
209
+ * decided through a relay, and such a mask is refused before anything is
210
+ * signed.
211
+ */
212
+ defaultCapabilities?: number;
213
+ }
214
+ /** A namespace founded through a relay. */
215
+ export interface FoundedNamespace {
216
+ /** The new namespace's id, hex: `foundedNamespaceId(author, salt)`. */
217
+ namespaceId: string;
218
+ /**
219
+ * The salt it was derived with, hex. Keep it if anyone will need to be shown
220
+ * who founded the namespace: `(founder, salt)` reproduces the id.
221
+ */
222
+ salt: string;
223
+ /**
224
+ * Whether the relay admitted itself as the namespace's first TEE, which also
225
+ * sets its default relay-mode admission policy. `false` from a relay that is
226
+ * not a TEE, or whose attestation failed (see `teeError`). The namespace is
227
+ * founded either way.
228
+ */
229
+ teeEnabled: boolean;
230
+ /** Why `teeEnabled` is `false`, when the relay tried and failed. */
231
+ teeError?: string;
232
+ /**
233
+ * Present only when `defaultCapabilities` was asked for: whether the mask was
234
+ * set. `false` leaves the namespace founded with core's default; retry with
235
+ * `govern({ groupId: namespaceId, op: defaultCapabilitiesSetOp(mask) })`.
236
+ */
237
+ defaultCapabilitiesSet?: boolean;
238
+ /** Why `defaultCapabilitiesSet` is `false`. */
239
+ defaultCapabilitiesError?: string;
240
+ }
172
241
  /** Where an accepted intent landed. */
173
242
  export interface IntentResult<T = unknown> {
174
243
  /** The context's scope root after the run — did this change anything? */
@@ -208,11 +277,35 @@ export declare class RelayClient {
208
277
  private readonly config;
209
278
  private readonly fetchImpl;
210
279
  private readonly timeoutMs;
280
+ /** How many times a 429 is waited out before it is raised. */
281
+ private readonly rateLimitRetries;
211
282
  constructor(config: RelayClientConfig);
212
283
  /**
213
- * The author device's signer, built from `deviceSecret` when that is what was
214
- * configured. A {@link Signer} and never the secret, so no caller of a
215
- * `RelayClient` gains the ability to read key material.
284
+ * The relay's origin, trailing slash stripped.
285
+ *
286
+ * Exposed because a relay **is a node**: the same origin that answers the
287
+ * intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
288
+ * `/ws`. Anything building an observing session over this relay needs the
289
+ * URL, and asking the caller to pass it a second time is how the two drift.
290
+ */
291
+ get relayUrl(): string;
292
+ /** The author's account, hex — whose consent every warrant here carries. */
293
+ get authorAccount(): string;
294
+ /**
295
+ * The author's `AccountProof<DeviceCert>`, hex.
296
+ *
297
+ * The same credential `login()` wants, which is why it is readable: a caller
298
+ * that already handed it to this client should not have to hand an identical
299
+ * copy to the login as well, where the two could disagree.
300
+ */
301
+ get authorProof(): string;
302
+ /**
303
+ * The author device's signer.
304
+ *
305
+ * A {@link Signer} and never the secret: this returns something that can
306
+ * *sign*, so no caller of a `RelayClient` gains the ability to read a key
307
+ * that was passed in as `deviceSecret`. That asymmetry is the point — the
308
+ * login needs a signature, not key material.
216
309
  */
217
310
  authorSigner(): Promise<Signer>;
218
311
  /**
@@ -284,10 +377,60 @@ export declare class RelayClient {
284
377
  * other warrant.
285
378
  */
286
379
  govern(input: GovernInput): Promise<GovernResult>;
380
+ /**
381
+ * Found a namespace through the relay, with the author as its founder, owner
382
+ * and admin. For an account with no node, this is how it gets a namespace at
383
+ * all.
384
+ *
385
+ * The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
386
+ * author's own credential and the salt) under a root-plane governance
387
+ * warrant scoped to the new namespace's id, which is derived from the
388
+ * author's account and the salt, so the relay can neither choose another id
389
+ * nor found it for anyone else. The relay takes an identity in the new
390
+ * namespace, mints its key, publishes the genesis, and is seated as the
391
+ * founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
392
+ * `govern`, `createContext` and `execute` calls in the namespace work
393
+ * through it straight away. A TEE relay then admits itself as the
394
+ * namespace's first TEE; `teeEnabled` reports whether it did.
395
+ *
396
+ * The executor cannot be learned from the relay first: the namespace does not
397
+ * exist, so `describeGovernance` has nothing to answer about. It comes from
398
+ * `input.executorAccount`, the configured one, or one an earlier call learned
399
+ * (`describe`, `createContext`, `govern`), and without any of them this
400
+ * throws before taking a nonce.
401
+ * The nonce is spent in the new namespace's ledger.
402
+ *
403
+ * With `defaultCapabilities`, the founder then sets the namespace's default
404
+ * capability mask through the same relay (a second warrant, a second nonce)
405
+ * and `defaultCapabilitiesSet` reports whether it landed. A failure there
406
+ * does not throw, since the namespace is already founded.
407
+ *
408
+ * Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
409
+ * that names an existing namespace is a `409`, an `HTTPError`.
410
+ */
411
+ foundNamespace(input?: FoundNamespaceInput): Promise<FoundedNamespace>;
412
+ private postGovernance;
287
413
  /**
288
414
  * The executor a warrant must name: the one the relay answered with, which a
289
415
  * configured account must agree with. Remembered for later calls.
290
416
  */
291
417
  private checkedExecutor;
418
+ /**
419
+ * One request, waiting out a rate limit rather than failing the caller.
420
+ *
421
+ * The relay's ingress limits the delegated-execution routes to 20 requests a
422
+ * second per source IP, burst 40 — tighter than everything else it serves,
423
+ * because those two routes are the ones it answers to anonymous callers.
424
+ *
425
+ * Retrying a 429 is safe here in a way retrying most refusals is not: the
426
+ * limiter sits *first* in the middleware chain, so a throttled request never
427
+ * reached the node and its warrant nonce was never spent. The same bytes can
428
+ * go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
429
+ *
430
+ * `Retry-After` is honoured when the ingress sends one; otherwise the wait
431
+ * doubles from a little over the limiter's own window, which is the shortest
432
+ * delay that can actually clear.
433
+ */
292
434
  private json;
435
+ private jsonOnce;
293
436
  }
@@ -28,11 +28,12 @@
28
28
  * unspendable: the number is gone and the write never happened.
29
29
  */
30
30
  import { signWarrant } from '../warrant/warrant.js';
31
+ import { resolveSigner } from '../signer/signer.js';
31
32
  import { signCreationWarrant } from '../warrant/creation-warrant.js';
32
33
  import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
34
+ import { defaultCapabilitiesSetOp, foundedNamespaceId, namespaceCreatedOp, } from '../warrant/governance-op.js';
33
35
  import { HTTPError } from '../http-client/web-client.js';
34
- import { hex } from '../crypto/internal.js';
35
- import { resolveSigner } from '../signer/signer.js';
36
+ import { fromHex, hex } from '../crypto/internal.js';
36
37
  /** How long a freshly minted warrant stays presentable, in seconds. */
37
38
  const DEFAULT_TTL_SECONDS = 300;
38
39
  /**
@@ -61,6 +62,25 @@ export class IntentRefusedError extends Error {
61
62
  this.name = 'IntentRefusedError';
62
63
  }
63
64
  }
65
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
66
+ /**
67
+ * `Retry-After`, as milliseconds — seconds or an HTTP date, per RFC 9110.
68
+ *
69
+ * Returns `null` when the header is absent or unusable, so the caller falls back
70
+ * to its own backoff rather than treating a malformed header as "retry now".
71
+ */
72
+ function retryAfterMs(headers) {
73
+ const raw = headers?.get?.('Retry-After');
74
+ if (!raw)
75
+ return null;
76
+ const seconds = Number.parseInt(raw, 10);
77
+ if (Number.isFinite(seconds) && seconds >= 0)
78
+ return seconds * 1000;
79
+ const at = Date.parse(raw);
80
+ if (Number.isFinite(at))
81
+ return Math.max(0, at - Date.now());
82
+ return null;
83
+ }
64
84
  export class RelayClient {
65
85
  constructor(config) {
66
86
  this.config = config;
@@ -71,11 +91,40 @@ export class RelayClient {
71
91
  ? (input, init) => injected(input, init)
72
92
  : (input, init) => globalThis.fetch(input, init);
73
93
  this.timeoutMs = config.timeoutMs ?? 10000;
94
+ this.rateLimitRetries = config.rateLimitRetries ?? 3;
95
+ }
96
+ /**
97
+ * The relay's origin, trailing slash stripped.
98
+ *
99
+ * Exposed because a relay **is a node**: the same origin that answers the
100
+ * intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
101
+ * `/ws`. Anything building an observing session over this relay needs the
102
+ * URL, and asking the caller to pass it a second time is how the two drift.
103
+ */
104
+ get relayUrl() {
105
+ return this.baseUrl;
106
+ }
107
+ /** The author's account, hex — whose consent every warrant here carries. */
108
+ get authorAccount() {
109
+ return this.config.authorAccount;
74
110
  }
75
111
  /**
76
- * The author device's signer, built from `deviceSecret` when that is what was
77
- * configured. A {@link Signer} and never the secret, so no caller of a
78
- * `RelayClient` gains the ability to read key material.
112
+ * The author's `AccountProof<DeviceCert>`, hex.
113
+ *
114
+ * The same credential `login()` wants, which is why it is readable: a caller
115
+ * that already handed it to this client should not have to hand an identical
116
+ * copy to the login as well, where the two could disagree.
117
+ */
118
+ get authorProof() {
119
+ return this.config.authorProof;
120
+ }
121
+ /**
122
+ * The author device's signer.
123
+ *
124
+ * A {@link Signer} and never the secret: this returns something that can
125
+ * *sign*, so no caller of a `RelayClient` gains the ability to read a key
126
+ * that was passed in as `deviceSecret`. That asymmetry is the point — the
127
+ * login needs a signature, not key material.
79
128
  */
80
129
  async authorSigner() {
81
130
  return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
@@ -237,8 +286,107 @@ export class RelayClient {
237
286
  notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
238
287
  signer,
239
288
  });
240
- const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(input.groupId)}/governance-intents`, { warrant, authorProof: this.config.authorProof, op: hex(input.op.bytes) });
241
- return { groupId: body.data.groupId };
289
+ const data = await this.postGovernance(input.groupId, input.op, warrant);
290
+ return { groupId: data.groupId };
291
+ }
292
+ /**
293
+ * Found a namespace through the relay, with the author as its founder, owner
294
+ * and admin. For an account with no node, this is how it gets a namespace at
295
+ * all.
296
+ *
297
+ * The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
298
+ * author's own credential and the salt) under a root-plane governance
299
+ * warrant scoped to the new namespace's id, which is derived from the
300
+ * author's account and the salt, so the relay can neither choose another id
301
+ * nor found it for anyone else. The relay takes an identity in the new
302
+ * namespace, mints its key, publishes the genesis, and is seated as the
303
+ * founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
304
+ * `govern`, `createContext` and `execute` calls in the namespace work
305
+ * through it straight away. A TEE relay then admits itself as the
306
+ * namespace's first TEE; `teeEnabled` reports whether it did.
307
+ *
308
+ * The executor cannot be learned from the relay first: the namespace does not
309
+ * exist, so `describeGovernance` has nothing to answer about. It comes from
310
+ * `input.executorAccount`, the configured one, or one an earlier call learned
311
+ * (`describe`, `createContext`, `govern`), and without any of them this
312
+ * throws before taking a nonce.
313
+ * The nonce is spent in the new namespace's ledger.
314
+ *
315
+ * With `defaultCapabilities`, the founder then sets the namespace's default
316
+ * capability mask through the same relay (a second warrant, a second nonce)
317
+ * and `defaultCapabilitiesSet` reports whether it landed. A failure there
318
+ * does not throw, since the namespace is already founded.
319
+ *
320
+ * Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
321
+ * that names an existing namespace is a `409`, an `HTTPError`.
322
+ */
323
+ async foundNamespace(input = {}) {
324
+ const executor = input.executorAccount
325
+ ? this.checkedExecutor(input.executorAccount)
326
+ : this.executorAccount;
327
+ if (!executor) {
328
+ throw new Error("foundNamespace needs the relay's account: pass executorAccount or configure it " +
329
+ '(checked before signing, no nonce was spent)');
330
+ }
331
+ const salt = input.salt
332
+ ? hex(fromHex(input.salt, 'salt', 32))
333
+ : hex(crypto.getRandomValues(new Uint8Array(32)));
334
+ const namespaceId = await foundedNamespaceId(this.config.authorAccount, salt);
335
+ const op = namespaceCreatedOp({
336
+ founder: this.config.authorAccount,
337
+ credential: this.config.authorProof,
338
+ salt,
339
+ });
340
+ // Encoded (and so validated) before anything is signed: a mask the relay
341
+ // would refuse must not cost the founding a nonce.
342
+ const capabilitiesOp = input.defaultCapabilities === undefined
343
+ ? undefined
344
+ : defaultCapabilitiesSetOp(input.defaultCapabilities);
345
+ // Resolved before the nonce, so a misconfigured key burns none.
346
+ const signer = await this.authorSigner();
347
+ const nonce = await this.config.nonces.next();
348
+ const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
349
+ const warrant = await signGovernanceWarrant({
350
+ scope: namespaceId,
351
+ op,
352
+ authorAccount: this.config.authorAccount,
353
+ executor,
354
+ nonce,
355
+ notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
356
+ signer,
357
+ });
358
+ const data = await this.postGovernance(namespaceId, op, warrant);
359
+ const founded = {
360
+ namespaceId: data.groupId,
361
+ salt,
362
+ teeEnabled: data.teeEnabled ?? false,
363
+ ...(data.teeError ? { teeError: data.teeError } : {}),
364
+ };
365
+ if (!capabilitiesOp)
366
+ return founded;
367
+ // A second op, under its own warrant, on the namespace root: the founder is
368
+ // its admin and the relay was seated to act for members, so it goes through
369
+ // the ordinary `govern` path. The namespace exists whatever happens here, so
370
+ // a failure is reported rather than thrown.
371
+ try {
372
+ await this.govern({ groupId: founded.namespaceId, op: capabilitiesOp });
373
+ return { ...founded, defaultCapabilitiesSet: true };
374
+ }
375
+ catch (err) {
376
+ return {
377
+ ...founded,
378
+ defaultCapabilitiesSet: false,
379
+ defaultCapabilitiesError: err instanceof Error ? err.message : String(err),
380
+ };
381
+ }
382
+ }
383
+ async postGovernance(groupId, op, warrant) {
384
+ const body = await this.json('POST', `/admin-api/groups/${encodeURIComponent(groupId)}/governance-intents`, {
385
+ warrant,
386
+ authorProof: this.config.authorProof,
387
+ op: hex(op.bytes),
388
+ });
389
+ return body.data;
242
390
  }
243
391
  /**
244
392
  * The executor a warrant must name: the one the relay answered with, which a
@@ -253,7 +401,39 @@ export class RelayClient {
253
401
  this.executorAccount = answered;
254
402
  return answered;
255
403
  }
404
+ /**
405
+ * One request, waiting out a rate limit rather than failing the caller.
406
+ *
407
+ * The relay's ingress limits the delegated-execution routes to 20 requests a
408
+ * second per source IP, burst 40 — tighter than everything else it serves,
409
+ * because those two routes are the ones it answers to anonymous callers.
410
+ *
411
+ * Retrying a 429 is safe here in a way retrying most refusals is not: the
412
+ * limiter sits *first* in the middleware chain, so a throttled request never
413
+ * reached the node and its warrant nonce was never spent. The same bytes can
414
+ * go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
415
+ *
416
+ * `Retry-After` is honoured when the ingress sends one; otherwise the wait
417
+ * doubles from a little over the limiter's own window, which is the shortest
418
+ * delay that can actually clear.
419
+ */
256
420
  async json(method, path, body) {
421
+ let wait = 250;
422
+ for (let attempt = 0;; attempt += 1) {
423
+ try {
424
+ return await this.jsonOnce(method, path, body);
425
+ }
426
+ catch (err) {
427
+ const limited = err instanceof HTTPError && err.status === 429;
428
+ if (!limited || attempt >= this.rateLimitRetries)
429
+ throw err;
430
+ const after = retryAfterMs(err.headers);
431
+ await sleep(after ?? wait);
432
+ wait *= 2;
433
+ }
434
+ }
435
+ }
436
+ async jsonOnce(method, path, body) {
257
437
  const headers = { Accept: 'application/json' };
258
438
  if (body !== undefined)
259
439
  headers['Content-Type'] = 'application/json';
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Learn a relay's node signing key from its TEE attestation.
3
+ *
4
+ * `login()` binds a session to the node key the client names, and that key must
5
+ * never be taken from the node being logged in to on its word — the answering
6
+ * party would choose what the device signs about. A TEE quote is different: the
7
+ * node cannot put a key into it that the TEE did not bind. With `bindNodeKey`,
8
+ * core commits the node's signing key into the quote's report data:
9
+ *
10
+ * report_data = nonce (32) ‖ SHA-256("calimero.tee-attest.key-binding.v1" ‖ 0^32 ‖ key)
11
+ *
12
+ * (the 32 zero bytes stand for "no application", core's `attest_key_binding`).
13
+ * So the client sends a fresh nonce, reads the report data out of the RAW quote
14
+ * bytes — never the server's parsed rendering of them — and accepts the returned
15
+ * key only if both halves match.
16
+ *
17
+ * That proves the key came from whatever produced the quote. Whether that is a
18
+ * genuine TDX guest running a trusted image is the quote's signature and
19
+ * measurements, which this module does not judge: a real quote needs a
20
+ * `verifyQuote` supplied by the caller, and a mock quote (a dev-only format with
21
+ * no signature at all) is refused unless `allowMock` is set.
22
+ */
23
+ import type { VerifyTransportQuote } from '../sealed/sealed.js';
24
+ export interface AttestedNodeKey {
25
+ /** The relay's node signing key, hex (32 bytes), as the quote binds it. */
26
+ readonly nodeKey: string;
27
+ /** True when the quote was a mock — no TEE vouched for anything. */
28
+ readonly mock: boolean;
29
+ }
30
+ export interface AttestRelayNodeKeyOptions {
31
+ readonly relayUrl: string;
32
+ /**
33
+ * Accept a mock quote. DEV/TEST ONLY: a mock quote carries no signature, so it
34
+ * proves nothing about the hardware — only that the node bound this key into
35
+ * something it produced for our nonce.
36
+ */
37
+ readonly allowMock?: boolean;
38
+ /**
39
+ * Verify a real quote's signature chain and measurements; throw to refuse.
40
+ * Required for a non-mock quote: without it the binding would be checked
41
+ * against bytes nothing has authenticated.
42
+ */
43
+ readonly verifyQuote?: (quote: Uint8Array) => Promise<void>;
44
+ /**
45
+ * Verify a real quote the way a sealed transport does: a
46
+ * {@link VerifyTransportQuote}, such as `createSignedReleaseVerifier` (the
47
+ * relay's signed mero-tee release, Intel's chain, every register of its
48
+ * image). Called with our nonce and the node-key binding as the expected
49
+ * report data, and with the relay's collateral when it asks for it. Takes
50
+ * precedence over `verifyQuote`.
51
+ */
52
+ readonly verify?: VerifyTransportQuote;
53
+ readonly fetch?: typeof fetch;
54
+ }
55
+ /** Core's `attest_key_binding(None, key)`. */
56
+ export declare function attestKeyBinding(nodeKey: Uint8Array): Promise<Uint8Array>;
57
+ /** The 64 report-data bytes of a quote, read from the quote itself. */
58
+ export declare function reportDataOf(quote: Uint8Array): {
59
+ reportData: Uint8Array;
60
+ mock: boolean;
61
+ };
62
+ /**
63
+ * Ask a relay for a quote binding its node key to a fresh nonce, check the
64
+ * binding, and return the key.
65
+ */
66
+ export declare function attestRelayNodeKey(opts: AttestRelayNodeKeyOptions): Promise<AttestedNodeKey>;
@@ -0,0 +1,110 @@
1
+ import { hex } from '../crypto/internal.js';
2
+ /** Core's `ATTEST_KEY_BINDING_DOMAIN`. */
3
+ const KEY_BINDING_DOMAIN = new TextEncoder().encode('calimero.tee-attest.key-binding.v1');
4
+ /** Core's `MOCK_QUOTE_HEADER`: a mock quote is this, then the 64-byte report data. */
5
+ const MOCK_QUOTE_HEADER = new TextEncoder().encode('MOCK_TDX_QUOTE_V1');
6
+ /** A TDX v4 quote: 48-byte header, then the TD report body; report data at body offset 520. */
7
+ const TDX_REPORT_DATA_OFFSET = 48 + 520;
8
+ function concat(...parts) {
9
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
10
+ let at = 0;
11
+ for (const p of parts) {
12
+ out.set(p, at);
13
+ at += p.length;
14
+ }
15
+ return out;
16
+ }
17
+ function equal(a, b) {
18
+ if (a.length !== b.length)
19
+ return false;
20
+ let diff = 0;
21
+ for (let i = 0; i < a.length; i += 1)
22
+ diff |= a[i] ^ b[i];
23
+ return diff === 0;
24
+ }
25
+ function fromBase64(b64) {
26
+ const bin = atob(b64);
27
+ const out = new Uint8Array(bin.length);
28
+ for (let i = 0; i < bin.length; i += 1)
29
+ out[i] = bin.charCodeAt(i);
30
+ return out;
31
+ }
32
+ function fromHex32(value, label) {
33
+ if (typeof value !== 'string' || !/^[0-9a-fA-F]{64}$/.test(value)) {
34
+ throw new Error(`${label} must be 64 hex characters`);
35
+ }
36
+ const out = new Uint8Array(32);
37
+ for (let i = 0; i < 32; i += 1)
38
+ out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
39
+ return out;
40
+ }
41
+ /** Core's `attest_key_binding(None, key)`. */
42
+ export async function attestKeyBinding(nodeKey) {
43
+ const digest = await crypto.subtle.digest('SHA-256', concat(KEY_BINDING_DOMAIN, new Uint8Array(32), nodeKey));
44
+ return new Uint8Array(digest);
45
+ }
46
+ /** The 64 report-data bytes of a quote, read from the quote itself. */
47
+ export function reportDataOf(quote) {
48
+ const mock = quote.length >= MOCK_QUOTE_HEADER.length &&
49
+ equal(quote.subarray(0, MOCK_QUOTE_HEADER.length), MOCK_QUOTE_HEADER);
50
+ const at = mock ? MOCK_QUOTE_HEADER.length : TDX_REPORT_DATA_OFFSET;
51
+ if (quote.length < at + 64) {
52
+ throw new Error(`the quote is too short to carry report data (${quote.length} bytes)`);
53
+ }
54
+ return { reportData: quote.slice(at, at + 64), mock };
55
+ }
56
+ /**
57
+ * Ask a relay for a quote binding its node key to a fresh nonce, check the
58
+ * binding, and return the key.
59
+ */
60
+ export async function attestRelayNodeKey(opts) {
61
+ const nonce = crypto.getRandomValues(new Uint8Array(32));
62
+ const url = `${opts.relayUrl.replace(/\/+$/, '')}/admin-api/tee/attest`;
63
+ const body = JSON.stringify({
64
+ nonce: hex(nonce),
65
+ bindNodeKey: true,
66
+ ...(opts.verify?.includeCollateral ? { includeCollateral: true } : {}),
67
+ });
68
+ const init = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body };
69
+ const response = opts.fetch ? await opts.fetch(url, init) : await globalThis.fetch(url, init);
70
+ if (!response.ok) {
71
+ const text = await response.text().catch(() => '');
72
+ throw new Error(`the relay would not attest (HTTP ${response.status})${text ? `: ${text}` : ''}`);
73
+ }
74
+ const parsed = (await response.json());
75
+ const data = parsed.data ?? parsed;
76
+ if (typeof data.quoteB64 !== 'string')
77
+ throw new Error('the relay returned no quote');
78
+ const nodeKey = fromHex32(data.boundPublicKey, 'boundPublicKey');
79
+ const quote = fromBase64(data.quoteB64);
80
+ const { reportData, mock } = reportDataOf(quote);
81
+ if (mock) {
82
+ if (!opts.allowMock) {
83
+ throw new Error('the relay answered with a MOCK quote, which proves nothing about the hardware');
84
+ }
85
+ }
86
+ else if (opts.verify) {
87
+ const accepted = await opts.verify({
88
+ quoteB64: data.quoteB64,
89
+ nonce: hex(nonce),
90
+ reportDataSuffix: hex(await attestKeyBinding(nodeKey)),
91
+ ...(data.collateral ? { collateral: data.collateral } : {}),
92
+ });
93
+ if (!accepted) {
94
+ throw new Error("the relay's quote did not verify: not a trusted image, or not bound to this request");
95
+ }
96
+ }
97
+ else if (opts.verifyQuote) {
98
+ await opts.verifyQuote(quote);
99
+ }
100
+ else {
101
+ throw new Error('a real TDX quote needs a verify (or verifyQuote) to check its signature and measurements');
102
+ }
103
+ if (!equal(reportData.subarray(0, 32), nonce)) {
104
+ throw new Error('the quote does not carry our nonce: it was not made for this request');
105
+ }
106
+ if (!equal(reportData.subarray(32, 64), await attestKeyBinding(nodeKey))) {
107
+ throw new Error('the quote does not bind the key the relay named: refusing it');
108
+ }
109
+ return { nodeKey: hex(nodeKey), mock };
110
+ }
@@ -0,0 +1 @@
1
+ export { attestRelayNodeKey, attestKeyBinding, reportDataOf, type AttestedNodeKey, type AttestRelayNodeKeyOptions, } from './attest-node-key.js';
@@ -0,0 +1 @@
1
+ export { attestRelayNodeKey, attestKeyBinding, reportDataOf, } from './attest-node-key.js';
@@ -0,0 +1,48 @@
1
+ import type { Signer } from '../signer/index.js';
2
+ /** What a keyholder holds: a certificate, and the key it certifies. */
3
+ export interface ProofCredential {
4
+ /** The `AccountProof<DeviceCert>` from `signDeviceCert`, hex. */
5
+ credential: string;
6
+ /** The certified device's ed25519 secret, hex. Or pass `signer`. */
7
+ deviceSecret?: string;
8
+ /** A signer, for a key that cannot be exported to hex. */
9
+ signer?: Signer;
10
+ /** Seconds each signature stays valid. Defaults to `signRequestProof`'s. */
11
+ validForSeconds?: number;
12
+ }
13
+ /** One request, as the proof has to see it. */
14
+ export interface RequestToAuthorize {
15
+ method: string;
16
+ /** Path only — a query string is excluded from the signature by design. */
17
+ path: string;
18
+ /** The body exactly as it will be sent, if there is one. */
19
+ body?: string | Uint8Array;
20
+ }
21
+ /**
22
+ * A per-request authorizer. Returns the headers to merge into the request.
23
+ *
24
+ * Returning headers rather than setting them keeps the caller in charge of the
25
+ * request: a transport that already has an `Authorization` header can decide
26
+ * which wins, and core decides the same way — a request carrying a token is
27
+ * answered by that token, and the proof is only consulted when there is none.
28
+ */
29
+ export type RequestAuthorizer = (request: RequestToAuthorize) => Promise<Record<string, string>>;
30
+ /**
31
+ * Turn a device cert into a {@link RequestAuthorizer}.
32
+ *
33
+ * The credential is validated once, here, rather than on the first request:
34
+ * a malformed one otherwise surfaces as a 401 from whichever read happened to
35
+ * run first, which reads as "the node refused me" and sends the caller looking
36
+ * in the wrong place.
37
+ */
38
+ export declare function createProofAuthorizer(credential: ProofCredential): RequestAuthorizer;
39
+ /**
40
+ * The path a proof must be signed over, taken from a URL.
41
+ *
42
+ * Callers hold a base URL and a relative path; the proof commits to the path the
43
+ * node will see, which is their join with the query dropped. Deriving it from
44
+ * the assembled URL rather than concatenating strings is what keeps a base URL
45
+ * carrying its own prefix (`…/admin-api`) from signing a path the node never
46
+ * receives.
47
+ */
48
+ export declare function signedPath(url: string): string;