@calimero-network/mero-js 22.7.0 → 22.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account/account.d.ts +99 -4
- package/dist/account/account.js +0 -0
- package/dist/account/index.d.ts +6 -2
- package/dist/account/index.js +3 -1
- package/dist/admin-api/admin-client.d.ts +154 -2
- package/dist/admin-api/admin-client.js +255 -1
- package/dist/admin-api/admin-types.d.ts +193 -5
- package/dist/cloud/cloud-client.d.ts +38 -8
- package/dist/cloud/cloud-client.js +38 -21
- package/dist/cloud/connect.d.ts +101 -2
- package/dist/cloud/connect.js +84 -8
- package/dist/cloud/enrol-redirect.d.ts +195 -0
- package/dist/cloud/enrol-redirect.js +236 -0
- package/dist/cloud/index.d.ts +6 -2
- package/dist/cloud/index.js +7 -1
- package/dist/cloud/link-redirect.d.ts +85 -0
- package/dist/cloud/link-redirect.js +105 -0
- package/dist/crypto/internal.d.ts +15 -0
- package/dist/crypto/internal.js +18 -0
- package/dist/device-cert/device-cert.d.ts +96 -2
- package/dist/device-cert/device-cert.js +101 -11
- package/dist/device-cert/device-scope.d.ts +58 -0
- package/dist/device-cert/device-scope.js +76 -0
- package/dist/device-cert/index.d.ts +4 -2
- package/dist/device-cert/index.js +2 -1
- package/dist/events/sse.d.ts +22 -2
- package/dist/events/sse.js +20 -11
- package/dist/http-client/http-types.d.ts +18 -0
- package/dist/http-client/web-client.js +21 -1
- package/dist/index.browser.mjs +2 -2
- package/dist/index.browser.mjs.map +4 -4
- package/dist/index.cjs +1955 -496
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +21 -2
- package/dist/index.mjs +1955 -496
- package/dist/index.mjs.map +4 -4
- package/dist/invitation/index.d.ts +2 -0
- package/dist/invitation/index.js +2 -0
- package/dist/invitation/invitation.d.ts +106 -0
- package/dist/invitation/invitation.js +104 -0
- package/dist/login/login.d.ts +11 -1
- package/dist/login/login.js +5 -4
- package/dist/login/session.d.ts +21 -5
- package/dist/login/session.js +7 -15
- package/dist/namespace-op/namespace-op.d.ts +14 -2
- package/dist/namespace-op/namespace-op.js +13 -19
- package/dist/relay/index.d.ts +2 -2
- package/dist/relay/index.js +1 -1
- package/dist/relay/nonce-source.d.ts +135 -0
- package/dist/relay/nonce-source.js +187 -19
- package/dist/relay/relay-client.d.ts +60 -5
- package/dist/relay/relay-client.js +84 -4
- package/dist/relay-attestation/attest-node-key.d.ts +66 -0
- package/dist/relay-attestation/attest-node-key.js +110 -0
- package/dist/relay-attestation/index.d.ts +1 -0
- package/dist/relay-attestation/index.js +1 -0
- package/dist/request-proof/authorizer.d.ts +48 -0
- package/dist/request-proof/authorizer.js +65 -0
- package/dist/request-proof/index.d.ts +4 -0
- package/dist/request-proof/index.js +2 -0
- package/dist/request-proof/request-proof.d.ts +56 -0
- package/dist/request-proof/request-proof.js +111 -0
- package/dist/rpc/index.d.ts +21 -0
- package/dist/rpc/index.js +20 -0
- package/dist/transport/index.d.ts +6 -0
- package/dist/transport/index.js +3 -0
- package/dist/transport/mero-client.d.ts +286 -0
- package/dist/transport/mero-client.js +379 -0
- package/dist/transport/relay-observer.d.ts +152 -0
- package/dist/transport/relay-observer.js +173 -0
- package/dist/transport/relay-transport.d.ts +73 -0
- package/dist/transport/relay-transport.js +94 -0
- package/dist/transport/types.d.ts +100 -0
- package/dist/transport/types.js +32 -0
- package/package.json +1 -1
package/dist/cloud/connect.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
86
|
-
nonces: options.nonces ??
|
|
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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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
|
-
|
|
177
|
-
const key = `mero-js:warrant-nonce:${
|
|
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
|
+
}
|
package/dist/cloud/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/cloud/index.js
CHANGED
|
@@ -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';
|