@calimero-network/mero-js 22.7.0 → 22.9.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/account/account.d.ts +99 -4
- package/dist/account/account.js +0 -0
- package/dist/account/index.d.ts +6 -2
- package/dist/account/index.js +3 -1
- package/dist/admin-api/admin-client.d.ts +154 -2
- package/dist/admin-api/admin-client.js +255 -1
- package/dist/admin-api/admin-types.d.ts +193 -5
- package/dist/cloud/cloud-client.d.ts +38 -8
- package/dist/cloud/cloud-client.js +38 -21
- package/dist/cloud/connect.d.ts +101 -2
- package/dist/cloud/connect.js +84 -8
- package/dist/cloud/enrol-redirect.d.ts +195 -0
- package/dist/cloud/enrol-redirect.js +236 -0
- package/dist/cloud/index.d.ts +6 -2
- package/dist/cloud/index.js +7 -1
- package/dist/cloud/link-redirect.d.ts +85 -0
- package/dist/cloud/link-redirect.js +105 -0
- package/dist/crypto/internal.d.ts +15 -0
- package/dist/crypto/internal.js +18 -0
- package/dist/device-cert/device-cert.d.ts +96 -2
- package/dist/device-cert/device-cert.js +101 -11
- package/dist/device-cert/device-scope.d.ts +58 -0
- package/dist/device-cert/device-scope.js +76 -0
- package/dist/device-cert/index.d.ts +4 -2
- package/dist/device-cert/index.js +2 -1
- package/dist/events/sse.d.ts +22 -2
- package/dist/events/sse.js +20 -11
- package/dist/http-client/http-types.d.ts +18 -0
- package/dist/http-client/web-client.js +21 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1955 -496
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +21 -2
- package/dist/index.mjs +1955 -496
- package/dist/index.mjs.map +4 -4
- package/dist/invitation/index.d.ts +2 -0
- package/dist/invitation/index.js +2 -0
- package/dist/invitation/invitation.d.ts +106 -0
- package/dist/invitation/invitation.js +104 -0
- package/dist/login/login.d.ts +11 -1
- package/dist/login/login.js +5 -4
- package/dist/login/session.d.ts +21 -5
- package/dist/login/session.js +7 -15
- package/dist/namespace-op/namespace-op.d.ts +14 -2
- package/dist/namespace-op/namespace-op.js +13 -19
- package/dist/relay/index.d.ts +2 -2
- package/dist/relay/index.js +1 -1
- package/dist/relay/nonce-source.d.ts +135 -0
- package/dist/relay/nonce-source.js +187 -19
- package/dist/relay/relay-client.d.ts +60 -5
- package/dist/relay/relay-client.js +84 -4
- package/dist/relay-attestation/attest-node-key.d.ts +66 -0
- package/dist/relay-attestation/attest-node-key.js +110 -0
- package/dist/relay-attestation/index.d.ts +1 -0
- package/dist/relay-attestation/index.js +1 -0
- package/dist/request-proof/authorizer.d.ts +48 -0
- package/dist/request-proof/authorizer.js +65 -0
- package/dist/request-proof/index.d.ts +4 -0
- package/dist/request-proof/index.js +2 -0
- package/dist/request-proof/request-proof.d.ts +56 -0
- package/dist/request-proof/request-proof.js +111 -0
- package/dist/rpc/index.d.ts +21 -0
- package/dist/rpc/index.js +20 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.js +3 -0
- package/dist/transport/mero-client.d.ts +286 -0
- package/dist/transport/mero-client.js +379 -0
- package/dist/transport/relay-observer.d.ts +152 -0
- package/dist/transport/relay-observer.js +173 -0
- package/dist/transport/relay-transport.d.ts +73 -0
- package/dist/transport/relay-transport.js +94 -0
- package/dist/transport/types.d.ts +100 -0
- package/dist/transport/types.js +32 -0
- package/package.json +1 -1
|
@@ -41,6 +41,11 @@ export function createMemoryNonceSource(start = 1) {
|
|
|
41
41
|
next += 1n;
|
|
42
42
|
return Promise.resolve(value);
|
|
43
43
|
},
|
|
44
|
+
advanceTo(floor) {
|
|
45
|
+
if (floor > next)
|
|
46
|
+
next = floor;
|
|
47
|
+
return Promise.resolve();
|
|
48
|
+
},
|
|
44
49
|
};
|
|
45
50
|
}
|
|
46
51
|
/**
|
|
@@ -67,29 +72,192 @@ export function createLocalStorageNonceSource(key, storage) {
|
|
|
67
72
|
if (!store) {
|
|
68
73
|
throw new Error('no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence');
|
|
69
74
|
}
|
|
75
|
+
// A missing, empty, or corrupted value means "no sequence recorded", and the
|
|
76
|
+
// safe reading of that is not 1 — a cleared storage on a device that has
|
|
77
|
+
// already written would replay. But the client cannot know, and 1 is the only
|
|
78
|
+
// defensible start, so it is what a fresh key gets. This is why the doc above
|
|
79
|
+
// tells apps to key per device: a device whose storage was cleared should be
|
|
80
|
+
// re-paired rather than resumed — or handed a recovering source, which asks
|
|
81
|
+
// the node what it already spent instead of guessing.
|
|
82
|
+
const lastHandedOut = () => {
|
|
83
|
+
const raw = store.getItem(key);
|
|
84
|
+
if (raw === null || raw === '')
|
|
85
|
+
return 0n;
|
|
86
|
+
try {
|
|
87
|
+
const parsed = BigInt(raw);
|
|
88
|
+
return parsed > 0n ? parsed : 0n;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0n;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
70
94
|
return {
|
|
71
95
|
next() {
|
|
72
|
-
const
|
|
73
|
-
// A missing, empty, or corrupted value means "no sequence recorded", and
|
|
74
|
-
// the safe reading of that is not 1 — a cleared storage on a device that
|
|
75
|
-
// has already written would replay. But the client cannot know, and 1 is
|
|
76
|
-
// the only defensible start, so it is what a fresh key gets. This is why
|
|
77
|
-
// the doc above tells apps to key per device: a device whose storage was
|
|
78
|
-
// cleared should be re-paired rather than resumed.
|
|
79
|
-
let current = 0n;
|
|
80
|
-
if (raw !== null && raw !== '') {
|
|
81
|
-
try {
|
|
82
|
-
const parsed = BigInt(raw);
|
|
83
|
-
if (parsed > 0n)
|
|
84
|
-
current = parsed;
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
current = 0n;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
const value = current + 1n;
|
|
96
|
+
const value = lastHandedOut() + 1n;
|
|
91
97
|
store.setItem(key, value.toString());
|
|
92
98
|
return Promise.resolve(value);
|
|
93
99
|
},
|
|
100
|
+
advanceTo(floor) {
|
|
101
|
+
// Stored is the last number handed out, so the floor is recorded as the
|
|
102
|
+
// one below it — persisted rather than held in memory, which is the whole
|
|
103
|
+
// point: the next cold start resumes above the jump even if the node
|
|
104
|
+
// cannot be reached then.
|
|
105
|
+
const wanted = floor > 0n ? floor - 1n : 0n;
|
|
106
|
+
if (wanted > lastHandedOut())
|
|
107
|
+
store.setItem(key, wanted.toString());
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A lookup a delegated client can actually use.
|
|
114
|
+
*
|
|
115
|
+
* {@link WarrantNonceLookup} names the device key because the admin route takes
|
|
116
|
+
* it in the path. The delegated route does not: it reads the key from the
|
|
117
|
+
* certificate it verifies, so the only way to ask is to present that
|
|
118
|
+
* certificate. This adapter bridges the two — it satisfies the interface, and
|
|
119
|
+
* ignores the device key argument because the credential already determines it.
|
|
120
|
+
*
|
|
121
|
+
* That the argument is ignored is not an oversight to tidy away. The whole
|
|
122
|
+
* point of the delegated route is that the device key is not caller-supplied:
|
|
123
|
+
* a key passed alongside the proof would be a second spelling of a fact the
|
|
124
|
+
* signature already fixes, and two spellings can differ. The adapter therefore
|
|
125
|
+
* has nothing to check it against, and pretending otherwise would invent a
|
|
126
|
+
* comparison the protocol deliberately does not make.
|
|
127
|
+
*
|
|
128
|
+
* Use with {@link createRecoveringNonceSource} exactly as an `AdminApiClient`
|
|
129
|
+
* would be used — on a relay, this is the form that works.
|
|
130
|
+
*/
|
|
131
|
+
export function authorNonceLookup(options) {
|
|
132
|
+
return {
|
|
133
|
+
getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** Thrown when every node asked says this device has spent `u64::MAX` here. */
|
|
137
|
+
export class WarrantNonceExhaustedError extends Error {
|
|
138
|
+
constructor(contextId, authorDeviceKey) {
|
|
139
|
+
super(`author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`);
|
|
140
|
+
this.contextId = contextId;
|
|
141
|
+
this.authorDeviceKey = authorDeviceKey;
|
|
142
|
+
this.name = 'WarrantNonceExhaustedError';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* A nonce source that asks the node where the sequence stands before its first
|
|
147
|
+
* mint, then carries on locally.
|
|
148
|
+
*
|
|
149
|
+
* # Why this is not the default
|
|
150
|
+
*
|
|
151
|
+
* It costs a round trip and an admin credential, and an author that never lost
|
|
152
|
+
* its counter gains nothing from it. It earns its place in exactly one
|
|
153
|
+
* situation, which is common rather than exotic: a client whose local state is
|
|
154
|
+
* gone. A browser keyholder lives in storage that is partitioned per top-level
|
|
155
|
+
* site and cleared on a schedule it does not control, so for it, losing the
|
|
156
|
+
* counter is the normal lifecycle. Without this, such a client either restarts
|
|
157
|
+
* at 1 — replaying every nonce it already spent, each write refused, which
|
|
158
|
+
* reads to the user as the app silently not saving — or guesses upward, one
|
|
159
|
+
* burnt round trip per wrong guess.
|
|
160
|
+
*
|
|
161
|
+
* # What it does
|
|
162
|
+
*
|
|
163
|
+
* The lookup happens once, lazily, on the first {@link NonceSource.next}: a
|
|
164
|
+
* source that is constructed and never used costs nothing, and an app that
|
|
165
|
+
* builds its relay client at startup does not pay a network call for it. The
|
|
166
|
+
* answer becomes a **floor**, and every nonce handed out afterwards is
|
|
167
|
+
* `max(localCounter, floor)`. It never goes backwards from either input, which
|
|
168
|
+
* is the one property a nonce source owes its caller.
|
|
169
|
+
*
|
|
170
|
+
* If the local counter is the one that is ahead, it wins — the node may simply
|
|
171
|
+
* not have applied this client's recent warrants yet.
|
|
172
|
+
*
|
|
173
|
+
* The floor is also pushed into the local source through
|
|
174
|
+
* {@link NonceSource.advanceTo} when it has one, so a persistent counter
|
|
175
|
+
* remembers the jump.
|
|
176
|
+
*
|
|
177
|
+
* # Exhaustion
|
|
178
|
+
*
|
|
179
|
+
* A node reporting no `nextNonce` means `u64::MAX` is spent and this device can
|
|
180
|
+
* never write here again. `next()` rejects with
|
|
181
|
+
* {@link WarrantNonceExhaustedError} rather than guessing, because every guess
|
|
182
|
+
* from there is refused forever.
|
|
183
|
+
*
|
|
184
|
+
* # On an older node
|
|
185
|
+
*
|
|
186
|
+
* The route is new and absent from core `master` builds, which answer 404 —
|
|
187
|
+
* {@link AdminApiClient.getWarrantNonce} surfaces that as
|
|
188
|
+
* `WarrantNonceRouteUnavailableError`. It reaches the caller as a lookup
|
|
189
|
+
* failure, so `onLookupFailure` decides: refuse to mint, or fall back to the
|
|
190
|
+
* local counter.
|
|
191
|
+
*/
|
|
192
|
+
export function createRecoveringNonceSource(options) {
|
|
193
|
+
const lookups = Array.isArray(options.lookups)
|
|
194
|
+
? options.lookups
|
|
195
|
+
: [options.lookups];
|
|
196
|
+
if (lookups.length === 0) {
|
|
197
|
+
throw new Error('a recovering nonce source needs at least one node to ask');
|
|
198
|
+
}
|
|
199
|
+
const local = options.local ?? createMemoryNonceSource(1);
|
|
200
|
+
const onLookupFailure = options.onLookupFailure ?? 'throw';
|
|
201
|
+
let floor = null;
|
|
202
|
+
// Memoised so concurrent first calls ask once. Cleared on failure, since a
|
|
203
|
+
// node that was unreachable a moment ago may answer the next call — caching
|
|
204
|
+
// the failure would make one flaky request poison the whole session.
|
|
205
|
+
let pending = null;
|
|
206
|
+
const resolveFloor = async () => {
|
|
207
|
+
const answers = await Promise.allSettled(lookups.map((lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)));
|
|
208
|
+
let highest = null;
|
|
209
|
+
let exhausted = false;
|
|
210
|
+
for (const answer of answers) {
|
|
211
|
+
if (answer.status !== 'fulfilled')
|
|
212
|
+
continue;
|
|
213
|
+
const state = answer.value;
|
|
214
|
+
if (state.kind === 'exhausted') {
|
|
215
|
+
exhausted = true;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if (highest === null || state.nextNonce > highest)
|
|
219
|
+
highest = state.nextNonce;
|
|
220
|
+
}
|
|
221
|
+
// Exhaustion is only decisive when nothing usable came back. One node
|
|
222
|
+
// saturated while another still has room is not a thing the ledger can
|
|
223
|
+
// produce — the sequence is the device's, not the node's — but if it ever
|
|
224
|
+
// is, the usable answer is the actionable one.
|
|
225
|
+
if (highest === null && exhausted) {
|
|
226
|
+
throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
|
|
227
|
+
}
|
|
228
|
+
if (highest === null) {
|
|
229
|
+
const first = answers.find((a) => a.status === 'rejected');
|
|
230
|
+
const reason = first && first.status === 'rejected' ? first.reason : undefined;
|
|
231
|
+
if (onLookupFailure === 'local') {
|
|
232
|
+
floor = 0n;
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
throw reason instanceof Error
|
|
236
|
+
? reason
|
|
237
|
+
: new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
|
|
238
|
+
}
|
|
239
|
+
floor = highest;
|
|
240
|
+
await local.advanceTo?.(highest);
|
|
241
|
+
};
|
|
242
|
+
return {
|
|
243
|
+
async next() {
|
|
244
|
+
if (floor === null) {
|
|
245
|
+
pending ?? (pending = resolveFloor().finally(() => {
|
|
246
|
+
pending = null;
|
|
247
|
+
}));
|
|
248
|
+
await pending;
|
|
249
|
+
}
|
|
250
|
+
const mine = await local.next();
|
|
251
|
+
const value = floor !== null && floor > mine ? floor : mine;
|
|
252
|
+
// Advance the floor past what was just handed out, so a local source that
|
|
253
|
+
// is behind cannot re-offer this number on the next call.
|
|
254
|
+
floor = value + 1n;
|
|
255
|
+
return value;
|
|
256
|
+
},
|
|
257
|
+
async advanceTo(wanted) {
|
|
258
|
+
if (floor === null || wanted > floor)
|
|
259
|
+
floor = wanted;
|
|
260
|
+
await local.advanceTo?.(wanted);
|
|
261
|
+
},
|
|
94
262
|
};
|
|
95
263
|
}
|
|
@@ -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
|
-
/**
|
|
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 {
|
|
@@ -263,11 +277,35 @@ export declare class RelayClient {
|
|
|
263
277
|
private readonly config;
|
|
264
278
|
private readonly fetchImpl;
|
|
265
279
|
private readonly timeoutMs;
|
|
280
|
+
/** How many times a 429 is waited out before it is raised. */
|
|
281
|
+
private readonly rateLimitRetries;
|
|
266
282
|
constructor(config: RelayClientConfig);
|
|
267
283
|
/**
|
|
268
|
-
* The
|
|
269
|
-
*
|
|
270
|
-
*
|
|
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.
|
|
271
309
|
*/
|
|
272
310
|
authorSigner(): Promise<Signer>;
|
|
273
311
|
/**
|
|
@@ -377,5 +415,22 @@ export declare class RelayClient {
|
|
|
377
415
|
* configured account must agree with. Remembered for later calls.
|
|
378
416
|
*/
|
|
379
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
|
+
*/
|
|
380
434
|
private json;
|
|
435
|
+
private jsonOnce;
|
|
381
436
|
}
|
|
@@ -28,12 +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';
|
|
33
34
|
import { defaultCapabilitiesSetOp, foundedNamespaceId, namespaceCreatedOp, } from '../warrant/governance-op.js';
|
|
34
35
|
import { HTTPError } from '../http-client/web-client.js';
|
|
35
36
|
import { fromHex, hex } from '../crypto/internal.js';
|
|
36
|
-
import { resolveSigner } from '../signer/signer.js';
|
|
37
37
|
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
38
38
|
const DEFAULT_TTL_SECONDS = 300;
|
|
39
39
|
/**
|
|
@@ -62,6 +62,25 @@ export class IntentRefusedError extends Error {
|
|
|
62
62
|
this.name = 'IntentRefusedError';
|
|
63
63
|
}
|
|
64
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
|
+
}
|
|
65
84
|
export class RelayClient {
|
|
66
85
|
constructor(config) {
|
|
67
86
|
this.config = config;
|
|
@@ -72,11 +91,40 @@ export class RelayClient {
|
|
|
72
91
|
? (input, init) => injected(input, init)
|
|
73
92
|
: (input, init) => globalThis.fetch(input, init);
|
|
74
93
|
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
94
|
+
this.rateLimitRetries = config.rateLimitRetries ?? 3;
|
|
75
95
|
}
|
|
76
96
|
/**
|
|
77
|
-
* The
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
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.
|
|
80
128
|
*/
|
|
81
129
|
async authorSigner() {
|
|
82
130
|
return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
|
|
@@ -353,7 +401,39 @@ export class RelayClient {
|
|
|
353
401
|
this.executorAccount = answered;
|
|
354
402
|
return answered;
|
|
355
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
|
+
*/
|
|
356
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) {
|
|
357
437
|
const headers = { Accept: 'application/json' };
|
|
358
438
|
if (body !== undefined)
|
|
359
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;
|