@calimero-network/mero-js 24.6.0 → 24.7.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.
@@ -13,7 +13,7 @@
13
13
  import { AccountHaRefusedError, AccountNotLinkedError, CloudClient, } from '../cloud/index.js';
14
14
  import { memberAddedOp, subgroupCreation, } from '../warrant/index.js';
15
15
  import { RelayClient, } from '../relay/index.js';
16
- import { attemptRelayNodeKey, knownRelays, markContextNonceSpent, readRelayMap, rememberRelay, } from './session.js';
16
+ import { attemptRelayNodeKey, knownRelays, markContextNonceSpent, readRelayMap, relayTransportFetch, rememberRelay, } from './session.js';
17
17
  import { fetchRegistryBundles, selectLatestBundle } from './application-id.js';
18
18
  /**
19
19
  * A governance warrant's nonce, for one (relay, group): spent in a sliding
@@ -55,7 +55,11 @@ function initArgsOf(params) {
55
55
  return {};
56
56
  return JSON.parse(new TextDecoder().decode(Uint8Array.from(params)));
57
57
  }
58
- /** A relay client for these calls: the author's credential, whatever nonces the call needs. */
58
+ /**
59
+ * A relay client for these calls: the author's credential, whatever nonces the
60
+ * call needs, and the relay's sealed transport unless the caller brings its own
61
+ * `fetch` (see `relayTransportFetch`).
62
+ */
59
63
  function client(s, relayUrl, nonces, fetch) {
60
64
  return new RelayClient({
61
65
  relayUrl,
@@ -63,7 +67,7 @@ function client(s, relayUrl, nonces, fetch) {
63
67
  authorProof: s.credential,
64
68
  deviceSecret: s.deviceSecret,
65
69
  nonces,
66
- fetch,
70
+ fetch: fetch ?? relayTransportFetch(relayUrl),
67
71
  });
68
72
  }
69
73
  export async function createDelegatedContext(s, req, deps = {}) {
@@ -176,14 +180,21 @@ async function foundingExecutor(s, relay, fetch) {
176
180
  }
177
181
  return { executorAccount: described.executorAccount, executorKey };
178
182
  }
179
- /** The relay's pinned or attested node key; any failed attestation throws. */
183
+ /**
184
+ * The relay's node key, attested now; any failed attestation throws.
185
+ *
186
+ * Never the pin alone: the relay founded through becomes the namespace's first
187
+ * TEE and holds its keys, so it is checked against the signed release on the
188
+ * day it is trusted with them, not on whichever day its key was pinned. A key
189
+ * pinned by hand therefore no longer stands in for an attestation here.
190
+ */
180
191
  async function attestedNodeKey(relay) {
181
- const key = await attemptRelayNodeKey(relay);
192
+ const key = await attemptRelayNodeKey(relay, { fresh: true });
182
193
  if (key.kind === 'learned')
183
194
  return key.nodeKey;
184
195
  throw new Error(key.kind === 'unavailable'
185
196
  ? "the relay's signing key could not be learned: its attestation did not answer, try again"
186
- : "the relay's attestation was refused, so founding through it is not possible (only a key from its operator, pinned with pinRelayNodeKey, can stand in)");
197
+ : "the relay's attestation was refused, so founding through it is not possible: it is not a signed locked-read-only release");
187
198
  }
188
199
  /** What an app can tell a person whose account the cloud cannot place. */
189
200
  export const HA_ACCOUNT_NOT_LINKED_MESSAGE = 'link this account to your cloud user in the wallet so invitees can find this namespace';
@@ -5,7 +5,7 @@ export type { RootSource } from './account.js';
5
5
  export { resolveRootPair } from './account.js';
6
6
  export type { ResolvedRoot } from './account.js';
7
7
  export type { DelegatedCredential, DelegatedSession as DelegatedAccountSession, RelayMap, RelayNodeKeyAttempt } from './session.js';
8
- export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
8
+ export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, forgetRelaySealing, relayTransportFetch, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
9
9
  export { resolveRelayFromInvitation, normaliseAccount } from './relay-from-invitation.js';
10
10
  export type { RelayResolutionStep, RelayResolutionFailure, ResolvedInvitationRelay, ResolveRelayResult, ResolveRelayFromInvitationInput, } from './relay-from-invitation.js';
11
11
  export { joinWithNode } from './join-with-node.js';
@@ -2,7 +2,7 @@
2
2
  export { generateAccountRoot, accountRootFromPhrase, accountRootFromSecret, createAccountRootSigner, accountRootSignerFromPhrase, signAccountLink, signAccountLogin, } from './account.js';
3
3
  export { resolveRoot } from './account.js';
4
4
  export { resolveRootPair } from './account.js';
5
- export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
5
+ export { readDelegatedSession, saveDelegatedSession, clearDelegatedSession, readDelegatedCredential, saveDelegatedCredential, clearDelegatedCredential, persistedNonces, markContextNonceSpent, pinRelayNodeKey, readPinnedRelayNodeKey, attemptRelayNodeKey, resolveRelayNodeKey, learnRelayNodeKey, RELAY_NODE_KEY_RETRY_MS, forgetMethodKinds, forgetRelaySealing, relayTransportFetch, buildDelegatedClient, readRelayMap, rememberRelay, relayForContext, carryExecutorAccount, knownRelays, listDelegatedContexts, listDelegatedNamespaces, } from './session.js';
6
6
  export { resolveRelayFromInvitation, normaliseAccount } from './relay-from-invitation.js';
7
7
  export { joinWithNode } from './join-with-node.js';
8
8
  export { bootstrapFromInvitation } from './bootstrap-from-invitation.js';
@@ -124,7 +124,21 @@ export type RelayNodeKeyAttempt = {
124
124
  kind: 'refused';
125
125
  error: unknown;
126
126
  };
127
- export declare function attemptRelayNodeKey(relayUrl: string): Promise<RelayNodeKeyAttempt>;
127
+ /**
128
+ * The relay's node key: the pinned one, or else learned from the relay's TEE
129
+ * attestation (see mero-js `attestRelayNodeKey`) and pinned for next time.
130
+ *
131
+ * A mock quote proves nothing about hardware, so one is accepted only from a
132
+ * loopback relay — a local rig. A real one must be a signed release of the
133
+ * trusted profile ({@link relayQuoteVerifier}).
134
+ *
135
+ * `fresh` skips the pin and attests now, for a step that hands the relay
136
+ * something it keeps — founding a namespace through it. A pinned key was
137
+ * checked once, on some earlier day, against whatever answered then.
138
+ */
139
+ export declare function attemptRelayNodeKey(relayUrl: string, options?: {
140
+ fresh?: boolean;
141
+ }): Promise<RelayNodeKeyAttempt>;
128
142
  /**
129
143
  * The relay's node key: the pinned one, or else learned from the relay's TEE
130
144
  * attestation in one attempt; `null` when that attempt did not learn it, for
@@ -151,6 +165,14 @@ export declare const RELAY_NODE_KEY_RETRY_MS: readonly number[];
151
165
  export declare function learnRelayNodeKey(relayUrl: string, options?: {
152
166
  signal?: AbortSignal;
153
167
  }): Promise<string | null>;
168
+ /** Forget every relay's sealed transport. For tests. */
169
+ export declare function forgetRelaySealing(): void;
170
+ /**
171
+ * The `fetch` a delegated session uses for `relayUrl`: sealed to its attested
172
+ * TD, one per relay so its session is shared. `undefined` for a loopback relay,
173
+ * a dev rig answering mock quotes, which keeps the global `fetch`.
174
+ */
175
+ export declare function relayTransportFetch(relayUrl: string): typeof fetch | undefined;
154
176
  /** Forget what was learned about which methods read. For tests. */
155
177
  export declare function forgetMethodKinds(): void;
156
178
  /**
@@ -14,7 +14,7 @@
14
14
  * the same application code in both.
15
15
  */
16
16
  import { attestRelayNodeKey, } from '../relay-attestation/index.js';
17
- import { cloudNodeReleaseUrl, createSignedReleaseVerifier, DEFAULT_RELEASE_MIRROR, fetchNodeRelease, fetchNodeReleaseVersion, } from '../sealed/index.js';
17
+ import { cloudNodeReleaseUrl, createAttestedSealedFetch, createSignedReleaseVerifier, DEFAULT_RELEASE_MIRROR, fetchNodeRelease, fetchNodeReleaseVersion, } from '../sealed/index.js';
18
18
  import { createMeroClient, defaultAudience, } from '../transport/index.js';
19
19
  import { login, } from '../login/index.js';
20
20
  import { SseClient, } from '../events/index.js';
@@ -222,15 +222,6 @@ function isLoopback(relayUrl) {
222
222
  return false;
223
223
  }
224
224
  }
225
- /**
226
- * The relay's node key: the pinned one, or else learned from the relay's TEE
227
- * attestation (see mero-js `attestRelayNodeKey`) and pinned for next time.
228
- *
229
- * A mock quote proves nothing about hardware, so one is accepted only from a
230
- * loopback relay — a local rig. A real quote needs signature and measurement
231
- * verification this client does not do yet, so a real relay still needs its
232
- * key pinned; `null` then, and admin reads and events stay off.
233
- */
234
225
  /** The one relay image trusted: no shell, so nobody reads the TD. */
235
226
  const TRUSTED_RELAY_PROFILE = 'locked-read-only';
236
227
  /**
@@ -342,8 +333,20 @@ function relayQuoteVerifier(relayUrl, fetchRelay) {
342
333
  };
343
334
  return Object.assign(verify, { includeCollateral: true });
344
335
  }
345
- export async function attemptRelayNodeKey(relayUrl) {
346
- const pinned = readPinnedRelayNodeKey(relayUrl);
336
+ /**
337
+ * The relay's node key: the pinned one, or else learned from the relay's TEE
338
+ * attestation (see mero-js `attestRelayNodeKey`) and pinned for next time.
339
+ *
340
+ * A mock quote proves nothing about hardware, so one is accepted only from a
341
+ * loopback relay — a local rig. A real one must be a signed release of the
342
+ * trusted profile ({@link relayQuoteVerifier}).
343
+ *
344
+ * `fresh` skips the pin and attests now, for a step that hands the relay
345
+ * something it keeps — founding a namespace through it. A pinned key was
346
+ * checked once, on some earlier day, against whatever answered then.
347
+ */
348
+ export async function attemptRelayNodeKey(relayUrl, options = {}) {
349
+ const pinned = options.fresh ? null : readPinnedRelayNodeKey(relayUrl);
347
350
  if (pinned)
348
351
  return { kind: 'learned', nodeKey: pinned };
349
352
  const marked = { failure: null };
@@ -435,6 +438,82 @@ function jwtExpiryMs(token) {
435
438
  return null;
436
439
  }
437
440
  }
441
+ // ------------------------------------------------------------------- sealing
442
+ //
443
+ // Everything a delegated session sends a hosted relay is sealed to the relay's
444
+ // attested TD (mero-js `createAttestedSealedFetch`), so TLS that ends at the
445
+ // relay's ingress, and whoever holds its certificate, reads none of it: not the
446
+ // login, not the token, not a query or its result, not an event.
447
+ //
448
+ // A relay whose node leaves auth to its proxy, and has no
449
+ // `[server.sealed] forward_auth`, cannot guard a logged-in route inside the
450
+ // envelope and answers `403 sealed_route_unguarded` from inside it. Only then
451
+ // does a request go out plain, as it always did before sealing: that answer is
452
+ // sealed under the session, so nobody on the way can forge it to force the
453
+ // downgrade. Anything else that goes wrong, an attestation refused or a relay
454
+ // that does not answer, fails the request; it never falls back.
455
+ const sealedFetches = new Map();
456
+ /** Forget every relay's sealed transport. For tests. */
457
+ export function forgetRelaySealing() {
458
+ sealedFetches.clear();
459
+ }
460
+ /** Whether a sealed answer is the TD saying it cannot guard this route sealed. */
461
+ async function isUnguardedRefusal(response) {
462
+ if (response.status !== 403)
463
+ return false;
464
+ try {
465
+ const body = (await response.clone().json());
466
+ return body.error?.code === 'sealed_route_unguarded';
467
+ }
468
+ catch {
469
+ return false;
470
+ }
471
+ }
472
+ /** Whether `request` needs a session the relay's proxy checks: a token, or a login. */
473
+ function needsSession(request) {
474
+ return request.headers.has('authorization') || new URL(request.url).pathname.startsWith('/auth/');
475
+ }
476
+ function sealedRelayFetch(relayUrl) {
477
+ const plain = (input, init) => globalThis.fetch(input, init);
478
+ const sealed = createAttestedSealedFetch({
479
+ baseUrl: relayUrl,
480
+ verify: relayQuoteVerifier(relayUrl, plain),
481
+ fetch: plain,
482
+ });
483
+ // Set once the TD has said it cannot guard a logged-in call: those go plain
484
+ // straight away instead of paying a refused round trip each.
485
+ let unguarded = false;
486
+ return async (input, init) => {
487
+ const request = new Request(input, init);
488
+ if (unguarded && needsSession(request))
489
+ return plain(request);
490
+ const response = await sealed(request.clone());
491
+ if (!(await isUnguardedRefusal(response)))
492
+ return response;
493
+ if (!unguarded) {
494
+ unguarded = true;
495
+ console.warn(`[mero-js] ${relayOrigin(relayUrl)} cannot guard logged-in calls sealed (no forward_auth); ` +
496
+ 'they go over its TLS until it does');
497
+ }
498
+ return plain(request);
499
+ };
500
+ }
501
+ /**
502
+ * The `fetch` a delegated session uses for `relayUrl`: sealed to its attested
503
+ * TD, one per relay so its session is shared. `undefined` for a loopback relay,
504
+ * a dev rig answering mock quotes, which keeps the global `fetch`.
505
+ */
506
+ export function relayTransportFetch(relayUrl) {
507
+ if (isLoopback(relayUrl))
508
+ return undefined;
509
+ const origin = relayOrigin(relayUrl);
510
+ let found = sealedFetches.get(origin);
511
+ if (!found) {
512
+ found = sealedRelayFetch(relayUrl);
513
+ sealedFetches.set(origin, found);
514
+ }
515
+ return found;
516
+ }
438
517
  /**
439
518
  * A lazily minted `account_proof` session on the relay.
440
519
  *
@@ -455,6 +534,8 @@ function relaySession(s, nodeKey) {
455
534
  if (!inflight) {
456
535
  inflight = login({
457
536
  nodeUrl: s.relayUrl,
537
+ // Sealed: the token is the session, and the TLS ends outside the TD.
538
+ fetch: relayTransportFetch(s.relayUrl),
458
539
  node: nodeKey,
459
540
  deviceSecret: s.deviceSecret,
460
541
  accountProof: s.credential,
@@ -610,6 +691,9 @@ _contextId) {
610
691
  const nodeKey = readPinnedRelayNodeKey(s.relayUrl);
611
692
  const client = createMeroClient({
612
693
  transport: 'relay',
694
+ // Every request sealed to the relay's attested TD: intents, admin reads,
695
+ // queries, the login and events (see `relayTransportFetch`).
696
+ fetch: relayTransportFetch(s.relayUrl),
613
697
  relay: {
614
698
  relayUrl: s.relayUrl,
615
699
  authorAccount: s.account,
@@ -1896,6 +1896,8 @@ export interface AdminApiClientConfig {
1896
1896
  baseUrl: string;
1897
1897
  getAuthToken?: () => Promise<string | undefined>;
1898
1898
  timeoutMs?: number;
1899
+ /** Fetch every request goes through. Defaults to global `fetch`. */
1900
+ fetch?: typeof fetch;
1899
1901
  }
1900
1902
  /** A join signed by its subject, handed to an admitter to publish. */
1901
1903
  export interface AdmitJoinRequest {