@calimero-network/mero-js 19.7.1 → 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.
- package/dist/cloud/cloud-client.d.ts +38 -0
- package/dist/cloud/cloud-client.js +46 -1
- package/dist/cloud/index.d.ts +2 -0
- package/dist/cloud/index.js +1 -0
- package/dist/cloud/routing-proof.d.ts +92 -0
- package/dist/cloud/routing-proof.js +92 -0
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +76 -2
- package/dist/index.cjs.map +4 -4
- package/dist/index.mjs +76 -2
- package/dist/index.mjs.map +4 -4
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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) {
|
package/dist/cloud/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cloud/index.js
CHANGED
|
@@ -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
|
+
}
|