@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
@@ -19,6 +19,12 @@
19
19
  * function is that missing answer, which is why the login step it replaces is
20
20
  * the *node URL prompt* rather than the authentication.
21
21
  *
22
+ * `connectCloudWithAccount(...)` is the same connection reached by the other
23
+ * door: the caller holds an account root and signs in as the account itself,
24
+ * with no Google login anywhere in it. Identical from there on, deliberately —
25
+ * which login proved who you are changes nothing about how a relay is found or
26
+ * how a write is authorised.
27
+ *
22
28
  * # What it does not do
23
29
  *
24
30
  * # Sealing the relay
@@ -37,7 +43,9 @@
37
43
  import { CloudClient, pickExecutingRelay } from './cloud-client.js';
38
44
  import { RelayClient } from '../relay/relay-client.js';
39
45
  import { createLocalStorageNonceSource, createMemoryNonceSource } from '../relay/nonce-source.js';
40
- import { derivePublicKey, hex } from '../crypto/internal.js';
46
+ import { resolveSigner } from '../signer/signer.js';
47
+ import { resolveRoot } from '../account/account.js';
48
+ import { MeroClient } from '../transport/mero-client.js';
41
49
  import { createAttestedSealedFetch } from '../sealed/sealed.js';
42
50
  /**
43
51
  * Sign in to the cloud and return a connection that can write.
@@ -53,6 +61,10 @@ export async function connectCloud(options) {
53
61
  if (!options.sessionToken && !options.googleIdToken) {
54
62
  throw new Error('connectCloud needs either a sessionToken or a googleIdToken');
55
63
  }
64
+ // Resolved before the first network call: a missing or duplicated key is the
65
+ // caller's mistake to fix, and finding out after signing in leaves a session
66
+ // minted for a connection that was never going to be built.
67
+ const signer = await resolveSigner(options.deviceSecret, options.signer, 'deviceSecret');
56
68
  const cloud = new CloudClient({
57
69
  cloudBaseUrl: options.cloudBaseUrl,
58
70
  sessionToken: options.sessionToken,
@@ -82,8 +94,8 @@ export async function connectCloud(options) {
82
94
  executorAccount: relayInfo.executorAccount,
83
95
  authorAccount: options.authorAccount,
84
96
  authorProof: options.authorProof,
85
- deviceSecret: options.deviceSecret,
86
- nonces: options.nonces ?? (await defaultNonceSource(options.deviceSecret)),
97
+ signer,
98
+ nonces: options.nonces ?? defaultNonceSource(signer.publicKey),
87
99
  ttlSeconds: options.ttlSeconds,
88
100
  // Only the relay is sealed: a sealed fetch is bound to one node and refuses
89
101
  // any other URL, and the cloud calls above carry no intent.
@@ -103,8 +115,70 @@ export async function connectCloud(options) {
103
115
  namespace,
104
116
  relayInfo,
105
117
  execute: (contextId, method, argsJson) => relay.execute(contextId, method, argsJson),
118
+ // Built from the same `relay`, not a second one: one nonce sequence, one
119
+ // warrant signer. Two clients over one relay would hand out the same nonce
120
+ // twice and have the network refuse the loser as a replay.
121
+ //
122
+ // `observe` is always passed, with whatever key is known — the caller's if
123
+ // they had one, otherwise the cloud's, which is `null` until mdma #312. One
124
+ // code path rather than two, so the hosted case starts observing the day
125
+ // the field appears, without a line changing here or at the call site.
126
+ client: new MeroClient({
127
+ transport: 'relay',
128
+ relay,
129
+ observe: {
130
+ nodeKey: options.relayNodeKey ?? relayInfo.nodeKey,
131
+ audience: options.observeAudience,
132
+ fetch: options.fetch,
133
+ timeoutMs: options.timeoutMs,
134
+ },
135
+ }),
106
136
  };
107
137
  }
138
+ /**
139
+ * The same connection, opened with an account root instead of a cloud session.
140
+ *
141
+ * The two sign-in paths end in the same place, and until now only one of them
142
+ * ended in a usable client: a caller signing in with a root had to open the
143
+ * session, read the relays and assemble a {@link RelayClient} by hand, which is
144
+ * three chances to pair the wrong author with the wrong executor.
145
+ *
146
+ * It is one call rather than one function: the session is minted here and
147
+ * everything after it is {@link connectCloud}, unchanged. Duplicating the
148
+ * namespace choice and the three no-relay diagnostics would mean two copies of
149
+ * the messages that tell a user what to go and do.
150
+ *
151
+ * The author defaults to the account the root derives. That is not a
152
+ * convenience — passing an account the root does not own is the mistake this
153
+ * shape removes, since the cloud would answer with that root's namespaces while
154
+ * every warrant claimed somebody else's authorship.
155
+ */
156
+ export async function connectCloudWithAccount(options) {
157
+ const { root, authorAccount, ...rest } = options;
158
+ // Both keys resolved before the sign-in, for the reason `connectCloud` gives:
159
+ // a session minted for a connection that cannot be built is a credential
160
+ // issued for nothing.
161
+ const signer = await resolveSigner(rest.deviceSecret, rest.signer, 'deviceSecret');
162
+ const resolved = await resolveRoot(root);
163
+ const cloud = new CloudClient({
164
+ cloudBaseUrl: options.cloudBaseUrl,
165
+ // Reported here as well as on the connection's own client: this is where
166
+ // the session is first minted, and a caller persisting it would otherwise
167
+ // only hear about the rolling refreshes of a token it never saw.
168
+ onSession: options.onSession,
169
+ fetch: options.fetch,
170
+ timeoutMs: options.timeoutMs,
171
+ });
172
+ const session = await cloud.signInWithAccount(resolved.signer);
173
+ return connectCloud({
174
+ ...rest,
175
+ // Already resolved above, and passing both forms on would be refused.
176
+ deviceSecret: undefined,
177
+ signer,
178
+ sessionToken: session.sessionToken,
179
+ authorAccount: authorAccount ?? resolved.accountId,
180
+ });
181
+ }
108
182
  /** The one namespace to write in, or an error naming the actual ambiguity. */
109
183
  function pickNamespace(namespaces, requested) {
110
184
  if (requested) {
@@ -165,16 +239,18 @@ function explainNoRelay(namespace, relays) {
165
239
  /**
166
240
  * A persisted nonce source keyed by the author's device public key.
167
241
  *
168
- * The key is the *public* key, derived locally: a per-device key is required
169
- * (two devices of one account are independent replicas) and the secret must
170
- * never end up in a storage key an extension or another script can enumerate.
242
+ * The key is the *public* key: a per-device key is required (two devices of one
243
+ * account are independent replicas) and the secret must never end up in a
244
+ * storage key an extension or another script can enumerate. The signer names
245
+ * its own public key, which is also the only form available when the private
246
+ * half cannot be exported.
171
247
  *
172
248
  * Falls back to memory where there is no `localStorage` — Node, a worker, an
173
249
  * edge runtime. Those are processes that own their whole sequence, which is
174
250
  * exactly the case a memory counter is correct for.
175
251
  */
176
- async function defaultNonceSource(deviceSecret) {
177
- const key = `mero-js:warrant-nonce:${hex(await derivePublicKey(deviceSecret))}`;
252
+ function defaultNonceSource(devicePublicKey) {
253
+ const key = `mero-js:warrant-nonce:${devicePublicKey}`;
178
254
  try {
179
255
  return createLocalStorageNonceSource(key);
180
256
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Getting an app's device key certified **by a wallet on another origin**.
3
+ *
4
+ * An app holds a device keypair and no account. It cannot certify that key
5
+ * itself: certifying is a signature by an account root, and the root is the one
6
+ * key in the system with nothing above it to revoke it — so a page that can
7
+ * reach it can take the account for good. The whole design is that the app
8
+ * never can.
9
+ *
10
+ * What separates them is the **same-origin policy**, and that is why this is a
11
+ * redirect rather than a message, a popup API or a shared library. The wallet
12
+ * keeps the root as a non-extractable key in *its* origin's IndexedDB; script
13
+ * running on the app's origin cannot read another origin's storage, cannot read
14
+ * its keys, and cannot draw its consent screen. Nothing weaker enforces that —
15
+ * an iframe with a `postMessage` bridge would put the approval dialog inside a
16
+ * document the app can style, overlay and click for you.
17
+ *
18
+ * So the shape is: send the person to the wallet naming the key you want
19
+ * certified, they approve there, and the wallet redirects back with an
20
+ * `AccountProof<DeviceCert>` over that key.
21
+ *
22
+ * ## The credential comes back in the FRAGMENT
23
+ *
24
+ * Same reason the cloud's link grant does — see `link-redirect.ts`. A fragment
25
+ * is not sent to the server, so it lands in nobody's access log, no referrer
26
+ * header and no proxy trace on the way. A query string would put a credential
27
+ * into every log between here and the app's host.
28
+ *
29
+ * ## What the app must check before storing anything
30
+ *
31
+ * A fragment is written by whoever sent the person to this URL, which is not
32
+ * necessarily the wallet. Three things therefore have to be checked locally:
33
+ *
34
+ * - the credential certifies **the key this app asked about** — otherwise the
35
+ * app stores a credential it cannot sign for, and every warrant it mints is
36
+ * refused somewhere far from here;
37
+ * - the credential is **internally consistent** — the root signed it and the
38
+ * account it names is the one that root derives ({@link
39
+ * verifyDeviceCredential});
40
+ * - the account reported beside it is **the account the credential names** —
41
+ * the app shows that id to a person and keys state by it, so a mismatch is a
42
+ * credential filed under someone else's name.
43
+ *
44
+ * {@link completeDeviceEnrolment} does all three and throws on any of them,
45
+ * before the caller has anything to store. What none of them establish is that
46
+ * the account is the *person's* — anyone can mint a root offline and certify
47
+ * any public key with it. That is what {@link DeviceEnrolmentOptions.state}
48
+ * narrows: it proves the answer belongs to the request this app started, rather
49
+ * than to a link someone sent.
50
+ */
51
+ import { type DeviceCredential } from '../device-cert/index.js';
52
+ export interface DeviceEnrolmentOptions {
53
+ /**
54
+ * The wallet's origin — a different origin from this app's, and the reason
55
+ * the flow exists. Pointing it at this app's own origin is accepted (a
56
+ * wallet may be hosted anywhere) but certifies nothing the app could not
57
+ * already do.
58
+ */
59
+ walletUrl: string;
60
+ /** The device's ed25519 signing key to be certified, 64 hex. */
61
+ devicePublicKey: string;
62
+ /**
63
+ * The device's X25519 key-delivery key, 64 hex.
64
+ *
65
+ * Required, not optional: a certificate covers both keys, and one minted
66
+ * without a delivery key names a device no group key can ever be sealed to.
67
+ * Generate it alongside the signing key rather than enrolling twice.
68
+ */
69
+ kemPublicKey: string;
70
+ /**
71
+ * Where the wallet sends them back. Must be `https:`, or `http:` on
72
+ * localhost — see {@link assertSafeReturnTo}.
73
+ */
74
+ returnTo: string;
75
+ /**
76
+ * An opaque value echoed back unchanged, to be compared on return.
77
+ *
78
+ * Optional, and worth setting: without it, any link that lands on the app's
79
+ * callback with a well-formed fragment enrols an account of the sender's
80
+ * choosing, and everything the app then does is filed under it. Store it
81
+ * where the round trip survives but another origin cannot read it —
82
+ * `sessionStorage` — and pass it to {@link completeDeviceEnrolment} as
83
+ * `expectState`.
84
+ */
85
+ state?: string;
86
+ }
87
+ /**
88
+ * The URL to send a person to.
89
+ *
90
+ * Built rather than documented as a string so the parameter names live in one
91
+ * place: a wallet and an app that disagree about them fail as a blank consent
92
+ * screen, which looks like an outage rather than a contract mismatch.
93
+ */
94
+ export declare function deviceEnrolmentUrl(options: DeviceEnrolmentOptions): string;
95
+ /** Send the person to the wallet to approve this device. Does not return. */
96
+ export declare function beginDeviceEnrolment(options: DeviceEnrolmentOptions): never;
97
+ /** What came back in the fragment, unverified. */
98
+ export interface DeviceEnrolmentCallback {
99
+ /** The `AccountProof<DeviceCert>`, hex borsh — as `signDeviceCert` returns it. */
100
+ credential: string;
101
+ /** The account the wallet says certified this device, 64 hex. */
102
+ account: string;
103
+ /** The device id the wallet minted, 64 hex. */
104
+ device: string;
105
+ /** Whatever {@link DeviceEnrolmentOptions.state} was sent, if any. */
106
+ state?: string;
107
+ }
108
+ /**
109
+ * Read an enrolment out of the current URL fragment, and strip it.
110
+ *
111
+ * Returns `null` on an ordinary page load, so a caller can run it
112
+ * unconditionally at startup. Throws when the person declined, because that is
113
+ * an outcome the app has to show rather than a state to wait in.
114
+ *
115
+ * Nothing is verified here — the shape is separated from the checking so a
116
+ * caller can see what arrived (to log it, or to name the account in a refusal)
117
+ * even when it goes on to be refused. Nothing read out of this belongs in
118
+ * storage until {@link completeDeviceEnrolment} has passed.
119
+ */
120
+ export declare function readEnrolmentCallback(location?: {
121
+ hash: string;
122
+ }): DeviceEnrolmentCallback | null;
123
+ export interface CompleteDeviceEnrolmentOptions extends DeviceEnrolmentCallback {
124
+ /**
125
+ * The signing key this app asked to have certified, 64 hex.
126
+ *
127
+ * The binding that makes the returned credential usable: it must be *this*
128
+ * app's key, held in this app, or nothing it signs verifies against the
129
+ * certificate it presents.
130
+ */
131
+ devicePublicKey: string;
132
+ /** The delivery key asked about, 64 hex. Checked when supplied. */
133
+ kemPublicKey?: string;
134
+ /**
135
+ * The {@link DeviceEnrolmentOptions.state} this app sent, if it sent one.
136
+ *
137
+ * Supplied and mismatched is a refusal. Not supplied is accepted — an app may
138
+ * legitimately have none to compare against, having been reloaded — and the
139
+ * gap is documented rather than closed here, because forcing it would break
140
+ * the app whose session storage the reload cleared.
141
+ */
142
+ expectState?: string;
143
+ }
144
+ /** A device this app may now sign as. */
145
+ export interface EnrolledDevice {
146
+ /** The credential to present, hex — `RelayClientConfig.authorProof`. */
147
+ credential: string;
148
+ /** The account it belongs to, 64 hex. */
149
+ account: string;
150
+ /** The device id within that account, 64 hex. */
151
+ device: string;
152
+ /** Everything the certificate says, already checked. */
153
+ certificate: DeviceCredential;
154
+ }
155
+ /**
156
+ * Verify what came back, and refuse loudly rather than returning a half-checked
157
+ * credential.
158
+ *
159
+ * Every throw here is a credential that must not reach storage. The order is
160
+ * deliberate: the cheap local comparisons first, so a fragment aimed at the
161
+ * wrong app is rejected without a signature verification, and the message names
162
+ * what disagreed rather than reporting "invalid credential".
163
+ */
164
+ export declare function completeDeviceEnrolment(options: CompleteDeviceEnrolmentOptions): Promise<EnrolledDevice>;
165
+ /**
166
+ * Where a credential may be delivered.
167
+ *
168
+ * `https:` anywhere, `http:` only on loopback. Plain HTTP elsewhere would put
169
+ * the fragment on a page any network position can rewrite, and the fragment is
170
+ * the credential; loopback is exempt because it never leaves the machine and is
171
+ * the only way to develop against a wallet at all.
172
+ *
173
+ * Everything else — `javascript:`, `data:`, a custom app scheme — is refused.
174
+ * The first two are script the wallet would be asked to run; a custom scheme is
175
+ * a legitimate thing to want for a native app, but which installed handler
176
+ * claims it is not something this SDK can check, so it is refused here rather
177
+ * than accepted on trust.
178
+ */
179
+ /**
180
+ * Is the credential coming back to somewhere other than the page that asked?
181
+ *
182
+ * Normally an app enrols its own device key and gets the answer back on its own
183
+ * origin, so the two match. They can legitimately differ — a callback page on a
184
+ * sibling host, an app mid-migration — which is why this is not refused.
185
+ *
186
+ * But it is the shape an abuse would also take: send someone to the real wallet
187
+ * and have the credential delivered elsewhere. Nothing here can tell the two
188
+ * apart, and a client-side check constrains nobody who did not call this
189
+ * function anyway. What it is good for is the honest case — surfacing the
190
+ * mismatch to whoever is building the app, and giving a wallet the same fact to
191
+ * put in front of the person approving.
192
+ *
193
+ * Exported so a wallet can render it, rather than each caller re-deriving it.
194
+ */
195
+ export declare function callbackIsForeign(returnTo: string, pageOrigin?: string): boolean;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Getting an app's device key certified **by a wallet on another origin**.
3
+ *
4
+ * An app holds a device keypair and no account. It cannot certify that key
5
+ * itself: certifying is a signature by an account root, and the root is the one
6
+ * key in the system with nothing above it to revoke it — so a page that can
7
+ * reach it can take the account for good. The whole design is that the app
8
+ * never can.
9
+ *
10
+ * What separates them is the **same-origin policy**, and that is why this is a
11
+ * redirect rather than a message, a popup API or a shared library. The wallet
12
+ * keeps the root as a non-extractable key in *its* origin's IndexedDB; script
13
+ * running on the app's origin cannot read another origin's storage, cannot read
14
+ * its keys, and cannot draw its consent screen. Nothing weaker enforces that —
15
+ * an iframe with a `postMessage` bridge would put the approval dialog inside a
16
+ * document the app can style, overlay and click for you.
17
+ *
18
+ * So the shape is: send the person to the wallet naming the key you want
19
+ * certified, they approve there, and the wallet redirects back with an
20
+ * `AccountProof<DeviceCert>` over that key.
21
+ *
22
+ * ## The credential comes back in the FRAGMENT
23
+ *
24
+ * Same reason the cloud's link grant does — see `link-redirect.ts`. A fragment
25
+ * is not sent to the server, so it lands in nobody's access log, no referrer
26
+ * header and no proxy trace on the way. A query string would put a credential
27
+ * into every log between here and the app's host.
28
+ *
29
+ * ## What the app must check before storing anything
30
+ *
31
+ * A fragment is written by whoever sent the person to this URL, which is not
32
+ * necessarily the wallet. Three things therefore have to be checked locally:
33
+ *
34
+ * - the credential certifies **the key this app asked about** — otherwise the
35
+ * app stores a credential it cannot sign for, and every warrant it mints is
36
+ * refused somewhere far from here;
37
+ * - the credential is **internally consistent** — the root signed it and the
38
+ * account it names is the one that root derives ({@link
39
+ * verifyDeviceCredential});
40
+ * - the account reported beside it is **the account the credential names** —
41
+ * the app shows that id to a person and keys state by it, so a mismatch is a
42
+ * credential filed under someone else's name.
43
+ *
44
+ * {@link completeDeviceEnrolment} does all three and throws on any of them,
45
+ * before the caller has anything to store. What none of them establish is that
46
+ * the account is the *person's* — anyone can mint a root offline and certify
47
+ * any public key with it. That is what {@link DeviceEnrolmentOptions.state}
48
+ * narrows: it proves the answer belongs to the request this app started, rather
49
+ * than to a link someone sent.
50
+ */
51
+ import { verifyDeviceCredential, } from '../device-cert/index.js';
52
+ /** Query parameters the wallet reads. Mirrors the cloud's `link-account` shape. */
53
+ const DEVICE_PARAM = 'enrol-device';
54
+ const KEM_PARAM = 'enrol-kem';
55
+ const CALLBACK_PARAM = 'callback-url';
56
+ const STATE_PARAM = 'state';
57
+ /**
58
+ * The URL to send a person to.
59
+ *
60
+ * Built rather than documented as a string so the parameter names live in one
61
+ * place: a wallet and an app that disagree about them fail as a blank consent
62
+ * screen, which looks like an outage rather than a contract mismatch.
63
+ */
64
+ export function deviceEnrolmentUrl(options) {
65
+ assertHex(options.devicePublicKey, 'devicePublicKey');
66
+ assertHex(options.kemPublicKey, 'kemPublicKey');
67
+ assertSafeReturnTo(options.returnTo);
68
+ warnOnForeignCallback(options.returnTo);
69
+ const url = new URL(options.walletUrl);
70
+ url.searchParams.set(DEVICE_PARAM, options.devicePublicKey);
71
+ url.searchParams.set(KEM_PARAM, options.kemPublicKey);
72
+ url.searchParams.set(CALLBACK_PARAM, options.returnTo);
73
+ if (options.state)
74
+ url.searchParams.set(STATE_PARAM, options.state);
75
+ return url.toString();
76
+ }
77
+ /** Send the person to the wallet to approve this device. Does not return. */
78
+ export function beginDeviceEnrolment(options) {
79
+ window.location.assign(deviceEnrolmentUrl(options));
80
+ // `assign` does not stop execution, and a caller that kept going would act on
81
+ // a page already being replaced.
82
+ throw new Error('redirecting to the wallet to approve this device');
83
+ }
84
+ /**
85
+ * Read an enrolment out of the current URL fragment, and strip it.
86
+ *
87
+ * Returns `null` on an ordinary page load, so a caller can run it
88
+ * unconditionally at startup. Throws when the person declined, because that is
89
+ * an outcome the app has to show rather than a state to wait in.
90
+ *
91
+ * Nothing is verified here — the shape is separated from the checking so a
92
+ * caller can see what arrived (to log it, or to name the account in a refusal)
93
+ * even when it goes on to be refused. Nothing read out of this belongs in
94
+ * storage until {@link completeDeviceEnrolment} has passed.
95
+ */
96
+ export function readEnrolmentCallback(location = window.location) {
97
+ const params = new URLSearchParams(location.hash.replace(/^#/, ''));
98
+ const error = params.get('error');
99
+ if (error) {
100
+ stripFragment();
101
+ throw new Error(error === 'denied' || error === 'cancelled'
102
+ ? 'the device was not approved at the wallet'
103
+ : `the wallet refused this enrolment: ${error}`);
104
+ }
105
+ const credential = params.get('credential');
106
+ const account = params.get('account');
107
+ const device = params.get('device');
108
+ if (!credential || !account || !device)
109
+ return null;
110
+ // Consumed once. A credential left in the address bar is replayed by a
111
+ // reload, ends up in the browser's history and session restore, and is the
112
+ // one value here worth keeping out of both.
113
+ stripFragment();
114
+ const state = params.get('state');
115
+ return { credential, account, device, ...(state ? { state } : {}) };
116
+ }
117
+ function stripFragment() {
118
+ if (typeof history === 'undefined' || !history.replaceState)
119
+ return;
120
+ history.replaceState(null, '', location.pathname + location.search);
121
+ }
122
+ /**
123
+ * Verify what came back, and refuse loudly rather than returning a half-checked
124
+ * credential.
125
+ *
126
+ * Every throw here is a credential that must not reach storage. The order is
127
+ * deliberate: the cheap local comparisons first, so a fragment aimed at the
128
+ * wrong app is rejected without a signature verification, and the message names
129
+ * what disagreed rather than reporting "invalid credential".
130
+ */
131
+ export async function completeDeviceEnrolment(options) {
132
+ if (options.expectState !== undefined && options.expectState !== options.state) {
133
+ throw new Error('this enrolment answers a request this app did not make — the state it ' +
134
+ 'carries is not the one sent');
135
+ }
136
+ const certificate = await verifyDeviceCredential(options.credential);
137
+ if (certificate.signPublicKey !== normaliseHex(options.devicePublicKey)) {
138
+ throw new Error(`this credential certifies device key ${certificate.signPublicKey}, but ` +
139
+ `this app holds ${normaliseHex(options.devicePublicKey)} — it would ` +
140
+ 'sign with a key the certificate does not cover');
141
+ }
142
+ if (options.kemPublicKey !== undefined &&
143
+ certificate.kemPublicKey !== normaliseHex(options.kemPublicKey)) {
144
+ throw new Error(`this credential names delivery key ${certificate.kemPublicKey}, not the ` +
145
+ 'one this app asked about — group keys would be sealed to somebody else');
146
+ }
147
+ if (certificate.account !== normaliseHex(options.account)) {
148
+ throw new Error(`the wallet reported account ${normaliseHex(options.account)}, but the ` +
149
+ `credential names ${certificate.account}`);
150
+ }
151
+ if (certificate.device !== normaliseHex(options.device)) {
152
+ throw new Error(`the wallet reported device ${normaliseHex(options.device)}, but the ` +
153
+ `credential names ${certificate.device}`);
154
+ }
155
+ return {
156
+ credential: options.credential,
157
+ account: certificate.account,
158
+ device: certificate.device,
159
+ certificate,
160
+ };
161
+ }
162
+ function assertHex(value, label) {
163
+ if (!/^[0-9a-f]{64}$/.test(value)) {
164
+ throw new Error(`${label} must be 64 lowercase hex characters`);
165
+ }
166
+ }
167
+ function normaliseHex(value) {
168
+ return value.trim().toLowerCase();
169
+ }
170
+ /**
171
+ * Where a credential may be delivered.
172
+ *
173
+ * `https:` anywhere, `http:` only on loopback. Plain HTTP elsewhere would put
174
+ * the fragment on a page any network position can rewrite, and the fragment is
175
+ * the credential; loopback is exempt because it never leaves the machine and is
176
+ * the only way to develop against a wallet at all.
177
+ *
178
+ * Everything else — `javascript:`, `data:`, a custom app scheme — is refused.
179
+ * The first two are script the wallet would be asked to run; a custom scheme is
180
+ * a legitimate thing to want for a native app, but which installed handler
181
+ * claims it is not something this SDK can check, so it is refused here rather
182
+ * than accepted on trust.
183
+ */
184
+ /**
185
+ * Is the credential coming back to somewhere other than the page that asked?
186
+ *
187
+ * Normally an app enrols its own device key and gets the answer back on its own
188
+ * origin, so the two match. They can legitimately differ — a callback page on a
189
+ * sibling host, an app mid-migration — which is why this is not refused.
190
+ *
191
+ * But it is the shape an abuse would also take: send someone to the real wallet
192
+ * and have the credential delivered elsewhere. Nothing here can tell the two
193
+ * apart, and a client-side check constrains nobody who did not call this
194
+ * function anyway. What it is good for is the honest case — surfacing the
195
+ * mismatch to whoever is building the app, and giving a wallet the same fact to
196
+ * put in front of the person approving.
197
+ *
198
+ * Exported so a wallet can render it, rather than each caller re-deriving it.
199
+ */
200
+ export function callbackIsForeign(returnTo, pageOrigin) {
201
+ const here = pageOrigin ?? (typeof window === 'undefined' ? undefined : window.location.origin);
202
+ if (!here)
203
+ return false;
204
+ try {
205
+ return new URL(returnTo).origin !== here;
206
+ }
207
+ catch {
208
+ return false;
209
+ }
210
+ }
211
+ function warnOnForeignCallback(returnTo) {
212
+ if (!callbackIsForeign(returnTo))
213
+ return;
214
+ // A warning, not a throw: see `callbackIsForeign` for why this is a smell
215
+ // rather than a verdict.
216
+ console.warn(`[calimero] enrolling a device but sending the credential to ${new URL(returnTo).origin}, ` +
217
+ `which is not this page's origin. Legitimate when deliberate; worth a second look otherwise.`);
218
+ }
219
+ function assertSafeReturnTo(returnTo) {
220
+ let url;
221
+ try {
222
+ url = new URL(returnTo);
223
+ }
224
+ catch {
225
+ throw new Error(`returnTo must be an absolute URL, got ${returnTo}`);
226
+ }
227
+ const loopback = url.hostname === 'localhost' ||
228
+ url.hostname === '127.0.0.1' ||
229
+ url.hostname === '[::1]' ||
230
+ url.hostname === '::1';
231
+ if (url.protocol === 'https:')
232
+ return;
233
+ if (url.protocol === 'http:' && loopback)
234
+ return;
235
+ throw new Error(`returnTo must be https:, or http: on localhost — got ${url.protocol}//${url.hostname}`);
236
+ }
@@ -1,6 +1,10 @@
1
1
  export { CloudClient, admitterAddrsFromNetworkStatus } from './cloud-client.js';
2
2
  export type { CloudClientConfig, CloudSession, CloudNamespace, CloudRelay, CloudAccountRelay, CloudNamespaceNode, CloudNamespaceRouting, CloudMachine, CloudMachineNamespace, CloudLinkedAccount, CloudLinkedAccounts, CloudAccountLink, AccountLinkChallenge, AccountLinkProof, AccountLinkHandoff, AccountLinkCallback, AccountLoginChallenge, AccountLoginProof, EnableHAOptions, DisableHAOptions, } from './cloud-client.js';
3
- export { connectCloud } from './connect.js';
4
- export type { ConnectCloudOptions, CloudConnection } from './connect.js';
3
+ export { connectCloud, connectCloudWithAccount } from './connect.js';
4
+ export type { ConnectCloudOptions, ConnectCloudWithAccountOptions, CloudConnection, } from './connect.js';
5
5
  export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
6
6
  export type { RoutingCredential, DiscoveryChallenge, RoutingChallenge, RoutingProofHeaders, } from './routing-proof.js';
7
+ export { cloudLinkUrl, beginCloudLink, readCloudLinkCallback, completeCloudLink, } from './link-redirect.js';
8
+ export type { BeginCloudLinkOptions, CloudLinkCallback, CompleteCloudLinkOptions, } from './link-redirect.js';
9
+ export { deviceEnrolmentUrl, beginDeviceEnrolment, readEnrolmentCallback, completeDeviceEnrolment, callbackIsForeign, } from './enrol-redirect.js';
10
+ export type { DeviceEnrolmentOptions, DeviceEnrolmentCallback, CompleteDeviceEnrolmentOptions, EnrolledDevice, } from './enrol-redirect.js';
@@ -1,3 +1,9 @@
1
1
  export { CloudClient, admitterAddrsFromNetworkStatus } from './cloud-client.js';
2
- export { connectCloud } from './connect.js';
2
+ export { connectCloud, connectCloudWithAccount } from './connect.js';
3
3
  export { signRoutingChallenge, routingProofHeaders } from './routing-proof.js';
4
+ // Linking an account to a cloud login from a browser — the redirect half the
5
+ // cloud's consent screen expects an app to implement.
6
+ export { cloudLinkUrl, beginCloudLink, readCloudLinkCallback, completeCloudLink, } from './link-redirect.js';
7
+ // Getting this app's device key certified by a wallet on another origin — the
8
+ // redirect that keeps the account root out of the app's reach.
9
+ export { deviceEnrolmentUrl, beginDeviceEnrolment, readEnrolmentCallback, completeDeviceEnrolment, callbackIsForeign, } from './enrol-redirect.js';