@calimero-network/mero-js 26.11.0 → 27.0.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/README.md +73 -288
- package/dist/cloud-v2/attestation.d.ts +91 -0
- package/dist/cloud-v2/attestation.js +135 -0
- package/dist/cloud-v2/capabilities.d.ts +30 -0
- package/dist/cloud-v2/capabilities.js +31 -0
- package/dist/cloud-v2/client.d.ts +426 -0
- package/dist/cloud-v2/client.js +646 -0
- package/dist/cloud-v2/errors.d.ts +98 -0
- package/dist/cloud-v2/errors.js +165 -0
- package/dist/cloud-v2/index.d.ts +10 -0
- package/dist/cloud-v2/index.js +5 -0
- package/dist/cloud-v2/passkey.d.ts +27 -0
- package/dist/cloud-v2/passkey.js +73 -0
- package/dist/crypto/internal.d.ts +3 -37
- package/dist/crypto/internal.js +3 -72
- package/dist/index.browser.mjs +4 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1750 -12772
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +3 -56
- package/dist/index.js +14 -75
- package/dist/index.mjs +1750 -12772
- package/dist/index.mjs.map +4 -4
- package/dist/sealed/index.d.ts +4 -4
- package/dist/sealed/index.js +2 -2
- package/dist/sealed/release.d.ts +21 -76
- package/dist/sealed/release.js +24 -108
- package/dist/sealed/sealed.d.ts +8 -56
- package/dist/sealed/sealed.js +11 -89
- package/dist/sealed/verify.d.ts +7 -13
- package/dist/sealed/verify.js +3 -7
- package/package.json +8 -13
- package/dist/account/account-admin.d.ts +0 -80
- package/dist/account/account-admin.js +0 -346
- package/dist/account/account.d.ts +0 -196
- package/dist/account/account.js +0 -269
- package/dist/account/application-id.d.ts +0 -50
- package/dist/account/application-id.js +0 -83
- package/dist/account/bootstrap-from-invitation.d.ts +0 -110
- package/dist/account/bootstrap-from-invitation.js +0 -80
- package/dist/account/create-context.d.ts +0 -103
- package/dist/account/create-context.js +0 -267
- package/dist/account/govern.d.ts +0 -24
- package/dist/account/govern.js +0 -50
- package/dist/account/index.d.ts +0 -26
- package/dist/account/index.js +0 -15
- package/dist/account/join-as-account.d.ts +0 -23
- package/dist/account/join-as-account.js +0 -34
- package/dist/account/join-with-node.d.ts +0 -115
- package/dist/account/join-with-node.js +0 -161
- package/dist/account/node-admin.d.ts +0 -20
- package/dist/account/node-admin.js +0 -33
- package/dist/account/relay-from-invitation.d.ts +0 -137
- package/dist/account/relay-from-invitation.js +0 -180
- package/dist/account/relay-login-error.d.ts +0 -32
- package/dist/account/relay-login-error.js +0 -34
- package/dist/account/session.d.ts +0 -266
- package/dist/account/session.js +0 -915
- package/dist/account/wordlist.d.ts +0 -16
- package/dist/account/wordlist.js +0 -273
- package/dist/admin-api/admin-client.d.ts +0 -688
- package/dist/admin-api/admin-client.js +0 -1230
- package/dist/admin-api/admin-factory.d.ts +0 -14
- package/dist/admin-api/admin-factory.js +0 -22
- package/dist/admin-api/admin-types.d.ts +0 -2046
- package/dist/admin-api/admin-types.js +0 -12
- package/dist/admin-api/index.d.ts +0 -4
- package/dist/admin-api/index.js +0 -5
- package/dist/admin-api/redeem-invitation.d.ts +0 -101
- package/dist/admin-api/redeem-invitation.js +0 -200
- package/dist/auth/index.d.ts +0 -34
- package/dist/auth/index.js +0 -67
- package/dist/auth-api/auth-client.d.ts +0 -31
- package/dist/auth-api/auth-client.js +0 -117
- package/dist/auth-api/auth-factory.d.ts +0 -7
- package/dist/auth-api/auth-factory.js +0 -41
- package/dist/auth-api/auth-types.d.ts +0 -130
- package/dist/auth-api/auth-types.js +0 -2
- package/dist/auth-api/index.d.ts +0 -3
- package/dist/auth-api/index.js +0 -4
- package/dist/capabilities.d.ts +0 -84
- package/dist/capabilities.js +0 -117
- package/dist/cloud/account-ownership.d.ts +0 -63
- package/dist/cloud/account-ownership.js +0 -87
- package/dist/cloud/cloud-client.d.ts +0 -949
- package/dist/cloud/cloud-client.js +0 -1068
- package/dist/cloud/connect.d.ts +0 -213
- package/dist/cloud/connect.js +0 -260
- package/dist/cloud/enrol-redirect.d.ts +0 -181
- package/dist/cloud/enrol-redirect.js +0 -244
- package/dist/cloud/index.d.ts +0 -12
- package/dist/cloud/index.js +0 -10
- package/dist/cloud/link-redirect.d.ts +0 -95
- package/dist/cloud/link-redirect.js +0 -121
- package/dist/cloud/link-state.d.ts +0 -36
- package/dist/cloud/link-state.js +0 -104
- package/dist/cloud/routing-proof.d.ts +0 -113
- package/dist/cloud/routing-proof.js +0 -96
- package/dist/device-cert/device-cert.d.ts +0 -189
- package/dist/device-cert/device-cert.js +0 -239
- package/dist/device-cert/device-scope.d.ts +0 -58
- package/dist/device-cert/device-scope.js +0 -76
- package/dist/device-cert/index.d.ts +0 -4
- package/dist/device-cert/index.js +0 -3
- package/dist/ephemeral/codec.d.ts +0 -4
- package/dist/ephemeral/codec.js +0 -12
- package/dist/ephemeral/index.d.ts +0 -57
- package/dist/ephemeral/index.js +0 -101
- package/dist/ephemeral/types.d.ts +0 -27
- package/dist/ephemeral/types.js +0 -1
- package/dist/errors/classify.d.ts +0 -65
- package/dist/errors/classify.js +0 -117
- package/dist/errors/index.d.ts +0 -2
- package/dist/errors/index.js +0 -1
- package/dist/events/group.d.ts +0 -95
- package/dist/events/group.js +0 -10
- package/dist/events/index.d.ts +0 -5
- package/dist/events/index.js +0 -2
- package/dist/events/sse.d.ts +0 -127
- package/dist/events/sse.js +0 -469
- package/dist/events/ws.d.ts +0 -57
- package/dist/events/ws.js +0 -212
- package/dist/http-client/api-response.d.ts +0 -15
- package/dist/http-client/api-response.js +0 -1
- package/dist/http-client/http-factory.d.ts +0 -71
- package/dist/http-client/http-factory.js +0 -68
- package/dist/http-client/http-types.d.ts +0 -87
- package/dist/http-client/http-types.js +0 -1
- package/dist/http-client/index.d.ts +0 -7
- package/dist/http-client/index.js +0 -11
- package/dist/http-client/path-segment.d.ts +0 -1
- package/dist/http-client/path-segment.js +0 -8
- package/dist/http-client/retry.d.ts +0 -5
- package/dist/http-client/retry.js +0 -82
- package/dist/http-client/signal-utils.d.ts +0 -2
- package/dist/http-client/signal-utils.js +0 -41
- package/dist/http-client/web-client.d.ts +0 -80
- package/dist/http-client/web-client.js +0 -557
- package/dist/invitation/index.d.ts +0 -2
- package/dist/invitation/index.js +0 -2
- package/dist/invitation/invitation.d.ts +0 -106
- package/dist/invitation/invitation.js +0 -104
- package/dist/login/index.d.ts +0 -4
- package/dist/login/index.js +0 -3
- package/dist/login/login.d.ts +0 -111
- package/dist/login/login.js +0 -110
- package/dist/login/session.d.ts +0 -124
- package/dist/login/session.js +0 -141
- package/dist/mero-js.d.ts +0 -231
- package/dist/mero-js.js +0 -469
- package/dist/namespace-op/index.d.ts +0 -2
- package/dist/namespace-op/index.js +0 -3
- package/dist/namespace-op/namespace-op.d.ts +0 -150
- package/dist/namespace-op/namespace-op.js +0 -191
- package/dist/nodeDiscovery.d.ts +0 -47
- package/dist/nodeDiscovery.js +0 -101
- package/dist/owner-op/index.d.ts +0 -2
- package/dist/owner-op/index.js +0 -3
- package/dist/owner-op/owner-op.d.ts +0 -119
- package/dist/owner-op/owner-op.js +0 -214
- package/dist/presence/relay-presence.d.ts +0 -56
- package/dist/presence/relay-presence.js +0 -107
- package/dist/presence/statement.d.ts +0 -12
- package/dist/presence/statement.js +0 -17
- package/dist/relay/index.d.ts +0 -4
- package/dist/relay/index.js +0 -2
- package/dist/relay/nonce-source.d.ts +0 -72
- package/dist/relay/nonce-source.js +0 -111
- package/dist/relay/relay-client.d.ts +0 -472
- package/dist/relay/relay-client.js +0 -518
- package/dist/relay-attestation/attest-node-key.d.ts +0 -66
- package/dist/relay-attestation/attest-node-key.js +0 -110
- package/dist/relay-attestation/index.d.ts +0 -1
- package/dist/relay-attestation/index.js +0 -1
- package/dist/request/caller-proof.d.ts +0 -43
- package/dist/request/caller-proof.js +0 -36
- package/dist/request/index.d.ts +0 -3
- package/dist/request/index.js +0 -3
- package/dist/request/proof-signer.d.ts +0 -49
- package/dist/request/proof-signer.js +0 -59
- package/dist/request/request-sig.d.ts +0 -99
- package/dist/request/request-sig.js +0 -95
- package/dist/request-proof/authorizer.d.ts +0 -41
- package/dist/request-proof/authorizer.js +0 -60
- package/dist/request-proof/index.d.ts +0 -4
- package/dist/request-proof/index.js +0 -2
- package/dist/request-proof/request-proof.d.ts +0 -53
- package/dist/request-proof/request-proof.js +0 -95
- package/dist/rpc/index.d.ts +0 -64
- package/dist/rpc/index.js +0 -78
- package/dist/signer/index.d.ts +0 -2
- package/dist/signer/index.js +0 -3
- package/dist/signer/signer.d.ts +0 -92
- package/dist/signer/signer.js +0 -147
- package/dist/token-store/index.d.ts +0 -27
- package/dist/token-store/index.js +0 -62
- package/dist/transport/index.d.ts +0 -6
- package/dist/transport/index.js +0 -3
- package/dist/transport/mero-client.d.ts +0 -294
- package/dist/transport/mero-client.js +0 -386
- package/dist/transport/relay-observer.d.ts +0 -152
- package/dist/transport/relay-observer.js +0 -173
- package/dist/transport/relay-transport.d.ts +0 -73
- package/dist/transport/relay-transport.js +0 -94
- package/dist/transport/types.d.ts +0 -100
- package/dist/transport/types.js +0 -32
- package/dist/types/api-response.d.ts +0 -15
- package/dist/types/api-response.js +0 -1
- package/dist/types/index.d.ts +0 -1
- package/dist/types/index.js +0 -1
- package/dist/warrant/creation-warrant.d.ts +0 -163
- package/dist/warrant/creation-warrant.js +0 -194
- package/dist/warrant/governance-op.d.ts +0 -202
- package/dist/warrant/governance-op.js +0 -355
- package/dist/warrant/governance-warrant.d.ts +0 -124
- package/dist/warrant/governance-warrant.js +0 -164
- package/dist/warrant/index.d.ts +0 -8
- package/dist/warrant/index.js +0 -5
- package/dist/warrant/warrant.d.ts +0 -188
- package/dist/warrant/warrant.js +0 -202
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Write to a context through a relay, holding nothing but a signing key.
|
|
3
|
-
*
|
|
4
|
-
* # What this replaces
|
|
5
|
-
*
|
|
6
|
-
* `sdk.rpc.execute` asks a node to run a method *as itself*: it needs a node,
|
|
7
|
-
* that node needs to be a member, and the caller needs a credential on it. For
|
|
8
|
-
* a browser tab, a phone, or an agent, none of those three is available — the
|
|
9
|
-
* runtime cannot compile WASM against materialized state, never received the
|
|
10
|
-
* scope key that seals the deltas, and has no account on anybody's node.
|
|
11
|
-
*
|
|
12
|
-
* This client is the other path. The caller signs a warrant locally, the relay
|
|
13
|
-
* runs the method as the *author's* principal, and the resulting delta is
|
|
14
|
-
* attributed to the author's account and device — their replica slot, their
|
|
15
|
-
* membership, their name in the app. The relay's key signs the envelope and
|
|
16
|
-
* nothing else.
|
|
17
|
-
*
|
|
18
|
-
* # The three preconditions, and why they are checked in this order
|
|
19
|
-
*
|
|
20
|
-
* `describe()` answers the first two in one call, before anything is signed:
|
|
21
|
-
* the relay's executor account and signing key and the release the context's
|
|
22
|
-
* group names (which the warrant must name), and whether it holds
|
|
23
|
-
* `CAN_AUTHOR_ON_BEHALF` on the owning group. The third,
|
|
24
|
-
* that the author's account is a member, only the group knows, and it surfaces
|
|
25
|
-
* as a refusal.
|
|
26
|
-
*
|
|
27
|
-
* Checking before signing is not politeness. A warrant consumes a nonce from a
|
|
28
|
-
* monotonic per-device sequence, and one minted against the wrong executor is
|
|
29
|
-
* unspendable: the number is gone and the write never happened.
|
|
30
|
-
*/
|
|
31
|
-
import { releaseVersionBytes, signWarrant } from '../warrant/warrant.js';
|
|
32
|
-
import { resolveSigner } from '../signer/signer.js';
|
|
33
|
-
import { signCreationWarrant } from '../warrant/creation-warrant.js';
|
|
34
|
-
import { signGovernanceWarrant } from '../warrant/governance-warrant.js';
|
|
35
|
-
import { defaultCapabilitiesSetOp, targetApplicationSetOp, foundedNamespaceId, namespaceCreatedOp, } from '../warrant/governance-op.js';
|
|
36
|
-
import { HTTPError } from '../http-client/web-client.js';
|
|
37
|
-
import { pathSegment } from '../http-client/path-segment.js';
|
|
38
|
-
import { fromHex, hex } from '../crypto/internal.js';
|
|
39
|
-
/** How long a freshly minted warrant stays presentable, in seconds. */
|
|
40
|
-
const DEFAULT_TTL_SECONDS = 300;
|
|
41
|
-
/**
|
|
42
|
-
* Thrown when a relay refuses an intent, carrying which precondition failed.
|
|
43
|
-
*
|
|
44
|
-
* The distinction is the whole reason this type exists. A `403` from this
|
|
45
|
-
* endpoint means one of several unrelated things — no grant on the relay, the
|
|
46
|
-
* relay is a TEE replica (`ReadOnlyTee`) or a `ReadOnly` member (neither ever
|
|
47
|
-
* relays), the author is
|
|
48
|
-
* not a member or is read-only in the context, or the nonce was already spent
|
|
49
|
-
* — and they send a caller somewhere completely different: ask an admin, pick
|
|
50
|
-
* a relay the namespace admits in `relay` mode, ask for an invitation, or just
|
|
51
|
-
* retry. Collapsing them into "forbidden" makes a retryable replay look
|
|
52
|
-
* like a permissions bug.
|
|
53
|
-
*/
|
|
54
|
-
export class IntentRefusedError extends Error {
|
|
55
|
-
constructor(
|
|
56
|
-
/** The relay's own explanation, verbatim. */
|
|
57
|
-
reason,
|
|
58
|
-
/** `true` when re-presenting the same intent under a *fresh* warrant may work. */
|
|
59
|
-
retryable, status) {
|
|
60
|
-
super(`relay refused the intent (HTTP ${status}): ${reason}`);
|
|
61
|
-
this.reason = reason;
|
|
62
|
-
this.retryable = retryable;
|
|
63
|
-
this.status = status;
|
|
64
|
-
this.name = 'IntentRefusedError';
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
-
/**
|
|
69
|
-
* `Retry-After`, as milliseconds — seconds or an HTTP date, per RFC 9110.
|
|
70
|
-
*
|
|
71
|
-
* Returns `null` when the header is absent or unusable, so the caller falls back
|
|
72
|
-
* to its own backoff rather than treating a malformed header as "retry now".
|
|
73
|
-
*/
|
|
74
|
-
function retryAfterMs(headers) {
|
|
75
|
-
const raw = headers?.get?.('Retry-After');
|
|
76
|
-
if (!raw)
|
|
77
|
-
return null;
|
|
78
|
-
const seconds = Number.parseInt(raw, 10);
|
|
79
|
-
if (Number.isFinite(seconds) && seconds >= 0)
|
|
80
|
-
return seconds * 1000;
|
|
81
|
-
const at = Date.parse(raw);
|
|
82
|
-
if (Number.isFinite(at))
|
|
83
|
-
return Math.max(0, at - Date.now());
|
|
84
|
-
return null;
|
|
85
|
-
}
|
|
86
|
-
export class RelayClient {
|
|
87
|
-
constructor(config) {
|
|
88
|
-
this.config = config;
|
|
89
|
-
this.baseUrl = config.relayUrl.replace(/\/+$/, '');
|
|
90
|
-
const injected = config.fetch;
|
|
91
|
-
this.fetchImpl = injected
|
|
92
|
-
? (input, init) => injected(input, init)
|
|
93
|
-
: (input, init) => globalThis.fetch(input, init);
|
|
94
|
-
this.timeoutMs = config.timeoutMs ?? 10000;
|
|
95
|
-
this.rateLimitRetries = config.rateLimitRetries ?? 3;
|
|
96
|
-
}
|
|
97
|
-
/**
|
|
98
|
-
* The relay's origin, trailing slash stripped.
|
|
99
|
-
*
|
|
100
|
-
* Exposed because a relay **is a node**: the same origin that answers the
|
|
101
|
-
* intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
|
|
102
|
-
* `/ws`. Anything building an observing session over this relay needs the
|
|
103
|
-
* URL, and asking the caller to pass it a second time is how the two drift.
|
|
104
|
-
*/
|
|
105
|
-
get relayUrl() {
|
|
106
|
-
return this.baseUrl;
|
|
107
|
-
}
|
|
108
|
-
/** The author's account, hex — whose consent every warrant here carries. */
|
|
109
|
-
get authorAccount() {
|
|
110
|
-
return this.config.authorAccount;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* The author's `AccountProof<DeviceCert>`, hex.
|
|
114
|
-
*
|
|
115
|
-
* The same credential `login()` wants, which is why it is readable: a caller
|
|
116
|
-
* that already handed it to this client should not have to hand an identical
|
|
117
|
-
* copy to the login as well, where the two could disagree.
|
|
118
|
-
*/
|
|
119
|
-
get authorProof() {
|
|
120
|
-
return this.config.authorProof;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* The author device's signer.
|
|
124
|
-
*
|
|
125
|
-
* A {@link Signer} and never the secret: this returns something that can
|
|
126
|
-
* *sign*, so no caller of a `RelayClient` gains the ability to read a key
|
|
127
|
-
* that was passed in as `deviceSecret`. That asymmetry is the point — the
|
|
128
|
-
* login needs a signature, not key material.
|
|
129
|
-
*/
|
|
130
|
-
async authorSigner() {
|
|
131
|
-
return resolveSigner(this.config.deviceSecret, this.config.signer, 'deviceSecret');
|
|
132
|
-
}
|
|
133
|
-
/**
|
|
134
|
-
* Ask the relay what it can do in `contextId`.
|
|
135
|
-
*
|
|
136
|
-
* Cheap and unauthenticated on a relay that serves delegated execution
|
|
137
|
-
* publicly, which is what makes it usable as a precondition check rather than
|
|
138
|
-
* as diagnostics after a failure.
|
|
139
|
-
*/
|
|
140
|
-
async describe(contextId) {
|
|
141
|
-
const body = await this.json('GET', `/admin-api/contexts/${pathSegment(contextId)}/intents`);
|
|
142
|
-
return body.data;
|
|
143
|
-
}
|
|
144
|
-
/**
|
|
145
|
-
* Mint a warrant for `method(args)` and present it to the relay.
|
|
146
|
-
*
|
|
147
|
-
* One intent, once: the warrant authorizes exactly this method and these
|
|
148
|
-
* arguments in exactly this context, and its nonce is spent by the network on
|
|
149
|
-
* apply. Nothing accumulates and nothing is reusable.
|
|
150
|
-
*/
|
|
151
|
-
async execute(contextId, method, argsJson = {}) {
|
|
152
|
-
// Asked on every write: the release moves with each upgrade of the group, and a
|
|
153
|
-
// warrant pinning the one before is refused. All of it is checked before the nonce.
|
|
154
|
-
const described = await this.describe(contextId);
|
|
155
|
-
const executor = this.checkedExecutor(described);
|
|
156
|
-
const releaseBytecodeId = hex(fromHex(described.releaseBytecodeId ?? '', "the relay's releaseBytecodeId", 32));
|
|
157
|
-
const releaseVersion = described.releaseVersion ?? '';
|
|
158
|
-
releaseVersionBytes(releaseVersion);
|
|
159
|
-
// Resolved before the nonce, so a misconfigured key burns none.
|
|
160
|
-
const signer = await this.authorSigner();
|
|
161
|
-
const nonce = await this.config.nonces.next();
|
|
162
|
-
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
163
|
-
const warrant = await signWarrant({
|
|
164
|
-
context: contextId,
|
|
165
|
-
authorAccount: this.config.authorAccount,
|
|
166
|
-
executor: executor.executorAccount,
|
|
167
|
-
executorKey: executor.executorKey,
|
|
168
|
-
releaseBytecodeId,
|
|
169
|
-
releaseVersion,
|
|
170
|
-
method,
|
|
171
|
-
argsJson,
|
|
172
|
-
nonce,
|
|
173
|
-
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
174
|
-
signer,
|
|
175
|
-
});
|
|
176
|
-
const body = await this.json('POST', `/admin-api/contexts/${pathSegment(contextId)}/intents`, { method, argsJson, warrant, authorProof: this.config.authorProof });
|
|
177
|
-
return { rootHash: body.data.rootHash, returns: body.data.returns ?? null };
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Publish one presence statement through the relay (`presence-intents`).
|
|
181
|
-
*
|
|
182
|
-
* Not retried on 429: presence resends every 2.5 s anyway, and a retry would
|
|
183
|
-
* only queue behind the next resend. A refusal throws with its `status`.
|
|
184
|
-
*/
|
|
185
|
-
async presenceIntent(contextId, body) {
|
|
186
|
-
await this.jsonOnce('POST', `/admin-api/contexts/${pathSegment(contextId)}/presence-intents`, body);
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Ask the relay what it can do about creating contexts in `groupId`.
|
|
190
|
-
*
|
|
191
|
-
* Pass `author` (an account, hex) to also learn whether that account may
|
|
192
|
-
* create here — `authorMayCreate` is present only then.
|
|
193
|
-
*/
|
|
194
|
-
async describeCreation(groupId, opts = {}) {
|
|
195
|
-
const query = opts.author ? `?author=${encodeURIComponent(opts.author)}` : '';
|
|
196
|
-
const body = await this.json('GET', `/admin-api/groups/${pathSegment(groupId)}/context-intents${query}`);
|
|
197
|
-
return body.data;
|
|
198
|
-
}
|
|
199
|
-
/**
|
|
200
|
-
* Create a context in a group through the relay, as the author.
|
|
201
|
-
*
|
|
202
|
-
* Always asks {@link describeCreation} first — with the author, so both
|
|
203
|
-
* standing checks happen before anything is signed. A relay without standing
|
|
204
|
-
* in the group, or an author without `CAN_CREATE_CONTEXT`, is refused here as
|
|
205
|
-
* an {@link IntentRefusedError} *before* a nonce is taken: the nonce source
|
|
206
|
-
* is monotonic, and a warrant nobody will spend burns one for nothing.
|
|
207
|
-
*
|
|
208
|
-
* The creation warrant's nonce is spent in the new context's per-device
|
|
209
|
-
* ledger — the same one later `execute` calls in that context draw from — so
|
|
210
|
-
* it comes from the configured {@link NonceSource} like any other warrant.
|
|
211
|
-
*
|
|
212
|
-
* Sealing: nothing here is plaintext-specific. The request goes through the
|
|
213
|
-
* injected `fetch`, so a `createAttestedSealedFetch` seals it exactly as it
|
|
214
|
-
* seals `execute`. Whether the relay *accepts* it sealed is the node's call:
|
|
215
|
-
* a proxy-auth relay only admits sealed requests for routes `merod` serves
|
|
216
|
-
* without a credential, so `/admin-api/groups/:id/context-intents` must be on
|
|
217
|
-
* that list on the core side or the request comes back
|
|
218
|
-
* `403 sealed_route_unguarded`.
|
|
219
|
-
*/
|
|
220
|
-
async createContext(input) {
|
|
221
|
-
const described = await this.describeCreation(input.groupId, {
|
|
222
|
-
author: this.config.authorAccount,
|
|
223
|
-
});
|
|
224
|
-
if (!described.canCreateOnBehalf) {
|
|
225
|
-
throw new IntentRefusedError(`the relay (${described.executorAccount}) has no standing to act for members of group ` +
|
|
226
|
-
`${described.groupId}; an admin must admit it as a relay or grant it CAN_AUTHOR_ON_BEHALF ` +
|
|
227
|
-
'(checked before signing — no nonce was spent)', false, 403);
|
|
228
|
-
}
|
|
229
|
-
if (described.authorMayCreate === false) {
|
|
230
|
-
throw new IntentRefusedError(`the author (${this.config.authorAccount}) may not create contexts in group ` +
|
|
231
|
-
`${described.groupId}; it needs CAN_CREATE_CONTEXT or admin ` +
|
|
232
|
-
'(checked before signing — no nonce was spent)', false, 403);
|
|
233
|
-
}
|
|
234
|
-
const executor = this.checkedExecutor(described);
|
|
235
|
-
const initArgs = input.initArgs ?? {};
|
|
236
|
-
// Resolved before the nonce, so a misconfigured key burns none.
|
|
237
|
-
const signer = await this.authorSigner();
|
|
238
|
-
const nonce = await this.config.nonces.next();
|
|
239
|
-
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
240
|
-
const { warrant } = await signCreationWarrant({
|
|
241
|
-
group: input.groupId,
|
|
242
|
-
seed: input.seed,
|
|
243
|
-
authorAccount: this.config.authorAccount,
|
|
244
|
-
executor: executor.executorAccount,
|
|
245
|
-
executorKey: executor.executorKey,
|
|
246
|
-
applicationId: input.applicationId,
|
|
247
|
-
serviceName: input.serviceName,
|
|
248
|
-
name: input.name,
|
|
249
|
-
initArgs,
|
|
250
|
-
nonce,
|
|
251
|
-
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
252
|
-
signer,
|
|
253
|
-
});
|
|
254
|
-
const body = await this.json('POST', `/admin-api/groups/${pathSegment(input.groupId)}/context-intents`, { warrant, authorProof: this.config.authorProof, initArgs });
|
|
255
|
-
return {
|
|
256
|
-
contextId: body.data.contextId,
|
|
257
|
-
groupId: body.data.groupId,
|
|
258
|
-
memberPublicKey: body.data.memberPublicKey,
|
|
259
|
-
};
|
|
260
|
-
}
|
|
261
|
-
/**
|
|
262
|
-
* Ask the relay whether it may publish governance ops for members of
|
|
263
|
-
* `groupId`, and which account and key a warrant must name.
|
|
264
|
-
*/
|
|
265
|
-
async describeGovernance(groupId) {
|
|
266
|
-
const body = await this.json('GET', `/admin-api/groups/${pathSegment(groupId)}/governance-intents`);
|
|
267
|
-
return body.data;
|
|
268
|
-
}
|
|
269
|
-
/**
|
|
270
|
-
* Publish one governance op through the relay, as the author.
|
|
271
|
-
*
|
|
272
|
-
* The warrant commits to the op's exact bytes and is scoped to `groupId`, so
|
|
273
|
-
* the relay can neither change what the op does nor spend it elsewhere. Every
|
|
274
|
-
* peer applies the op as the author, so the author's own rights decide
|
|
275
|
-
* (`MANAGE_MEMBERS` to add a member, `CAN_CREATE_SUBGROUP` to create a
|
|
276
|
-
* subgroup, and so on); a lack of them comes back as an
|
|
277
|
-
* {@link IntentRefusedError} from the relay.
|
|
278
|
-
*
|
|
279
|
-
* Always asks {@link describeGovernance} first and refuses *before* taking a
|
|
280
|
-
* nonce when the relay has no standing, for the reason `createContext` does.
|
|
281
|
-
* The nonce comes from the configured {@link NonceSource}; it is spent in a
|
|
282
|
-
* per-group ledger, and a monotonic source is correct for it as for every
|
|
283
|
-
* other warrant.
|
|
284
|
-
*/
|
|
285
|
-
async govern(input) {
|
|
286
|
-
const described = await this.describeGovernance(input.groupId);
|
|
287
|
-
if (!described.canActOnBehalf) {
|
|
288
|
-
throw new IntentRefusedError(`the relay (${described.executorAccount}) has no standing to act for members of group ` +
|
|
289
|
-
`${described.groupId}; an admin must admit it as a relay or grant it CAN_AUTHOR_ON_BEHALF ` +
|
|
290
|
-
'(checked before signing, no nonce was spent)', false, 403);
|
|
291
|
-
}
|
|
292
|
-
const executor = this.checkedExecutor(described);
|
|
293
|
-
// Resolved before the nonce, so a misconfigured key burns none.
|
|
294
|
-
const signer = await this.authorSigner();
|
|
295
|
-
const nonce = await this.config.nonces.next();
|
|
296
|
-
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
297
|
-
const warrant = await signGovernanceWarrant({
|
|
298
|
-
scope: input.groupId,
|
|
299
|
-
op: input.op,
|
|
300
|
-
authorAccount: this.config.authorAccount,
|
|
301
|
-
executor: executor.executorAccount,
|
|
302
|
-
executorKey: executor.executorKey,
|
|
303
|
-
nonce,
|
|
304
|
-
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
305
|
-
signer,
|
|
306
|
-
});
|
|
307
|
-
const data = await this.postGovernance(input.groupId, input.op, warrant);
|
|
308
|
-
return { groupId: data.groupId };
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Found a namespace through the relay, with the author as its founder, owner
|
|
312
|
-
* and admin. For an account with no node, this is how it gets a namespace at
|
|
313
|
-
* all.
|
|
314
|
-
*
|
|
315
|
-
* The author signs the exact genesis (`NamespaceCreatedV2`, carrying the
|
|
316
|
-
* author's own credential and the salt) under a root-plane governance
|
|
317
|
-
* warrant scoped to the new namespace's id, which is derived from the
|
|
318
|
-
* author's account and the salt, so the relay can neither choose another id
|
|
319
|
-
* nor found it for anyone else. The relay takes an identity in the new
|
|
320
|
-
* namespace, mints its key, publishes the genesis, and is seated as the
|
|
321
|
-
* founding relay (a `Member` holding `CAN_AUTHOR_ON_BEHALF`), so later
|
|
322
|
-
* `govern`, `createContext` and `execute` calls in the namespace work
|
|
323
|
-
* through it straight away. A TEE relay then admits itself as the
|
|
324
|
-
* namespace's first TEE; `teeEnabled` reports whether it did.
|
|
325
|
-
*
|
|
326
|
-
* The executor cannot be learned from the relay first: the namespace does not
|
|
327
|
-
* exist, so `describeGovernance` has nothing to answer about. It comes from
|
|
328
|
-
* `input.executor` only, and without it this throws before taking a nonce.
|
|
329
|
-
* The nonce is spent in the new namespace's ledger.
|
|
330
|
-
*
|
|
331
|
-
* With `defaultCapabilities`, the founder then sets the namespace's default
|
|
332
|
-
* capability mask through the same relay (a second warrant, a second nonce)
|
|
333
|
-
* and `defaultCapabilitiesSet` reports whether it landed. A failure there
|
|
334
|
-
* does not throw, since the namespace is already founded.
|
|
335
|
-
*
|
|
336
|
-
* Refusals (`400`/`403`) come back as {@link IntentRefusedError}; a salt
|
|
337
|
-
* that names an existing namespace is a `409`, an `HTTPError`.
|
|
338
|
-
*/
|
|
339
|
-
async foundNamespace(input) {
|
|
340
|
-
if (!input?.executor) {
|
|
341
|
-
throw new Error("foundNamespace needs the relay's account and signing key: pass executor " +
|
|
342
|
-
'(checked before signing, no nonce was spent)');
|
|
343
|
-
}
|
|
344
|
-
const executor = this.checkedExecutor(input.executor);
|
|
345
|
-
const salt = input.salt
|
|
346
|
-
? hex(fromHex(input.salt, 'salt', 32))
|
|
347
|
-
: hex(crypto.getRandomValues(new Uint8Array(32)));
|
|
348
|
-
const namespaceId = await foundedNamespaceId(this.config.authorAccount, salt);
|
|
349
|
-
const op = namespaceCreatedOp({
|
|
350
|
-
founder: this.config.authorAccount,
|
|
351
|
-
credential: this.config.authorProof,
|
|
352
|
-
salt,
|
|
353
|
-
});
|
|
354
|
-
// Encoded (and so validated) before anything is signed: a mask the relay
|
|
355
|
-
// would refuse must not cost the founding a nonce.
|
|
356
|
-
const capabilitiesOp = input.defaultCapabilities === undefined
|
|
357
|
-
? undefined
|
|
358
|
-
: defaultCapabilitiesSetOp(input.defaultCapabilities);
|
|
359
|
-
const applicationOp = input.application ? targetApplicationSetOp(input.application) : undefined;
|
|
360
|
-
// Resolved before the nonce, so a misconfigured key burns none.
|
|
361
|
-
const signer = await this.authorSigner();
|
|
362
|
-
const nonce = await this.config.nonces.next();
|
|
363
|
-
const ttl = this.config.ttlSeconds ?? DEFAULT_TTL_SECONDS;
|
|
364
|
-
const warrant = await signGovernanceWarrant({
|
|
365
|
-
scope: namespaceId,
|
|
366
|
-
op,
|
|
367
|
-
authorAccount: this.config.authorAccount,
|
|
368
|
-
executor: executor.executorAccount,
|
|
369
|
-
executorKey: executor.executorKey,
|
|
370
|
-
nonce,
|
|
371
|
-
notAfter: BigInt(Math.floor(Date.now() / 1000)) + BigInt(ttl),
|
|
372
|
-
signer,
|
|
373
|
-
});
|
|
374
|
-
const data = await this.postGovernance(namespaceId, op, warrant);
|
|
375
|
-
const founded = {
|
|
376
|
-
namespaceId: data.groupId,
|
|
377
|
-
salt,
|
|
378
|
-
teeEnabled: data.teeEnabled ?? false,
|
|
379
|
-
...(data.teeError ? { teeError: data.teeError } : {}),
|
|
380
|
-
};
|
|
381
|
-
// Follow-up ops, each under its own warrant, on the namespace root: the
|
|
382
|
-
// founder is its admin and the relay was seated to act for members, so they
|
|
383
|
-
// go through the ordinary `govern` path. The namespace exists whatever
|
|
384
|
-
// happens here, so a failure is reported rather than thrown. The
|
|
385
|
-
// application first: without it the namespace can hold no context at all.
|
|
386
|
-
let result = founded;
|
|
387
|
-
const message = (err) => (err instanceof Error ? err.message : String(err));
|
|
388
|
-
if (applicationOp) {
|
|
389
|
-
try {
|
|
390
|
-
await this.govern({ groupId: founded.namespaceId, op: applicationOp });
|
|
391
|
-
result = { ...result, applicationSet: true };
|
|
392
|
-
}
|
|
393
|
-
catch (err) {
|
|
394
|
-
result = { ...result, applicationSet: false, applicationError: message(err) };
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
if (capabilitiesOp) {
|
|
398
|
-
try {
|
|
399
|
-
await this.govern({ groupId: founded.namespaceId, op: capabilitiesOp });
|
|
400
|
-
result = { ...result, defaultCapabilitiesSet: true };
|
|
401
|
-
}
|
|
402
|
-
catch (err) {
|
|
403
|
-
result = { ...result, defaultCapabilitiesSet: false, defaultCapabilitiesError: message(err) };
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
return result;
|
|
407
|
-
}
|
|
408
|
-
async postGovernance(groupId, op, warrant) {
|
|
409
|
-
const body = await this.json('POST', `/admin-api/groups/${pathSegment(groupId)}/governance-intents`, {
|
|
410
|
-
warrant,
|
|
411
|
-
authorProof: this.config.authorProof,
|
|
412
|
-
op: hex(op.bytes),
|
|
413
|
-
});
|
|
414
|
-
return body.data;
|
|
415
|
-
}
|
|
416
|
-
/**
|
|
417
|
-
* The relay's answer, checked against a configured account.
|
|
418
|
-
* Decoded here so a relay naming no key is refused before a nonce is taken.
|
|
419
|
-
*/
|
|
420
|
-
checkedExecutor(answered) {
|
|
421
|
-
const executor = {
|
|
422
|
-
executorAccount: hex(fromHex(answered.executorAccount ?? '', "the relay's executorAccount", 32)),
|
|
423
|
-
executorKey: hex(fromHex(answered.executorKey ?? '', "the relay's executorKey", 32)),
|
|
424
|
-
};
|
|
425
|
-
const configured = this.config.executorAccount;
|
|
426
|
-
if (configured && configured.toLowerCase() !== executor.executorAccount) {
|
|
427
|
-
throw new Error(`configured executorAccount ${configured} is not the relay's account ` +
|
|
428
|
-
`${executor.executorAccount}; a warrant naming it would be unspendable`);
|
|
429
|
-
}
|
|
430
|
-
return executor;
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* One request, waiting out a rate limit rather than failing the caller.
|
|
434
|
-
*
|
|
435
|
-
* The relay's ingress limits the delegated-execution routes to 20 requests a
|
|
436
|
-
* second per source IP, burst 40 — tighter than everything else it serves,
|
|
437
|
-
* because those two routes are the ones it answers to anonymous callers.
|
|
438
|
-
*
|
|
439
|
-
* Retrying a 429 is safe here in a way retrying most refusals is not: the
|
|
440
|
-
* limiter sits *first* in the middleware chain, so a throttled request never
|
|
441
|
-
* reached the node and its warrant nonce was never spent. The same bytes can
|
|
442
|
-
* go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
|
|
443
|
-
*
|
|
444
|
-
* `Retry-After` is honoured when the ingress sends one; otherwise the wait
|
|
445
|
-
* doubles from a little over the limiter's own window, which is the shortest
|
|
446
|
-
* delay that can actually clear.
|
|
447
|
-
*/
|
|
448
|
-
async json(method, path, body) {
|
|
449
|
-
let wait = 250;
|
|
450
|
-
for (let attempt = 0;; attempt += 1) {
|
|
451
|
-
try {
|
|
452
|
-
return await this.jsonOnce(method, path, body);
|
|
453
|
-
}
|
|
454
|
-
catch (err) {
|
|
455
|
-
const limited = err instanceof HTTPError && err.status === 429;
|
|
456
|
-
if (!limited || attempt >= this.rateLimitRetries)
|
|
457
|
-
throw err;
|
|
458
|
-
const after = retryAfterMs(err.headers);
|
|
459
|
-
await sleep(after ?? wait);
|
|
460
|
-
wait *= 2;
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
async jsonOnce(method, path, body) {
|
|
465
|
-
const headers = { Accept: 'application/json' };
|
|
466
|
-
if (body !== undefined)
|
|
467
|
-
headers['Content-Type'] = 'application/json';
|
|
468
|
-
const controller = new AbortController();
|
|
469
|
-
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
470
|
-
let response;
|
|
471
|
-
try {
|
|
472
|
-
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
473
|
-
method,
|
|
474
|
-
headers,
|
|
475
|
-
body: body === undefined ? undefined : JSON.stringify(body),
|
|
476
|
-
signal: controller.signal,
|
|
477
|
-
});
|
|
478
|
-
}
|
|
479
|
-
catch (err) {
|
|
480
|
-
throw new HTTPError(0, err instanceof Error ? err.name : 'NetworkError', `${this.baseUrl}${path}`, new Headers(), err instanceof Error ? err.message : String(err));
|
|
481
|
-
}
|
|
482
|
-
finally {
|
|
483
|
-
clearTimeout(timer);
|
|
484
|
-
}
|
|
485
|
-
if (!response.ok) {
|
|
486
|
-
const text = await response.text().catch(() => '');
|
|
487
|
-
// 400 and 403 are both caller preconditions and core separates them
|
|
488
|
-
// deliberately: 400 means re-sending the same bytes cannot help, 403
|
|
489
|
-
// means the bytes are genuine but authority is missing. Only the spent
|
|
490
|
-
// nonce is worth retrying, and only under a fresh warrant.
|
|
491
|
-
if (response.status === 400 || response.status === 403) {
|
|
492
|
-
const reason = extractReason(text);
|
|
493
|
-
throw new IntentRefusedError(reason, /nonce/i.test(reason), response.status);
|
|
494
|
-
}
|
|
495
|
-
throw new HTTPError(response.status, response.statusText, `${this.baseUrl}${path}`, response.headers, text);
|
|
496
|
-
}
|
|
497
|
-
// `presence-intents` answers an accepted update with no body.
|
|
498
|
-
if (response.status === 204)
|
|
499
|
-
return undefined;
|
|
500
|
-
return (await response.json());
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
/** Pull the relay's explanation out of its error body, or fall back to the raw text. */
|
|
504
|
-
function extractReason(bodyText) {
|
|
505
|
-
if (!bodyText)
|
|
506
|
-
return 'no reason given';
|
|
507
|
-
try {
|
|
508
|
-
const parsed = JSON.parse(bodyText);
|
|
509
|
-
for (const candidate of [parsed.error, parsed.message]) {
|
|
510
|
-
if (typeof candidate === 'string' && candidate)
|
|
511
|
-
return candidate;
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
catch {
|
|
515
|
-
// Not JSON — the text itself is the best available explanation.
|
|
516
|
-
}
|
|
517
|
-
return bodyText;
|
|
518
|
-
}
|
|
@@ -1,66 +0,0 @@
|
|
|
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>;
|