@waaskey/sdk 0.4.1 → 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/README.md +113 -3
- package/dist/index.cjs +620 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +687 -3
- package/dist/index.d.ts +687 -3
- package/dist/index.js +612 -68
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
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) {
|
|
@@ -486,6 +611,54 @@ var Onramp = class {
|
|
|
486
611
|
}
|
|
487
612
|
};
|
|
488
613
|
|
|
614
|
+
// src/passkey/assertion.ts
|
|
615
|
+
function isPasskeyAssertionSupported() {
|
|
616
|
+
return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined";
|
|
617
|
+
}
|
|
618
|
+
async function defaultAssertionCeremony() {
|
|
619
|
+
let mod;
|
|
620
|
+
try {
|
|
621
|
+
mod = await import('@simplewebauthn/browser');
|
|
622
|
+
} catch {
|
|
623
|
+
throw new WaaskeyError('Passkey step-up requires "@simplewebauthn/browser" installed, or pass a custom ceremony.', "unsupported");
|
|
624
|
+
}
|
|
625
|
+
return {
|
|
626
|
+
get: (options) => mod.startAuthentication({ optionsJSON: options })
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async function getSigningAssertion(challenge, options = {}) {
|
|
630
|
+
if (options.ceremony === void 0 && !isPasskeyAssertionSupported()) {
|
|
631
|
+
throw new WaaskeyError("Passkey step-up is not available in this runtime (no WebAuthn API).", "unsupported");
|
|
632
|
+
}
|
|
633
|
+
const ceremony = options.ceremony ?? await defaultAssertionCeremony();
|
|
634
|
+
const rpId = options.rpId ?? (typeof location === "undefined" ? void 0 : location.hostname);
|
|
635
|
+
const requestOptions = {
|
|
636
|
+
challenge,
|
|
637
|
+
userVerification: "required",
|
|
638
|
+
...rpId ? { rpId } : {},
|
|
639
|
+
...options.credentialId ? { allowCredentials: [{ id: options.credentialId, type: "public-key" }] } : {}
|
|
640
|
+
};
|
|
641
|
+
try {
|
|
642
|
+
return await ceremony.get(requestOptions);
|
|
643
|
+
} catch (cause) {
|
|
644
|
+
if (cause instanceof WaaskeyError) throw cause;
|
|
645
|
+
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
646
|
+
if (/cancel|abort|not allowed|user gesture/i.test(msg)) {
|
|
647
|
+
throw new WaaskeyError("Passkey authentication was cancelled by the user.", "aborted", { cause });
|
|
648
|
+
}
|
|
649
|
+
throw new WaaskeyError("Passkey step-up assertion failed.", "unsupported", { cause });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// src/passkey/recovery-factor.ts
|
|
654
|
+
function passkeyFactorEnrollment(credentialId) {
|
|
655
|
+
return { type: "passkey", credential: credentialId };
|
|
656
|
+
}
|
|
657
|
+
async function passkeyFactorVerification(challenge, options = {}) {
|
|
658
|
+
const assertion = await getSigningAssertion(challenge, options);
|
|
659
|
+
return { type: "passkey", token: JSON.stringify(assertion) };
|
|
660
|
+
}
|
|
661
|
+
|
|
489
662
|
// src/storage/crypto.ts
|
|
490
663
|
var PBKDF2_ITERATIONS = 21e4;
|
|
491
664
|
var PBKDF2_HASH = "SHA-256";
|
|
@@ -687,8 +860,12 @@ var Recovery = class {
|
|
|
687
860
|
async function buildRecoveryRegistration(params) {
|
|
688
861
|
const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
|
|
689
862
|
const backup = params.passkey ? await sealRecoveryBackup(params.share, recoveryCode, params.passkey) : { ciphertext: await sealWithPassword(recoveryCode, params.share), keyWraps: void 0 };
|
|
863
|
+
if (params.passkeyFactor && !params.passkey) {
|
|
864
|
+
throw new WaaskeyError("passkeyFactor requires `passkey` \u2014 a factor for a credential this backup cannot open would be a lock-out.", "validation");
|
|
865
|
+
}
|
|
866
|
+
const strongFactor = params.passkeyFactor ? passkeyFactorEnrollment(params.passkey.credentialId) : { type: "recovery_code", credential: await sha256Hex(recoveryCode) };
|
|
690
867
|
const factors = [
|
|
691
|
-
|
|
868
|
+
strongFactor,
|
|
692
869
|
{ type: "totp", credential: params.totpSecret },
|
|
693
870
|
{ type: "email_otp", credential: params.email },
|
|
694
871
|
...params.extraFactors ?? []
|
|
@@ -1035,6 +1212,259 @@ function assertPublicKey(actual, expected, walletId) {
|
|
|
1035
1212
|
function serializeCompletedShare(completed) {
|
|
1036
1213
|
return JSON.stringify({ keyShare: completed.keyShare, sharedPublicKey: completed.sharedPublicKey });
|
|
1037
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
|
+
}
|
|
1038
1468
|
|
|
1039
1469
|
// src/custody.ts
|
|
1040
1470
|
var CUSTODY_KINDS = /* @__PURE__ */ new Set(["user_device", "user_backup", "platform_signer", "platform_recovery", "external_party"]);
|
|
@@ -1171,45 +1601,6 @@ function deserializeShare(blob) {
|
|
|
1171
1601
|
};
|
|
1172
1602
|
}
|
|
1173
1603
|
|
|
1174
|
-
// src/passkey/assertion.ts
|
|
1175
|
-
function isPasskeyAssertionSupported() {
|
|
1176
|
-
return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined";
|
|
1177
|
-
}
|
|
1178
|
-
async function defaultAssertionCeremony() {
|
|
1179
|
-
let mod;
|
|
1180
|
-
try {
|
|
1181
|
-
mod = await import('@simplewebauthn/browser');
|
|
1182
|
-
} catch {
|
|
1183
|
-
throw new WaaskeyError('Passkey step-up requires "@simplewebauthn/browser" installed, or pass a custom ceremony.', "unsupported");
|
|
1184
|
-
}
|
|
1185
|
-
return {
|
|
1186
|
-
get: (options) => mod.startAuthentication({ optionsJSON: options })
|
|
1187
|
-
};
|
|
1188
|
-
}
|
|
1189
|
-
async function getSigningAssertion(challenge, options = {}) {
|
|
1190
|
-
if (options.ceremony === void 0 && !isPasskeyAssertionSupported()) {
|
|
1191
|
-
throw new WaaskeyError("Passkey step-up is not available in this runtime (no WebAuthn API).", "unsupported");
|
|
1192
|
-
}
|
|
1193
|
-
const ceremony = options.ceremony ?? await defaultAssertionCeremony();
|
|
1194
|
-
const rpId = options.rpId ?? (typeof location === "undefined" ? void 0 : location.hostname);
|
|
1195
|
-
const requestOptions = {
|
|
1196
|
-
challenge,
|
|
1197
|
-
userVerification: "required",
|
|
1198
|
-
...rpId ? { rpId } : {},
|
|
1199
|
-
...options.credentialId ? { allowCredentials: [{ id: options.credentialId, type: "public-key" }] } : {}
|
|
1200
|
-
};
|
|
1201
|
-
try {
|
|
1202
|
-
return await ceremony.get(requestOptions);
|
|
1203
|
-
} catch (cause) {
|
|
1204
|
-
if (cause instanceof WaaskeyError) throw cause;
|
|
1205
|
-
const msg = cause instanceof Error ? cause.message : String(cause);
|
|
1206
|
-
if (/cancel|abort|not allowed|user gesture/i.test(msg)) {
|
|
1207
|
-
throw new WaaskeyError("Passkey authentication was cancelled by the user.", "aborted", { cause });
|
|
1208
|
-
}
|
|
1209
|
-
throw new WaaskeyError("Passkey step-up assertion failed.", "unsupported", { cause });
|
|
1210
|
-
}
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
1604
|
// src/wallet.ts
|
|
1214
1605
|
var DEVICE_ROLE = "device";
|
|
1215
1606
|
var Wallet = class {
|
|
@@ -1653,7 +2044,7 @@ var Wallets = class {
|
|
|
1653
2044
|
if (curve === "ed25519") {
|
|
1654
2045
|
await this.runEddsaKeygen(mpc, shareStore, created.id, ceremony, deviceEncKeypair);
|
|
1655
2046
|
} else {
|
|
1656
|
-
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);
|
|
1657
2048
|
}
|
|
1658
2049
|
this.deps.analytics?.track("wallet.created", { walletId: created.id, chain: params.chain, curve });
|
|
1659
2050
|
if (options.waitForActive === false) {
|
|
@@ -1753,13 +2144,15 @@ var Wallets = class {
|
|
|
1753
2144
|
}
|
|
1754
2145
|
const { signal } = options;
|
|
1755
2146
|
throwIfAborted(signal);
|
|
2147
|
+
const deviceId = await this.resolveDeviceId(options.deviceId);
|
|
2148
|
+
const query = deviceId ? `?deviceId=${encodeURIComponent(deviceId)}` : "";
|
|
1756
2149
|
const [ceremony, shareholders] = await Promise.all([
|
|
1757
|
-
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),
|
|
1758
2151
|
this.http.requestAsMember("GET", `/v1/wallets/${walletId}/shareholders`, void 0, signal)
|
|
1759
2152
|
]);
|
|
1760
2153
|
const roles = buildMemberRoster(shareholders, ceremony.parties);
|
|
1761
2154
|
throwIfAborted(signal);
|
|
1762
|
-
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);
|
|
1763
2156
|
throwIfAborted(signal);
|
|
1764
2157
|
let keygen;
|
|
1765
2158
|
try {
|
|
@@ -1776,7 +2169,7 @@ var Wallets = class {
|
|
|
1776
2169
|
if (cause instanceof WaaskeyError) throw cause;
|
|
1777
2170
|
throw new WaaskeyError("The member device keygen ceremony failed.", "keygen_failed", { cause });
|
|
1778
2171
|
}
|
|
1779
|
-
await shareStore.put(memberShareKey(walletId, membershipIdFromRole(ceremony.role)), serializeShare(keygen));
|
|
2172
|
+
await shareStore.put(memberShareKey(walletId, membershipIdFromRole(ceremony.role), deviceId), serializeShare(keygen));
|
|
1780
2173
|
return joined;
|
|
1781
2174
|
}
|
|
1782
2175
|
/**
|
|
@@ -1807,12 +2200,13 @@ var Wallets = class {
|
|
|
1807
2200
|
const { signal } = options;
|
|
1808
2201
|
throwIfAborted(signal);
|
|
1809
2202
|
await this.http.requestAsMember("POST", `/v1/wallets/${walletId}/sign-requests/${reqId}/approve`, void 0, signal);
|
|
1810
|
-
const
|
|
2203
|
+
const deviceId = await this.resolveDeviceId(options.deviceId);
|
|
2204
|
+
const ceremony = await this.waitUntilReady(walletId, reqId, options, deviceId);
|
|
1811
2205
|
const membershipId = membershipIdFromRole(ceremony.role);
|
|
1812
|
-
const blob = await shareStore.get(memberShareKey(walletId, membershipId));
|
|
2206
|
+
const blob = await shareStore.get(memberShareKey(walletId, membershipId, deviceId));
|
|
1813
2207
|
if (!blob) {
|
|
1814
2208
|
throw new WaaskeyError(`No stored device share for wallet "${walletId}" / member "${membershipId}" \u2014 this device never completed joinCeremony.`, "share_not_found", {
|
|
1815
|
-
details: { walletId, membershipId }
|
|
2209
|
+
details: { walletId, membershipId, ...deviceId ? { deviceId } : {} }
|
|
1816
2210
|
});
|
|
1817
2211
|
}
|
|
1818
2212
|
const { keyShare } = deserializeShare(blob);
|
|
@@ -1895,7 +2289,7 @@ var Wallets = class {
|
|
|
1895
2289
|
* Any additional party the client cannot drive (a non-`user_backup` role) is refused up front — leaving
|
|
1896
2290
|
* it unjoined would hang the whole ceremony, so failing loud beats silently deadlocking.
|
|
1897
2291
|
*/
|
|
1898
|
-
async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
|
|
2292
|
+
async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal, onEnrolled) {
|
|
1899
2293
|
const extras = ceremony.additionalParties ?? [];
|
|
1900
2294
|
const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE2);
|
|
1901
2295
|
if (unsupported.length > 0) {
|
|
@@ -1907,10 +2301,15 @@ var Wallets = class {
|
|
|
1907
2301
|
}
|
|
1908
2302
|
const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE2);
|
|
1909
2303
|
if (!userBackupParty) {
|
|
1910
|
-
const
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
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
|
+
}
|
|
1914
2313
|
return;
|
|
1915
2314
|
}
|
|
1916
2315
|
if (!backup) {
|
|
@@ -1919,15 +2318,42 @@ var Wallets = class {
|
|
|
1919
2318
|
"validation"
|
|
1920
2319
|
);
|
|
1921
2320
|
}
|
|
1922
|
-
const [
|
|
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) {
|
|
1923
2346
|
throwIfAborted(signal);
|
|
1924
2347
|
const [deviceKeygen, backupKeygen] = await this.runKeygenParties(mpc, [
|
|
1925
2348
|
{ ...ceremony, curve, pregeneratedPrimes: devicePrimes },
|
|
1926
2349
|
{ ...userBackupParty, curve, pregeneratedPrimes: backupPrimes }
|
|
1927
2350
|
]);
|
|
2351
|
+
onDerived();
|
|
1928
2352
|
await shareStore.put(walletId, serializeShare(deviceKeygen, ceremony.relayUrl));
|
|
1929
2353
|
throwIfAborted(signal);
|
|
1930
|
-
const
|
|
2354
|
+
const enrolment = await resolveRecoveryEnrolment(backup);
|
|
2355
|
+
onEnrolled?.(enrolment.outcome);
|
|
2356
|
+
const { payload } = await buildRecoveryRegistration({ share: serializeShare(backupKeygen), ...backup, ...enrolment.params });
|
|
1931
2357
|
await shareStore.put(userBackupPendingKey(walletId), JSON.stringify(payload));
|
|
1932
2358
|
await this.registerBackup(walletId, payload, signal);
|
|
1933
2359
|
await shareStore.remove(userBackupPendingKey(walletId));
|
|
@@ -2159,14 +2585,26 @@ var Wallets = class {
|
|
|
2159
2585
|
}
|
|
2160
2586
|
}
|
|
2161
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. */
|
|
2162
|
-
|
|
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) {
|
|
2163
2600
|
const timeoutMs = options.readyTimeoutMs ?? DEFAULT_ACTIVATION_TIMEOUT_MS;
|
|
2164
2601
|
const intervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
2165
2602
|
const { signal } = options;
|
|
2166
2603
|
const deadline = Date.now() + timeoutMs;
|
|
2167
2604
|
for (; ; ) {
|
|
2168
2605
|
throwIfAborted(signal);
|
|
2169
|
-
const
|
|
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);
|
|
2170
2608
|
if (isReadyMemberSignCeremony(ceremony)) return ceremony;
|
|
2171
2609
|
if (Date.now() >= deadline) {
|
|
2172
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 } });
|
|
@@ -2184,8 +2622,9 @@ async function restoreUserBackupShare(backup, opener) {
|
|
|
2184
2622
|
}
|
|
2185
2623
|
return deserializeShare(blob).keyShare;
|
|
2186
2624
|
}
|
|
2187
|
-
function memberShareKey(walletId, membershipId) {
|
|
2188
|
-
|
|
2625
|
+
function memberShareKey(walletId, membershipId, deviceId) {
|
|
2626
|
+
const base = `${walletId}@member-${membershipId}`;
|
|
2627
|
+
return deviceId ? `${base}:${deviceId}` : base;
|
|
2189
2628
|
}
|
|
2190
2629
|
function userBackupPendingKey(walletId) {
|
|
2191
2630
|
return `${walletId}:user_backup_pending`;
|
|
@@ -2300,6 +2739,26 @@ function readEddsaEncRoster(ceremony, encKeypair) {
|
|
|
2300
2739
|
function isReadyMemberSignCeremony(ceremony) {
|
|
2301
2740
|
return ceremony.ready && ceremony.quorumRoles !== void 0 && ceremony.signerPosition !== void 0 && ceremony.participants !== void 0 && ceremony.digest !== void 0;
|
|
2302
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
|
+
}
|
|
2303
2762
|
|
|
2304
2763
|
// src/client.ts
|
|
2305
2764
|
var Waaskey = class {
|
|
@@ -2315,8 +2774,12 @@ var Waaskey = class {
|
|
|
2315
2774
|
auth;
|
|
2316
2775
|
/** The `members` resource — org-member (dashboard "plane B") bearer login for headless consumers. */
|
|
2317
2776
|
members;
|
|
2777
|
+
/** The `devices` resource — this client as a member DEVICE that can hold an MPC share (#53). */
|
|
2778
|
+
devices;
|
|
2318
2779
|
/** The `onramp` resource — fund the wallet with fiat via a provider on-ramp. */
|
|
2319
2780
|
onramp;
|
|
2781
|
+
/** The `sessionKeys` resource — act with a delegated, scoped permission (#112). */
|
|
2782
|
+
sessionKeys;
|
|
2320
2783
|
/** Default fetch used by the optional {@link broadcast} helper (from `WaaskeyOptions.fetch`). */
|
|
2321
2784
|
defaultFetch;
|
|
2322
2785
|
constructor(options) {
|
|
@@ -2331,11 +2794,13 @@ var Waaskey = class {
|
|
|
2331
2794
|
this.auth = new Auth(http);
|
|
2332
2795
|
this.members = new Members(http);
|
|
2333
2796
|
http.useMemberAccessToken(() => this.members.accessToken);
|
|
2334
|
-
this.
|
|
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 });
|
|
2335
2799
|
this.recovery = new Recovery(http, { shareStore: options.shareStore, analytics });
|
|
2336
2800
|
this.reshare = new Reshare(http, { mpc: options.mpc, shareStore: options.shareStore, analytics });
|
|
2337
2801
|
this.balances = new Balances(options.chains, options.fetch);
|
|
2338
2802
|
this.onramp = new Onramp(http);
|
|
2803
|
+
this.sessionKeys = new SessionKeys(http, options.walletUrl);
|
|
2339
2804
|
this.defaultFetch = options.fetch;
|
|
2340
2805
|
}
|
|
2341
2806
|
/**
|
|
@@ -2361,13 +2826,66 @@ function resolveSink(analytics, http) {
|
|
|
2361
2826
|
return analytics ?? new HttpAnalyticsSink(http);
|
|
2362
2827
|
}
|
|
2363
2828
|
|
|
2364
|
-
// src/
|
|
2365
|
-
|
|
2366
|
-
|
|
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 };
|
|
2367
2853
|
}
|
|
2368
|
-
async function
|
|
2369
|
-
const
|
|
2370
|
-
|
|
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
|
+
}
|
|
2371
2889
|
}
|
|
2372
2890
|
|
|
2373
2891
|
// src/mpc/wasm-core.ts
|
|
@@ -2801,11 +3319,13 @@ var PrimePool = class {
|
|
|
2801
3319
|
this.store = options.store ?? new MemoryPrimeStore();
|
|
2802
3320
|
this.targetSize = Math.max(1, options.targetSize ?? 2);
|
|
2803
3321
|
this.autoRefill = options.autoRefill ?? false;
|
|
3322
|
+
this.concurrency = Math.max(1, options.concurrency ?? 1);
|
|
2804
3323
|
}
|
|
2805
3324
|
core;
|
|
2806
3325
|
store;
|
|
2807
3326
|
targetSize;
|
|
2808
3327
|
autoRefill;
|
|
3328
|
+
concurrency;
|
|
2809
3329
|
/** Per-curve in-flight refill, so concurrent calls don't over-generate. */
|
|
2810
3330
|
refilling = /* @__PURE__ */ new Map();
|
|
2811
3331
|
/**
|
|
@@ -2820,8 +3340,10 @@ var PrimePool = class {
|
|
|
2820
3340
|
return task;
|
|
2821
3341
|
}
|
|
2822
3342
|
async refill(curve) {
|
|
2823
|
-
|
|
2824
|
-
|
|
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);
|
|
2825
3347
|
}
|
|
2826
3348
|
}
|
|
2827
3349
|
/**
|
|
@@ -2836,6 +3358,28 @@ var PrimePool = class {
|
|
|
2836
3358
|
if (this.autoRefill) void this.ensure(curve).catch(() => void 0);
|
|
2837
3359
|
return primes;
|
|
2838
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
|
+
}
|
|
2839
3383
|
};
|
|
2840
3384
|
|
|
2841
3385
|
// src/storage/indexeddb-store.ts
|
|
@@ -3137,6 +3681,7 @@ exports.Analytics = Analytics;
|
|
|
3137
3681
|
exports.Auth = Auth;
|
|
3138
3682
|
exports.Balances = Balances;
|
|
3139
3683
|
exports.CLIENT_WASM_VERSION = CLIENT_WASM_VERSION;
|
|
3684
|
+
exports.Devices = Devices;
|
|
3140
3685
|
exports.EncryptedShareStore = EncryptedShareStore;
|
|
3141
3686
|
exports.EvmRpcProvider = EvmRpcProvider;
|
|
3142
3687
|
exports.HttpAnalyticsSink = HttpAnalyticsSink;
|
|
@@ -3149,13 +3694,16 @@ exports.PasskeyPrfSecretProvider = PasskeyPrfSecretProvider;
|
|
|
3149
3694
|
exports.PrimePool = PrimePool;
|
|
3150
3695
|
exports.Recovery = Recovery;
|
|
3151
3696
|
exports.Reshare = Reshare;
|
|
3697
|
+
exports.SessionKeys = SessionKeys;
|
|
3152
3698
|
exports.Waaskey = Waaskey;
|
|
3153
3699
|
exports.WaaskeyError = WaaskeyError;
|
|
3154
3700
|
exports.Wallet = Wallet;
|
|
3155
3701
|
exports.Wallets = Wallets;
|
|
3156
3702
|
exports.WasmMpcCore = WasmMpcCore;
|
|
3703
|
+
exports.addMethod = addMethod;
|
|
3157
3704
|
exports.broadcast = broadcast;
|
|
3158
3705
|
exports.createVerifiedClientWasmLoader = createVerifiedClientWasmLoader;
|
|
3706
|
+
exports.enrolledMethods = enrolledMethods;
|
|
3159
3707
|
exports.epochShareKey = epochShareKey;
|
|
3160
3708
|
exports.formatUnits = formatUnits;
|
|
3161
3709
|
exports.generateRecoveryCode = generateRecoveryCode;
|
|
@@ -3166,10 +3714,15 @@ exports.isPasskeySupported = isPasskeySupported;
|
|
|
3166
3714
|
exports.isPrfSupported = isPrfSupported;
|
|
3167
3715
|
exports.loadClientWasm = loadClientWasm;
|
|
3168
3716
|
exports.memberShareKey = memberShareKey;
|
|
3717
|
+
exports.newUnlockSecret = newUnlockSecret;
|
|
3169
3718
|
exports.openRecoveryBackup = openRecoveryBackup;
|
|
3719
|
+
exports.openSecret = openSecret;
|
|
3170
3720
|
exports.passkeyFactorEnrollment = passkeyFactorEnrollment;
|
|
3171
3721
|
exports.passkeyFactorVerification = passkeyFactorVerification;
|
|
3722
|
+
exports.removeMethod = removeMethod;
|
|
3172
3723
|
exports.sealRecoveryBackup = sealRecoveryBackup;
|
|
3724
|
+
exports.sealSecret = sealSecret;
|
|
3725
|
+
exports.signUserOpHash = signUserOpHash;
|
|
3173
3726
|
exports.userBackupPendingKey = userBackupPendingKey;
|
|
3174
3727
|
exports.validateCustodyPolicy = validateCustodyPolicy;
|
|
3175
3728
|
exports.verifyWasmIntegrity = verifyWasmIntegrity;
|