@waaskey/sdk 0.4.2 → 0.6.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/index.cjs CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  var ed25519_js = require('@noble/curves/ed25519.js');
4
+ var secp256k1_js = require('@noble/curves/secp256k1.js');
5
+ var sha3_js = require('@noble/hashes/sha3.js');
4
6
 
5
7
  // src/analytics/analytics.ts
6
8
  var Analytics = class {
@@ -316,6 +318,99 @@ async function broadcast(signedTx, options) {
316
318
  return { txHash: json.result };
317
319
  }
318
320
 
321
+ // src/devices.ts
322
+ var DEVICE_ID_KEY = "waaskey:device-id";
323
+ var Devices = class {
324
+ constructor(http, store) {
325
+ this.http = http;
326
+ this.store = store;
327
+ }
328
+ http;
329
+ store;
330
+ /**
331
+ * This device's stable id, minting and persisting one on first use.
332
+ *
333
+ * A caller with no configured store must pass its own id to {@link register} — an id that is
334
+ * regenerated per call is worse than none: the registry would fill with orphan devices and none
335
+ * of them would match the share this browser actually holds.
336
+ *
337
+ * @throws {WaaskeyError} `device_store_required` — no store configured to persist the id.
338
+ */
339
+ async deviceId() {
340
+ if (!this.store) {
341
+ throw new WaaskeyError("A stable device id needs somewhere to live \u2014 pass `deviceStore` to `new Waaskey(...)`, or supply `deviceId` yourself.", "device_store_required");
342
+ }
343
+ const existing = await this.store.get(DEVICE_ID_KEY);
344
+ if (existing) return existing;
345
+ const minted = crypto.randomUUID();
346
+ await this.store.set(DEVICE_ID_KEY, minted);
347
+ return minted;
348
+ }
349
+ /**
350
+ * The persisted device id, or `undefined` when this client has never minted one.
351
+ *
352
+ * Read-only on purpose: it is how other resources DEFAULT to this device without minting an
353
+ * identity as a side effect of joining a ceremony. A silently minted id would be one this
354
+ * device never registered and holds no share for.
355
+ */
356
+ async stored() {
357
+ return await this.store?.get(DEVICE_ID_KEY) ?? void 0;
358
+ }
359
+ /**
360
+ * Register (or re-register) this device under the caller's member seat.
361
+ *
362
+ * Idempotent by `deviceId`: the backend upserts, so calling it on every start refreshes
363
+ * `lastSeenAt` and rotates the encryption public key without creating a second device. That
364
+ * matters — a duplicate device would appear in the picker as a share-holder candidate that holds
365
+ * nothing.
366
+ *
367
+ * `encryptionPublicKey` is this device's X25519 key; dealers seal reshare sub-shares and FROST
368
+ * round-2 packages to it, so a device that will hold a share should register one.
369
+ */
370
+ async register(registration, signal) {
371
+ const deviceId = registration.deviceId ?? await this.deviceId();
372
+ return this.http.requestAsMember(
373
+ "POST",
374
+ "/v1/devices",
375
+ { deviceId, label: registration.label, ...registration.encryptionPublicKey ? { encryptionPublicKey: registration.encryptionPublicKey } : {} },
376
+ signal
377
+ );
378
+ }
379
+ /** The caller's own registered devices — this one and their others. */
380
+ async list(signal) {
381
+ return this.http.requestAsMember("GET", "/v1/devices", void 0, signal);
382
+ }
383
+ /**
384
+ * The caller's devices, each marked whether it is THIS client (#57).
385
+ *
386
+ * A device-management UI cannot work this out on its own: the registry returns a member's
387
+ * devices with no notion of who is asking, and the only thing that distinguishes this browser is
388
+ * the id stored here. Getting it wrong is not cosmetic — "revoke" next to the wrong row revokes
389
+ * the device the user is standing on, and the backend will happily do it.
390
+ *
391
+ * Every device reads as "not this one" when this client has no stored id, which is the honest
392
+ * answer: it has no identity to match against.
393
+ */
394
+ async mine(signal) {
395
+ const [devices, current] = await Promise.all([this.list(signal), this.stored()]);
396
+ return devices.map((device) => ({ ...device, isThisDevice: current !== void 0 && device.deviceId === current }));
397
+ }
398
+ /**
399
+ * Revoke one of the caller's own devices by its registry id (not its `deviceId`).
400
+ *
401
+ * The backend tombstones any live shares it holds, and REFUSES (409) when that would strand a
402
+ * wallet mid-keygen or drop an active wallet below its signing threshold — losing the ability to
403
+ * sign is not something a device-management action gets to do quietly.
404
+ */
405
+ async revoke(id, signal) {
406
+ await this.http.requestAsMember("DELETE", `/v1/devices/${encodeURIComponent(id)}`, void 0, signal);
407
+ }
408
+ /** Forget this browser's device id, so the next {@link deviceId} mints a new one. */
409
+ async forget() {
410
+ await this.store?.delete(DEVICE_ID_KEY);
411
+ }
412
+ };
413
+
319
414
  // src/http.ts
320
415
  var HttpClient = class {
321
416
  apiKey;
@@ -460,6 +555,36 @@ var Members = class {
460
555
  memberSignOut() {
461
556
  this.held = void 0;
462
557
  }
558
+ /**
559
+ * The tenant's team members, optionally narrowed to the share-holder-eligible roster (#57) —
560
+ * what a wallet creator picks share-holders from.
561
+ *
562
+ * `canHoldShare` is a per-membership capability, not part of the login identity, so it is only
563
+ * on this view: the session's own `member` does not carry it (see {@link eligibility}).
564
+ */
565
+ async list(options = {}, signal) {
566
+ const query = options.canHoldShare === void 0 ? "" : `?canHoldShare=${options.canHoldShare}`;
567
+ return this.http.requestAsMember("GET", `/v1/members${query}`, void 0, signal);
568
+ }
569
+ /**
570
+ * Whether the logged-in member may hold a wallet key share, and who else may — one call, because
571
+ * a device-picker UI needs both at once.
572
+ *
573
+ * It reads the roster to answer a question about the caller because the member session does not
574
+ * carry `canHoldShare` (the backend's session view is the login identity, `IMember`). Worth
575
+ * knowing rather than hiding: a UI that offers "hold a share on this device" to an ineligible
576
+ * member produces a create that the backend refuses at binding time, after the user has chosen.
577
+ *
578
+ * @throws {WaaskeyError} `unauthorized` — no member session is held.
579
+ */
580
+ async eligibility(signal) {
581
+ const self = this.held?.member;
582
+ if (!self) {
583
+ throw new WaaskeyError("Share-holder eligibility is a property of the logged-in member \u2014 call `members.loginWithFirebase(...)` first.", "unauthorized");
584
+ }
585
+ const eligible = await this.list({ canHoldShare: true }, signal);
586
+ return { canHoldShare: eligible.some((member) => member.id === self.id), eligible };
587
+ }
463
588
  };
464
589
  function mapFirebaseMemberError(error) {
465
590
  if (error instanceof WaaskeyError && error.status === 401) {
@@ -1087,6 +1212,259 @@ function assertPublicKey(actual, expected, walletId) {
1087
1212
  function serializeCompletedShare(completed) {
1088
1213
  return JSON.stringify({ keyShare: completed.keyShare, sharedPublicKey: completed.sharedPublicKey });
1089
1214
  }
1215
+ var SessionKeys = class {
1216
+ constructor(http, walletUrl) {
1217
+ this.http = http;
1218
+ this.walletUrl = walletUrl;
1219
+ }
1220
+ http;
1221
+ walletUrl;
1222
+ /**
1223
+ * Ask a user for a permission, and get back a link to send them to (#113).
1224
+ *
1225
+ * The keypair is generated HERE and only its public half is registered, so nothing secret ever
1226
+ * travels: not in the request, not in the link, not through the wallet. The private key in the
1227
+ * result is the app's to keep — there is no way to retrieve it later, by design.
1228
+ *
1229
+ * The link is where the user answers. Open it, or render it as a QR code for a wallet on another
1230
+ * device. Then wait for the answer with {@link waitForDecision}.
1231
+ *
1232
+ * @example
1233
+ * ```ts
1234
+ * const req = await waaskey.sessionKeys.request({
1235
+ * walletId, label: 'Dungeon Quest — one battle',
1236
+ * scope: { allowedContracts: [game], maxCalls: 50, periodSeconds: 3600, maxCallsPerPeriod: 10 },
1237
+ * expiresAt: new Date(Date.now() + 24 * 3600_000),
1238
+ * });
1239
+ * showQrCode(req.url);
1240
+ * const { status, key, narrowed } = await waaskey.sessionKeys.waitForDecision(req.sessionKeyId);
1241
+ * ```
1242
+ */
1243
+ async request(params, signal) {
1244
+ if (!this.walletUrl) {
1245
+ throw new WaaskeyError("Waaskey: `walletUrl` is required to request a permission \u2014 pass it to the client, pointing at the wallet that answers.", "validation");
1246
+ }
1247
+ const privateKey = generatePrivateKey();
1248
+ const publicKey = `0x${toHex(secp256k1_js.secp256k1.getPublicKey(hexToBytes(privateKey), true))}`;
1249
+ const key = await this.http.request(
1250
+ "POST",
1251
+ "/aa/session-keys",
1252
+ {
1253
+ walletId: params.walletId,
1254
+ publicKey,
1255
+ permissions: params.scope,
1256
+ expiresAt: toIso(params.expiresAt),
1257
+ label: params.label,
1258
+ requesterId: params.requesterId,
1259
+ paymasterPolicyId: params.paymasterPolicyId
1260
+ },
1261
+ signal
1262
+ );
1263
+ return { sessionKeyId: key.id, privateKey, publicKey, url: permissionUrl(this.walletUrl, key.id), status: key.grantStatus ?? "approved" };
1264
+ }
1265
+ /** One permission as it now stands — including whether its owner has answered yet. */
1266
+ get(sessionKeyId, signal) {
1267
+ return this.http.request("GET", `/aa/session-keys/${encodeURIComponent(sessionKeyId)}`, void 0, signal);
1268
+ }
1269
+ /**
1270
+ * The app that asked for a permission — who it says it is, and whether its domain proved it.
1271
+ *
1272
+ * A wallet rendering a consent screen needs this, and hand-rolling the call is how a client ends
1273
+ * up showing `name` without `status` beside it — which reads as verified whether or not it is.
1274
+ */
1275
+ requester(requesterId, signal) {
1276
+ return this.http.request("GET", `/aa/requesters/${encodeURIComponent(requesterId)}`, void 0, signal);
1277
+ }
1278
+ /**
1279
+ * Wait for the user to answer a permission request (#113).
1280
+ *
1281
+ * Polls, rather than waiting on a redirect back: a user who answers on their phone, or closes the
1282
+ * tab, or takes a minute to read the screen would otherwise strand the app with no answer at all.
1283
+ *
1284
+ * Returns as soon as the wallet's owner has decided — `declined` is an answer, not an error, and
1285
+ * is returned rather than thrown so the app can say something useful. A request that is never
1286
+ * answered times out, which IS an error: it is indistinguishable from a user who walked away.
1287
+ *
1288
+ * `narrowed` says the user accepted less than was asked (they may narrow, never widen), so an app
1289
+ * can adapt to the smaller scope instead of failing opaquely on the first call outside it.
1290
+ */
1291
+ async waitForDecision(sessionKeyId, options = {}) {
1292
+ const { timeoutMs = 5 * 6e4, pollMs = 2e3, signal, asked } = options;
1293
+ const deadline = Date.now() + timeoutMs;
1294
+ for (; ; ) {
1295
+ const key = await this.get(sessionKeyId, signal);
1296
+ const status = key.grantStatus ?? "approved";
1297
+ if (status !== "pending") {
1298
+ return { status, key, narrowed: asked ? wasNarrowed(asked, key) : false };
1299
+ }
1300
+ if (Date.now() + pollMs >= deadline) {
1301
+ throw new WaaskeyError(`Waaskey: the permission request was not answered within ${Math.round(timeoutMs / 1e3)}s.`, "permission_request_timeout", {
1302
+ details: { sessionKeyId }
1303
+ });
1304
+ }
1305
+ await delay2(pollMs, signal);
1306
+ }
1307
+ }
1308
+ /** The tenant's active session keys, newest first (optionally for one wallet). */
1309
+ list(walletId, signal) {
1310
+ const query = walletId ? `?walletId=${encodeURIComponent(walletId)}` : "";
1311
+ return this.http.request("GET", `/aa/session-keys${query}`, void 0, signal);
1312
+ }
1313
+ /**
1314
+ * Sign a call with a session key and submit it as a UserOperation.
1315
+ *
1316
+ * The op is prepared server-side first, because its gas and paymaster fields are part of what the
1317
+ * signature covers: signing a locally-guessed op would produce a signature for an operation the
1318
+ * bundler never sees. Those exact fields are then sent back with the signature, so what was
1319
+ * signed is what is submitted.
1320
+ *
1321
+ * @example
1322
+ * ```ts
1323
+ * const { userOpHash } = await waaskey.sessionKeys.send({
1324
+ * sessionKeyId, privateKey, // the granted permission
1325
+ * sender, chainId: 'evm:1', // the smart account it acts for
1326
+ * callData, contract, selector, valueWei: '0x0',
1327
+ * });
1328
+ * ```
1329
+ */
1330
+ async send(params, signal) {
1331
+ const prepared = await this.http.request(
1332
+ "POST",
1333
+ "/aa/user-ops/prepare",
1334
+ { sender: params.sender, callData: params.callData, chainId: params.chainId, nonce: params.nonce },
1335
+ signal
1336
+ );
1337
+ const signature = signUserOpHash(prepared.userOpHash, params.privateKey);
1338
+ try {
1339
+ return await this.http.request(
1340
+ "POST",
1341
+ `/aa/session-keys/${encodeURIComponent(params.sessionKeyId)}/send`,
1342
+ {
1343
+ sender: params.sender,
1344
+ nonce: prepared.nonce,
1345
+ callData: params.callData,
1346
+ signature,
1347
+ chainId: params.chainId,
1348
+ entryPoint: prepared.entryPoint,
1349
+ contract: params.contract,
1350
+ selector: params.selector,
1351
+ valueWei: params.valueWei,
1352
+ // Exactly the op that was signed — see the note above.
1353
+ prepared: {
1354
+ initCode: prepared.initCode,
1355
+ callGasLimit: prepared.callGasLimit,
1356
+ verificationGasLimit: prepared.verificationGasLimit,
1357
+ preVerificationGas: prepared.preVerificationGas,
1358
+ maxFeePerGas: prepared.maxFeePerGas,
1359
+ maxPriorityFeePerGas: prepared.maxPriorityFeePerGas,
1360
+ paymasterAndData: prepared.paymasterAndData
1361
+ }
1362
+ },
1363
+ signal
1364
+ );
1365
+ } catch (error) {
1366
+ throw asPermissionError(error);
1367
+ }
1368
+ }
1369
+ };
1370
+ function signUserOpHash(userOpHash, privateKey) {
1371
+ const hash = hexToBytes(userOpHash);
1372
+ if (hash.length !== 32) {
1373
+ throw new WaaskeyError("Waaskey: userOpHash must be 32 bytes.", "validation");
1374
+ }
1375
+ const prefix = new TextEncoder().encode("Ethereum Signed Message:\n32");
1376
+ const digest = sha3_js.keccak_256(concat(prefix, hash));
1377
+ const signature = secp256k1_js.secp256k1.sign(digest, hexToBytes(privateKey), { prehash: false, format: "recovered" });
1378
+ const [recovery] = signature;
1379
+ if (recovery === void 0) {
1380
+ throw new WaaskeyError("Waaskey: could not sign the userOpHash.", "sign_failed");
1381
+ }
1382
+ return `0x${toHex(signature.slice(1))}${(27 + recovery).toString(16).padStart(2, "0")}`;
1383
+ }
1384
+ function asPermissionError(error) {
1385
+ if (!(error instanceof WaaskeyError) || error.status !== 422) return error;
1386
+ const code = fromServerCode(error.details) ?? fromWording(error.message);
1387
+ return code ? new WaaskeyError(error.message, code, { status: error.status, details: error.details }) : error;
1388
+ }
1389
+ function fromServerCode(details) {
1390
+ const code = details?.code;
1391
+ if (typeof code !== "string") return void 0;
1392
+ const byCode = {
1393
+ expired: "permission_expired",
1394
+ revoked: "permission_revoked",
1395
+ scope_chain: "permission_scope",
1396
+ scope_contract: "permission_scope",
1397
+ scope_selector: "permission_scope",
1398
+ value_exceeded: "permission_value_exceeded",
1399
+ calls_exhausted: "permission_exhausted",
1400
+ // Both rate bounds mean the same thing to a caller: wait for the next period, do not re-request
1401
+ // the grant. The message says which one ran out.
1402
+ rate_calls_exhausted: "permission_rate_limited",
1403
+ rate_value_exceeded: "permission_rate_limited"
1404
+ };
1405
+ return byCode[code];
1406
+ }
1407
+ function fromWording(message) {
1408
+ const reason = message.toLowerCase();
1409
+ return reason.includes("expired") ? "permission_expired" : reason.includes("inactive") ? "permission_revoked" : reason.includes("maxcalls") ? "permission_exhausted" : reason.includes("value exceeds") ? "permission_value_exceeded" : reason.includes("allowlist") || reason.includes("not permitted") ? "permission_scope" : void 0;
1410
+ }
1411
+ function generatePrivateKey() {
1412
+ return `0x${toHex(secp256k1_js.secp256k1.utils.randomSecretKey())}`;
1413
+ }
1414
+ function permissionUrl(walletUrl, sessionKeyId) {
1415
+ const base = walletUrl.endsWith("/") ? walletUrl.slice(0, -1) : walletUrl;
1416
+ return `${base}/permissions/${encodeURIComponent(sessionKeyId)}`;
1417
+ }
1418
+ function toIso(when) {
1419
+ return typeof when === "string" ? when : when.toISOString();
1420
+ }
1421
+ function wasNarrowed(asked, key) {
1422
+ const got = key.permissions ?? {};
1423
+ const tighter = (before, after) => {
1424
+ if (after === void 0) return false;
1425
+ if (before === void 0) return true;
1426
+ return BigInt(after) < BigInt(before);
1427
+ };
1428
+ const shorter = (before, after) => (after?.length ?? 0) > 0 && (before?.length ?? 0) !== (after?.length ?? 0);
1429
+ return tighter(asked.maxCalls, got.maxCalls) || tighter(asked.maxValueWei, got.maxValueWei) || tighter(asked.maxCallsPerPeriod, got.maxCallsPerPeriod) || tighter(asked.maxValueWeiPerPeriod, got.maxValueWeiPerPeriod) || shorter(asked.allowedContracts, got.allowedContracts) || shorter(asked.allowedSelectors, got.allowedSelectors) || shorter(asked.allowedChains, got.allowedChains);
1430
+ }
1431
+ function delay2(ms, signal) {
1432
+ return new Promise((resolve, reject) => {
1433
+ if (signal?.aborted) {
1434
+ reject(new WaaskeyError("Waaskey: the permission request was aborted.", "aborted"));
1435
+ return;
1436
+ }
1437
+ const timer = setTimeout(() => {
1438
+ signal?.removeEventListener("abort", onAbort);
1439
+ resolve();
1440
+ }, ms);
1441
+ function onAbort() {
1442
+ clearTimeout(timer);
1443
+ reject(new WaaskeyError("Waaskey: the permission request was aborted.", "aborted"));
1444
+ }
1445
+ signal?.addEventListener("abort", onAbort, { once: true });
1446
+ });
1447
+ }
1448
+ function hexToBytes(hex) {
1449
+ const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
1450
+ if (clean.length % 2 !== 0 || /[^0-9a-fA-F]/.test(clean)) {
1451
+ throw new WaaskeyError("Waaskey: expected a hex string.", "validation");
1452
+ }
1453
+ const bytes = new Uint8Array(clean.length / 2);
1454
+ for (let i = 0; i < bytes.length; i++) {
1455
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
1456
+ }
1457
+ return bytes;
1458
+ }
1459
+ function concat(a, b) {
1460
+ const out = new Uint8Array(a.length + b.length);
1461
+ out.set(a);
1462
+ out.set(b, a.length);
1463
+ return out;
1464
+ }
1465
+ function toHex(bytes) {
1466
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1467
+ }
1090
1468
 
1091
1469
  // src/custody.ts
1092
1470
  var CUSTODY_KINDS = /* @__PURE__ */ new Set(["user_device", "user_backup", "platform_signer", "platform_recovery", "external_party"]);
@@ -1666,7 +2044,7 @@ var Wallets = class {
1666
2044
  if (curve === "ed25519") {
1667
2045
  await this.runEddsaKeygen(mpc, shareStore, created.id, ceremony, deviceEncKeypair);
1668
2046
  } else {
1669
- await this.runSecpKeygen(mpc, shareStore, primePool, created.id, ceremony, curve, options.backup, signal);
2047
+ await this.runSecpKeygen(mpc, shareStore, primePool, created.id, ceremony, curve, options.backup, signal, options.onRecoveryEnrolled);
1670
2048
  }
1671
2049
  this.deps.analytics?.track("wallet.created", { walletId: created.id, chain: params.chain, curve });
1672
2050
  if (options.waitForActive === false) {
@@ -1766,13 +2144,15 @@ var Wallets = class {
1766
2144
  }
1767
2145
  const { signal } = options;
1768
2146
  throwIfAborted(signal);
2147
+ const deviceId = await this.resolveDeviceId(options.deviceId);
2148
+ const query = deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : "";
1769
2149
  const [ceremony, shareholders] = await Promise.all([
1770
- this.http.requestAsMember("GET", `/v1/wallets/${walletId}/ceremony/mine`, void 0, signal),
2150
+ this.http.requestAsMember("GET", `/v1/wallets/${walletId}/ceremony/mine${query}`, void 0, signal),
1771
2151
  this.http.requestAsMember("GET", `/v1/wallets/${walletId}/shareholders`, void 0, signal)
1772
2152
  ]);
1773
2153
  const roles = buildMemberRoster(shareholders, ceremony.parties);
1774
2154
  throwIfAborted(signal);
1775
- const joined = await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/ceremony/join`, void 0, signal);
2155
+ const joined = await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/ceremony/join${query}`, void 0, signal);
1776
2156
  throwIfAborted(signal);
1777
2157
  let keygen;
1778
2158
  try {
@@ -1789,7 +2169,7 @@ var Wallets = class {
1789
2169
  if (cause instanceof WaaskeyError) throw cause;
1790
2170
  throw new WaaskeyError("The member device keygen ceremony failed.", "keygen_failed", { cause });
1791
2171
  }
1792
- await shareStore.put(memberShareKey(walletId, membershipIdFromRole(ceremony.role)), serializeShare(keygen));
2172
+ await shareStore.put(memberShareKey(walletId, membershipIdFromRole(ceremony.role), deviceId), serializeShare(keygen));
1793
2173
  return joined;
1794
2174
  }
1795
2175
  /**
@@ -1820,12 +2200,13 @@ var Wallets = class {
1820
2200
  const { signal } = options;
1821
2201
  throwIfAborted(signal);
1822
2202
  await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/sign-requests/${reqId}/approve`, void 0, signal);
1823
- const ceremony = await this.waitUntilReady(walletId, reqId, options);
2203
+ const deviceId = await this.resolveDeviceId(options.deviceId);
2204
+ const ceremony = await this.waitUntilReady(walletId, reqId, options, deviceId);
1824
2205
  const membershipId = membershipIdFromRole(ceremony.role);
1825
- const blob = await shareStore.get(memberShareKey(walletId, membershipId));
2206
+ const blob = await shareStore.get(memberShareKey(walletId, membershipId, deviceId));
1826
2207
  if (!blob) {
1827
2208
  throw new WaaskeyError(`No stored device share for wallet "${walletId}" / member "${membershipId}" \u2014 this device never completed joinCeremony.`, "share_not_found", {
1828
- details: { walletId, membershipId }
2209
+ details: { walletId, membershipId, ...deviceId ? { deviceId } : {} }
1829
2210
  });
1830
2211
  }
1831
2212
  const { keyShare } = deserializeShare(blob);
@@ -1908,7 +2289,7 @@ var Wallets = class {
1908
2289
  * Any additional party the client cannot drive (a non-`user_backup` role) is refused up front — leaving
1909
2290
  * it unjoined would hang the whole ceremony, so failing loud beats silently deadlocking.
1910
2291
  */
1911
- async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
2292
+ async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal, onEnrolled) {
1912
2293
  const extras = ceremony.additionalParties ?? [];
1913
2294
  const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE2);
1914
2295
  if (unsupported.length > 0) {
@@ -1920,10 +2301,15 @@ var Wallets = class {
1920
2301
  }
1921
2302
  const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE2);
1922
2303
  if (!userBackupParty) {
1923
- const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
1924
- throwIfAborted(signal);
1925
- const [keygen] = await this.runKeygenParties(mpc, [{ ...ceremony, curve, pregeneratedPrimes }]);
1926
- await shareStore.put(walletId, serializeShare(keygen, ceremony.relayUrl));
2304
+ const lease = primePool ? await primePool.borrow(curve) : void 0;
2305
+ try {
2306
+ throwIfAborted(signal);
2307
+ const [keygen] = await this.runKeygenParties(mpc, [{ ...ceremony, curve, pregeneratedPrimes: lease?.primes }]);
2308
+ lease?.consume();
2309
+ await shareStore.put(walletId, serializeShare(keygen, ceremony.relayUrl));
2310
+ } finally {
2311
+ await lease?.release();
2312
+ }
1927
2313
  return;
1928
2314
  }
1929
2315
  if (!backup) {
@@ -1932,15 +2318,42 @@ var Wallets = class {
1932
2318
  "validation"
1933
2319
  );
1934
2320
  }
1935
- const [devicePrimes, backupPrimes] = primePool ? await Promise.all([primePool.take(curve), primePool.take(curve)]) : [void 0, void 0];
2321
+ const [deviceLease, backupLease] = primePool ? await Promise.all([primePool.borrow(curve), primePool.borrow(curve)]) : [void 0, void 0];
2322
+ try {
2323
+ await this.runNonCustodialKeygen(
2324
+ mpc,
2325
+ shareStore,
2326
+ walletId,
2327
+ ceremony,
2328
+ userBackupParty,
2329
+ curve,
2330
+ backup,
2331
+ signal,
2332
+ deviceLease?.primes,
2333
+ backupLease?.primes,
2334
+ () => {
2335
+ deviceLease?.consume();
2336
+ backupLease?.consume();
2337
+ },
2338
+ onEnrolled
2339
+ );
2340
+ } finally {
2341
+ await Promise.all([deviceLease?.release(), backupLease?.release()]);
2342
+ }
2343
+ }
2344
+ /** The `[device, server, user_backup]` ceremony itself — see {@link runSecpKeygen} for why the primes are leased. */
2345
+ async runNonCustodialKeygen(mpc, shareStore, walletId, ceremony, userBackupParty, curve, backup, signal, devicePrimes, backupPrimes, onDerived, onEnrolled) {
1936
2346
  throwIfAborted(signal);
1937
2347
  const [deviceKeygen, backupKeygen] = await this.runKeygenParties(mpc, [
1938
2348
  { ...ceremony, curve, pregeneratedPrimes: devicePrimes },
1939
2349
  { ...userBackupParty, curve, pregeneratedPrimes: backupPrimes }
1940
2350
  ]);
2351
+ onDerived();
1941
2352
  await shareStore.put(walletId, serializeShare(deviceKeygen, ceremony.relayUrl));
1942
2353
  throwIfAborted(signal);
1943
- const { payload } = await buildRecoveryRegistration({ share: serializeShare(backupKeygen), ...backup });
2354
+ const enrolment = await resolveRecoveryEnrolment(backup);
2355
+ onEnrolled?.(enrolment.outcome);
2356
+ const { payload } = await buildRecoveryRegistration({ share: serializeShare(backupKeygen), ...backup, ...enrolment.params });
1944
2357
  await shareStore.put(userBackupPendingKey(walletId), JSON.stringify(payload));
1945
2358
  await this.registerBackup(walletId, payload, signal);
1946
2359
  await shareStore.remove(userBackupPendingKey(walletId));
@@ -2172,14 +2585,26 @@ var Wallets = class {
2172
2585
  }
2173
2586
  }
2174
2587
  /** Poll the member's own sign ceremony until the t-of-n quorum is fixed AND this member is selected, or throw on timeout. */
2175
- async waitUntilReady(walletId, reqId, options) {
2588
+ /**
2589
+ * The device id to act as: an explicit one, else the client's stored id, else none.
2590
+ *
2591
+ * `undefined` is a legitimate answer, not a failure — a single-device member and every existing
2592
+ * consumer built before per-device shares work exactly as they did, on the un-suffixed key and
2593
+ * the un-parameterised endpoints.
2594
+ */
2595
+ async resolveDeviceId(explicit) {
2596
+ if (explicit) return explicit;
2597
+ return this.deps.devices ? this.deps.devices.stored() : void 0;
2598
+ }
2599
+ async waitUntilReady(walletId, reqId, options, deviceId) {
2176
2600
  const timeoutMs = options.readyTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS;
2177
2601
  const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
2178
2602
  const { signal } = options;
2179
2603
  const deadline = Date.now() + timeoutMs;
2180
2604
  for (; ; ) {
2181
2605
  throwIfAborted(signal);
2182
- const ceremony = await this.http.requestAsMember("GET", `/v1/wallets/${walletId}/sign-requests/${reqId}/ceremony/mine`, void 0, signal);
2606
+ const query = deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : "";
2607
+ const ceremony = await this.http.requestAsMember("GET", `/v1/wallets/${walletId}/sign-requests/${reqId}/ceremony/mine${query}`, void 0, signal);
2183
2608
  if (isReadyMemberSignCeremony(ceremony)) return ceremony;
2184
2609
  if (Date.now() >= deadline) {
2185
2610
  throw new WaaskeyError("The signing quorum did not fix (or did not select this member) before the timeout.", "sign_ceremony_timeout", { details: { walletId, reqId } });
@@ -2197,8 +2622,9 @@ async function restoreUserBackupShare(backup, opener) {
2197
2622
  }
2198
2623
  return deserializeShare(blob).keyShare;
2199
2624
  }
2200
- function memberShareKey(walletId, membershipId) {
2201
- return `${walletId}@member-${membershipId}`;
2625
+ function memberShareKey(walletId, membershipId, deviceId) {
2626
+ const base = `${walletId}@member-${membershipId}`;
2627
+ return deviceId ? `${base}:${deviceId}` : base;
2202
2628
  }
2203
2629
  function userBackupPendingKey(walletId) {
2204
2630
  return `${walletId}:user_backup_pending`;
@@ -2313,6 +2739,26 @@ function readEddsaEncRoster(ceremony, encKeypair) {
2313
2739
  function isReadyMemberSignCeremony(ceremony) {
2314
2740
  return ceremony.ready && ceremony.quorumRoles !== void 0 && ceremony.signerPosition !== void 0 && ceremony.participants !== void 0 && ceremony.digest !== void 0;
2315
2741
  }
2742
+ async function resolveRecoveryEnrolment(backup) {
2743
+ if (backup.passkey) {
2744
+ return {
2745
+ params: { passkey: backup.passkey, passkeyFactor: backup.passkeyFactor ?? true },
2746
+ outcome: { strongFactor: backup.passkeyFactor === false ? "recovery_code" : "passkey" }
2747
+ };
2748
+ }
2749
+ if (!backup.passkeyEnroller) {
2750
+ return { params: {}, outcome: { strongFactor: "recovery_code", fallbackReason: "no_passkey_enroller" } };
2751
+ }
2752
+ try {
2753
+ const credential = await backup.passkeyEnroller();
2754
+ if (!credential) {
2755
+ return { params: {}, outcome: { strongFactor: "recovery_code", fallbackReason: "device_cannot_prf" } };
2756
+ }
2757
+ return { params: { passkey: credential, passkeyFactor: true }, outcome: { strongFactor: "passkey" } };
2758
+ } catch {
2759
+ return { params: {}, outcome: { strongFactor: "recovery_code", fallbackReason: "enrolment_failed" } };
2760
+ }
2761
+ }
2316
2762
 
2317
2763
  // src/client.ts
2318
2764
  var Waaskey = class {
@@ -2328,8 +2774,12 @@ var Waaskey = class {
2328
2774
  auth;
2329
2775
  /** The `members` resource — org-member (dashboard "plane B") bearer login for headless consumers. */
2330
2776
  members;
2777
+ /** The `devices` resource — this client as a member DEVICE that can hold an MPC share (#53). */
2778
+ devices;
2331
2779
  /** The `onramp` resource — fund the wallet with fiat via a provider on-ramp. */
2332
2780
  onramp;
2781
+ /** The `sessionKeys` resource — act with a delegated, scoped permission (#112). */
2782
+ sessionKeys;
2333
2783
  /** Default fetch used by the optional {@link broadcast} helper (from `WaaskeyOptions.fetch`). */
2334
2784
  defaultFetch;
2335
2785
  constructor(options) {
@@ -2344,11 +2794,13 @@ var Waaskey = class {
2344
2794
  this.auth = new Auth(http);
2345
2795
  this.members = new Members(http);
2346
2796
  http.useMemberAccessToken(() => this.members.accessToken);
2347
- this.wallets = new Wallets(http, { mpc: options.mpc, shareStore: options.shareStore, primePool: options.primePool, analytics });
2797
+ this.devices = new Devices(http, options.deviceStore);
2798
+ this.wallets = new Wallets(http, { mpc: options.mpc, shareStore: options.shareStore, primePool: options.primePool, analytics, devices: this.devices });
2348
2799
  this.recovery = new Recovery(http, { shareStore: options.shareStore, analytics });
2349
2800
  this.reshare = new Reshare(http, { mpc: options.mpc, shareStore: options.shareStore, analytics });
2350
2801
  this.balances = new Balances(options.chains, options.fetch);
2351
2802
  this.onramp = new Onramp(http);
2803
+ this.sessionKeys = new SessionKeys(http, options.walletUrl);
2352
2804
  this.defaultFetch = options.fetch;
2353
2805
  }
2354
2806
  /**
@@ -2374,6 +2826,68 @@ function resolveSink(analytics, http) {
2374
2826
  return analytics ?? new HttpAnalyticsSink(http);
2375
2827
  }
2376
2828
 
2829
+ // src/secret-envelope.ts
2830
+ var KEK_INFO2 = "waaskey-unlock-kek-v1";
2831
+ function newUnlockSecret() {
2832
+ return bytesToBase64(randomBytes(32));
2833
+ }
2834
+ async function sealSecret(secret, methods) {
2835
+ const wraps = [];
2836
+ if (methods.passphrase !== void 0) {
2837
+ const salt = freshSalt();
2838
+ wraps.push({ method: "passphrase", wrapped: await seal(await deriveKey(methods.passphrase, salt), secret), salt: bytesToBase64(salt) });
2839
+ }
2840
+ if (methods.passkey) {
2841
+ const salt = base64ToBytes(methods.passkey.salt);
2842
+ wraps.push({
2843
+ method: "passkey_prf",
2844
+ wrapped: await seal(await deriveKeyFromBytes(base64ToBytes(methods.passkey.secret), salt, KEK_INFO2), secret),
2845
+ salt: methods.passkey.salt,
2846
+ credentialId: methods.passkey.credentialId
2847
+ });
2848
+ }
2849
+ if (wraps.length === 0) {
2850
+ throw new WaaskeyError("An unlock envelope needs at least one method \u2014 a secret nothing can unwrap is unrecoverable.", "validation");
2851
+ }
2852
+ return { wraps };
2853
+ }
2854
+ async function addMethod(envelope, secret, method) {
2855
+ const added = await sealSecret(secret, method);
2856
+ const kinds = new Set(added.wraps.map((wrap) => wrap.method));
2857
+ return { wraps: [...envelope.wraps.filter((wrap) => !kinds.has(wrap.method)), ...added.wraps] };
2858
+ }
2859
+ function removeMethod(envelope, method) {
2860
+ const wraps = envelope.wraps.filter((wrap) => wrap.method !== method);
2861
+ if (wraps.length === 0) {
2862
+ throw new WaaskeyError(`Removing '${method}' would leave nothing that can unlock this device \u2014 enrol another method first.`, "validation");
2863
+ }
2864
+ return { wraps };
2865
+ }
2866
+ async function openSecret(envelope, opener) {
2867
+ const prfWrap = envelope.wraps.find((wrap) => wrap.method === "passkey_prf");
2868
+ if (opener.passkeySecret !== void 0 && prfWrap) {
2869
+ const kek = await deriveKeyFromBytes(base64ToBytes(opener.passkeySecret), base64ToBytes(prfWrap.salt), KEK_INFO2);
2870
+ return unwrap(prfWrap, kek, "passkey");
2871
+ }
2872
+ const passphraseWrap = envelope.wraps.find((wrap) => wrap.method === "passphrase");
2873
+ if (opener.passphrase !== void 0 && passphraseWrap) {
2874
+ const kek = await deriveKey(opener.passphrase, base64ToBytes(passphraseWrap.salt));
2875
+ return unwrap(passphraseWrap, kek, "passphrase");
2876
+ }
2877
+ const enrolled = envelope.wraps.map((wrap) => wrap.method).join(", ") || "none";
2878
+ throw new WaaskeyError(`Nothing supplied can unlock this device \u2014 it accepts [${enrolled}].`, "unauthorized");
2879
+ }
2880
+ function enrolledMethods(envelope) {
2881
+ return envelope.wraps.map((wrap) => wrap.method);
2882
+ }
2883
+ async function unwrap(wrap, kek, label) {
2884
+ try {
2885
+ return await open(kek, wrap.wrapped);
2886
+ } catch (cause) {
2887
+ throw new WaaskeyError(`Could not unlock with the ${label} \u2014 wrong ${label}?`, "unauthorized", { cause });
2888
+ }
2889
+ }
2890
+
2377
2891
  // src/mpc/wasm-core.ts
2378
2892
  var WasmMpcCore = class {
2379
2893
  constructor(load) {
@@ -2805,11 +3319,13 @@ var PrimePool = class {
2805
3319
  this.store = options.store ?? new MemoryPrimeStore();
2806
3320
  this.targetSize = Math.max(1, options.targetSize ?? 2);
2807
3321
  this.autoRefill = options.autoRefill ?? false;
3322
+ this.concurrency = Math.max(1, options.concurrency ?? 1);
2808
3323
  }
2809
3324
  core;
2810
3325
  store;
2811
3326
  targetSize;
2812
3327
  autoRefill;
3328
+ concurrency;
2813
3329
  /** Per-curve in-flight refill, so concurrent calls don't over-generate. */
2814
3330
  refilling = /* @__PURE__ */ new Map();
2815
3331
  /**
@@ -2824,8 +3340,10 @@ var PrimePool = class {
2824
3340
  return task;
2825
3341
  }
2826
3342
  async refill(curve) {
2827
- while (await this.store.size(curve) < this.targetSize) {
2828
- await this.store.add(curve, await this.core.pregeneratePrimes(curve));
3343
+ for (let missing = this.targetSize - await this.store.size(curve); missing > 0; missing = this.targetSize - await this.store.size(curve)) {
3344
+ const batch = Math.min(missing, this.concurrency);
3345
+ const generated = await Promise.all(Array.from({ length: batch }, () => this.core.pregeneratePrimes(curve)));
3346
+ for (const primes of generated) await this.store.add(curve, primes);
2829
3347
  }
2830
3348
  }
2831
3349
  /**
@@ -2840,6 +3358,28 @@ var PrimePool = class {
2840
3358
  if (this.autoRefill) void this.ensure(curve).catch(() => void 0);
2841
3359
  return primes;
2842
3360
  }
3361
+ /**
3362
+ * Claim primes as a LEASE, so a failed ceremony gives them back (#83).
3363
+ *
3364
+ * Prefer this over {@link take} anywhere the primes feed a ceremony that can fail: `take` hands
3365
+ * them over unconditionally, and a keygen that dies on a relay timeout then costs the next
3366
+ * attempt a full generation it did not need to pay.
3367
+ */
3368
+ async borrow(curve) {
3369
+ const primes = await this.take(curve);
3370
+ let settled = false;
3371
+ return {
3372
+ primes,
3373
+ consume: () => {
3374
+ settled = true;
3375
+ },
3376
+ release: async () => {
3377
+ if (settled) return;
3378
+ settled = true;
3379
+ await this.store.add(curve, primes);
3380
+ }
3381
+ };
3382
+ }
2843
3383
  };
2844
3384
 
2845
3385
  // src/storage/indexeddb-store.ts
@@ -3141,6 +3681,7 @@ exports.Analytics = Analytics;
3141
3681
  exports.Auth = Auth;
3142
3682
  exports.Balances = Balances;
3143
3683
  exports.CLIENT_WASM_VERSION = CLIENT_WASM_VERSION;
3684
+ exports.Devices = Devices;
3144
3685
  exports.EncryptedShareStore = EncryptedShareStore;
3145
3686
  exports.EvmRpcProvider = EvmRpcProvider;
3146
3687
  exports.HttpAnalyticsSink = HttpAnalyticsSink;
@@ -3153,13 +3694,16 @@ exports.PasskeyPrfSecretProvider = PasskeyPrfSecretProvider;
3153
3694
  exports.PrimePool = PrimePool;
3154
3695
  exports.Recovery = Recovery;
3155
3696
  exports.Reshare = Reshare;
3697
+ exports.SessionKeys = SessionKeys;
3156
3698
  exports.Waaskey = Waaskey;
3157
3699
  exports.WaaskeyError = WaaskeyError;
3158
3700
  exports.Wallet = Wallet;
3159
3701
  exports.Wallets = Wallets;
3160
3702
  exports.WasmMpcCore = WasmMpcCore;
3703
+ exports.addMethod = addMethod;
3161
3704
  exports.broadcast = broadcast;
3162
3705
  exports.createVerifiedClientWasmLoader = createVerifiedClientWasmLoader;
3706
+ exports.enrolledMethods = enrolledMethods;
3163
3707
  exports.epochShareKey = epochShareKey;
3164
3708
  exports.formatUnits = formatUnits;
3165
3709
  exports.generateRecoveryCode = generateRecoveryCode;
@@ -3170,10 +3714,15 @@ exports.isPasskeySupported = isPasskeySupported;
3170
3714
  exports.isPrfSupported = isPrfSupported;
3171
3715
  exports.loadClientWasm = loadClientWasm;
3172
3716
  exports.memberShareKey = memberShareKey;
3717
+ exports.newUnlockSecret = newUnlockSecret;
3173
3718
  exports.openRecoveryBackup = openRecoveryBackup;
3719
+ exports.openSecret = openSecret;
3174
3720
  exports.passkeyFactorEnrollment = passkeyFactorEnrollment;
3175
3721
  exports.passkeyFactorVerification = passkeyFactorVerification;
3722
+ exports.removeMethod = removeMethod;
3176
3723
  exports.sealRecoveryBackup = sealRecoveryBackup;
3724
+ exports.sealSecret = sealSecret;
3725
+ exports.signUserOpHash = signUserOpHash;
3177
3726
  exports.userBackupPendingKey = userBackupPendingKey;
3178
3727
  exports.validateCustodyPolicy = validateCustodyPolicy;
3179
3728
  exports.verifyWasmIntegrity = verifyWasmIntegrity;