@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.
Files changed (76) hide show
  1. package/dist/account/account.d.ts +99 -4
  2. package/dist/account/account.js +0 -0
  3. package/dist/account/index.d.ts +6 -2
  4. package/dist/account/index.js +3 -1
  5. package/dist/admin-api/admin-client.d.ts +154 -2
  6. package/dist/admin-api/admin-client.js +255 -1
  7. package/dist/admin-api/admin-types.d.ts +193 -5
  8. package/dist/cloud/cloud-client.d.ts +38 -8
  9. package/dist/cloud/cloud-client.js +38 -21
  10. package/dist/cloud/connect.d.ts +101 -2
  11. package/dist/cloud/connect.js +84 -8
  12. package/dist/cloud/enrol-redirect.d.ts +195 -0
  13. package/dist/cloud/enrol-redirect.js +236 -0
  14. package/dist/cloud/index.d.ts +6 -2
  15. package/dist/cloud/index.js +7 -1
  16. package/dist/cloud/link-redirect.d.ts +85 -0
  17. package/dist/cloud/link-redirect.js +105 -0
  18. package/dist/crypto/internal.d.ts +15 -0
  19. package/dist/crypto/internal.js +18 -0
  20. package/dist/device-cert/device-cert.d.ts +96 -2
  21. package/dist/device-cert/device-cert.js +101 -11
  22. package/dist/device-cert/device-scope.d.ts +58 -0
  23. package/dist/device-cert/device-scope.js +76 -0
  24. package/dist/device-cert/index.d.ts +4 -2
  25. package/dist/device-cert/index.js +2 -1
  26. package/dist/events/sse.d.ts +22 -2
  27. package/dist/events/sse.js +20 -11
  28. package/dist/http-client/http-types.d.ts +18 -0
  29. package/dist/http-client/web-client.js +21 -1
  30. package/dist/index.browser.mjs +2 -2
  31. package/dist/index.browser.mjs.map +4 -4
  32. package/dist/index.cjs +1955 -496
  33. package/dist/index.cjs.map +4 -4
  34. package/dist/index.d.ts +13 -4
  35. package/dist/index.js +21 -2
  36. package/dist/index.mjs +1955 -496
  37. package/dist/index.mjs.map +4 -4
  38. package/dist/invitation/index.d.ts +2 -0
  39. package/dist/invitation/index.js +2 -0
  40. package/dist/invitation/invitation.d.ts +106 -0
  41. package/dist/invitation/invitation.js +104 -0
  42. package/dist/login/login.d.ts +11 -1
  43. package/dist/login/login.js +5 -4
  44. package/dist/login/session.d.ts +21 -5
  45. package/dist/login/session.js +7 -15
  46. package/dist/namespace-op/namespace-op.d.ts +14 -2
  47. package/dist/namespace-op/namespace-op.js +13 -19
  48. package/dist/relay/index.d.ts +2 -2
  49. package/dist/relay/index.js +1 -1
  50. package/dist/relay/nonce-source.d.ts +135 -0
  51. package/dist/relay/nonce-source.js +187 -19
  52. package/dist/relay/relay-client.d.ts +60 -5
  53. package/dist/relay/relay-client.js +84 -4
  54. package/dist/relay-attestation/attest-node-key.d.ts +66 -0
  55. package/dist/relay-attestation/attest-node-key.js +110 -0
  56. package/dist/relay-attestation/index.d.ts +1 -0
  57. package/dist/relay-attestation/index.js +1 -0
  58. package/dist/request-proof/authorizer.d.ts +48 -0
  59. package/dist/request-proof/authorizer.js +65 -0
  60. package/dist/request-proof/index.d.ts +4 -0
  61. package/dist/request-proof/index.js +2 -0
  62. package/dist/request-proof/request-proof.d.ts +56 -0
  63. package/dist/request-proof/request-proof.js +111 -0
  64. package/dist/rpc/index.d.ts +21 -0
  65. package/dist/rpc/index.js +20 -0
  66. package/dist/transport/index.d.ts +6 -0
  67. package/dist/transport/index.js +3 -0
  68. package/dist/transport/mero-client.d.ts +286 -0
  69. package/dist/transport/mero-client.js +379 -0
  70. package/dist/transport/relay-observer.d.ts +152 -0
  71. package/dist/transport/relay-observer.js +173 -0
  72. package/dist/transport/relay-transport.d.ts +73 -0
  73. package/dist/transport/relay-transport.js +94 -0
  74. package/dist/transport/types.d.ts +100 -0
  75. package/dist/transport/types.js +32 -0
  76. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -30,12 +30,18 @@ __export(index_exports, {
30
30
  EphemeralClient: () => EphemeralClient,
31
31
  HANDSHAKE_PATH: () => HANDSHAKE_PATH,
32
32
  HTTPError: () => HTTPError,
33
+ INVITED_ROLE: () => INVITED_ROLE,
33
34
  IntentRefusedError: () => IntentRefusedError,
34
35
  LocalStorageTokenStore: () => LocalStorageTokenStore,
36
+ MAX_INVITATION_VALIDITY_SECS: () => MAX_INVITATION_VALIDITY_SECS,
35
37
  MemoryTokenStore: () => MemoryTokenStore,
38
+ MeroClient: () => MeroClient,
36
39
  MeroJs: () => MeroJs,
37
40
  NODE_RELEASE_SIGNER: () => NODE_RELEASE_SIGNER,
41
+ REQUEST_PROOF_HEADER: () => REQUEST_PROOF_HEADER,
38
42
  RelayClient: () => RelayClient,
43
+ RelayObserver: () => RelayObserver,
44
+ RelayTransport: () => RelayTransport,
39
45
  RpcClient: () => RpcClient,
40
46
  RpcError: () => RpcError,
41
47
  SEALED_CONTENT_TYPE: () => SEALED_CONTENT_TYPE,
@@ -44,21 +50,36 @@ __export(index_exports, {
44
50
  SealedTransportError: () => SealedTransportError,
45
51
  SseClient: () => SseClient,
46
52
  StaleTransportKeyError: () => StaleTransportKeyError,
53
+ WarrantNonceExhaustedError: () => WarrantNonceExhaustedError,
54
+ WarrantNonceRouteUnavailableError: () => WarrantNonceRouteUnavailableError,
47
55
  WebHttpClient: () => WebHttpClient,
48
56
  WsClient: () => WsClient,
49
57
  accountForRoot: () => accountForRoot,
58
+ accountForRootPublicKey: () => accountForRootPublicKey,
50
59
  accountRootFromPhrase: () => accountRootFromPhrase,
51
60
  accountRootFromSecret: () => accountRootFromSecret,
61
+ accountRootSignerFromPhrase: () => accountRootSignerFromPhrase,
52
62
  admitterAddrsFromNetworkStatus: () => admitterAddrsFromNetworkStatus,
63
+ attestKeyBinding: () => attestKeyBinding,
64
+ attestRelayNodeKey: () => attestRelayNodeKey,
65
+ authorNonceLookup: () => authorNonceLookup,
66
+ beginCloudLink: () => beginCloudLink,
67
+ beginDeviceEnrolment: () => beginDeviceEnrolment,
53
68
  buildAuthLoginUrl: () => buildAuthLoginUrl,
69
+ callbackIsForeign: () => callbackIsForeign,
54
70
  callerProof: () => callerProof,
55
71
  capMask: () => capMask,
56
72
  capNames: () => capNames,
57
73
  classifyError: () => classifyError,
74
+ cloudLinkUrl: () => cloudLinkUrl,
58
75
  cloudNodeReleaseUrl: () => cloudNodeReleaseUrl,
59
76
  combineSignals: () => combineSignals,
60
77
  compareSemver: () => compareSemver,
78
+ completeCloudLink: () => completeCloudLink,
79
+ completeDeviceEnrolment: () => completeDeviceEnrolment,
61
80
  connectCloud: () => connectCloud,
81
+ connectCloudWithAccount: () => connectCloudWithAccount,
82
+ createAccountRootSigner: () => createAccountRootSigner,
62
83
  createAdminApiClient: () => createAdminApiClient,
63
84
  createAdminApiClientFromHttpClient: () => createAdminApiClientFromHttpClient,
64
85
  createAttestedSealedFetch: () => createAttestedSealedFetch,
@@ -70,12 +91,15 @@ __export(index_exports, {
70
91
  createHttpClient: () => createHttpClient,
71
92
  createLocalStorageNonceSource: () => createLocalStorageNonceSource,
72
93
  createMemoryNonceSource: () => createMemoryNonceSource,
94
+ createMeroClient: () => createMeroClient,
73
95
  createMeroJs: () => createMeroJs,
74
96
  createNodeAdminApiClient: () => createNodeAdminApiClient,
75
97
  createNodeAuthApiClient: () => createNodeAuthApiClient,
76
98
  createNodeHttpClient: () => createNodeHttpClient,
99
+ createProofAuthorizer: () => createProofAuthorizer,
77
100
  createProofSigner: () => createProofSigner,
78
101
  createQuoteVerifier: () => createQuoteVerifier,
102
+ createRecoveringNonceSource: () => createRecoveringNonceSource,
79
103
  createRetryableMethod: () => createRetryableMethod,
80
104
  createSealedFetch: () => createSealedFetch,
81
105
  createSignedReleaseSealedFetch: () => createSignedReleaseSealedFetch,
@@ -83,9 +107,14 @@ __export(index_exports, {
83
107
  createTimeoutSignal: () => createTimeoutSignal,
84
108
  createUniversalHttpClient: () => createUniversalHttpClient,
85
109
  creationInitHash: () => creationInitHash,
110
+ defaultAdmitters: () => defaultAdmitters,
111
+ defaultAudience: () => defaultAudience,
86
112
  defaultCapabilitiesSetOp: () => defaultCapabilitiesSetOp,
87
113
  deviceCertPayload: () => deviceCertPayload,
114
+ deviceEnrolmentUrl: () => deviceEnrolmentUrl,
115
+ deviceScopePayload: () => deviceScopePayload,
88
116
  discoverLocalNodes: () => discoverLocalNodes,
117
+ encodeGroupInvitation: () => encodeGroupInvitation,
89
118
  encodeSignedInvitation: () => encodeSignedInvitation,
90
119
  fetchAttestedTransportKey: () => fetchAttestedTransportKey,
91
120
  fetchNodeRelease: () => fetchNodeRelease,
@@ -96,6 +125,7 @@ __export(index_exports, {
96
125
  governanceOpHash: () => governanceOpHash,
97
126
  groupCreatedOp: () => groupCreatedOp,
98
127
  groupDeletedOp: () => groupDeletedOp,
128
+ groupInvitationHash: () => groupInvitationHash,
99
129
  groupReparentedOp: () => groupReparentedOp,
100
130
  hasCap: () => hasCap,
101
131
  hexEncodeUtf8: () => hexEncodeUtf8,
@@ -114,27 +144,39 @@ __export(index_exports, {
114
144
  nodeReleaseVersionFromImage: () => nodeReleaseVersionFromImage,
115
145
  parseAuthCallback: () => parseAuthCallback,
116
146
  parseCreationWarrant: () => parseCreationWarrant,
147
+ parseDeviceCredential: () => parseDeviceCredential,
117
148
  parseGovernanceWarrant: () => parseGovernanceWarrant,
149
+ parseWarrantNonce: () => parseWarrantNonce,
118
150
  probeNodeHealth: () => probeNodeHealth,
151
+ readCloudLinkCallback: () => readCloudLinkCallback,
152
+ readEnrolmentCallback: () => readEnrolmentCallback,
119
153
  redeemInvitation: () => redeemInvitation,
154
+ reportDataOf: () => reportDataOf,
120
155
  requestBodyHash: () => requestBodyHash,
156
+ resolveRoot: () => resolveRoot,
157
+ resolveRootPair: () => resolveRootPair,
121
158
  routingProofHeaders: () => routingProofHeaders,
122
159
  shouldRetain: () => shouldRetain,
123
160
  signAccountLink: () => signAccountLink,
124
161
  signAccountLogin: () => signAccountLogin,
125
162
  signCreationWarrant: () => signCreationWarrant,
126
163
  signDeviceCert: () => signDeviceCert,
164
+ signDeviceScope: () => signDeviceScope,
127
165
  signGovernanceWarrant: () => signGovernanceWarrant,
166
+ signGroupInvitation: () => signGroupInvitation,
128
167
  signLoginStatement: () => signLoginStatement,
129
168
  signMemberJoinOp: () => signMemberJoinOp,
130
169
  signRequest: () => signRequest,
170
+ signRequestProof: () => signRequestProof,
131
171
  signRoutingChallenge: () => signRoutingChallenge,
132
172
  signWarrant: () => signWarrant,
173
+ signedPath: () => signedPath,
133
174
  signerFromCryptoKey: () => signerFromCryptoKey,
134
175
  signerFromSecret: () => signerFromSecret,
135
176
  transportKeyBinding: () => transportKeyBinding,
136
177
  trustSignedRelease: () => trustSignedRelease,
137
178
  trustedMeasurementsFromReleases: () => trustedMeasurementsFromReleases,
179
+ verifyDeviceCredential: () => verifyDeviceCredential,
138
180
  verifySignedNodeRelease: () => verifySignedNodeRelease,
139
181
  withCap: () => withCap,
140
182
  withRetry: () => withRetry,
@@ -314,8 +356,27 @@ var WebHttpClient = class {
314
356
  } else {
315
357
  headersObj = headers;
316
358
  }
359
+ const method = init?.method || "GET";
360
+ if (this.transport.authorizeRequest) {
361
+ const body = init?.body;
362
+ if (body !== void 0 && typeof body !== "string" && !(body instanceof Uint8Array)) {
363
+ throw new Error(
364
+ "this transport authorizes each request against its body, so the body must be a string or Uint8Array \u2014 a stream cannot be signed, because the signature would have to read bytes the request then cannot send."
365
+ );
366
+ }
367
+ Object.assign(
368
+ headersObj,
369
+ await this.transport.authorizeRequest({
370
+ method,
371
+ path: new URL(url).pathname,
372
+ // Narrowed by the guard above; `BodyInit` is wider than what can be
373
+ // signed.
374
+ body
375
+ })
376
+ );
377
+ }
317
378
  const requestInit = {
318
- method: init?.method || "GET",
379
+ method,
319
380
  headers: headersObj
320
381
  };
321
382
  const isStreamBody = init?.body instanceof ReadableStream || typeof init?.body === "object" && init?.body !== null && "getReader" in init.body && !(init.body instanceof Blob);
@@ -1075,6 +1136,50 @@ async function redeemInvitation(parsed, redeemer) {
1075
1136
  }
1076
1137
 
1077
1138
  // src/admin-api/admin-client.ts
1139
+ var WarrantNonceRouteUnavailableError = class extends Error {
1140
+ constructor(path, cause) {
1141
+ super(
1142
+ `this node does not serve warrant-nonce discovery (404 on ${path}); it predates the route, so a client must fall back to its own nonce counter`
1143
+ );
1144
+ this.path = path;
1145
+ this.cause = cause;
1146
+ this.name = "WarrantNonceRouteUnavailableError";
1147
+ }
1148
+ };
1149
+ function u64FromBody(body, parsed, field) {
1150
+ const value = parsed[field];
1151
+ if (value === void 0 || value === null) return void 0;
1152
+ const digits = new RegExp(`"${field}"\\s*:\\s*(\\d+)`).exec(body);
1153
+ if (digits) return BigInt(digits[1]);
1154
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
1155
+ throw new Error(`warrant-nonce response field \`${field}\` is not a u64: ${JSON.stringify(value)}`);
1156
+ }
1157
+ function parseWarrantNonce(body) {
1158
+ const parsed = JSON.parse(body);
1159
+ const data = parsed?.data;
1160
+ if (!data || typeof data !== "object") {
1161
+ throw new Error(`warrant-nonce response had no \`data\`: ${body.slice(0, 200)}`);
1162
+ }
1163
+ const common = {
1164
+ contextId: String(data.contextId ?? ""),
1165
+ authorDeviceKey: String(data.authorDeviceKey ?? ""),
1166
+ seen: data.seen === true,
1167
+ highWaterNonce: u64FromBody(body, data, "highWaterNonce"),
1168
+ // Not defaulted: a node that stopped sending it is a node whose window this
1169
+ // client would be guessing at, and the window is not a nonce input anyway.
1170
+ windowWidth: u64FromBody(body, data, "windowWidth") ?? 0n
1171
+ };
1172
+ const nextNonce = u64FromBody(body, data, "nextNonce");
1173
+ if (nextNonce === void 0) {
1174
+ return {
1175
+ ...common,
1176
+ kind: "exhausted",
1177
+ seen: true,
1178
+ highWaterNonce: common.highWaterNonce ?? 0xffffffffffffffffn
1179
+ };
1180
+ }
1181
+ return { ...common, kind: "open", nextNonce };
1182
+ }
1078
1183
  function unwrap(response) {
1079
1184
  return response?.data;
1080
1185
  }
@@ -1875,6 +1980,13 @@ var AdminApiClient = class {
1875
1980
  * `namespaceId` names where to publish from, not the extent of the revocation:
1876
1981
  * a device belongs to the account, so every namespace holding a binding for it
1877
1982
  * is withdrawn from and reported back.
1983
+ *
1984
+ * **Revocation is forward-only.** It withdraws the device's authority from
1985
+ * here on and does NOT retract past authorship: ops the device already signed
1986
+ * keep verifying and stay in the DAG, because the certificate that authorised
1987
+ * them was valid when the signature was made. Nothing in this system can
1988
+ * un-author what a device wrote, so a UI built on this call must not offer
1989
+ * that - "this device can no longer act" is the only claim it supports.
1878
1990
  */
1879
1991
  async revokeAccountDevice(namespaceId, request) {
1880
1992
  return unwrap(
@@ -1884,6 +1996,29 @@ var AdminApiClient = class {
1884
1996
  )
1885
1997
  );
1886
1998
  }
1999
+ /**
2000
+ * Carry the device link of an account **this node does not hold**, so the
2001
+ * namespace binds that device's key to the account.
2002
+ *
2003
+ * The relay half of a nodeless account minting invitations. An account added
2004
+ * to a group by account holds its grants but has no device bound, so peers
2005
+ * refuse whatever its device signs as "bound to no account". The credential
2006
+ * and scope are root-signed and self-certifying; this node adds its own
2007
+ * member endorsement and publishes the `AccountDeviceLinked`. After that the
2008
+ * device signs invitations itself — see `signGroupInvitation`.
2009
+ *
2010
+ * Refusals: `400` when a proof does not verify or the scope does not reach
2011
+ * this namespace (re-sign it), `403` when the device was revoked here or the
2012
+ * account is a member of nothing in the namespace (add it first).
2013
+ */
2014
+ async linkAccountDevice(namespaceId, request) {
2015
+ return unwrap(
2016
+ await this.httpClient.post(
2017
+ `/admin-api/namespaces/${namespaceId}/account/link-device`,
2018
+ request
2019
+ )
2020
+ );
2021
+ }
1887
2022
  // ---- Group Management ----
1888
2023
  async getGroupInfo(groupId) {
1889
2024
  return unwrap(await this.httpClient.get(`/admin-api/groups/${groupId}`));
@@ -1917,6 +2052,138 @@ var AdminApiClient = class {
1917
2052
  }
1918
2053
  return response;
1919
2054
  }
2055
+ /**
2056
+ * Account -> devices for the members of a group, as its live bindings record
2057
+ * them.
2058
+ *
2059
+ * The join between a group member listing, which names accounts, and a
2060
+ * context's identities, which name bare signing keys - both 64 hex, and
2061
+ * nothing but the source tells them apart. Batch rather than per-account
2062
+ * because the key -> account direction gets asked about arbitrary authors,
2063
+ * which a per-account route would answer in N calls.
2064
+ *
2065
+ * What comes back is **caller-scoped by the node**: an admin of the group sees
2066
+ * every member's devices, a plain member only its own entry. A single-entry
2067
+ * answer is therefore a statement about this caller's role, not about the
2068
+ * group's size.
2069
+ *
2070
+ * Only live bindings appear, so a revoked device is absent here - use
2071
+ * {@link listAccountDevices} for the account's own devices, which keeps a
2072
+ * revoked one with `revoked: true`. Absence here never means the device never
2073
+ * acted: revocation is forward-only and its past ops remain in the DAG.
2074
+ *
2075
+ * Wrapped in `members` on the wire rather than `data`, unwrapped here for the
2076
+ * same reason {@link listAccountDevices} unwraps `devices`.
2077
+ */
2078
+ async listGroupMemberDevices(groupId, options) {
2079
+ const params = new URLSearchParams();
2080
+ if (options?.offset !== void 0) params.set("offset", String(options.offset));
2081
+ if (options?.limit !== void 0) params.set("limit", String(options.limit));
2082
+ const query = params.toString();
2083
+ const base = `/admin-api/groups/${encodeURIComponent(groupId)}/member-devices`;
2084
+ const response = await this.httpClient.get(
2085
+ query ? `${base}?${query}` : base
2086
+ );
2087
+ return response.members;
2088
+ }
2089
+ /**
2090
+ * Where `authorDeviceKey` stands in its warrant-nonce sequence in `contextId`.
2091
+ *
2092
+ * # What this is for
2093
+ *
2094
+ * The nonce is the one input to a warrant that is stateful on the *client*.
2095
+ * Everything else an author needs it either holds (its keys, the method, the
2096
+ * args) or can read from the relay ({@link RelayClient.describe} gives the
2097
+ * executor account and the grant). So an author that has lost its counter —
2098
+ * a browser keyholder in partitioned or periodically cleared storage, which
2099
+ * is its normal lifecycle rather than a fault — otherwise has exactly one
2100
+ * strategy: guess upward and burn a refused round trip per wrong guess, with
2101
+ * each refusal indistinguishable at the API from a genuinely spent nonce.
2102
+ *
2103
+ * Read {@link WarrantNonceOpen.nextNonce} and mint there. If you still hold
2104
+ * your own counter, mint at `max(ownNext, nextNonce)`: nonce state folds per
2105
+ * peer, so a node can only be BEHIND a client whose warrants it has not
2106
+ * applied — never ahead of the truth. An author spread across several relays
2107
+ * asks each and takes the highest answer. {@link createRecoveringNonceSource}
2108
+ * does all of that.
2109
+ *
2110
+ * Purely a read. It spends nothing and advances nothing, so it may be called
2111
+ * as often as you like.
2112
+ *
2113
+ * # Forward compatibility — this route is NEW
2114
+ *
2115
+ * It is not on core `master` yet, so a current node answers **404**, which
2116
+ * this method surfaces as {@link WarrantNonceRouteUnavailableError} rather
2117
+ * than as an opaque {@link HTTPError}. That distinction matters because the
2118
+ * handler itself never 404s: a device with no row is a `200` with
2119
+ * `seen: false`, and a malformed id is a `400`. So a 404 here means the node
2120
+ * does not serve the route at all — an older build, or a deployment that has
2121
+ * not re-mounted it — and the answer is to fall back to a local counter, not
2122
+ * to retry.
2123
+ *
2124
+ * # Authentication
2125
+ *
2126
+ * On the PROTECTED admin router, unlike the two delegated-execution routes it
2127
+ * sits beside: it takes another principal's device key as input and reports
2128
+ * that principal's activity. A `--public-intents` deployment does not serve
2129
+ * this anonymously, so an admin credential is required.
2130
+ *
2131
+ * @param authorDeviceKey the device's signing key, base58 (43-44 chars) or
2132
+ * hex (exactly 64). The node accepts either; the two never collide.
2133
+ */
2134
+ async getWarrantNonce(contextId, authorDeviceKey) {
2135
+ const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce/${encodeURIComponent(authorDeviceKey)}`;
2136
+ let raw;
2137
+ try {
2138
+ raw = await this.httpClient.get(path, { parse: "text" });
2139
+ } catch (error) {
2140
+ if (error instanceof HTTPError && error.status === 404) {
2141
+ throw new WarrantNonceRouteUnavailableError(path, error);
2142
+ }
2143
+ throw error;
2144
+ }
2145
+ return parseWarrantNonce(raw);
2146
+ }
2147
+ /**
2148
+ * The same answer, asked for the way a delegated client has to ask.
2149
+ *
2150
+ * {@link getWarrantNonce} is on the protected admin router, so a keyholder
2151
+ * writing through a relay — the client this whole facility exists for — is
2152
+ * refused it: measured `403` for a session carrying exactly
2153
+ * `context:intent`, `context:query`, `context:subscribe`. That client has no
2154
+ * admin credential and cannot be given one without defeating the point of the
2155
+ * delegated surface.
2156
+ *
2157
+ * What it does have is the credential proving the device is its own, which it
2158
+ * already sends on every write. Presenting it here makes the request
2159
+ * self-scoped: you learn a device's position only by proving, at call time,
2160
+ * that the device is yours. So there is no oracle to protect against, and no
2161
+ * reason for an admin gate.
2162
+ *
2163
+ * The device key is NOT a parameter. It is read from the verified
2164
+ * certificate server-side, which is the only reading that cannot disagree
2165
+ * with the signature — a key supplied alongside would be a second spelling of
2166
+ * the same fact, and two spellings can differ.
2167
+ *
2168
+ * Returns the identical shape {@link getWarrantNonce} returns, so a caller can
2169
+ * swap one for the other without touching what it does with the answer.
2170
+ *
2171
+ * @param authorProof hex-encoded borsh `AccountProof<DeviceCert>` — the same
2172
+ * value passed as `authorProof` to the relay client.
2173
+ */
2174
+ async getWarrantNonceAsAuthor(contextId, authorProof) {
2175
+ const path = `/admin-api/contexts/${encodeURIComponent(contextId)}/warrant-nonce`;
2176
+ let raw;
2177
+ try {
2178
+ raw = await this.httpClient.post(path, { authorProof }, { parse: "text" });
2179
+ } catch (error) {
2180
+ if (error instanceof HTTPError && error.status === 404) {
2181
+ throw new WarrantNonceRouteUnavailableError(path, error);
2182
+ }
2183
+ throw error;
2184
+ }
2185
+ return parseWarrantNonce(raw);
2186
+ }
1920
2187
  /**
1921
2188
  * Member accounts in the group and the devices bound to each. An admin node
1922
2189
  * sees every account; a plain member only its own. `403` if this node is not a
@@ -2427,6 +2694,16 @@ async function importSigningKey(secretHex) {
2427
2694
  );
2428
2695
  }
2429
2696
  }
2697
+ async function verifySignature(publicKey, signature, payload) {
2698
+ const key = await crypto.subtle.importKey(
2699
+ "raw",
2700
+ fromHex(publicKey, "publicKey", 32),
2701
+ { name: "Ed25519" },
2702
+ false,
2703
+ ["verify"]
2704
+ );
2705
+ return crypto.subtle.verify({ name: "Ed25519" }, key, signature, payload);
2706
+ }
2430
2707
  async function derivePublicKey(secretHex) {
2431
2708
  const seed = fromHex(secretHex, "deviceSecret", 32);
2432
2709
  const pkcs8 = concat(PKCS8_ED25519_PREFIX, seed);
@@ -2550,6 +2827,15 @@ async function jsonRpcCall(httpClient, method, params) {
2550
2827
  }
2551
2828
  var RpcClient = class {
2552
2829
  constructor(opts) {
2830
+ /**
2831
+ * Which write path this is. Present so `RpcClient` satisfies
2832
+ * `ExecuteTransport` as-is — the transport abstraction adopted *this* class's
2833
+ * `execute` shape as the canonical one, so the node side is the interface
2834
+ * rather than an adapter onto it.
2835
+ */
2836
+ this.kind = "node";
2837
+ /** A node serves `/sse` and `/ws` to the credential it issued. */
2838
+ this.canSubscribe = true;
2553
2839
  this.httpClient = opts.httpClient;
2554
2840
  }
2555
2841
  async execute(params) {
@@ -2563,6 +2849,17 @@ var RpcClient = class {
2563
2849
  }
2564
2850
  return result;
2565
2851
  }
2852
+ /**
2853
+ * The same call, in the shape the transport abstraction shares with the
2854
+ * relay.
2855
+ *
2856
+ * `rootHash` is left absent rather than synthesized: JSON-RPC `execute` does
2857
+ * not report the context's scope root, and inventing a value — or a `''` —
2858
+ * would make "the node does not tell us" indistinguishable from an answer.
2859
+ */
2860
+ async executeWithMetadata(params) {
2861
+ return { returns: await this.execute(params), transport: "node" };
2862
+ }
2566
2863
  /**
2567
2864
  * One-tap owner-driven convert: re-signs the caller's identity-gated entries
2568
2865
  * to the current schema. The export converts all of the caller's
@@ -2608,7 +2905,14 @@ var SseClient = class {
2608
2905
  this.closed = false;
2609
2906
  this.listeners = { connect: [], event: [], error: [] };
2610
2907
  this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
2611
- this.getAuthToken = opts.getAuthToken;
2908
+ if (opts.authorize) {
2909
+ this.authorize = opts.authorize;
2910
+ } else if (opts.getAuthToken) {
2911
+ const getAuthToken = opts.getAuthToken;
2912
+ this.authorize = async () => ({ Authorization: `Bearer ${await getAuthToken()}` });
2913
+ } else {
2914
+ throw new Error("SseClient needs either `getAuthToken` or `authorize`");
2915
+ }
2612
2916
  this.reconnectDelayMs = opts.reconnectDelayMs ?? 3e3;
2613
2917
  this.fetchImpl = (url, init) => opts.fetch ? opts.fetch(url, init) : globalThis.fetch(url, init);
2614
2918
  }
@@ -2675,11 +2979,10 @@ var SseClient = class {
2675
2979
  this.closed = false;
2676
2980
  this.abortController = new AbortController();
2677
2981
  try {
2678
- const token = await this.getAuthToken();
2679
2982
  const url = `${this.baseUrl}/sse`;
2680
2983
  const response = await this.fetchImpl(url, {
2681
2984
  headers: {
2682
- "Authorization": `Bearer ${token}`,
2985
+ ...await this.authorize({ method: "GET", path: new URL(url).pathname }),
2683
2986
  "Accept": "text/event-stream"
2684
2987
  },
2685
2988
  signal: this.abortController.signal
@@ -2832,21 +3135,18 @@ var SseClient = class {
2832
3135
  }
2833
3136
  async sendSubscription(method, ids) {
2834
3137
  try {
2835
- const token = await this.getAuthToken();
2836
3138
  const params = {};
2837
3139
  if (ids.contextIds && ids.contextIds.length > 0) params.contextIds = ids.contextIds;
2838
3140
  if (ids.groupIds && ids.groupIds.length > 0) params.groupIds = ids.groupIds;
2839
- const response = await this.fetchImpl(`${this.baseUrl}/sse/subscription`, {
3141
+ const url = `${this.baseUrl}/sse/subscription`;
3142
+ const body = JSON.stringify({ id: this.sessionId, method, params });
3143
+ const response = await this.fetchImpl(url, {
2840
3144
  method: "POST",
2841
3145
  headers: {
2842
- "Authorization": `Bearer ${token}`,
3146
+ ...await this.authorize({ method: "POST", path: new URL(url).pathname, body }),
2843
3147
  "Content-Type": "application/json"
2844
3148
  },
2845
- body: JSON.stringify({
2846
- id: this.sessionId,
2847
- method,
2848
- params
2849
- })
3149
+ body
2850
3150
  });
2851
3151
  if (!response.ok) {
2852
3152
  const terminal = isTerminalAuthFailure(response);
@@ -3165,11 +3465,123 @@ var EphemeralClient = class {
3165
3465
  }
3166
3466
  };
3167
3467
 
3468
+ // src/signer/signer.ts
3469
+ var PKCS8_ED25519_PREFIX2 = new Uint8Array([
3470
+ 48,
3471
+ 46,
3472
+ 2,
3473
+ 1,
3474
+ 0,
3475
+ 48,
3476
+ 5,
3477
+ 6,
3478
+ 3,
3479
+ 43,
3480
+ 101,
3481
+ 112,
3482
+ 4,
3483
+ 34,
3484
+ 4,
3485
+ 32
3486
+ ]);
3487
+ function unsupported(cause) {
3488
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
3489
+ return new Error(
3490
+ "this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
3491
+ );
3492
+ }
3493
+ async function signerFromSecret(secret, label2 = "secret") {
3494
+ const seed = fromHex(secret, label2, 32);
3495
+ const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
3496
+ let privateKey;
3497
+ let publicKey;
3498
+ try {
3499
+ const exportable = await crypto.subtle.importKey(
3500
+ "pkcs8",
3501
+ pkcs8,
3502
+ { name: "Ed25519" },
3503
+ true,
3504
+ ["sign"]
3505
+ );
3506
+ const jwk = await crypto.subtle.exportKey("jwk", exportable);
3507
+ publicKey = hex(
3508
+ Uint8Array.from(
3509
+ atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
3510
+ (c) => c.charCodeAt(0)
3511
+ )
3512
+ );
3513
+ privateKey = await crypto.subtle.importKey(
3514
+ "pkcs8",
3515
+ pkcs8,
3516
+ { name: "Ed25519" },
3517
+ false,
3518
+ ["sign"]
3519
+ );
3520
+ } catch (cause) {
3521
+ throw unsupported(cause);
3522
+ }
3523
+ return fromKeyAndPublic(privateKey, publicKey);
3524
+ }
3525
+ async function signerFromCryptoKey(privateKey, publicKey) {
3526
+ if (privateKey.type !== "private") {
3527
+ throw new Error(
3528
+ `signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
3529
+ );
3530
+ }
3531
+ if (!privateKey.usages.includes("sign")) {
3532
+ throw new Error(
3533
+ "this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
3534
+ );
3535
+ }
3536
+ let publicHex;
3537
+ if (typeof publicKey === "string") {
3538
+ fromHex(publicKey, "publicKey", 32);
3539
+ publicHex = publicKey.trim().toLowerCase();
3540
+ } else {
3541
+ try {
3542
+ publicHex = hex(
3543
+ new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
3544
+ );
3545
+ } catch (cause) {
3546
+ const detail = cause instanceof Error ? `: ${cause.message}` : "";
3547
+ throw new Error(
3548
+ "could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
3549
+ );
3550
+ }
3551
+ }
3552
+ return fromKeyAndPublic(privateKey, publicHex);
3553
+ }
3554
+ function fromKeyAndPublic(privateKey, publicKey) {
3555
+ return {
3556
+ publicKey,
3557
+ async sign(payload) {
3558
+ try {
3559
+ return new Uint8Array(
3560
+ await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
3561
+ );
3562
+ } catch (cause) {
3563
+ throw unsupported(cause);
3564
+ }
3565
+ }
3566
+ };
3567
+ }
3568
+ async function resolveSigner(secret, signer, label2) {
3569
+ if (signer && secret !== void 0) {
3570
+ throw new Error(
3571
+ `pass either ${label2} or signer, not both \u2014 they may name different keys`
3572
+ );
3573
+ }
3574
+ if (signer) return signer;
3575
+ if (secret !== void 0) return signerFromSecret(secret, label2);
3576
+ throw new Error(`${label2} or signer is required`);
3577
+ }
3578
+
3168
3579
  // src/device-cert/device-cert.ts
3169
3580
  var CERT_DOMAIN = new TextEncoder().encode("calimero.device.cert.v1");
3170
3581
  var ACCOUNT_ID_DOMAIN = new TextEncoder().encode("calimero.account.genesis.v1");
3171
3582
  var DEVICE_ID_DOMAIN = new TextEncoder().encode("calimero.device.id.v1");
3172
3583
  var ACCOUNT_GENESIS_VERSION = 2;
3584
+ var ACCOUNT_PROOF_BYTES = 237;
3173
3585
  async function mintDeviceId(account, nonce2) {
3174
3586
  if (nonce2.length !== 16) {
3175
3587
  throw new Error(`nonce must be 16 bytes, got ${nonce2.length}`);
@@ -3202,12 +3614,9 @@ function accountProofBytes(input) {
3202
3614
  );
3203
3615
  }
3204
3616
  async function signDeviceCert(input) {
3205
- const rootPublicKey = await derivePublicKey(input.rootSecret);
3206
- const account = hex(
3207
- await domainHash(ACCOUNT_ID_DOMAIN, [
3208
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey)
3209
- ])
3210
- );
3617
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
3618
+ const rootPublicKey = fromHex(signer.publicKey, "signer.publicKey", 32);
3619
+ const account = await accountForRootPublicKey(signer.publicKey);
3211
3620
  const keyEpoch = 0;
3212
3621
  const payload = await deviceCertPayload({
3213
3622
  account,
@@ -3217,10 +3626,7 @@ async function signDeviceCert(input) {
3217
3626
  keyEpoch,
3218
3627
  deviceEpoch: input.deviceEpoch
3219
3628
  });
3220
- const key = await importSigningKey(input.rootSecret);
3221
- const signature = new Uint8Array(
3222
- await crypto.subtle.sign("Ed25519", key, payload)
3223
- );
3629
+ const signature = await signer.sign(payload);
3224
3630
  const credential = accountProofBytes({
3225
3631
  rootPublicKey,
3226
3632
  account,
@@ -3233,15 +3639,119 @@ async function signDeviceCert(input) {
3233
3639
  });
3234
3640
  return hex(credential);
3235
3641
  }
3642
+ function parseDeviceCredential(credential) {
3643
+ const bytes = fromHex(
3644
+ credential,
3645
+ "credential",
3646
+ // Fixed-width with an empty chain: 1 + 32 + 4 + 32*4 + 4 + 4 + 64.
3647
+ ACCOUNT_PROOF_BYTES
3648
+ );
3649
+ if (bytes[0] !== ACCOUNT_GENESIS_VERSION) {
3650
+ throw new Error(
3651
+ `this credential has account genesis version ${bytes[0]}, not ${ACCOUNT_GENESIS_VERSION} \u2014 it names a different account than its root key derives here, and core would refuse it`
3652
+ );
3653
+ }
3654
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
3655
+ const chainLength = view.getUint32(33, true);
3656
+ if (chainLength !== 0) {
3657
+ throw new Error(
3658
+ `this credential carries a ${chainLength}-handoff root chain, which this SDK cannot verify yet \u2014 the signing root is not the genesis root, so checking it means walking the chain`
3659
+ );
3660
+ }
3661
+ const at = (offset, length) => hex(bytes.subarray(offset, offset + length));
3662
+ return {
3663
+ rootPublicKey: at(1, 32),
3664
+ account: at(37, 32),
3665
+ device: at(69, 32),
3666
+ signPublicKey: at(101, 32),
3667
+ kemPublicKey: at(133, 32),
3668
+ keyEpoch: view.getUint32(165, true),
3669
+ deviceEpoch: view.getUint32(169, true),
3670
+ signature: at(173, 64)
3671
+ };
3672
+ }
3673
+ async function verifyDeviceCredential(credential) {
3674
+ const parsed = parseDeviceCredential(credential);
3675
+ const derived = await accountForRootPublicKey(parsed.rootPublicKey);
3676
+ if (derived !== parsed.account) {
3677
+ throw new Error(
3678
+ `this credential names account ${parsed.account}, but its root key derives ${derived} \u2014 the certificate was re-pointed at another account`
3679
+ );
3680
+ }
3681
+ const signed = await verifySignature(
3682
+ parsed.rootPublicKey,
3683
+ fromHex(parsed.signature, "signature", 64),
3684
+ await deviceCertPayload(parsed)
3685
+ );
3686
+ if (!signed) {
3687
+ throw new Error(
3688
+ `the root key of account ${parsed.account} did not sign this certificate`
3689
+ );
3690
+ }
3691
+ return parsed;
3692
+ }
3236
3693
  async function accountForRoot(rootSecret) {
3237
- const rootPublicKey = await derivePublicKey(rootSecret);
3694
+ return accountForRootPublicKey(hex(await derivePublicKey(rootSecret)));
3695
+ }
3696
+ async function accountForRootPublicKey(rootPublicKey) {
3238
3697
  return hex(
3239
3698
  await domainHash(ACCOUNT_ID_DOMAIN, [
3240
- concat(new Uint8Array([ACCOUNT_GENESIS_VERSION]), rootPublicKey)
3699
+ concat(
3700
+ new Uint8Array([ACCOUNT_GENESIS_VERSION]),
3701
+ fromHex(rootPublicKey, "rootPublicKey", 32)
3702
+ )
3241
3703
  ])
3242
3704
  );
3243
3705
  }
3244
3706
 
3707
+ // src/device-cert/device-scope.ts
3708
+ var SCOPE_DOMAIN = new TextEncoder().encode("calimero.device.scope.v1");
3709
+ async function deviceScopePayload(input) {
3710
+ return domainHash(SCOPE_DOMAIN, [
3711
+ fromHex(input.account, "account", 32),
3712
+ fromHex(input.device, "device", 32),
3713
+ u32le(input.scopeEpoch),
3714
+ u32le(input.keyEpoch),
3715
+ ...input.applications.map((a, i) => fromHex(a, `applications[${i}]`, 32))
3716
+ ]);
3717
+ }
3718
+ async function signDeviceScope(input) {
3719
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
3720
+ const rootPublicKey = fromHex(signer.publicKey, "signer.publicKey", 32);
3721
+ const account = await accountForRootPublicKey(hex(rootPublicKey));
3722
+ const applications = input.applications ?? [];
3723
+ const scopeEpoch = input.scopeEpoch ?? 0;
3724
+ if (!Number.isInteger(scopeEpoch) || scopeEpoch < 0 || scopeEpoch > 4294967295) {
3725
+ throw new Error(`scopeEpoch must be a u32, got ${scopeEpoch}`);
3726
+ }
3727
+ const keyEpoch = 0;
3728
+ const payload = await deviceScopePayload({
3729
+ account,
3730
+ device: input.device,
3731
+ applications,
3732
+ scopeEpoch,
3733
+ keyEpoch
3734
+ });
3735
+ const signature = await signer.sign(payload);
3736
+ if (signature.length !== 64) {
3737
+ throw new Error(`signer returned ${signature.length} bytes, expected a 64-byte Ed25519 signature`);
3738
+ }
3739
+ return hex(
3740
+ concat(
3741
+ new Uint8Array([ACCOUNT_GENESIS_VERSION]),
3742
+ rootPublicKey,
3743
+ u32le(0),
3744
+ fromHex(account, "account", 32),
3745
+ fromHex(input.device, "device", 32),
3746
+ u32le(applications.length),
3747
+ ...applications.map((a, i) => fromHex(a, `applications[${i}]`, 32)),
3748
+ u32le(scopeEpoch),
3749
+ u32le(keyEpoch),
3750
+ signature
3751
+ )
3752
+ );
3753
+ }
3754
+
3245
3755
  // src/account/wordlist.ts
3246
3756
  var ENGLISH_WORDLIST = [
3247
3757
  "abandon",
@@ -5320,27 +5830,50 @@ async function accountRootFromSecret(secret) {
5320
5830
  accountId: await accountForRoot(secret)
5321
5831
  };
5322
5832
  }
5833
+ async function createAccountRootSigner() {
5834
+ const entropy = crypto.getRandomValues(new Uint8Array(ENTROPY_BITS / 8));
5835
+ const phrase = await encodePhrase(entropy);
5836
+ return { ...await rootSignerFromEntropy(entropy), phrase };
5837
+ }
5838
+ async function accountRootSignerFromPhrase(phrase) {
5839
+ return rootSignerFromEntropy(await decodePhrase(normalisePhrase(phrase)));
5840
+ }
5841
+ async function rootSignerFromEntropy(entropy) {
5842
+ const signer = await signerFromSecret(hex(entropy), "rootSecret");
5843
+ entropy.fill(0);
5844
+ return {
5845
+ publicKey: signer.publicKey,
5846
+ accountId: await accountForRootPublicKey(signer.publicKey),
5847
+ signer
5848
+ };
5849
+ }
5850
+ async function resolveRoot(source) {
5851
+ const signer = typeof source === "string" ? await signerFromSecret(source, "rootSecret") : source;
5852
+ return identify(signer);
5853
+ }
5854
+ async function resolveRootPair(rootSecret, signer) {
5855
+ return identify(await resolveSigner(rootSecret, signer, "rootSecret"));
5856
+ }
5857
+ async function identify(signer) {
5858
+ return {
5859
+ signer,
5860
+ publicKey: signer.publicKey,
5861
+ accountId: await accountForRootPublicKey(signer.publicKey)
5862
+ };
5863
+ }
5323
5864
  async function signAccountLink(input) {
5324
5865
  if (!input.nonce) throw new Error("nonce is required to sign an account link");
5325
- const key = await importSigningKey(input.rootSecret);
5326
- const signature = new Uint8Array(
5327
- await crypto.subtle.sign(
5328
- "Ed25519",
5329
- key,
5330
- concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
5331
- )
5866
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
5867
+ const signature = await signer.sign(
5868
+ concat(ACCOUNT_LINK_DOMAIN, new TextEncoder().encode(input.nonce))
5332
5869
  );
5333
5870
  return base64(signature);
5334
5871
  }
5335
5872
  async function signAccountLogin(input) {
5336
5873
  if (!input.nonce) throw new Error("nonce is required to sign an account login");
5337
- const key = await importSigningKey(input.rootSecret);
5338
- const signature = new Uint8Array(
5339
- await crypto.subtle.sign(
5340
- "Ed25519",
5341
- key,
5342
- concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
5343
- )
5874
+ const signer = await resolveSigner(input.rootSecret, input.signer, "rootSecret");
5875
+ const signature = await signer.sign(
5876
+ concat(ACCOUNT_LOGIN_DOMAIN, new TextEncoder().encode(input.nonce))
5344
5877
  );
5345
5878
  return base64(signature);
5346
5879
  }
@@ -5434,7 +5967,7 @@ function base642(bytes) {
5434
5967
  }
5435
5968
 
5436
5969
  // src/cloud/cloud-client.ts
5437
- var DEFAULT_CLOUD_BASE_URL = "https://cloud.calimero.network";
5970
+ var DEFAULT_CLOUD_BASE_URL = "https://manager.cloud.calimero.network";
5438
5971
  function canExecuteOf(row, legacy) {
5439
5972
  return typeof row.can_execute === "boolean" ? row.can_execute : legacy;
5440
5973
  }
@@ -5498,8 +6031,10 @@ var CloudClient = class {
5498
6031
  * account behind those reads was claimed by its owner. The claim is recorded
5499
6032
  * even when the session is refused.
5500
6033
  *
5501
- * @param rootSecret the account root's signing secret, 64 hex. It never
5502
- * leaves this process — only a signature over the cloud's nonce travels.
6034
+ * @param root the account root: its 64-hex secret, or a `Signer` over a root
6035
+ * this process may use and cannot read — the form a phrase-held root in a
6036
+ * browser takes. Either way nothing but a signature over the cloud's nonce
6037
+ * travels.
5503
6038
  *
5504
6039
  * A `403` means either the signature did not verify, or the account is not
5505
6040
  * linked to a cloud login: ownership alone says who you are, and the link
@@ -5507,13 +6042,13 @@ var CloudClient = class {
5507
6042
  * on the proof alone would authenticate perfectly and authorize nothing. The
5508
6043
  * ownership claim is recorded either way, so linking later needs no re-proof.
5509
6044
  */
5510
- async signInWithAccount(rootSecret) {
5511
- const root = await accountRootFromSecret(rootSecret);
6045
+ async signInWithAccount(root) {
6046
+ const { signer, publicKey } = await resolveRoot(root);
5512
6047
  const { nonce: nonce2 } = await this.getAccountLoginChallenge();
5513
6048
  return this.submitAccountLogin({
5514
- rootPublicKey: root.publicKey,
6049
+ rootPublicKey: publicKey,
5515
6050
  nonce: nonce2,
5516
- signature: await signAccountLogin({ rootSecret, nonce: nonce2 })
6051
+ signature: await signAccountLogin({ signer, nonce: nonce2 })
5517
6052
  });
5518
6053
  }
5519
6054
  /**
@@ -5610,6 +6145,7 @@ var CloudClient = class {
5610
6145
  peerId: String(row.peer_id ?? ""),
5611
6146
  relayUrl,
5612
6147
  executorAccount,
6148
+ nodeKey: row.node_key ?? row.node_public_key ?? null,
5613
6149
  status: String(row.status ?? ""),
5614
6150
  authorshipReady,
5615
6151
  teeRole: teeRoleOf(row),
@@ -5952,22 +6488,23 @@ var CloudClient = class {
5952
6488
  * an intent; the cloud needs it to know whose plan a hosted node is spending,
5953
6489
  * and to sign that account back in later.
5954
6490
  *
5955
- * @param rootSecret the account root's signing secret, 64 hex — from
5956
- * {@link generateAccountRoot}, from a restored recovery phrase, or from a
5957
- * desktop app or hardware key that already holds one.
6491
+ * @param root the account root: its 64-hex secret — from
6492
+ * {@link generateAccountRoot}, a restored recovery phrase, or a desktop app
6493
+ * or hardware key that already holds one — or a `Signer` over a root this
6494
+ * process cannot read, which is what a phrase-held browser root is.
5958
6495
  *
5959
6496
  * Idempotent for the same login. An account already linked to a *different*
5960
6497
  * cloud login is a `409`; a replayed or expired challenge is a `403`; and
5961
6498
  * exceeding the plan's account limit is a `402`.
5962
6499
  */
5963
- async linkAccount(rootSecret) {
5964
- const root = await accountRootFromSecret(rootSecret);
6500
+ async linkAccount(root) {
6501
+ const { signer, publicKey, accountId } = await resolveRoot(root);
5965
6502
  const { nonce: nonce2 } = await this.getAccountLinkChallenge();
5966
6503
  return this.submitAccountLink({
5967
- accountId: root.accountId,
5968
- rootPublicKey: root.publicKey,
6504
+ accountId,
6505
+ rootPublicKey: publicKey,
5969
6506
  nonce: nonce2,
5970
- signature: await signAccountLink({ rootSecret, nonce: nonce2 })
6507
+ signature: await signAccountLink({ signer, nonce: nonce2 })
5971
6508
  });
5972
6509
  }
5973
6510
  /**
@@ -6087,16 +6624,19 @@ var CloudClient = class {
6087
6624
  * login; `402` that the plan is full.
6088
6625
  */
6089
6626
  async linkAccountWithGrant(options) {
6090
- const root = await accountRootFromSecret(options.rootSecret);
6627
+ const { signer, publicKey, accountId } = await resolveRootPair(
6628
+ options.rootSecret,
6629
+ options.signer
6630
+ );
6091
6631
  const body = await this.request(
6092
6632
  "POST",
6093
6633
  "/api/cloud/accounts/link",
6094
6634
  {
6095
6635
  body: {
6096
6636
  grant: options.grant,
6097
- root_public_key: root.publicKey,
6637
+ root_public_key: publicKey,
6098
6638
  signature: await signAccountLink({
6099
- rootSecret: options.rootSecret,
6639
+ signer,
6100
6640
  nonce: options.grant
6101
6641
  })
6102
6642
  },
@@ -6104,7 +6644,7 @@ var CloudClient = class {
6104
6644
  }
6105
6645
  );
6106
6646
  return {
6107
- accountId: String(body.account_id ?? root.accountId),
6647
+ accountId: String(body.account_id ?? accountId),
6108
6648
  linkedAt: body.linked_at ?? null,
6109
6649
  alreadyLinked: body.already_linked === true
6110
6650
  };
@@ -6628,117 +7168,6 @@ var LocalStorageTokenStore = class {
6628
7168
  }
6629
7169
  };
6630
7170
 
6631
- // src/signer/signer.ts
6632
- var PKCS8_ED25519_PREFIX2 = new Uint8Array([
6633
- 48,
6634
- 46,
6635
- 2,
6636
- 1,
6637
- 0,
6638
- 48,
6639
- 5,
6640
- 6,
6641
- 3,
6642
- 43,
6643
- 101,
6644
- 112,
6645
- 4,
6646
- 34,
6647
- 4,
6648
- 32
6649
- ]);
6650
- function unsupported(cause) {
6651
- const detail = cause instanceof Error ? `: ${cause.message}` : "";
6652
- return new Error(
6653
- "this runtime has no WebCrypto Ed25519, so nothing can be signed here. Node 18.4+, Safari 17+, Firefox 129+ and Chrome 137+ have it" + detail
6654
- );
6655
- }
6656
- async function signerFromSecret(secret, label2 = "secret") {
6657
- const seed = fromHex(secret, label2, 32);
6658
- const pkcs8 = concat(PKCS8_ED25519_PREFIX2, seed);
6659
- let privateKey;
6660
- let publicKey;
6661
- try {
6662
- const exportable = await crypto.subtle.importKey(
6663
- "pkcs8",
6664
- pkcs8,
6665
- { name: "Ed25519" },
6666
- true,
6667
- ["sign"]
6668
- );
6669
- const jwk = await crypto.subtle.exportKey("jwk", exportable);
6670
- publicKey = hex(
6671
- Uint8Array.from(
6672
- atob(jwk.x.replace(/-/g, "+").replace(/_/g, "/")),
6673
- (c) => c.charCodeAt(0)
6674
- )
6675
- );
6676
- privateKey = await crypto.subtle.importKey(
6677
- "pkcs8",
6678
- pkcs8,
6679
- { name: "Ed25519" },
6680
- false,
6681
- ["sign"]
6682
- );
6683
- } catch (cause) {
6684
- throw unsupported(cause);
6685
- }
6686
- return fromKeyAndPublic(privateKey, publicKey);
6687
- }
6688
- async function signerFromCryptoKey(privateKey, publicKey) {
6689
- if (privateKey.type !== "private") {
6690
- throw new Error(
6691
- `signerFromCryptoKey needs the private half of the pair, got a ${privateKey.type} key`
6692
- );
6693
- }
6694
- if (!privateKey.usages.includes("sign")) {
6695
- throw new Error(
6696
- "this CryptoKey cannot sign: generate it with usages ['sign', 'verify']"
6697
- );
6698
- }
6699
- let publicHex;
6700
- if (typeof publicKey === "string") {
6701
- fromHex(publicKey, "publicKey", 32);
6702
- publicHex = publicKey.trim().toLowerCase();
6703
- } else {
6704
- try {
6705
- publicHex = hex(
6706
- new Uint8Array(await crypto.subtle.exportKey("raw", publicKey))
6707
- );
6708
- } catch (cause) {
6709
- const detail = cause instanceof Error ? `: ${cause.message}` : "";
6710
- throw new Error(
6711
- "could not read the public half of this key pair; pass the public key as 64 hex instead" + detail
6712
- );
6713
- }
6714
- }
6715
- return fromKeyAndPublic(privateKey, publicHex);
6716
- }
6717
- function fromKeyAndPublic(privateKey, publicKey) {
6718
- return {
6719
- publicKey,
6720
- async sign(payload) {
6721
- try {
6722
- return new Uint8Array(
6723
- await crypto.subtle.sign({ name: "Ed25519" }, privateKey, payload)
6724
- );
6725
- } catch (cause) {
6726
- throw unsupported(cause);
6727
- }
6728
- }
6729
- };
6730
- }
6731
- async function resolveSigner(secret, signer, label2) {
6732
- if (signer && secret !== void 0) {
6733
- throw new Error(
6734
- `pass either ${label2} or signer, not both \u2014 they may name different keys`
6735
- );
6736
- }
6737
- if (signer) return signer;
6738
- if (secret !== void 0) return signerFromSecret(secret, label2);
6739
- throw new Error(`${label2} or signer is required`);
6740
- }
6741
-
6742
7171
  // src/warrant/warrant.ts
6743
7172
  var SIGN_DOMAIN2 = new TextEncoder().encode("calimero.warrant.v2");
6744
7173
  var INTENT_DOMAIN = new TextEncoder().encode("calimero.warrant.intent.v1");
@@ -7243,6 +7672,16 @@ var IntentRefusedError = class extends Error {
7243
7672
  this.name = "IntentRefusedError";
7244
7673
  }
7245
7674
  };
7675
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
7676
+ function retryAfterMs(headers) {
7677
+ const raw = headers?.get?.("Retry-After");
7678
+ if (!raw) return null;
7679
+ const seconds = Number.parseInt(raw, 10);
7680
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
7681
+ const at = Date.parse(raw);
7682
+ if (Number.isFinite(at)) return Math.max(0, at - Date.now());
7683
+ return null;
7684
+ }
7246
7685
  var RelayClient = class {
7247
7686
  constructor(config) {
7248
7687
  this.config = config;
@@ -7251,11 +7690,40 @@ var RelayClient = class {
7251
7690
  const injected = config.fetch;
7252
7691
  this.fetchImpl = injected ? (input, init) => injected(input, init) : (input, init) => globalThis.fetch(input, init);
7253
7692
  this.timeoutMs = config.timeoutMs ?? 1e4;
7693
+ this.rateLimitRetries = config.rateLimitRetries ?? 3;
7694
+ }
7695
+ /**
7696
+ * The relay's origin, trailing slash stripped.
7697
+ *
7698
+ * Exposed because a relay **is a node**: the same origin that answers the
7699
+ * intents routes also answers `/auth/challenge`, `/auth/token`, `/sse` and
7700
+ * `/ws`. Anything building an observing session over this relay needs the
7701
+ * URL, and asking the caller to pass it a second time is how the two drift.
7702
+ */
7703
+ get relayUrl() {
7704
+ return this.baseUrl;
7705
+ }
7706
+ /** The author's account, hex — whose consent every warrant here carries. */
7707
+ get authorAccount() {
7708
+ return this.config.authorAccount;
7709
+ }
7710
+ /**
7711
+ * The author's `AccountProof<DeviceCert>`, hex.
7712
+ *
7713
+ * The same credential `login()` wants, which is why it is readable: a caller
7714
+ * that already handed it to this client should not have to hand an identical
7715
+ * copy to the login as well, where the two could disagree.
7716
+ */
7717
+ get authorProof() {
7718
+ return this.config.authorProof;
7254
7719
  }
7255
7720
  /**
7256
- * The author device's signer, built from `deviceSecret` when that is what was
7257
- * configured. A {@link Signer} and never the secret, so no caller of a
7258
- * `RelayClient` gains the ability to read key material.
7721
+ * The author device's signer.
7722
+ *
7723
+ * A {@link Signer} and never the secret: this returns something that can
7724
+ * *sign*, so no caller of a `RelayClient` gains the ability to read a key
7725
+ * that was passed in as `deviceSecret`. That asymmetry is the point — the
7726
+ * login needs a signature, not key material.
7259
7727
  */
7260
7728
  async authorSigner() {
7261
7729
  return resolveSigner(this.config.deviceSecret, this.config.signer, "deviceSecret");
@@ -7534,7 +8002,37 @@ var RelayClient = class {
7534
8002
  this.executorAccount = answered;
7535
8003
  return answered;
7536
8004
  }
8005
+ /**
8006
+ * One request, waiting out a rate limit rather than failing the caller.
8007
+ *
8008
+ * The relay's ingress limits the delegated-execution routes to 20 requests a
8009
+ * second per source IP, burst 40 — tighter than everything else it serves,
8010
+ * because those two routes are the ones it answers to anonymous callers.
8011
+ *
8012
+ * Retrying a 429 is safe here in a way retrying most refusals is not: the
8013
+ * limiter sits *first* in the middleware chain, so a throttled request never
8014
+ * reached the node and its warrant nonce was never spent. The same bytes can
8015
+ * go again. Contrast the spent-nonce 403 below, which needs a fresh warrant.
8016
+ *
8017
+ * `Retry-After` is honoured when the ingress sends one; otherwise the wait
8018
+ * doubles from a little over the limiter's own window, which is the shortest
8019
+ * delay that can actually clear.
8020
+ */
7537
8021
  async json(method, path, body) {
8022
+ let wait = 250;
8023
+ for (let attempt = 0; ; attempt += 1) {
8024
+ try {
8025
+ return await this.jsonOnce(method, path, body);
8026
+ } catch (err) {
8027
+ const limited = err instanceof HTTPError && err.status === 429;
8028
+ if (!limited || attempt >= this.rateLimitRetries) throw err;
8029
+ const after = retryAfterMs(err.headers);
8030
+ await sleep(after ?? wait);
8031
+ wait *= 2;
8032
+ }
8033
+ }
8034
+ }
8035
+ async jsonOnce(method, path, body) {
7538
8036
  const headers = { Accept: "application/json" };
7539
8037
  if (body !== void 0) headers["Content-Type"] = "application/json";
7540
8038
  const controller = new AbortController();
@@ -7587,133 +8085,879 @@ function extractReason(bodyText) {
7587
8085
  return bodyText;
7588
8086
  }
7589
8087
 
7590
- // src/relay/nonce-source.ts
7591
- function createMemoryNonceSource(start = 1) {
7592
- let next = BigInt(start);
7593
- if (next < 1n) {
7594
- throw new Error("nonce sequence starts at 1");
7595
- }
7596
- return {
7597
- next() {
7598
- const value = next;
7599
- next += 1n;
7600
- return Promise.resolve(value);
7601
- }
7602
- };
7603
- }
7604
- function createLocalStorageNonceSource(key, storage) {
7605
- const store = storage ?? globalThis.localStorage;
7606
- if (!store) {
7607
- throw new Error(
7608
- "no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
7609
- );
7610
- }
7611
- return {
7612
- next() {
7613
- const raw = store.getItem(key);
7614
- let current = 0n;
7615
- if (raw !== null && raw !== "") {
7616
- try {
7617
- const parsed = BigInt(raw);
7618
- if (parsed > 0n) current = parsed;
7619
- } catch {
7620
- current = 0n;
7621
- }
7622
- }
7623
- const value = current + 1n;
7624
- store.setItem(key, value.toString());
7625
- return Promise.resolve(value);
7626
- }
7627
- };
7628
- }
7629
-
7630
- // src/sealed/noise.ts
7631
- var PROTOCOL_NAME = "Noise_NK_25519_AESGCM_SHA256";
7632
- var DH_LEN = 32;
7633
- var TAG_LEN = 16;
7634
- var PKCS8_X25519_PREFIX = new Uint8Array([
7635
- 48,
7636
- 46,
7637
- 2,
7638
- 1,
7639
- 0,
7640
- 48,
7641
- 5,
7642
- 6,
7643
- 3,
7644
- 43,
7645
- 101,
7646
- 110,
7647
- 4,
7648
- 34,
7649
- 4,
7650
- 32
7651
- ]);
7652
- var SymmetricState = class _SymmetricState {
7653
- constructor(h) {
7654
- this.k = null;
7655
- this.n = 0;
7656
- this.ck = h;
7657
- this.h = h;
8088
+ // src/transport/relay-transport.ts
8089
+ var RelayTransport = class {
8090
+ constructor(relay, observer = null) {
8091
+ this.relay = relay;
8092
+ this.observer = observer;
8093
+ this.kind = "relay";
7658
8094
  }
7659
- static async initialize() {
7660
- const name = new TextEncoder().encode(PROTOCOL_NAME);
7661
- const h = new Uint8Array(32);
7662
- h.set(name);
7663
- return new _SymmetricState(h);
8095
+ /**
8096
+ * Whether this relay can be observed — a fact, not a constant.
8097
+ *
8098
+ * A relay IS a node: it serves `/auth/challenge`, `/auth/token`, `/sse` and
8099
+ * `/ws`, and the `account_proof` provider grants `context:subscribe` by
8100
+ * default. What decides the answer is therefore not the transport but whether
8101
+ * a caller supplied the node's device signing key, which cannot be inferred
8102
+ * from the node or from its `peerId`. See `./relay-observer.ts`.
8103
+ */
8104
+ get canSubscribe() {
8105
+ return this.observer !== null;
7664
8106
  }
7665
- async mixHash(data) {
7666
- this.h = await sha256(concat(this.h, data));
8107
+ /** The event session, or `null` when no node key was supplied. */
8108
+ get events() {
8109
+ return this.observer;
7667
8110
  }
7668
- async mixKey(inputKeyMaterial) {
7669
- const [ck, key] = await hkdf2(this.ck, inputKeyMaterial);
7670
- this.ck = ck;
7671
- this.k = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt", "decrypt"]);
7672
- this.n = 0;
8111
+ /** The underlying client, for `describe()` and anything else relay-specific. */
8112
+ get client() {
8113
+ return this.relay;
7673
8114
  }
7674
- async encryptAndHash(plaintext) {
7675
- if (!this.k) throw new Error("Noise: no cipher key yet");
7676
- const ciphertext = new Uint8Array(
7677
- await crypto.subtle.encrypt(
7678
- { name: "AES-GCM", iv: noiseNonce(this.n), additionalData: this.h },
7679
- this.k,
7680
- plaintext
7681
- )
8115
+ async execute(params) {
8116
+ const { returns } = await this.executeWithMetadata(params);
8117
+ return returns;
8118
+ }
8119
+ async executeWithMetadata(params) {
8120
+ const result = await this.relay.execute(
8121
+ params.contextId,
8122
+ params.method,
8123
+ // The node defaults a missing `argsJson` to `{}`; matching that here keeps
8124
+ // a zero-argument call identical on both transports rather than sending
8125
+ // `undefined` and letting the relay's JSON body decide.
8126
+ params.argsJson ?? {}
7682
8127
  );
7683
- this.n += 1;
7684
- await this.mixHash(ciphertext);
7685
- return ciphertext;
8128
+ return {
8129
+ // Verbatim. `RelayClient` has already normalized a missing `returns` to
8130
+ // `null`, which is what a node reports for a null output — see the
8131
+ // module note on why this is not rewritten to `undefined`.
8132
+ returns: result.returns,
8133
+ transport: "relay",
8134
+ rootHash: result.rootHash
8135
+ };
7686
8136
  }
7687
- async decryptAndHash(ciphertext) {
7688
- if (!this.k) throw new Error("Noise: no cipher key yet");
7689
- const plaintext = new Uint8Array(
7690
- await crypto.subtle.decrypt(
7691
- { name: "AES-GCM", iv: noiseNonce(this.n), additionalData: this.h },
7692
- this.k,
7693
- ciphertext
7694
- )
7695
- );
7696
- this.n += 1;
7697
- await this.mixHash(ciphertext);
7698
- return plaintext;
8137
+ /**
8138
+ * Both of these are plain context methods, so they work through a relay
8139
+ * exactly as they do on a node — the warrant names `migrate_my_entries` and
8140
+ * the delta is attributed to the author, which is precisely the identity the
8141
+ * convert is scoped to. They are re-declared rather than inherited because
8142
+ * `ExecuteTransport` is an interface, not a base class.
8143
+ */
8144
+ async migrateMyEntries(contextId) {
8145
+ return this.execute({
8146
+ contextId,
8147
+ method: "migrate_my_entries"
8148
+ });
7699
8149
  }
7700
- /** The initiator-to-responder key, then the responder-to-initiator key. */
7701
- split() {
7702
- return hkdf2(this.ck, new Uint8Array());
8150
+ /** Read-only count of the caller's entries still below the target schema. */
8151
+ async countMyPending(contextId) {
8152
+ return this.execute({ contextId, method: "count_my_pending" });
7703
8153
  }
7704
8154
  };
7705
- async function initiate(remoteStatic, prologue, fixedEphemeral) {
7706
- const state = await SymmetricState.initialize();
7707
- await state.mixHash(prologue);
7708
- await state.mixHash(remoteStatic);
7709
- const ephemeral = await x25519KeyPair(fixedEphemeral);
7710
- await state.mixHash(ephemeral.publicKey);
7711
- await state.mixKey(await dh(ephemeral.privateKey, remoteStatic));
7712
- const message1 = concat(ephemeral.publicKey, await state.encryptAndHash(new Uint8Array()));
7713
- return {
7714
- message1,
7715
- async finish(message2) {
7716
- if (message2.length < DH_LEN + TAG_LEN) throw new Error("Noise: message 2 is too short");
8155
+
8156
+ // src/login/login.ts
8157
+ var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
8158
+ var AUDIENCE_TAG = {
8159
+ webOrigin: 0,
8160
+ codeSigningId: 1,
8161
+ cli: 2
8162
+ };
8163
+ function audienceBody(audience) {
8164
+ const encoder = new TextEncoder();
8165
+ switch (audience.kind) {
8166
+ case "webOrigin":
8167
+ return encoder.encode(audience.origin);
8168
+ case "codeSigningId":
8169
+ return encoder.encode(audience.id);
8170
+ case "cli":
8171
+ return new Uint8Array(0);
8172
+ }
8173
+ }
8174
+ function audienceSigningBytes(audience) {
8175
+ return concat(
8176
+ Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
8177
+ audienceBody(audience)
8178
+ );
8179
+ }
8180
+ function audienceWire(audience) {
8181
+ const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
8182
+ if (audience.kind === "cli") {
8183
+ return tag2;
8184
+ }
8185
+ const body = audienceBody(audience);
8186
+ return concat(tag2, u32le(body.length), body);
8187
+ }
8188
+ async function signLoginStatement(input) {
8189
+ const node = fromHex(input.node, "node", 32);
8190
+ const challenge = fromHex(input.challenge, "challenge", 32);
8191
+ const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
8192
+ const signer = await resolveSigner(
8193
+ input.deviceSecret,
8194
+ input.signer,
8195
+ "deviceSecret"
8196
+ );
8197
+ const deviceKey = fromHex(signer.publicKey, "signer.publicKey", 32);
8198
+ const issuedAt = u64le(input.issuedAt);
8199
+ const expiresAt = u64le(input.expiresAt);
8200
+ const preimage = await domainHash(SIGN_DOMAIN5, [
8201
+ node,
8202
+ audienceSigningBytes(input.audience),
8203
+ challenge,
8204
+ sessionKey,
8205
+ deviceKey,
8206
+ issuedAt,
8207
+ expiresAt
8208
+ ]);
8209
+ const signature = await signer.sign(preimage);
8210
+ return hex(
8211
+ concat(
8212
+ node,
8213
+ audienceWire(input.audience),
8214
+ challenge,
8215
+ sessionKey,
8216
+ deviceKey,
8217
+ issuedAt,
8218
+ expiresAt,
8219
+ signature
8220
+ )
8221
+ );
8222
+ }
8223
+
8224
+ // src/login/session.ts
8225
+ var DEFAULT_TTL_SECONDS2 = 300;
8226
+ async function generateSessionKey() {
8227
+ const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
8228
+ return { publicKey: hex(await derivePublicKey(secret)), secret };
8229
+ }
8230
+ async function login(config) {
8231
+ const baseUrl = config.nodeUrl.replace(/\/+$/, "");
8232
+ const timeoutMs = config.timeoutMs ?? 1e4;
8233
+ const request = async (path, init) => {
8234
+ const controller = new AbortController();
8235
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
8236
+ let response;
8237
+ try {
8238
+ const requestInit = { ...init, signal: controller.signal };
8239
+ response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
8240
+ } catch (err) {
8241
+ throw new HTTPError(
8242
+ 0,
8243
+ err instanceof Error ? err.name : "NetworkError",
8244
+ `${baseUrl}${path}`,
8245
+ new Headers(),
8246
+ err instanceof Error ? err.message : String(err)
8247
+ );
8248
+ } finally {
8249
+ clearTimeout(timer);
8250
+ }
8251
+ const text = await response.text().catch(() => "");
8252
+ if (!response.ok) {
8253
+ throw new HTTPError(
8254
+ response.status,
8255
+ response.statusText,
8256
+ `${baseUrl}${path}`,
8257
+ response.headers,
8258
+ text
8259
+ );
8260
+ }
8261
+ return text ? JSON.parse(text) : {};
8262
+ };
8263
+ const challengeBody = await request("/auth/challenge", {
8264
+ method: "GET",
8265
+ headers: { Accept: "application/json" }
8266
+ });
8267
+ const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
8268
+ if (!challenge) {
8269
+ throw new Error("the node issued no challenge");
8270
+ }
8271
+ const session = await generateSessionKey();
8272
+ const issuedAt = Math.floor(Date.now() / 1e3);
8273
+ const statement = await signLoginStatement({
8274
+ node: config.node,
8275
+ audience: config.audience,
8276
+ challenge,
8277
+ sessionKey: session.publicKey,
8278
+ issuedAt,
8279
+ expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
8280
+ // Resolved once and handed on, so a config carrying a secret and a config
8281
+ // carrying a `CryptoKey` take exactly the same path from here down.
8282
+ signer: await resolveSigner(config.deviceSecret, config.signer, "deviceSecret")
8283
+ });
8284
+ const tokenBody = await request("/auth/token", {
8285
+ method: "POST",
8286
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
8287
+ body: JSON.stringify({
8288
+ auth_method: "account_proof",
8289
+ public_key: session.publicKey,
8290
+ client_name: config.clientName ?? baseUrl,
8291
+ timestamp: issuedAt,
8292
+ provider_data: {
8293
+ challenge,
8294
+ login_statement: statement,
8295
+ account_proof: config.accountProof
8296
+ }
8297
+ })
8298
+ });
8299
+ const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
8300
+ const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
8301
+ if (!accessToken) {
8302
+ throw new Error("the node minted no session");
8303
+ }
8304
+ return {
8305
+ accessToken,
8306
+ refreshToken: refreshToken ?? "",
8307
+ sessionKey: session.publicKey,
8308
+ sessionSecret: session.secret
8309
+ };
8310
+ }
8311
+
8312
+ // src/transport/relay-observer.ts
8313
+ var EXPIRY_SKEW_MS = 3e4;
8314
+ function defaultAudience() {
8315
+ const origin = globalThis.location?.origin;
8316
+ return origin ? { kind: "webOrigin", origin } : { kind: "cli" };
8317
+ }
8318
+ function jwtExpiryMs(token) {
8319
+ try {
8320
+ const parts = token.split(".");
8321
+ if (parts.length !== 3) return null;
8322
+ let b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
8323
+ while (b64.length % 4) b64 += "=";
8324
+ const payload = JSON.parse(atob(b64));
8325
+ return typeof payload.exp === "number" ? payload.exp * 1e3 : null;
8326
+ } catch {
8327
+ return null;
8328
+ }
8329
+ }
8330
+ var RelayObserver = class {
8331
+ constructor(config) {
8332
+ this.session = null;
8333
+ this.sessionExpiresAt = null;
8334
+ /** In-flight login, so two surfaces asking at once mint one session, not two. */
8335
+ this.pending = null;
8336
+ this.sseClient = null;
8337
+ this.wsClient = null;
8338
+ this.config = config;
8339
+ }
8340
+ /** The node key this observer logs in with. Read for diagnostics. */
8341
+ get nodeKey() {
8342
+ return this.config.nodeKey;
8343
+ }
8344
+ /**
8345
+ * A usable access token, logging in if there is not one.
8346
+ *
8347
+ * Re-mints once the current token is within {@link EXPIRY_SKEW_MS} of
8348
+ * expiring, and on every call when the token's lifetime cannot be read —
8349
+ * reconnects are backed off, so an extra handshake costs far less than a
8350
+ * stream that silently stops. The refresh token is *held* but not spent:
8351
+ * rotating it is tracked in #87/#88 and a second login is correct in the
8352
+ * meantime, where re-presenting an unrotated refresh token would not be.
8353
+ */
8354
+ async accessToken() {
8355
+ const now = Date.now();
8356
+ if (this.session && this.sessionExpiresAt !== null && this.sessionExpiresAt - EXPIRY_SKEW_MS > now) {
8357
+ return this.session.accessToken;
8358
+ }
8359
+ if (!this.pending) {
8360
+ this.pending = this.loginNow().finally(() => {
8361
+ this.pending = null;
8362
+ });
8363
+ }
8364
+ const session = await this.pending;
8365
+ return session.accessToken;
8366
+ }
8367
+ async loginNow() {
8368
+ const session = await login({
8369
+ nodeUrl: this.config.nodeUrl,
8370
+ node: this.config.nodeKey,
8371
+ signer: await this.config.signer(),
8372
+ accountProof: this.config.accountProof,
8373
+ audience: this.config.audience,
8374
+ clientName: this.config.clientName,
8375
+ ttlSeconds: this.config.ttlSeconds,
8376
+ fetch: this.config.fetch,
8377
+ timeoutMs: this.config.timeoutMs
8378
+ });
8379
+ this.session = session;
8380
+ this.sessionExpiresAt = jwtExpiryMs(session.accessToken);
8381
+ return session;
8382
+ }
8383
+ /**
8384
+ * SSE events over the relay node — the same `SseClient` a node client hands
8385
+ * out, constructed the same way, so nothing downstream can tell which
8386
+ * transport produced it.
8387
+ */
8388
+ get events() {
8389
+ if (!this.sseClient) {
8390
+ this.sseClient = new SseClient({
8391
+ baseUrl: this.config.nodeUrl,
8392
+ getAuthToken: () => this.accessToken()
8393
+ });
8394
+ }
8395
+ return this.sseClient;
8396
+ }
8397
+ /** WebSocket events over the relay node. @experimental — prefer `events`. */
8398
+ get ws() {
8399
+ if (!this.wsClient) {
8400
+ this.wsClient = new WsClient({
8401
+ baseUrl: this.config.nodeUrl,
8402
+ getAuthToken: () => this.accessToken()
8403
+ });
8404
+ }
8405
+ return this.wsClient;
8406
+ }
8407
+ /** Close whatever was opened. Never touches the relay's write path. */
8408
+ close() {
8409
+ this.sseClient?.close();
8410
+ this.wsClient?.close();
8411
+ }
8412
+ };
8413
+
8414
+ // src/request-proof/request-proof.ts
8415
+ var utf8 = (text) => new TextEncoder().encode(text);
8416
+ var REQUEST_SIGN_DOMAIN = utf8("calimero.auth.request.v1");
8417
+ var REQUEST_BODY_DOMAIN = utf8("calimero.auth.request.body.v1");
8418
+ var REQUEST_PROOF_HEADER = "X-Calimero-Proof";
8419
+ var DEFAULT_VALID_FOR = 300;
8420
+ async function signRequestProof(input) {
8421
+ const { method, path, credential } = input;
8422
+ if (!method) throw new Error("signRequestProof needs the method the request will use");
8423
+ if (!path.startsWith("/")) {
8424
+ throw new Error(`signRequestProof needs a path, got ${JSON.stringify(path)}`);
8425
+ }
8426
+ if (path.includes("?")) {
8427
+ throw new Error(
8428
+ `the path must carry no query string, got ${JSON.stringify(path)} \u2014 a query is excluded from the signature on purpose, because a proxy may rewrite one. Anything that must be bound belongs in the body.`
8429
+ );
8430
+ }
8431
+ const bodyBytes2 = input.body === void 0 ? new Uint8Array() : typeof input.body === "string" ? utf8(input.body) : input.body;
8432
+ const bodyHash = await domainHash(REQUEST_BODY_DOMAIN, [bodyBytes2]);
8433
+ const issuedAt = Math.floor(input.now ?? Date.now() / 1e3);
8434
+ const expiresAt = issuedAt + (input.validForSeconds ?? DEFAULT_VALID_FOR);
8435
+ const payload = await domainHash(REQUEST_SIGN_DOMAIN, [
8436
+ utf8(method),
8437
+ utf8(path),
8438
+ bodyHash,
8439
+ u64le(issuedAt),
8440
+ u64le(expiresAt)
8441
+ ]);
8442
+ const signature = await signPayload(payload, input);
8443
+ if (signature.length !== 64) {
8444
+ throw new Error(`the signer returned ${signature.length} bytes, not a 64-byte signature`);
8445
+ }
8446
+ const requestSig = concat(
8447
+ u32le(utf8(method).length),
8448
+ utf8(method),
8449
+ u32le(utf8(path).length),
8450
+ utf8(path),
8451
+ bodyHash,
8452
+ u64le(issuedAt),
8453
+ u64le(expiresAt),
8454
+ signature
8455
+ );
8456
+ const accountProof = hexToBytes(credential, "credential");
8457
+ return hex(concat(accountProof, new Uint8Array([0]), requestSig));
8458
+ }
8459
+ async function signPayload(payload, input) {
8460
+ if (input.signer) return input.signer.sign(payload);
8461
+ if (!input.deviceSecret) {
8462
+ throw new Error("signRequestProof needs either `deviceSecret` or `signer`");
8463
+ }
8464
+ const key = await importSigningKey(input.deviceSecret);
8465
+ return new Uint8Array(await crypto.subtle.sign({ name: "Ed25519" }, key, payload));
8466
+ }
8467
+ function hexToBytes(value, label2) {
8468
+ const clean = value.trim();
8469
+ if (!/^[0-9a-fA-F]*$/.test(clean) || clean.length % 2 !== 0) {
8470
+ throw new Error(`${label2} is not an even-length hex string`);
8471
+ }
8472
+ return fromHex(clean, label2, clean.length / 2);
8473
+ }
8474
+
8475
+ // src/request-proof/authorizer.ts
8476
+ function createProofAuthorizer(credential) {
8477
+ if (!credential.credential) {
8478
+ throw new Error("a proof authorizer needs the `credential` from signDeviceCert");
8479
+ }
8480
+ if (!credential.deviceSecret && !credential.signer) {
8481
+ throw new Error("a proof authorizer needs either `deviceSecret` or `signer`");
8482
+ }
8483
+ return async ({ method, path, body }) => {
8484
+ const proof = await signRequestProof({
8485
+ method,
8486
+ path,
8487
+ body,
8488
+ credential: credential.credential,
8489
+ deviceSecret: credential.deviceSecret,
8490
+ signer: credential.signer,
8491
+ validForSeconds: credential.validForSeconds
8492
+ });
8493
+ return { [REQUEST_PROOF_HEADER]: proof };
8494
+ };
8495
+ }
8496
+ function signedPath(url) {
8497
+ return new URL(url).pathname;
8498
+ }
8499
+
8500
+ // src/transport/mero-client.ts
8501
+ function relayHasNo(surface, why) {
8502
+ return new Error(
8503
+ `this client was constructed with the relay transport, which has no ${surface}: ${why}. Construct a node client (createMeroClient({ baseUrl })) for that surface \u2014 nothing here will silently connect to a node you did not choose.`
8504
+ );
8505
+ }
8506
+ function missingNodeKey(surface) {
8507
+ return new Error(
8508
+ `this relay-transport client cannot open ${surface}: it was given no relay node key. A relay is an ordinary node and does serve /sse and /ws \u2014 but the login statement must be signed against the node's device signing key, learned out of band (never read from the node, and never derived from its peerId). Pass it as \`observe: { nodeKey }\`. For a cloud-hosted relay the cloud does not publish it yet \u2014 see mdma #312.`
8509
+ );
8510
+ }
8511
+ function buildObserver(relay, observe) {
8512
+ const nodeKey = observe?.nodeKey;
8513
+ if (!nodeKey) return null;
8514
+ return new RelayObserver({
8515
+ nodeUrl: observe?.nodeUrl ?? relay.relayUrl,
8516
+ nodeKey,
8517
+ accountProof: observe?.accountProof ?? relay.authorProof,
8518
+ audience: observe?.audience ?? defaultAudience(),
8519
+ signer: observe?.signer ? async () => observe.signer : () => relay.authorSigner(),
8520
+ clientName: observe?.clientName,
8521
+ ttlSeconds: observe?.ttlSeconds,
8522
+ fetch: observe?.fetch,
8523
+ timeoutMs: observe?.timeoutMs
8524
+ });
8525
+ }
8526
+ var MeroClient = class {
8527
+ constructor(config) {
8528
+ /** The relay's event session, or `null` on a node client / with no node key. */
8529
+ this.relayObserver = null;
8530
+ /** Admin reads against the relay, when a session or a proof was supplied. */
8531
+ this.relayAdmin = null;
8532
+ /**
8533
+ * Events off the relay authenticated by a request proof, built lazily.
8534
+ *
8535
+ * Separate from {@link relayObserver} rather than a mode inside it: the
8536
+ * observer's whole subject is a session addressed to a node key, and a proof
8537
+ * has neither. Holding the authorizer instead of the client keeps construction
8538
+ * free of the first signature.
8539
+ */
8540
+ this.proofAuthorizer = null;
8541
+ this.relayUrl = null;
8542
+ this.proofEvents = null;
8543
+ if (config.transport === "relay") {
8544
+ this.transport = "relay";
8545
+ this.nodeClient = null;
8546
+ const relay = config.relay instanceof RelayClient ? config.relay : new RelayClient(config.relay);
8547
+ this.relayObserver = buildObserver(relay, config.observe);
8548
+ this.rpcTransport = new RelayTransport(relay, this.relayObserver);
8549
+ this.relayUrl = relay.relayUrl;
8550
+ if (config.proof) {
8551
+ this.proofAuthorizer = createProofAuthorizer(config.proof);
8552
+ }
8553
+ if (config.session) {
8554
+ const token = config.session;
8555
+ this.relayAdmin = createAdminApiClient({
8556
+ baseUrl: relay.relayUrl,
8557
+ getAuthToken: typeof token === "string" ? async () => token : token
8558
+ });
8559
+ } else if (this.proofAuthorizer) {
8560
+ const authorizeRequest = this.proofAuthorizer;
8561
+ this.relayAdmin = createAdminApiClientFromHttpClient(
8562
+ createHttpClient({
8563
+ fetch: (url, init) => globalThis.fetch(url, init),
8564
+ baseUrl: relay.relayUrl,
8565
+ authorizeRequest
8566
+ }),
8567
+ { baseUrl: relay.relayUrl }
8568
+ );
8569
+ }
8570
+ return;
8571
+ }
8572
+ this.transport = "node";
8573
+ const { transport: _ignored, ...nodeConfig } = config;
8574
+ this.nodeClient = new MeroJs(nodeConfig);
8575
+ this.rpcTransport = this.nodeClient.rpc;
8576
+ }
8577
+ /**
8578
+ * The write surface. Identical on both transports — this is the whole point.
8579
+ *
8580
+ * On a node this IS the `RpcClient` a `MeroJs` would have handed out, not a
8581
+ * wrapper around it, so `client.rpc` and `client.node.rpc` are the same
8582
+ * object.
8583
+ */
8584
+ get rpc() {
8585
+ return this.rpcTransport;
8586
+ }
8587
+ /**
8588
+ * Whether `events` / `ws` will work on this client.
8589
+ *
8590
+ * Always `true` on the node transport. On the relay transport it says whether
8591
+ * a node key was supplied — the relay can be observed, but only by a caller
8592
+ * who can name the key the login statement is signed against.
8593
+ */
8594
+ get canSubscribe() {
8595
+ return this.rpcTransport.canSubscribe || this.proofAuthorizer !== null;
8596
+ }
8597
+ /** The underlying `MeroJs`. Node transport only. */
8598
+ get node() {
8599
+ if (!this.nodeClient) {
8600
+ throw relayHasNo("node connection", "it holds a signing key and a relay URL, not a node");
8601
+ }
8602
+ return this.nodeClient;
8603
+ }
8604
+ /** The underlying `RelayClient`, for `describe()`. Relay transport only. */
8605
+ get relay() {
8606
+ if (!(this.rpcTransport instanceof RelayTransport)) {
8607
+ throw new Error("this client was constructed with the node transport and has no relay");
8608
+ }
8609
+ return this.rpcTransport.client;
8610
+ }
8611
+ /**
8612
+ * Admin API. Node transport only.
8613
+ *
8614
+ * Two different things live behind this accessor today, and only one of them
8615
+ * is genuinely unavailable through a relay.
8616
+ *
8617
+ * The **mutations** — `createNamespace`, `setMemberCapabilities`,
8618
+ * `upgradeGroup` and the rest — are operator actions on somebody else's
8619
+ * machine. A keyholder does not get those, and should not; admin is for
8620
+ * admins.
8621
+ *
8622
+ * The **reads** are a different matter. `getContext`, `getContexts`,
8623
+ * `getApplication`, `listNamespaces`, `getBlob` are an app asking what it is
8624
+ * looking at, not an operator administering anything. They sit under
8625
+ * `/admin-api/` for historical reasons, and a delegated session is refused
8626
+ * them (measured: `403`, while the same session reads context state through
8627
+ * `POST /contexts/{ctx}/query` and writes through `/intents` quite happily).
8628
+ *
8629
+ * So this is the one place where "the same code runs on both transports"
8630
+ * does not hold yet, and it is a missing surface rather than a missing
8631
+ * permission. A transport-independent read API is designed but not built; the
8632
+ * throw below is deliberate until it exists, because the alternative — quietly
8633
+ * reaching for a node the caller did not choose — is worse than a clear stop.
8634
+ */
8635
+ /**
8636
+ * The admin surface.
8637
+ *
8638
+ * On a node client this is the node's own. On a relay client it exists only
8639
+ * when a `session` was supplied, and it is **the relay's admin API
8640
+ * authenticated as the account** — so the caller-scoped reads answer:
8641
+ * `getContexts` and `listNamespaces` return this account's own, because
8642
+ * `account_proof` grants `context:list-own` and `namespace:list-own`.
8643
+ *
8644
+ * Mutations are not filtered out here, deliberately. A delegated session does
8645
+ * not carry the grants they require, so the node refuses them with a `403`
8646
+ * that names the missing permission — which is a better answer than a
8647
+ * client-side guess, and keeps the policy in the one place that owns it.
8648
+ */
8649
+ get admin() {
8650
+ if (this.nodeClient) return this.nodeClient.admin;
8651
+ if (this.relayAdmin) return this.relayAdmin;
8652
+ throw new Error(
8653
+ "this relay-transport client has no admin surface because it was given neither a `proof` nor a `session`. A warrant authorises one intent and is not a credential for reading. The caller-scoped reads (getContexts, listNamespaces) want one of the other two: pass `proof: { credential, deviceSecret }` \u2014 the device cert you already hold, which needs no login and no node key \u2014 or a `session` minted with `login()`. Context STATE reads work through execute/query either way."
8654
+ );
8655
+ }
8656
+ /** Auth API. Node transport only — a relay issues no node credential. */
8657
+ get auth() {
8658
+ return this.node.auth;
8659
+ }
8660
+ /**
8661
+ * SSE events — on **both** transports.
8662
+ *
8663
+ * On a relay client this is an `SseClient` against the relay node, fed by a
8664
+ * session minted with the author's account proof. The session is minted
8665
+ * lazily, on the first token the stream asks for, so reading this getter
8666
+ * performs no network call.
8667
+ *
8668
+ * Throws only when the relay client was given no node key: the one input
8669
+ * that cannot be inferred. See {@link missingNodeKey}.
8670
+ */
8671
+ get events() {
8672
+ if (this.nodeClient) return this.nodeClient.events;
8673
+ if (this.relayObserver) return this.relayObserver.events;
8674
+ if (this.proofAuthorizer && this.relayUrl) {
8675
+ this.proofEvents ?? (this.proofEvents = new SseClient({
8676
+ baseUrl: this.relayUrl,
8677
+ authorize: this.proofAuthorizer
8678
+ }));
8679
+ return this.proofEvents;
8680
+ }
8681
+ throw missingNodeKey("SSE events");
8682
+ }
8683
+ /** WebSocket events, on both transports. @experimental — prefer `events`. */
8684
+ get ws() {
8685
+ if (this.nodeClient) return this.nodeClient.ws;
8686
+ if (this.relayObserver) return this.relayObserver.ws;
8687
+ if (this.proofAuthorizer) {
8688
+ throw new Error(
8689
+ "this client authenticates with a request proof, which cannot open a WebSocket from a browser: `WebSocket` cannot set the X-Calimero-Proof header, and core's guard reads the proof from a header only (the ?token= query parameter is the session equivalent and has no proof counterpart). Use `client.events` \u2014 SSE is fetch-based, carries the header, and is the recommended transport anyway."
8690
+ );
8691
+ }
8692
+ throw missingNodeKey("WebSocket events");
8693
+ }
8694
+ /**
8695
+ * Ephemeral presence. Node transport only.
8696
+ *
8697
+ * Not extended to the relay even though a logged-in session could reach both
8698
+ * halves of it mechanically. Publishing resolves the author from an *owned
8699
+ * context identity on the node*, and whether a delegated session has one has
8700
+ * not been established — a presence client that silently published nothing,
8701
+ * or published as the wrong identity, is worse than one that says it is not
8702
+ * available. The read half is not lost: presence arrives on `client.events`
8703
+ * like any other context event, and can be filtered there.
8704
+ *
8705
+ * The rejected alternative was to expose it over the relay session and let
8706
+ * `set` fail at the node. That trades a clear construction-time answer for a
8707
+ * runtime failure inside a UI that has already rendered.
8708
+ */
8709
+ get ephemeral() {
8710
+ if (!this.nodeClient) {
8711
+ throw relayHasNo(
8712
+ "ephemeral presence",
8713
+ "publishing resolves the author from an owned context identity on the node, which a delegated session is not known to have \u2014 read presence off `client.events` instead"
8714
+ );
8715
+ }
8716
+ return this.nodeClient.ephemeral;
8717
+ }
8718
+ /**
8719
+ * Calimero Cloud.
8720
+ *
8721
+ * Available on both transports, because it is neither: the cloud session is
8722
+ * independent of the node credential and of the relay's signing key, and a
8723
+ * relay-transport app still needs it to list namespaces and manage billing.
8724
+ */
8725
+ get cloud() {
8726
+ if (!this.nodeClient) {
8727
+ throw relayHasNo(
8728
+ "bundled cloud client",
8729
+ "a relay client is built from a cloud connection rather than owning one \u2014 reach it through the `cloud` field that `connectCloud` returns, or construct a `CloudClient` directly"
8730
+ );
8731
+ }
8732
+ return this.nodeClient.cloud;
8733
+ }
8734
+ /**
8735
+ * Release whatever the transport is holding.
8736
+ *
8737
+ * Safe on a relay client, where there is nothing open to close — a caller
8738
+ * that tears down on unmount should not have to know which transport it got.
8739
+ */
8740
+ close() {
8741
+ this.nodeClient?.close();
8742
+ this.relayObserver?.close();
8743
+ this.proofEvents?.close();
8744
+ }
8745
+ };
8746
+ function createMeroClient(config) {
8747
+ return new MeroClient(config);
8748
+ }
8749
+
8750
+ // src/relay/nonce-source.ts
8751
+ function createMemoryNonceSource(start = 1) {
8752
+ let next = BigInt(start);
8753
+ if (next < 1n) {
8754
+ throw new Error("nonce sequence starts at 1");
8755
+ }
8756
+ return {
8757
+ next() {
8758
+ const value = next;
8759
+ next += 1n;
8760
+ return Promise.resolve(value);
8761
+ },
8762
+ advanceTo(floor) {
8763
+ if (floor > next) next = floor;
8764
+ return Promise.resolve();
8765
+ }
8766
+ };
8767
+ }
8768
+ function createLocalStorageNonceSource(key, storage) {
8769
+ const store = storage ?? globalThis.localStorage;
8770
+ if (!store) {
8771
+ throw new Error(
8772
+ "no localStorage in this runtime: pass a storage implementation, or use createMemoryNonceSource for a process that owns its whole sequence"
8773
+ );
8774
+ }
8775
+ const lastHandedOut = () => {
8776
+ const raw = store.getItem(key);
8777
+ if (raw === null || raw === "") return 0n;
8778
+ try {
8779
+ const parsed = BigInt(raw);
8780
+ return parsed > 0n ? parsed : 0n;
8781
+ } catch {
8782
+ return 0n;
8783
+ }
8784
+ };
8785
+ return {
8786
+ next() {
8787
+ const value = lastHandedOut() + 1n;
8788
+ store.setItem(key, value.toString());
8789
+ return Promise.resolve(value);
8790
+ },
8791
+ advanceTo(floor) {
8792
+ const wanted = floor > 0n ? floor - 1n : 0n;
8793
+ if (wanted > lastHandedOut()) store.setItem(key, wanted.toString());
8794
+ return Promise.resolve();
8795
+ }
8796
+ };
8797
+ }
8798
+ function authorNonceLookup(options) {
8799
+ return {
8800
+ getWarrantNonce: (contextId, _authorDeviceKey) => options.client.getWarrantNonceAsAuthor(contextId, options.authorProof)
8801
+ };
8802
+ }
8803
+ var WarrantNonceExhaustedError = class extends Error {
8804
+ constructor(contextId, authorDeviceKey) {
8805
+ super(
8806
+ `author device ${authorDeviceKey} has spent every nonce in context ${contextId}; it must re-key to write again`
8807
+ );
8808
+ this.contextId = contextId;
8809
+ this.authorDeviceKey = authorDeviceKey;
8810
+ this.name = "WarrantNonceExhaustedError";
8811
+ }
8812
+ };
8813
+ function createRecoveringNonceSource(options) {
8814
+ const lookups = Array.isArray(options.lookups) ? options.lookups : [options.lookups];
8815
+ if (lookups.length === 0) {
8816
+ throw new Error("a recovering nonce source needs at least one node to ask");
8817
+ }
8818
+ const local = options.local ?? createMemoryNonceSource(1);
8819
+ const onLookupFailure = options.onLookupFailure ?? "throw";
8820
+ let floor = null;
8821
+ let pending = null;
8822
+ const resolveFloor = async () => {
8823
+ const answers = await Promise.allSettled(
8824
+ lookups.map(
8825
+ (lookup) => lookup.getWarrantNonce(options.contextId, options.authorDeviceKey)
8826
+ )
8827
+ );
8828
+ let highest = null;
8829
+ let exhausted = false;
8830
+ for (const answer of answers) {
8831
+ if (answer.status !== "fulfilled") continue;
8832
+ const state = answer.value;
8833
+ if (state.kind === "exhausted") {
8834
+ exhausted = true;
8835
+ continue;
8836
+ }
8837
+ if (highest === null || state.nextNonce > highest) highest = state.nextNonce;
8838
+ }
8839
+ if (highest === null && exhausted) {
8840
+ throw new WarrantNonceExhaustedError(options.contextId, options.authorDeviceKey);
8841
+ }
8842
+ if (highest === null) {
8843
+ const first = answers.find((a) => a.status === "rejected");
8844
+ const reason = first && first.status === "rejected" ? first.reason : void 0;
8845
+ if (onLookupFailure === "local") {
8846
+ floor = 0n;
8847
+ return;
8848
+ }
8849
+ throw reason instanceof Error ? reason : new Error(`no node could answer where the warrant-nonce sequence stands: ${String(reason)}`);
8850
+ }
8851
+ floor = highest;
8852
+ await local.advanceTo?.(highest);
8853
+ };
8854
+ return {
8855
+ async next() {
8856
+ if (floor === null) {
8857
+ pending ?? (pending = resolveFloor().finally(() => {
8858
+ pending = null;
8859
+ }));
8860
+ await pending;
8861
+ }
8862
+ const mine = await local.next();
8863
+ const value = floor !== null && floor > mine ? floor : mine;
8864
+ floor = value + 1n;
8865
+ return value;
8866
+ },
8867
+ async advanceTo(wanted) {
8868
+ if (floor === null || wanted > floor) floor = wanted;
8869
+ await local.advanceTo?.(wanted);
8870
+ }
8871
+ };
8872
+ }
8873
+
8874
+ // src/sealed/noise.ts
8875
+ var PROTOCOL_NAME = "Noise_NK_25519_AESGCM_SHA256";
8876
+ var DH_LEN = 32;
8877
+ var TAG_LEN = 16;
8878
+ var PKCS8_X25519_PREFIX = new Uint8Array([
8879
+ 48,
8880
+ 46,
8881
+ 2,
8882
+ 1,
8883
+ 0,
8884
+ 48,
8885
+ 5,
8886
+ 6,
8887
+ 3,
8888
+ 43,
8889
+ 101,
8890
+ 110,
8891
+ 4,
8892
+ 34,
8893
+ 4,
8894
+ 32
8895
+ ]);
8896
+ var SymmetricState = class _SymmetricState {
8897
+ constructor(h) {
8898
+ this.k = null;
8899
+ this.n = 0;
8900
+ this.ck = h;
8901
+ this.h = h;
8902
+ }
8903
+ static async initialize() {
8904
+ const name = new TextEncoder().encode(PROTOCOL_NAME);
8905
+ const h = new Uint8Array(32);
8906
+ h.set(name);
8907
+ return new _SymmetricState(h);
8908
+ }
8909
+ async mixHash(data) {
8910
+ this.h = await sha256(concat(this.h, data));
8911
+ }
8912
+ async mixKey(inputKeyMaterial) {
8913
+ const [ck, key] = await hkdf2(this.ck, inputKeyMaterial);
8914
+ this.ck = ck;
8915
+ this.k = await crypto.subtle.importKey("raw", key, "AES-GCM", false, ["encrypt", "decrypt"]);
8916
+ this.n = 0;
8917
+ }
8918
+ async encryptAndHash(plaintext) {
8919
+ if (!this.k) throw new Error("Noise: no cipher key yet");
8920
+ const ciphertext = new Uint8Array(
8921
+ await crypto.subtle.encrypt(
8922
+ { name: "AES-GCM", iv: noiseNonce(this.n), additionalData: this.h },
8923
+ this.k,
8924
+ plaintext
8925
+ )
8926
+ );
8927
+ this.n += 1;
8928
+ await this.mixHash(ciphertext);
8929
+ return ciphertext;
8930
+ }
8931
+ async decryptAndHash(ciphertext) {
8932
+ if (!this.k) throw new Error("Noise: no cipher key yet");
8933
+ const plaintext = new Uint8Array(
8934
+ await crypto.subtle.decrypt(
8935
+ { name: "AES-GCM", iv: noiseNonce(this.n), additionalData: this.h },
8936
+ this.k,
8937
+ ciphertext
8938
+ )
8939
+ );
8940
+ this.n += 1;
8941
+ await this.mixHash(ciphertext);
8942
+ return plaintext;
8943
+ }
8944
+ /** The initiator-to-responder key, then the responder-to-initiator key. */
8945
+ split() {
8946
+ return hkdf2(this.ck, new Uint8Array());
8947
+ }
8948
+ };
8949
+ async function initiate(remoteStatic, prologue, fixedEphemeral) {
8950
+ const state = await SymmetricState.initialize();
8951
+ await state.mixHash(prologue);
8952
+ await state.mixHash(remoteStatic);
8953
+ const ephemeral = await x25519KeyPair(fixedEphemeral);
8954
+ await state.mixHash(ephemeral.publicKey);
8955
+ await state.mixKey(await dh(ephemeral.privateKey, remoteStatic));
8956
+ const message1 = concat(ephemeral.publicKey, await state.encryptAndHash(new Uint8Array()));
8957
+ return {
8958
+ message1,
8959
+ async finish(message2) {
8960
+ if (message2.length < DH_LEN + TAG_LEN) throw new Error("Noise: message 2 is too short");
7717
8961
  const remoteEphemeral = message2.slice(0, DH_LEN);
7718
8962
  await state.mixHash(remoteEphemeral);
7719
8963
  await state.mixKey(await dh(ephemeral.privateKey, remoteEphemeral));
@@ -7795,7 +9039,7 @@ var MAX_HANDSHAKE_REPLY = 1024;
7795
9039
  var RENEW_BEFORE_MS = 6e4;
7796
9040
  var TRANSPORT_BINDING_DOMAIN = "calimero.tee-attest.transport-key.v1";
7797
9041
  var NULL_BODY_STATUSES = [101, 204, 205, 304];
7798
- var utf8 = new TextEncoder();
9042
+ var utf82 = new TextEncoder();
7799
9043
  var StaleTransportKeyError = class extends Error {
7800
9044
  constructor() {
7801
9045
  super("The node no longer holds the attested transport key; attest again for the current one");
@@ -7803,18 +9047,18 @@ var StaleTransportKeyError = class extends Error {
7803
9047
  }
7804
9048
  };
7805
9049
  var SealedTransportError = class extends Error {
7806
- constructor(status, code, message, retryAfterMs) {
9050
+ constructor(status, code, message, retryAfterMs2) {
7807
9051
  super(message);
7808
9052
  this.status = status;
7809
9053
  this.code = code;
7810
- this.retryAfterMs = retryAfterMs;
9054
+ this.retryAfterMs = retryAfterMs2;
7811
9055
  this.name = "SealedTransportError";
7812
9056
  }
7813
9057
  };
7814
9058
  async function transportKeyBinding(inner, transportKey) {
7815
9059
  const digest = await crypto.subtle.digest(
7816
9060
  "SHA-256",
7817
- concat(utf8.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey)
9061
+ concat(utf82.encode(TRANSPORT_BINDING_DOMAIN), inner, transportKey)
7818
9062
  );
7819
9063
  return new Uint8Array(digest);
7820
9064
  }
@@ -8018,7 +9262,7 @@ function nonce(requestId, index) {
8018
9262
  return bytes;
8019
9263
  }
8020
9264
  function joinInner(head, body) {
8021
- const encoded = utf8.encode(JSON.stringify(head));
9265
+ const encoded = utf82.encode(JSON.stringify(head));
8022
9266
  const len = new Uint8Array(4);
8023
9267
  new DataView(len.buffer).setUint32(0, encoded.length, false);
8024
9268
  return concat(len, encoded, body);
@@ -8127,8 +9371,8 @@ async function envelopeRefusal(response) {
8127
9371
  }
8128
9372
  if (code === "stale_transport_key") return new StaleTransportKeyError();
8129
9373
  const retryAfter = Number(response.headers.get("retry-after"));
8130
- const retryAfterMs = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1e3 : void 0;
8131
- return new SealedTransportError(response.status, code, message, retryAfterMs);
9374
+ const retryAfterMs2 = Number.isFinite(retryAfter) && retryAfter > 0 ? Math.min(retryAfter, 5) * 1e3 : void 0;
9375
+ return new SealedTransportError(response.status, code, message, retryAfterMs2);
8132
9376
  }
8133
9377
 
8134
9378
  // src/cloud/connect.ts
@@ -8136,6 +9380,7 @@ async function connectCloud(options) {
8136
9380
  if (!options.sessionToken && !options.googleIdToken) {
8137
9381
  throw new Error("connectCloud needs either a sessionToken or a googleIdToken");
8138
9382
  }
9383
+ const signer = await resolveSigner(options.deviceSecret, options.signer, "deviceSecret");
8139
9384
  const cloud = new CloudClient({
8140
9385
  cloudBaseUrl: options.cloudBaseUrl,
8141
9386
  sessionToken: options.sessionToken,
@@ -8161,8 +9406,8 @@ async function connectCloud(options) {
8161
9406
  executorAccount: relayInfo.executorAccount,
8162
9407
  authorAccount: options.authorAccount,
8163
9408
  authorProof: options.authorProof,
8164
- deviceSecret: options.deviceSecret,
8165
- nonces: options.nonces ?? await defaultNonceSource(options.deviceSecret),
9409
+ signer,
9410
+ nonces: options.nonces ?? defaultNonceSource(signer.publicKey),
8166
9411
  ttlSeconds: options.ttlSeconds,
8167
9412
  // Only the relay is sealed: a sealed fetch is bound to one node and refuses
8168
9413
  // any other URL, and the cloud calls above carry no intent.
@@ -8179,9 +9424,50 @@ async function connectCloud(options) {
8179
9424
  namespaceId: namespace.namespaceId,
8180
9425
  namespace,
8181
9426
  relayInfo,
8182
- execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson)
9427
+ execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson),
9428
+ // Built from the same `relay`, not a second one: one nonce sequence, one
9429
+ // warrant signer. Two clients over one relay would hand out the same nonce
9430
+ // twice and have the network refuse the loser as a replay.
9431
+ //
9432
+ // `observe` is always passed, with whatever key is known — the caller's if
9433
+ // they had one, otherwise the cloud's, which is `null` until mdma #312. One
9434
+ // code path rather than two, so the hosted case starts observing the day
9435
+ // the field appears, without a line changing here or at the call site.
9436
+ client: new MeroClient({
9437
+ transport: "relay",
9438
+ relay,
9439
+ observe: {
9440
+ nodeKey: options.relayNodeKey ?? relayInfo.nodeKey,
9441
+ audience: options.observeAudience,
9442
+ fetch: options.fetch,
9443
+ timeoutMs: options.timeoutMs
9444
+ }
9445
+ })
8183
9446
  };
8184
9447
  }
9448
+ async function connectCloudWithAccount(options) {
9449
+ const { root, authorAccount, ...rest } = options;
9450
+ const signer = await resolveSigner(rest.deviceSecret, rest.signer, "deviceSecret");
9451
+ const resolved = await resolveRoot(root);
9452
+ const cloud = new CloudClient({
9453
+ cloudBaseUrl: options.cloudBaseUrl,
9454
+ // Reported here as well as on the connection's own client: this is where
9455
+ // the session is first minted, and a caller persisting it would otherwise
9456
+ // only hear about the rolling refreshes of a token it never saw.
9457
+ onSession: options.onSession,
9458
+ fetch: options.fetch,
9459
+ timeoutMs: options.timeoutMs
9460
+ });
9461
+ const session = await cloud.signInWithAccount(resolved.signer);
9462
+ return connectCloud({
9463
+ ...rest,
9464
+ // Already resolved above, and passing both forms on would be refused.
9465
+ deviceSecret: void 0,
9466
+ signer,
9467
+ sessionToken: session.sessionToken,
9468
+ authorAccount: authorAccount ?? resolved.accountId
9469
+ });
9470
+ }
8185
9471
  function pickNamespace(namespaces, requested) {
8186
9472
  if (requested) {
8187
9473
  const found = namespaces.find((n) => n.namespaceId === requested);
@@ -8212,19 +9498,181 @@ function explainNoRelay(namespace, relays) {
8212
9498
  if (replicas.length === relays.length) {
8213
9499
  return `every node on namespace ${namespace.namespaceId} is a read-only TEE replica (ReadOnlyTee), which never relays delegated writes: an admin of the namespace must set its TEE admission policy with mode: 'relay' \u2014 AdminClient.setTeeAdmissionPolicy(namespaceId, { ..., mode: 'relay' }) \u2014 so fleet nodes are admitted as RelayTee`;
8214
9500
  }
8215
- const waitingOnGrant = relays.filter((r) => r.teeRole === null && !r.authorshipReady);
8216
- if (waitingOnGrant.length > 0 && waitingOnGrant.length + replicas.length === relays.length) {
8217
- return `every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(", ") || "the relay account"} \u2014 with AdminClient.grantAuthorship(namespaceId, account) for these relays, and AdminClient.openToDelegatedExecution(namespaceId) so relays assigned later need no further op`;
9501
+ const waitingOnGrant = relays.filter((r) => r.teeRole === null && !r.authorshipReady);
9502
+ if (waitingOnGrant.length > 0 && waitingOnGrant.length + replicas.length === relays.length) {
9503
+ return `every relay on namespace ${namespace.namespaceId} is waiting for the authorship grant: an admin of the namespace must grant CAN_AUTHOR_ON_BEHALF to ${waitingOnGrant.map((r) => r.executorAccount ?? r.peerId).join(", ") || "the relay account"} \u2014 with AdminClient.grantAuthorship(namespaceId, account) for these relays, and AdminClient.openToDelegatedExecution(namespaceId) so relays assigned later need no further op`;
9504
+ }
9505
+ return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none is ready to relay yet (a URL, an executor account and a recent heartbeat) \u2014 retry shortly`;
9506
+ }
9507
+ function defaultNonceSource(devicePublicKey) {
9508
+ const key = `mero-js:warrant-nonce:${devicePublicKey}`;
9509
+ try {
9510
+ return createLocalStorageNonceSource(key);
9511
+ } catch {
9512
+ return createMemoryNonceSource(1);
9513
+ }
9514
+ }
9515
+
9516
+ // src/cloud/link-redirect.ts
9517
+ var DEFAULT_CLOUD_WEB_URL = "https://cloud.calimero.network";
9518
+ function cloudLinkUrl(options) {
9519
+ if (!/^[0-9a-f]{64}$/.test(options.accountId)) {
9520
+ throw new Error("accountId must be 64 lowercase hex characters");
9521
+ }
9522
+ const url = new URL(options.cloudWebUrl ?? DEFAULT_CLOUD_WEB_URL);
9523
+ url.searchParams.set("link-account", options.accountId);
9524
+ url.searchParams.set("callback-url", options.returnTo);
9525
+ return url.toString();
9526
+ }
9527
+ function beginCloudLink(options) {
9528
+ window.location.assign(cloudLinkUrl(options));
9529
+ throw new Error("redirecting to the cloud to authorise this link");
9530
+ }
9531
+ function readCloudLinkCallback(location2 = window.location) {
9532
+ const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9533
+ const error = params.get("error");
9534
+ if (error) {
9535
+ stripFragment();
9536
+ throw new Error(
9537
+ error === "denied" ? "the account link was declined at the cloud" : `the cloud refused this link: ${error}`
9538
+ );
9539
+ }
9540
+ const grant = params.get("grant");
9541
+ const account = params.get("account");
9542
+ if (!grant || !account) return null;
9543
+ stripFragment();
9544
+ return { grant, account };
9545
+ }
9546
+ function stripFragment() {
9547
+ if (typeof history === "undefined" || !history.replaceState) return;
9548
+ history.replaceState(null, "", location.pathname + location.search);
9549
+ }
9550
+ async function completeCloudLink(options) {
9551
+ const root = await resolveRoot(options.root);
9552
+ if (root.accountId !== options.account) {
9553
+ throw new Error(
9554
+ `this grant is for account ${options.account}, but the root here derives ${root.accountId} \u2014 it was minted for a different account`
9555
+ );
9556
+ }
9557
+ const cloud = new CloudClient({
9558
+ cloudBaseUrl: options.cloudBaseUrl,
9559
+ fetch: options.fetch
9560
+ });
9561
+ return cloud.linkAccountWithGrant({ grant: options.grant, signer: root.signer });
9562
+ }
9563
+
9564
+ // src/cloud/enrol-redirect.ts
9565
+ var DEVICE_PARAM = "enrol-device";
9566
+ var KEM_PARAM = "enrol-kem";
9567
+ var CALLBACK_PARAM = "callback-url";
9568
+ var STATE_PARAM = "state";
9569
+ function deviceEnrolmentUrl(options) {
9570
+ assertHex(options.devicePublicKey, "devicePublicKey");
9571
+ assertHex(options.kemPublicKey, "kemPublicKey");
9572
+ assertSafeReturnTo(options.returnTo);
9573
+ warnOnForeignCallback(options.returnTo);
9574
+ const url = new URL(options.walletUrl);
9575
+ url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
9576
+ url.searchParams.set(KEM_PARAM, options.kemPublicKey);
9577
+ url.searchParams.set(CALLBACK_PARAM, options.returnTo);
9578
+ if (options.state) url.searchParams.set(STATE_PARAM, options.state);
9579
+ return url.toString();
9580
+ }
9581
+ function beginDeviceEnrolment(options) {
9582
+ window.location.assign(deviceEnrolmentUrl(options));
9583
+ throw new Error("redirecting to the wallet to approve this device");
9584
+ }
9585
+ function readEnrolmentCallback(location2 = window.location) {
9586
+ const params = new URLSearchParams(location2.hash.replace(/^#/, ""));
9587
+ const error = params.get("error");
9588
+ if (error) {
9589
+ stripFragment2();
9590
+ throw new Error(
9591
+ error === "denied" || error === "cancelled" ? "the device was not approved at the wallet" : `the wallet refused this enrolment: ${error}`
9592
+ );
9593
+ }
9594
+ const credential = params.get("credential");
9595
+ const account = params.get("account");
9596
+ const device = params.get("device");
9597
+ if (!credential || !account || !device) return null;
9598
+ stripFragment2();
9599
+ const state = params.get("state");
9600
+ return { credential, account, device, ...state ? { state } : {} };
9601
+ }
9602
+ function stripFragment2() {
9603
+ if (typeof history === "undefined" || !history.replaceState) return;
9604
+ history.replaceState(null, "", location.pathname + location.search);
9605
+ }
9606
+ async function completeDeviceEnrolment(options) {
9607
+ if (options.expectState !== void 0 && options.expectState !== options.state) {
9608
+ throw new Error(
9609
+ "this enrolment answers a request this app did not make \u2014 the state it carries is not the one sent"
9610
+ );
9611
+ }
9612
+ const certificate = await verifyDeviceCredential(options.credential);
9613
+ if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
9614
+ throw new Error(
9615
+ `this credential certifies device key ${certificate.signPublicKey}, but this app holds ${normaliseHex(options.devicePublicKey)} \u2014 it would sign with a key the certificate does not cover`
9616
+ );
9617
+ }
9618
+ if (options.kemPublicKey !== void 0 && certificate.kemPublicKey !== normaliseHex(options.kemPublicKey)) {
9619
+ throw new Error(
9620
+ `this credential names delivery key ${certificate.kemPublicKey}, not the one this app asked about \u2014 group keys would be sealed to somebody else`
9621
+ );
9622
+ }
9623
+ if (certificate.account !== normaliseHex(options.account)) {
9624
+ throw new Error(
9625
+ `the wallet reported account ${normaliseHex(options.account)}, but the credential names ${certificate.account}`
9626
+ );
9627
+ }
9628
+ if (certificate.device !== normaliseHex(options.device)) {
9629
+ throw new Error(
9630
+ `the wallet reported device ${normaliseHex(options.device)}, but the credential names ${certificate.device}`
9631
+ );
9632
+ }
9633
+ return {
9634
+ credential: options.credential,
9635
+ account: certificate.account,
9636
+ device: certificate.device,
9637
+ certificate
9638
+ };
9639
+ }
9640
+ function assertHex(value, label2) {
9641
+ if (!/^[0-9a-f]{64}$/.test(value)) {
9642
+ throw new Error(`${label2} must be 64 lowercase hex characters`);
8218
9643
  }
8219
- return `namespace ${namespace.namespaceId} has ${relays.length} relay(s), but none is ready to relay yet (a URL, an executor account and a recent heartbeat) \u2014 retry shortly`;
8220
9644
  }
8221
- async function defaultNonceSource(deviceSecret) {
8222
- const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
9645
+ function normaliseHex(value) {
9646
+ return value.trim().toLowerCase();
9647
+ }
9648
+ function callbackIsForeign(returnTo, pageOrigin) {
9649
+ const here = pageOrigin ?? (typeof window === "undefined" ? void 0 : window.location.origin);
9650
+ if (!here) return false;
8223
9651
  try {
8224
- return createLocalStorageNonceSource(key);
9652
+ return new URL(returnTo).origin !== here;
8225
9653
  } catch {
8226
- return createMemoryNonceSource(1);
9654
+ return false;
9655
+ }
9656
+ }
9657
+ function warnOnForeignCallback(returnTo) {
9658
+ if (!callbackIsForeign(returnTo)) return;
9659
+ console.warn(
9660
+ `[calimero] enrolling a device but sending the credential to ${new URL(returnTo).origin}, which is not this page's origin. Legitimate when deliberate; worth a second look otherwise.`
9661
+ );
9662
+ }
9663
+ function assertSafeReturnTo(returnTo) {
9664
+ let url;
9665
+ try {
9666
+ url = new URL(returnTo);
9667
+ } catch {
9668
+ throw new Error(`returnTo must be an absolute URL, got ${returnTo}`);
8227
9669
  }
9670
+ const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]" || url.hostname === "::1";
9671
+ if (url.protocol === "https:") return;
9672
+ if (url.protocol === "http:" && loopback) return;
9673
+ throw new Error(
9674
+ `returnTo must be https:, or http: on localhost \u2014 got ${url.protocol}//${url.hostname}`
9675
+ );
8228
9676
  }
8229
9677
 
8230
9678
  // src/sealed/verify.ts
@@ -8595,7 +10043,7 @@ var FULCIO_ISSUER_V1 = "1.3.6.1.4.1.57264.1.1";
8595
10043
  var SAN_URI = 134;
8596
10044
  var UTF8_STRING = 12;
8597
10045
  var SEQUENCE2 = 48;
8598
- var utf82 = new TextEncoder();
10046
+ var utf83 = new TextEncoder();
8599
10047
  var fromUtf8 = (bytes) => new TextDecoder().decode(bytes);
8600
10048
  async function verifyCosignBundle(body, bundle, signer, signature) {
8601
10049
  const { base64Signature, cert, rekorBundle } = cosignBundle(bundle);
@@ -8608,7 +10056,7 @@ async function verifyCosignBundle(body, bundle, signer, signature) {
8608
10056
  const leaf = parseCertificate(leafDer);
8609
10057
  const log = REKOR_LOGS.find((candidate) => candidate.logId === payload.logID.toLowerCase());
8610
10058
  if (!log) throw new Error(`The bundle names Rekor log ${payload.logID}, which the Sigstore trust root does not know`);
8611
- const canonical = utf82.encode(
10059
+ const canonical = utf83.encode(
8612
10060
  `{"body":${JSON.stringify(payload.body)},"integratedTime":${payload.integratedTime},"logID":${JSON.stringify(payload.logID)},"logIndex":${payload.logIndex}}`
8613
10061
  );
8614
10062
  const setVerifies = await verifyEcdsa(
@@ -8912,6 +10360,101 @@ function splitVersion2(version) {
8912
10360
  return [[Number(match[1]), Number(match[2]), Number(match[3])], match[4]];
8913
10361
  }
8914
10362
 
10363
+ // src/invitation/invitation.ts
10364
+ var MAX_INVITATION_VALIDITY_SECS = 24 * 60 * 60;
10365
+ var INVITED_ROLE = { Admin: 0, Member: 1, ReadOnly: 2 };
10366
+ function encodeGroupInvitation(body) {
10367
+ const admitters = body.admitters ?? [];
10368
+ return concat(
10369
+ // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
10370
+ // crosses as hex. Same width, different spelling.
10371
+ bytes32(body.inviter_identity, "inviter_identity"),
10372
+ bytes32(body.group_id, "group_id"),
10373
+ u64le(body.expiration_timestamp),
10374
+ bytes32(body.secret_salt, "secret_salt"),
10375
+ new Uint8Array([body.invited_role]),
10376
+ u32le(admitters.length),
10377
+ ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32))
10378
+ );
10379
+ }
10380
+ async function groupInvitationHash(body) {
10381
+ return new Uint8Array(
10382
+ await crypto.subtle.digest("SHA-256", encodeGroupInvitation(body))
10383
+ );
10384
+ }
10385
+ function defaultAdmitters(members) {
10386
+ const admins = new Set(
10387
+ members.filter((m) => m.role === "Admin").map((m) => m.identity.toLowerCase())
10388
+ );
10389
+ return [...admins].sort();
10390
+ }
10391
+ async function signGroupInvitation(input) {
10392
+ const signer = await resolveSigner(input.deviceSecret, input.signer, "deviceSecret");
10393
+ const inviterKey = fromHex(signer.publicKey, "signer.publicKey", 32);
10394
+ const groupId = fromHex(input.groupId, "groupId", 32);
10395
+ fromHex(input.inviterAccount, "inviterAccount", 32);
10396
+ let admitters = [...input.admitters ?? []].map((a) => a.trim().toLowerCase());
10397
+ if (admitters.length === 0) {
10398
+ if (!input.members) {
10399
+ throw new Error(
10400
+ "name the admitters, or pass the group members to default them to its admins: an invitation with no admitters is claimable by broadcast"
10401
+ );
10402
+ }
10403
+ admitters = defaultAdmitters(input.members);
10404
+ if (admitters.length === 0) {
10405
+ throw new Error(
10406
+ "the member list names no admin, so there is nobody to default the admitters to"
10407
+ );
10408
+ }
10409
+ }
10410
+ admitters.forEach((a, i) => fromHex(a, `admitters[${i}]`, 32));
10411
+ const invitedRole = input.invitedRole ?? INVITED_ROLE.Member;
10412
+ if (![0, 1, 2].includes(invitedRole)) {
10413
+ throw new Error(`invitedRole must be 0 (Admin), 1 (Member) or 2 (ReadOnly), got ${invitedRole}`);
10414
+ }
10415
+ const now = input.now ?? Math.floor(Date.now() / 1e3);
10416
+ const validFor = Math.min(
10417
+ input.validForSecs ?? MAX_INVITATION_VALIDITY_SECS,
10418
+ MAX_INVITATION_VALIDITY_SECS
10419
+ );
10420
+ if (!Number.isInteger(validFor) || validFor <= 0) {
10421
+ throw new Error(`validForSecs must be a positive whole number, got ${input.validForSecs}`);
10422
+ }
10423
+ const nonce2 = input.nonce ?? crypto.getRandomValues(new Uint8Array(32));
10424
+ if (nonce2.length !== 32) {
10425
+ throw new Error(`nonce must be 32 bytes, got ${nonce2.length}`);
10426
+ }
10427
+ const invitation = {
10428
+ inviter_identity: Array.from(inviterKey),
10429
+ group_id: Array.from(groupId),
10430
+ expiration_timestamp: now + validFor,
10431
+ secret_salt: Array.from(nonce2),
10432
+ invited_role: invitedRole,
10433
+ admitters
10434
+ };
10435
+ const signature = await signer.sign(await groupInvitationHash(invitation));
10436
+ if (signature.length !== 64) {
10437
+ throw new Error(`signer returned ${signature.length} bytes, expected a 64-byte Ed25519 signature`);
10438
+ }
10439
+ const signed = {
10440
+ invitation,
10441
+ inviter_signature: hex(signature),
10442
+ inviter_account: input.inviterAccount.trim().toLowerCase()
10443
+ };
10444
+ if (input.admitterAddrs?.length) signed.admitter_addrs = [...input.admitterAddrs];
10445
+ if (input.applicationId) {
10446
+ signed.application_id = Array.from(fromHex(input.applicationId, "applicationId", 32));
10447
+ }
10448
+ if (input.appKey) signed.app_key = Array.from(fromHex(input.appKey, "appKey", 32));
10449
+ return signed;
10450
+ }
10451
+ function bytes32(value, label2) {
10452
+ if (value.length !== 32) {
10453
+ throw new Error(`${label2} must be 32 bytes, got ${value.length}`);
10454
+ }
10455
+ return new Uint8Array(value);
10456
+ }
10457
+
8915
10458
  // src/namespace-op/namespace-op.ts
8916
10459
  var SIGNED_NAMESPACE_OP_SCHEMA_VERSION = 16;
8917
10460
  var NAMESPACE_SIGN_DOMAIN = new TextEncoder().encode("calimero.namespace.v1");
@@ -8929,12 +10472,6 @@ var ROOT_OP2 = {
8929
10472
  NamespaceCreatedV2: 9,
8930
10473
  MemberJoinedViaTeeAttestation: 10
8931
10474
  };
8932
- function bytes32(value, label2) {
8933
- if (value.length !== 32) {
8934
- throw new Error(`${label2} must be 32 bytes, got ${value.length}`);
8935
- }
8936
- return new Uint8Array(value);
8937
- }
8938
10475
  function optionalBytes32(value, label2) {
8939
10476
  if (value === null || value === void 0) {
8940
10477
  return new Uint8Array([0]);
@@ -8950,21 +10487,11 @@ function borshString2(value) {
8950
10487
  }
8951
10488
  function encodeSignedInvitation(signed) {
8952
10489
  const body = signed.invitation;
8953
- const admitters = body.admitters ?? [];
8954
10490
  const admitterAddrs = signed.admitter_addrs ?? [];
8955
10491
  return concat(
8956
- // GroupInvitationFromAdmin.
8957
- //
8958
- // `SignerId` and `ContextGroupId` cross JSON as 32 numbers; `AccountId`
8959
- // crosses as hex. Same width, different spelling, and reading either the
8960
- // wrong way yields 32 bytes that encode cleanly and verify as nothing.
8961
- bytes32(body.inviter_identity, "inviter_identity"),
8962
- bytes32(body.group_id, "group_id"),
8963
- u64le(body.expiration_timestamp),
8964
- bytes32(body.secret_salt, "secret_salt"),
8965
- new Uint8Array([body.invited_role]),
8966
- u32le(admitters.length),
8967
- ...admitters.map((a, i) => fromHex(a, `admitters[${i}]`, 32)),
10492
+ // GroupInvitationFromAdmin, from the one encoder the SDK's own invitation
10493
+ // signer uses: the bytes signed and the bytes carried must not drift.
10494
+ encodeGroupInvitation(body),
8968
10495
  // ...then the enclosing SignedGroupOpenInvitation, in declaration order.
8969
10496
  // `inviter_account` sits between the signature and the addresses; putting it
8970
10497
  // anywhere else still encodes 32 bytes and still verifies as nothing.
@@ -9003,186 +10530,22 @@ async function signMemberJoinOp(input) {
9003
10530
  credential
9004
10531
  );
9005
10532
  const op = concat(new Uint8Array([NAMESPACE_OP_ROOT]), rootOp);
9006
- const key = await importSigningKey(input.deviceSecret);
9007
- const signer = await derivePublicKey(input.deviceSecret);
10533
+ const signingKey = await resolveSigner(input.deviceSecret, input.signer, "deviceSecret");
10534
+ const signerPublicKey = fromHex(signingKey.publicKey, "signer.publicKey", 32);
9008
10535
  const signable = concat(
9009
10536
  new Uint8Array([SIGNED_NAMESPACE_OP_SCHEMA_VERSION]),
9010
10537
  fromHex(input.namespaceId, "namespaceId", 32),
9011
10538
  u32le(parents.length),
9012
10539
  ...parents.map((p, i) => fromHex(p, `parentOpHashes[${i}]`, 32)),
9013
- signer,
10540
+ signerPublicKey,
9014
10541
  u64le(input.nonce),
9015
10542
  op
9016
10543
  );
9017
- const signature = new Uint8Array(
9018
- await crypto.subtle.sign("Ed25519", key, concat(NAMESPACE_SIGN_DOMAIN, signable))
9019
- );
10544
+ const signature = await signingKey.sign(concat(NAMESPACE_SIGN_DOMAIN, signable));
9020
10545
  const unendorsed = new Uint8Array([0]);
9021
10546
  return hex(concat(signable, signature, unendorsed));
9022
10547
  }
9023
10548
 
9024
- // src/login/login.ts
9025
- var SIGN_DOMAIN5 = new TextEncoder().encode("calimero.auth.login.v1");
9026
- var AUDIENCE_TAG = {
9027
- webOrigin: 0,
9028
- codeSigningId: 1,
9029
- cli: 2
9030
- };
9031
- function audienceBody(audience) {
9032
- const encoder = new TextEncoder();
9033
- switch (audience.kind) {
9034
- case "webOrigin":
9035
- return encoder.encode(audience.origin);
9036
- case "codeSigningId":
9037
- return encoder.encode(audience.id);
9038
- case "cli":
9039
- return new Uint8Array(0);
9040
- }
9041
- }
9042
- function audienceSigningBytes(audience) {
9043
- return concat(
9044
- Uint8Array.from([AUDIENCE_TAG[audience.kind]]),
9045
- audienceBody(audience)
9046
- );
9047
- }
9048
- function audienceWire(audience) {
9049
- const tag2 = Uint8Array.from([AUDIENCE_TAG[audience.kind]]);
9050
- if (audience.kind === "cli") {
9051
- return tag2;
9052
- }
9053
- const body = audienceBody(audience);
9054
- return concat(tag2, u32le(body.length), body);
9055
- }
9056
- async function signLoginStatement(input) {
9057
- const node = fromHex(input.node, "node", 32);
9058
- const challenge = fromHex(input.challenge, "challenge", 32);
9059
- const sessionKey = fromHex(input.sessionKey, "sessionKey", 32);
9060
- const key = await importSigningKey(input.deviceSecret);
9061
- const deviceKey = await derivePublicKey(input.deviceSecret);
9062
- const issuedAt = u64le(input.issuedAt);
9063
- const expiresAt = u64le(input.expiresAt);
9064
- const preimage = await domainHash(SIGN_DOMAIN5, [
9065
- node,
9066
- audienceSigningBytes(input.audience),
9067
- challenge,
9068
- sessionKey,
9069
- deviceKey,
9070
- issuedAt,
9071
- expiresAt
9072
- ]);
9073
- const signature = new Uint8Array(
9074
- await crypto.subtle.sign({ name: "Ed25519" }, key, preimage)
9075
- );
9076
- return hex(
9077
- concat(
9078
- node,
9079
- audienceWire(input.audience),
9080
- challenge,
9081
- sessionKey,
9082
- deviceKey,
9083
- issuedAt,
9084
- expiresAt,
9085
- signature
9086
- )
9087
- );
9088
- }
9089
-
9090
- // src/login/session.ts
9091
- var DEFAULT_TTL_SECONDS2 = 300;
9092
- async function generateSessionKey() {
9093
- const secret = hex(crypto.getRandomValues(new Uint8Array(32)));
9094
- return { publicKey: hex(await derivePublicKey(secret)), secret };
9095
- }
9096
- function audienceLabel(audience) {
9097
- switch (audience.kind) {
9098
- case "webOrigin":
9099
- return audience.origin;
9100
- case "codeSigningId":
9101
- return audience.id;
9102
- case "cli":
9103
- return "cli";
9104
- }
9105
- }
9106
- async function login(config) {
9107
- const baseUrl = config.nodeUrl.replace(/\/+$/, "");
9108
- const timeoutMs = config.timeoutMs ?? 1e4;
9109
- const request = async (path, init) => {
9110
- const controller = new AbortController();
9111
- const timer = setTimeout(() => controller.abort(), timeoutMs);
9112
- let response;
9113
- try {
9114
- const requestInit = { ...init, signal: controller.signal };
9115
- response = config.fetch ? await config.fetch(`${baseUrl}${path}`, requestInit) : await globalThis.fetch(`${baseUrl}${path}`, requestInit);
9116
- } catch (err) {
9117
- throw new HTTPError(
9118
- 0,
9119
- err instanceof Error ? err.name : "NetworkError",
9120
- `${baseUrl}${path}`,
9121
- new Headers(),
9122
- err instanceof Error ? err.message : String(err)
9123
- );
9124
- } finally {
9125
- clearTimeout(timer);
9126
- }
9127
- const text = await response.text().catch(() => "");
9128
- if (!response.ok) {
9129
- throw new HTTPError(
9130
- response.status,
9131
- response.statusText,
9132
- `${baseUrl}${path}`,
9133
- response.headers,
9134
- text
9135
- );
9136
- }
9137
- return text ? JSON.parse(text) : {};
9138
- };
9139
- const challengeBody = await request("/auth/challenge", {
9140
- method: "GET",
9141
- headers: { Accept: "application/json" }
9142
- });
9143
- const challenge = challengeBody.challenge ?? challengeBody.data?.challenge;
9144
- if (!challenge) {
9145
- throw new Error("the node issued no challenge");
9146
- }
9147
- const session = await generateSessionKey();
9148
- const issuedAt = Math.floor(Date.now() / 1e3);
9149
- const statement = await signLoginStatement({
9150
- node: config.node,
9151
- audience: config.audience,
9152
- challenge,
9153
- sessionKey: session.publicKey,
9154
- issuedAt,
9155
- expiresAt: issuedAt + (config.ttlSeconds ?? DEFAULT_TTL_SECONDS2),
9156
- deviceSecret: config.deviceSecret
9157
- });
9158
- const tokenBody = await request("/auth/token", {
9159
- method: "POST",
9160
- headers: { "Content-Type": "application/json", Accept: "application/json" },
9161
- body: JSON.stringify({
9162
- auth_method: "account_proof",
9163
- public_key: session.publicKey,
9164
- client_name: config.clientName ?? audienceLabel(config.audience),
9165
- timestamp: issuedAt,
9166
- provider_data: {
9167
- challenge,
9168
- login_statement: statement,
9169
- account_proof: config.accountProof
9170
- }
9171
- })
9172
- });
9173
- const accessToken = tokenBody.access_token ?? tokenBody.data?.access_token;
9174
- const refreshToken = tokenBody.refresh_token ?? tokenBody.data?.refresh_token;
9175
- if (!accessToken) {
9176
- throw new Error("the node minted no session");
9177
- }
9178
- return {
9179
- accessToken,
9180
- refreshToken: refreshToken ?? "",
9181
- sessionKey: session.publicKey,
9182
- sessionSecret: session.secret
9183
- };
9184
- }
9185
-
9186
10549
  // src/nodeDiscovery.ts
9187
10550
  var DEFAULT_LOCAL_NODE_PORTS = [2428, 2429, 2528, 2529];
9188
10551
  var LOCAL_HOST = "localhost";
@@ -9235,4 +10598,100 @@ async function discoverLocalNodes(options = {}) {
9235
10598
  );
9236
10599
  return results.filter((url) => url !== null);
9237
10600
  }
10601
+
10602
+ // src/relay-attestation/attest-node-key.ts
10603
+ var KEY_BINDING_DOMAIN = new TextEncoder().encode("calimero.tee-attest.key-binding.v1");
10604
+ var MOCK_QUOTE_HEADER = new TextEncoder().encode("MOCK_TDX_QUOTE_V1");
10605
+ var TDX_REPORT_DATA_OFFSET = 48 + 520;
10606
+ function concat2(...parts) {
10607
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
10608
+ let at = 0;
10609
+ for (const p of parts) {
10610
+ out.set(p, at);
10611
+ at += p.length;
10612
+ }
10613
+ return out;
10614
+ }
10615
+ function equal2(a, b) {
10616
+ if (a.length !== b.length) return false;
10617
+ let diff = 0;
10618
+ for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];
10619
+ return diff === 0;
10620
+ }
10621
+ function fromBase643(b64) {
10622
+ const bin = atob(b64);
10623
+ const out = new Uint8Array(bin.length);
10624
+ for (let i = 0; i < bin.length; i += 1) out[i] = bin.charCodeAt(i);
10625
+ return out;
10626
+ }
10627
+ function fromHex32(value, label2) {
10628
+ if (typeof value !== "string" || !/^[0-9a-fA-F]{64}$/.test(value)) {
10629
+ throw new Error(`${label2} must be 64 hex characters`);
10630
+ }
10631
+ const out = new Uint8Array(32);
10632
+ for (let i = 0; i < 32; i += 1) out[i] = Number.parseInt(value.slice(i * 2, i * 2 + 2), 16);
10633
+ return out;
10634
+ }
10635
+ async function attestKeyBinding(nodeKey) {
10636
+ const digest = await crypto.subtle.digest(
10637
+ "SHA-256",
10638
+ concat2(KEY_BINDING_DOMAIN, new Uint8Array(32), nodeKey)
10639
+ );
10640
+ return new Uint8Array(digest);
10641
+ }
10642
+ function reportDataOf(quote) {
10643
+ const mock = quote.length >= MOCK_QUOTE_HEADER.length && equal2(quote.subarray(0, MOCK_QUOTE_HEADER.length), MOCK_QUOTE_HEADER);
10644
+ const at = mock ? MOCK_QUOTE_HEADER.length : TDX_REPORT_DATA_OFFSET;
10645
+ if (quote.length < at + 64) {
10646
+ throw new Error(`the quote is too short to carry report data (${quote.length} bytes)`);
10647
+ }
10648
+ return { reportData: quote.slice(at, at + 64), mock };
10649
+ }
10650
+ async function attestRelayNodeKey(opts) {
10651
+ const nonce2 = crypto.getRandomValues(new Uint8Array(32));
10652
+ const url = `${opts.relayUrl.replace(/\/+$/, "")}/admin-api/tee/attest`;
10653
+ const body = JSON.stringify({
10654
+ nonce: hex(nonce2),
10655
+ bindNodeKey: true,
10656
+ ...opts.verify?.includeCollateral ? { includeCollateral: true } : {}
10657
+ });
10658
+ const init = { method: "POST", headers: { "Content-Type": "application/json" }, body };
10659
+ const response = opts.fetch ? await opts.fetch(url, init) : await globalThis.fetch(url, init);
10660
+ if (!response.ok) {
10661
+ const text = await response.text().catch(() => "");
10662
+ throw new Error(`the relay would not attest (HTTP ${response.status})${text ? `: ${text}` : ""}`);
10663
+ }
10664
+ const parsed = await response.json();
10665
+ const data = parsed.data ?? parsed;
10666
+ if (typeof data.quoteB64 !== "string") throw new Error("the relay returned no quote");
10667
+ const nodeKey = fromHex32(data.boundPublicKey, "boundPublicKey");
10668
+ const quote = fromBase643(data.quoteB64);
10669
+ const { reportData, mock } = reportDataOf(quote);
10670
+ if (mock) {
10671
+ if (!opts.allowMock) {
10672
+ throw new Error("the relay answered with a MOCK quote, which proves nothing about the hardware");
10673
+ }
10674
+ } else if (opts.verify) {
10675
+ const accepted = await opts.verify({
10676
+ quoteB64: data.quoteB64,
10677
+ nonce: hex(nonce2),
10678
+ reportDataSuffix: hex(await attestKeyBinding(nodeKey)),
10679
+ ...data.collateral ? { collateral: data.collateral } : {}
10680
+ });
10681
+ if (!accepted) {
10682
+ throw new Error("the relay's quote did not verify: not a trusted image, or not bound to this request");
10683
+ }
10684
+ } else if (opts.verifyQuote) {
10685
+ await opts.verifyQuote(quote);
10686
+ } else {
10687
+ throw new Error("a real TDX quote needs a verify (or verifyQuote) to check its signature and measurements");
10688
+ }
10689
+ if (!equal2(reportData.subarray(0, 32), nonce2)) {
10690
+ throw new Error("the quote does not carry our nonce: it was not made for this request");
10691
+ }
10692
+ if (!equal2(reportData.subarray(32, 64), await attestKeyBinding(nodeKey))) {
10693
+ throw new Error("the quote does not bind the key the relay named: refusing it");
10694
+ }
10695
+ return { nodeKey: hex(nodeKey), mock };
10696
+ }
9238
10697
  //# sourceMappingURL=index.cjs.map