@calimero-network/mero-js 19.7.2 → 19.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.
@@ -24,6 +24,7 @@
24
24
  * ever sent, and would be a throughput bottleneck on a path that has no reason
25
25
  * to involve it.
26
26
  */
27
+ import { type RoutingChallenge, type RoutingCredential } from './routing-proof.js';
27
28
  export interface CloudClientConfig {
28
29
  cloudBaseUrl?: string;
29
30
  /**
@@ -45,6 +46,20 @@ export interface CloudClientConfig {
45
46
  fetch?: typeof fetch;
46
47
  /** Per-request timeout. Defaults to 10s, as the node clients do. */
47
48
  timeoutMs?: number;
49
+ /**
50
+ * The device credential to prove an account with on the routing read.
51
+ *
52
+ * Nothing to do with {@link sessionToken}: a cloud session says who is signed
53
+ * in, this says which account holds the key. A joiner has the second and
54
+ * cannot have the first — see `routing-proof.ts` for why that is not an
55
+ * oversight.
56
+ *
57
+ * Omitting it leaves {@link CloudClient.getNamespaceRouting} anonymous, which
58
+ * the cloud still answers while `require_routing_proof` is off. Supply it and
59
+ * the read is attributed; the cloud verifies a supplied proof either way, so
60
+ * a wrong one surfaces immediately rather than on the day the flag flips.
61
+ */
62
+ routingCredential?: RoutingCredential;
48
63
  }
49
64
  /** Who is signed in, and until when. */
50
65
  export interface CloudSession {
@@ -252,6 +267,7 @@ export declare class CloudClient {
252
267
  private readonly onSession?;
253
268
  private readonly fetchImpl;
254
269
  private readonly timeoutMs;
270
+ private readonly routingCredential?;
255
271
  constructor(config?: CloudClientConfig);
256
272
  /** The current session, or `null` when nobody is signed in. */
257
273
  getSession(): CloudSession | null;
@@ -323,6 +339,28 @@ export declare class CloudClient {
323
339
  * invitation's other admitters — another admin's node, or a self-hosted peer.
324
340
  */
325
341
  getNamespaceRouting(namespaceId: string): Promise<CloudNamespaceRouting>;
342
+ /**
343
+ * Ask the cloud for a challenge to prove an account against.
344
+ *
345
+ * Public and unauthenticated on purpose, and safe to be: the nonce is sealed
346
+ * and bound to this one namespace, so it is not a capability — it is the
347
+ * thing a capability gets demonstrated against. A caller with no credential
348
+ * gains nothing by holding one.
349
+ *
350
+ * {@link getNamespaceRouting} calls this for you when the client was
351
+ * constructed with a `routingCredential`. Call it directly only to sign a
352
+ * challenge somewhere this client cannot reach the key.
353
+ */
354
+ getRoutingChallenge(namespaceId: string): Promise<RoutingChallenge>;
355
+ /**
356
+ * The proof headers for one routing read, or nothing when no credential was
357
+ * configured.
358
+ *
359
+ * A challenge per read rather than a cached one: they expire in about two
360
+ * minutes and are single-namespace, so caching would trade one cheap request
361
+ * for a class of expiry bug that only shows up on a slow client.
362
+ */
363
+ private routingProof;
326
364
  /**
327
365
  * The node to send a signed join to, chosen from those an invitation names.
328
366
  *
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { accountRootFromSecret, signAccountLink, } from '../account/index.js';
28
28
  import { HTTPError } from '../http-client/web-client.js';
29
+ import { routingProofHeaders, } from './routing-proof.js';
29
30
  /** Where the hosted cloud lives when a caller names no other. */
30
31
  const DEFAULT_CLOUD_BASE_URL = 'https://cloud.calimero.network';
31
32
  /** Response headers MDMA uses to hand back a rolling-refreshed session. */
@@ -44,6 +45,7 @@ export class CloudClient {
44
45
  ? (input, init) => injected(input, init)
45
46
  : (input, init) => globalThis.fetch(input, init);
46
47
  this.timeoutMs = config.timeoutMs ?? 10000;
48
+ this.routingCredential = config.routingCredential;
47
49
  if (config.sessionToken) {
48
50
  // Expiry unknown until the server tells us; 0 means "do not reason about
49
51
  // it locally", never "expired" — the server is the only judge and it
@@ -171,6 +173,7 @@ export class CloudClient {
171
173
  async getNamespaceRouting(namespaceId) {
172
174
  const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/admitters`, {
173
175
  anonymous: true,
176
+ headers: await this.routingProof(namespaceId),
174
177
  });
175
178
  return {
176
179
  namespaceId: String(body.namespace_id ?? namespaceId),
@@ -189,6 +192,42 @@ export class CloudClient {
189
192
  writable: body.writable === true,
190
193
  };
191
194
  }
195
+ /**
196
+ * Ask the cloud for a challenge to prove an account against.
197
+ *
198
+ * Public and unauthenticated on purpose, and safe to be: the nonce is sealed
199
+ * and bound to this one namespace, so it is not a capability — it is the
200
+ * thing a capability gets demonstrated against. A caller with no credential
201
+ * gains nothing by holding one.
202
+ *
203
+ * {@link getNamespaceRouting} calls this for you when the client was
204
+ * constructed with a `routingCredential`. Call it directly only to sign a
205
+ * challenge somewhere this client cannot reach the key.
206
+ */
207
+ async getRoutingChallenge(namespaceId) {
208
+ const body = await this.request('GET', `/api/cloud/namespaces/${encodeURIComponent(namespaceId)}/challenge`, {
209
+ anonymous: true,
210
+ });
211
+ return {
212
+ namespaceId: String(body.namespace_id ?? namespaceId),
213
+ nonce: String(body.nonce ?? ''),
214
+ expiresAtMs: Number(body.expires_at_ms ?? 0),
215
+ };
216
+ }
217
+ /**
218
+ * The proof headers for one routing read, or nothing when no credential was
219
+ * configured.
220
+ *
221
+ * A challenge per read rather than a cached one: they expire in about two
222
+ * minutes and are single-namespace, so caching would trade one cheap request
223
+ * for a class of expiry bug that only shows up on a slow client.
224
+ */
225
+ async routingProof(namespaceId) {
226
+ if (!this.routingCredential)
227
+ return undefined;
228
+ const challenge = await this.getRoutingChallenge(namespaceId);
229
+ return routingProofHeaders(challenge, this.routingCredential);
230
+ }
192
231
  /**
193
232
  * The node to send a signed join to, chosen from those an invitation names.
194
233
  *
@@ -423,7 +462,13 @@ export class CloudClient {
423
462
  if (!options.anonymous && !this.session) {
424
463
  throw new Error('Not signed in to Calimero Cloud: call signInWithGoogle() or construct the client with a sessionToken');
425
464
  }
426
- const headers = { Accept: 'application/json' };
465
+ // Caller headers go in FIRST, so the lines below win on any collision: a
466
+ // per-call header must never be able to replace the session's
467
+ // `Authorization` or change how the body is parsed.
468
+ const headers = {
469
+ ...options.headers,
470
+ Accept: 'application/json',
471
+ };
427
472
  if (options.body !== undefined)
428
473
  headers['Content-Type'] = 'application/json';
429
474
  if (!options.anonymous && this.session) {
@@ -2,3 +2,5 @@ export { CloudClient } from './cloud-client.js';
2
2
  export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
3
  export { connectCloud } from './connect.js';
4
4
  export type { ConnectCloudOptions, CloudConnection } from './connect.js';
5
+ export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
6
+ export type { RoutingCredential, RoutingChallenge, RoutingProofHeaders, } from './routing-proof.js';
@@ -1,2 +1,3 @@
1
1
  export { CloudClient } from './cloud-client.js';
2
2
  export { connectCloud } from './connect.js';
3
+ export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Proving an account to the cloud for the one read a joiner cannot avoid.
3
+ *
4
+ * `GET /api/cloud/namespaces/{ns}/admitters` answers where to send a signed
5
+ * membership op. A joiner needs it and *cannot* hold a cloud login: it is by
6
+ * construction not the namespace owner, and not needing an account on the cloud
7
+ * is the entire point of the path — a keyholder mints a root, certifies a
8
+ * device and signs its own join offline, and this read is the only thing that
9
+ * tells it where to post the result.
10
+ *
11
+ * So the caller proves possession of the credential it already holds. The cloud
12
+ * mints a sealed, namespace-bound nonce; the device signs it; the read carries
13
+ * the certificate and that signature.
14
+ *
15
+ * ## What this establishes, and what it does not
16
+ *
17
+ * It establishes that the caller holds a device key certified by some account
18
+ * root, and *which* account that is. It does **not** establish that the account
19
+ * was invited to the namespace, nor that it is a member — the cloud cannot
20
+ * check either, because membership is governance state held by the nodes.
21
+ * Anyone can mint an account root offline, so this is not a wall. It buys
22
+ * attribution (every read names an account and can be rate-limited) and it ends
23
+ * anonymous bulk discovery. Authorization stays where it always was: at the
24
+ * node, on the signed op.
25
+ *
26
+ * ## Why the DEVICE signs, not the root
27
+ *
28
+ * The opposite choice from {@link signAccountLink}, deliberately. That one
29
+ * exists for the device-loss case, so only the root will do. This one runs on
30
+ * every routing read from a browser that discards the root immediately after
31
+ * certifying its device — requiring the root would mean re-entering a 24-word
32
+ * phrase to look up a URL.
33
+ *
34
+ * The certificate alone is not enough and the signature is not redundant with
35
+ * it: a certificate travels in the clear inside every device-link op, so anyone
36
+ * who has seen one can replay it. Only the nonce signature binds the presenter
37
+ * to the device.
38
+ */
39
+ /**
40
+ * The credential a routing read presents: a certificate, and the key to sign
41
+ * the challenge with.
42
+ *
43
+ * `credential` is the 237-byte `AccountProof<DeviceCert>` as hex — exactly what
44
+ * {@link signDeviceCert} returns, and the same string a login statement and a
45
+ * warrant carry. It is public by construction.
46
+ *
47
+ * `deviceSecret` never leaves the process. It signs the challenge locally and
48
+ * only the signature travels.
49
+ */
50
+ export interface RoutingCredential {
51
+ /** The `AccountProof<DeviceCert>`, hex borsh, as `signDeviceCert` returns it. */
52
+ credential: string;
53
+ /** The certified device's ed25519 signing secret, hex (32 bytes). */
54
+ deviceSecret: string;
55
+ }
56
+ /** A challenge to sign, as the cloud minted it. */
57
+ export interface RoutingChallenge {
58
+ namespaceId: string;
59
+ /** Opaque and sealed — the client neither parses nor constructs this. */
60
+ nonce: string;
61
+ /** Epoch **milliseconds**, as MDMA reports it. Nonces live about two minutes. */
62
+ expiresAtMs: number;
63
+ }
64
+ /**
65
+ * The three header values a proven routing read carries.
66
+ *
67
+ * Intersected with `Record<string, string>` rather than declared as a plain
68
+ * interface so it drops straight into a headers bag: an interface has no index
69
+ * signature, so TypeScript refuses it where `Record<string, string>` is wanted
70
+ * even though every field is a string.
71
+ */
72
+ export type RoutingProofHeaders = Record<string, string> & {
73
+ 'X-Calimero-Credential': string;
74
+ 'X-Calimero-Nonce': string;
75
+ 'X-Calimero-Signature': string;
76
+ };
77
+ /**
78
+ * Sign a routing challenge with the device key.
79
+ *
80
+ * Returns base64, which is what the header carries and what MDMA's verifier
81
+ * decodes — the one place this SDK does not spell a signature as hex, because
82
+ * the wire format is not ours to choose.
83
+ */
84
+ export declare function signRoutingChallenge(nonce: string, deviceSecret: string): Promise<string>;
85
+ /**
86
+ * Build the headers for one routing read, from a challenge the cloud minted.
87
+ *
88
+ * Separated from the request so a caller that fetches its own challenge — a
89
+ * test, or a client batching several reads against one nonce — can use the same
90
+ * encoding rather than a second spelling of it.
91
+ */
92
+ export declare function routingProofHeaders(challenge: RoutingChallenge, credential: RoutingCredential): Promise<RoutingProofHeaders>;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Proving an account to the cloud for the one read a joiner cannot avoid.
3
+ *
4
+ * `GET /api/cloud/namespaces/{ns}/admitters` answers where to send a signed
5
+ * membership op. A joiner needs it and *cannot* hold a cloud login: it is by
6
+ * construction not the namespace owner, and not needing an account on the cloud
7
+ * is the entire point of the path — a keyholder mints a root, certifies a
8
+ * device and signs its own join offline, and this read is the only thing that
9
+ * tells it where to post the result.
10
+ *
11
+ * So the caller proves possession of the credential it already holds. The cloud
12
+ * mints a sealed, namespace-bound nonce; the device signs it; the read carries
13
+ * the certificate and that signature.
14
+ *
15
+ * ## What this establishes, and what it does not
16
+ *
17
+ * It establishes that the caller holds a device key certified by some account
18
+ * root, and *which* account that is. It does **not** establish that the account
19
+ * was invited to the namespace, nor that it is a member — the cloud cannot
20
+ * check either, because membership is governance state held by the nodes.
21
+ * Anyone can mint an account root offline, so this is not a wall. It buys
22
+ * attribution (every read names an account and can be rate-limited) and it ends
23
+ * anonymous bulk discovery. Authorization stays where it always was: at the
24
+ * node, on the signed op.
25
+ *
26
+ * ## Why the DEVICE signs, not the root
27
+ *
28
+ * The opposite choice from {@link signAccountLink}, deliberately. That one
29
+ * exists for the device-loss case, so only the root will do. This one runs on
30
+ * every routing read from a browser that discards the root immediately after
31
+ * certifying its device — requiring the root would mean re-entering a 24-word
32
+ * phrase to look up a URL.
33
+ *
34
+ * The certificate alone is not enough and the signature is not redundant with
35
+ * it: a certificate travels in the clear inside every device-link op, so anyone
36
+ * who has seen one can replay it. Only the nonce signature binds the presenter
37
+ * to the device.
38
+ */
39
+ import { concat, fromHex, importSigningKey } from '../crypto/internal.js';
40
+ /**
41
+ * The signing domain, followed immediately by the nonce as UTF-8.
42
+ *
43
+ * A flat prefix rather than this SDK's usual length-prefixed `domainHash`,
44
+ * because the verifier is MDMA's `verify_routing_proof` and it checks
45
+ * `DOMAIN ‖ nonce` against the raw message. The trailing NUL is part of the
46
+ * domain string on both sides; it is what stops a domain that is a prefix of
47
+ * some future one from colliding with it.
48
+ */
49
+ const ROUTING_PROOF_DOMAIN = new TextEncoder().encode('calimero.mdma.routing-read.v1\0');
50
+ /**
51
+ * Sign a routing challenge with the device key.
52
+ *
53
+ * Returns base64, which is what the header carries and what MDMA's verifier
54
+ * decodes — the one place this SDK does not spell a signature as hex, because
55
+ * the wire format is not ours to choose.
56
+ */
57
+ export async function signRoutingChallenge(nonce, deviceSecret) {
58
+ // Validate the secret the same way every other signing path does, so a
59
+ // truncated or mis-cased key fails here rather than as a 403 from the cloud.
60
+ fromHex(deviceSecret, 'deviceSecret', 32);
61
+ const key = await importSigningKey(deviceSecret);
62
+ const message = concat(ROUTING_PROOF_DOMAIN, new TextEncoder().encode(nonce));
63
+ const signature = new Uint8Array(await crypto.subtle.sign({ name: 'Ed25519' }, key, message));
64
+ return base64(signature);
65
+ }
66
+ /**
67
+ * Build the headers for one routing read, from a challenge the cloud minted.
68
+ *
69
+ * Separated from the request so a caller that fetches its own challenge — a
70
+ * test, or a client batching several reads against one nonce — can use the same
71
+ * encoding rather than a second spelling of it.
72
+ */
73
+ export async function routingProofHeaders(challenge, credential) {
74
+ return {
75
+ 'X-Calimero-Credential': credential.credential,
76
+ 'X-Calimero-Nonce': challenge.nonce,
77
+ 'X-Calimero-Signature': await signRoutingChallenge(challenge.nonce, credential.deviceSecret),
78
+ };
79
+ }
80
+ /**
81
+ * base64, the encoding the header carries.
82
+ *
83
+ * `btoa` is global in every browser and in Node since 16, and this package
84
+ * requires Node 18 — so there is no runtime here that needs a `Buffer`
85
+ * fallback, and adding one would pull a Node-only global into browser code.
86
+ */
87
+ function base64(bytes) {
88
+ let binary = '';
89
+ for (const byte of bytes)
90
+ binary += String.fromCharCode(byte);
91
+ return btoa(binary);
92
+ }