@waaskey/sdk 0.3.2 → 0.4.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 +3 -1
- package/dist/index.cjs +301 -73
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +450 -90
- package/dist/index.d.ts +450 -90
- package/dist/index.js +298 -74
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +55 -1
- package/dist/node.js +24 -1
- package/dist/node.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -29,6 +29,7 @@ const sessionSecret = await deriveUserSessionSecret();
|
|
|
29
29
|
|
|
30
30
|
const waaskey = new Waaskey({
|
|
31
31
|
apiKey: process.env.WAASKEY_PUBLISHABLE_KEY!,
|
|
32
|
+
baseUrl: process.env.WAASKEY_API_URL!, // required — there is no default
|
|
32
33
|
// Non-custodial: the device runs its half of the ceremony and seals its key share.
|
|
33
34
|
mpc: new WasmMpcCore(loadClientWasm), // dynamic-imports @waaskey/client-wasm
|
|
34
35
|
shareStore: EncryptedShareStore.browser(sessionSecret), // sealed in IndexedDB
|
|
@@ -76,6 +77,7 @@ async function loadClientWasmNode() {
|
|
|
76
77
|
const mpc = new WasmMpcCore(loadClientWasmNode);
|
|
77
78
|
const waaskey = new Waaskey({
|
|
78
79
|
apiKey: process.env.WAASKEY_API_KEY!,
|
|
80
|
+
baseUrl: process.env.WAASKEY_API_URL!,
|
|
79
81
|
mpc,
|
|
80
82
|
shareStore: new EncryptedShareStore(new MemoryKeyValueStore(), process.env.SHARE_SECRET!),
|
|
81
83
|
primePool: new PrimePool(mpc),
|
|
@@ -158,7 +160,7 @@ const { items } = await wallet.signatures({ page: 1, limit: 20 });
|
|
|
158
160
|
|
|
159
161
|
| Method | Description |
|
|
160
162
|
| -------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
161
|
-
| `new Waaskey({ apiKey, mpc, shareStore,
|
|
163
|
+
| `new Waaskey({ apiKey, baseUrl, mpc, shareStore, fetch? })` | Create a client. `mpc` + `shareStore` are required to create wallets. |
|
|
162
164
|
| `waaskey.wallets.create({ chain, label?, threshold?, parties?, custodyKinds?, custodyType? }, options?)` | Create (keygen + sealed share). Optional custody policy (default: non-custodial 2-of-3 `[device, server, user_backup]`). Returns a `Wallet`. |
|
|
163
165
|
| `waaskey.wallets.list(query?, options?)` | List the tenant's wallets, newest first (paginated `WalletData` rows). |
|
|
164
166
|
| `waaskey.wallets.get(id, options?)` | Load an existing wallet. |
|
package/dist/index.cjs
CHANGED
|
@@ -519,6 +519,17 @@ async function deriveKey(secret, salt) {
|
|
|
519
519
|
"decrypt"
|
|
520
520
|
]);
|
|
521
521
|
}
|
|
522
|
+
async function deriveKeyFromBytes(material, salt, info) {
|
|
523
|
+
const s = subtle();
|
|
524
|
+
const baseKey = await s.importKey("raw", material, "HKDF", false, ["deriveKey"]);
|
|
525
|
+
return s.deriveKey(
|
|
526
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
|
|
527
|
+
baseKey,
|
|
528
|
+
{ name: "AES-GCM", length: AES_KEY_BITS },
|
|
529
|
+
false,
|
|
530
|
+
["encrypt", "decrypt"]
|
|
531
|
+
);
|
|
532
|
+
}
|
|
522
533
|
async function seal(key, plaintext) {
|
|
523
534
|
const iv = randomBytes(IV_BYTES);
|
|
524
535
|
const data = new TextEncoder().encode(plaintext);
|
|
@@ -561,6 +572,60 @@ function base64ToBytes(base64) {
|
|
|
561
572
|
return bytes;
|
|
562
573
|
}
|
|
563
574
|
|
|
575
|
+
// src/recovery-envelope.ts
|
|
576
|
+
var KEK_INFO = "waaskey-backup-kek-v1";
|
|
577
|
+
var DEK_INFO = "waaskey-backup-dek-v1";
|
|
578
|
+
async function sealRecoveryBackup(share, recoveryCode, passkey) {
|
|
579
|
+
const dataKeyBytes = randomBytes(32);
|
|
580
|
+
const dataKey = await deriveKeyFromBytes(dataKeyBytes, new Uint8Array(0), DEK_INFO);
|
|
581
|
+
const ciphertext = await seal(dataKey, share);
|
|
582
|
+
const codeSalt = freshSalt();
|
|
583
|
+
const keyWraps = [
|
|
584
|
+
{
|
|
585
|
+
method: "passphrase",
|
|
586
|
+
wrapped: await seal(await deriveKey(recoveryCode, codeSalt), bytesToBase64(dataKeyBytes)),
|
|
587
|
+
salt: bytesToBase64(codeSalt)
|
|
588
|
+
}
|
|
589
|
+
];
|
|
590
|
+
if (passkey) {
|
|
591
|
+
const prfSalt = base64ToBytes(passkey.salt);
|
|
592
|
+
keyWraps.push({
|
|
593
|
+
method: "passkey_prf",
|
|
594
|
+
wrapped: await seal(await deriveKeyFromBytes(base64ToBytes(passkey.secret), prfSalt, KEK_INFO), bytesToBase64(dataKeyBytes)),
|
|
595
|
+
salt: passkey.salt,
|
|
596
|
+
credentialId: passkey.credentialId
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
return { ciphertext, keyWraps };
|
|
600
|
+
}
|
|
601
|
+
async function openRecoveryBackup(envelope, opener) {
|
|
602
|
+
const prfWrap = envelope.keyWraps.find((w) => w.method === "passkey_prf");
|
|
603
|
+
if (opener.passkeySecret !== void 0 && prfWrap) {
|
|
604
|
+
const kek = await deriveKeyFromBytes(base64ToBytes(opener.passkeySecret), base64ToBytes(prfWrap.salt), KEK_INFO);
|
|
605
|
+
return openWith(envelope.ciphertext, prfWrap, kek, "passkey");
|
|
606
|
+
}
|
|
607
|
+
const codeWrap = envelope.keyWraps.find((w) => w.method === "passphrase");
|
|
608
|
+
if (opener.recoveryCode !== void 0 && codeWrap) {
|
|
609
|
+
const kek = await deriveKey(opener.recoveryCode, base64ToBytes(codeWrap.salt));
|
|
610
|
+
return openWith(envelope.ciphertext, codeWrap, kek, "recovery code");
|
|
611
|
+
}
|
|
612
|
+
const enrolled = envelope.keyWraps.map((w) => w.method).join(", ") || "none";
|
|
613
|
+
throw new WaaskeyError(`No key available to open this backup \u2014 it is wrapped for [${enrolled}] and none of those was supplied.`, "recovery_failed");
|
|
614
|
+
}
|
|
615
|
+
async function openWith(ciphertext, wrap, kek, label) {
|
|
616
|
+
let dataKeyBytes;
|
|
617
|
+
try {
|
|
618
|
+
dataKeyBytes = base64ToBytes(await open(kek, wrap.wrapped));
|
|
619
|
+
} catch (cause) {
|
|
620
|
+
throw new WaaskeyError(`Could not unwrap the backup key with the ${label} \u2014 wrong ${label}?`, "recovery_failed", { cause });
|
|
621
|
+
}
|
|
622
|
+
try {
|
|
623
|
+
return await open(await deriveKeyFromBytes(dataKeyBytes, new Uint8Array(0), DEK_INFO), ciphertext);
|
|
624
|
+
} catch (cause) {
|
|
625
|
+
throw new WaaskeyError("The backup key does not open this backup \u2014 the stored ciphertext and key wrap do not belong together.", "recovery_failed", { cause });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
564
629
|
// src/recovery.ts
|
|
565
630
|
var MIN_FACTORS = 3;
|
|
566
631
|
var Recovery = class {
|
|
@@ -598,7 +663,7 @@ var Recovery = class {
|
|
|
598
663
|
{ challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
|
|
599
664
|
options.signal
|
|
600
665
|
);
|
|
601
|
-
const share = await this.decrypt(res
|
|
666
|
+
const share = await this.decrypt(res, params);
|
|
602
667
|
await this.deps.shareStore?.put(walletId, share);
|
|
603
668
|
this.deps.analytics?.track("wallet.recovered", { walletId });
|
|
604
669
|
return { share, refreshedAt: res.refreshedAt };
|
|
@@ -613,20 +678,15 @@ var Recovery = class {
|
|
|
613
678
|
* device-loss so a leaked backup can't be replayed against a still-valid old share.
|
|
614
679
|
*/
|
|
615
680
|
async retrieveShare(walletId, params, options = {}) {
|
|
616
|
-
|
|
617
|
-
return this.decrypt(ciphertext, params.recoveryCode);
|
|
681
|
+
return this.decrypt(await fetchRecoveryBackup(this.http, walletId, params, options.signal), params);
|
|
618
682
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return await openWithPassword(recoveryCode, ciphertext);
|
|
622
|
-
} catch (cause) {
|
|
623
|
-
throw new WaaskeyError("Could not decrypt the recovery share \u2014 wrong recovery code?", "recovery_failed", { cause });
|
|
624
|
-
}
|
|
683
|
+
decrypt(backup, params) {
|
|
684
|
+
return openRecoveryBlob(backup, params);
|
|
625
685
|
}
|
|
626
686
|
};
|
|
627
687
|
async function buildRecoveryRegistration(params) {
|
|
628
688
|
const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
|
|
629
|
-
const
|
|
689
|
+
const backup = params.passkey ? await sealRecoveryBackup(params.share, recoveryCode, params.passkey) : { ciphertext: await sealWithPassword(recoveryCode, params.share), keyWraps: void 0 };
|
|
630
690
|
const factors = [
|
|
631
691
|
{ type: "recovery_code", credential: await sha256Hex(recoveryCode) },
|
|
632
692
|
{ type: "totp", credential: params.totpSecret },
|
|
@@ -636,19 +696,31 @@ async function buildRecoveryRegistration(params) {
|
|
|
636
696
|
if (factors.length < MIN_FACTORS) {
|
|
637
697
|
throw new WaaskeyError(`Recovery requires at least ${MIN_FACTORS} factors.`, "validation");
|
|
638
698
|
}
|
|
639
|
-
return { recoveryCode, payload: { ciphertext, factors } };
|
|
699
|
+
return { recoveryCode, payload: { ciphertext: backup.ciphertext, factors, ...backup.keyWraps ? { keyWraps: backup.keyWraps } : {} } };
|
|
640
700
|
}
|
|
641
701
|
function postRecoveryRegistration(http, walletId, payload, signal) {
|
|
642
702
|
return http.request("POST", `/v1/wallets/${walletId}/recovery/register`, payload, signal);
|
|
643
703
|
}
|
|
644
|
-
async function
|
|
645
|
-
|
|
704
|
+
async function fetchRecoveryBackup(http, walletId, params, signal) {
|
|
705
|
+
return http.request(
|
|
646
706
|
"POST",
|
|
647
707
|
`/v1/wallets/${walletId}/recovery/verify`,
|
|
648
708
|
{ challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
|
|
649
709
|
signal
|
|
650
710
|
);
|
|
651
|
-
|
|
711
|
+
}
|
|
712
|
+
async function openRecoveryBlob(backup, opener) {
|
|
713
|
+
if (backup.keyWraps?.length) {
|
|
714
|
+
return openRecoveryBackup({ ciphertext: backup.ciphertext, keyWraps: backup.keyWraps }, opener);
|
|
715
|
+
}
|
|
716
|
+
if (opener.recoveryCode === void 0) {
|
|
717
|
+
throw new WaaskeyError("This backup predates passkey wrapping and can only be opened with the recovery code.", "recovery_failed");
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
return await openWithPassword(opener.recoveryCode, backup.ciphertext);
|
|
721
|
+
} catch (cause) {
|
|
722
|
+
throw new WaaskeyError("Could not decrypt the recovery share \u2014 wrong recovery code?", "recovery_failed", { cause });
|
|
723
|
+
}
|
|
652
724
|
}
|
|
653
725
|
async function registerRecoveryShare(http, walletId, params, signal) {
|
|
654
726
|
const { recoveryCode, payload } = await buildRecoveryRegistration(params);
|
|
@@ -694,6 +766,47 @@ function toMpcCurve(curve) {
|
|
|
694
766
|
}
|
|
695
767
|
return curve;
|
|
696
768
|
}
|
|
769
|
+
var DEVICE_ENC_STORE_KEY = "__waaskey_device_enc_v1__";
|
|
770
|
+
async function getOrCreateDeviceEncKeypair(shareStore) {
|
|
771
|
+
const existing = await shareStore.get(DEVICE_ENC_STORE_KEY);
|
|
772
|
+
if (existing !== null) return parseDeviceEncKeypair(existing);
|
|
773
|
+
const keypair = generateDeviceEncKeypair();
|
|
774
|
+
await shareStore.put(DEVICE_ENC_STORE_KEY, JSON.stringify(keypair));
|
|
775
|
+
return keypair;
|
|
776
|
+
}
|
|
777
|
+
function generateDeviceEncKeypair() {
|
|
778
|
+
const secret = ed25519_js.x25519.utils.randomSecretKey();
|
|
779
|
+
const publicKey = ed25519_js.x25519.getPublicKey(secret);
|
|
780
|
+
if (isAllZero(publicKey)) {
|
|
781
|
+
throw new WaaskeyError("Generated an all-zero X25519 encryption public key \u2014 refusing to seal keygen packages to it.", "keygen_failed");
|
|
782
|
+
}
|
|
783
|
+
return { secretHex: bytesToHex(secret), publicHex: bytesToHex(publicKey) };
|
|
784
|
+
}
|
|
785
|
+
function parseDeviceEncKeypair(blob) {
|
|
786
|
+
let parsed;
|
|
787
|
+
try {
|
|
788
|
+
parsed = JSON.parse(blob);
|
|
789
|
+
} catch (cause) {
|
|
790
|
+
throw new WaaskeyError("Stored device encryption keypair is corrupt \u2014 it is not valid JSON.", "keygen_failed", { cause });
|
|
791
|
+
}
|
|
792
|
+
if (!isHex32(parsed.secretHex) || !isHex32(parsed.publicHex)) {
|
|
793
|
+
throw new WaaskeyError("Stored device encryption keypair is malformed \u2014 expected 32-byte (64 hex) secret + public keys.", "keygen_failed", {
|
|
794
|
+
details: { keys: Object.keys(parsed) }
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return { secretHex: parsed.secretHex, publicHex: parsed.publicHex };
|
|
798
|
+
}
|
|
799
|
+
function isHex32(v) {
|
|
800
|
+
return typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
|
|
801
|
+
}
|
|
802
|
+
function isAllZero(bytes) {
|
|
803
|
+
return bytes.every((byte) => byte === 0);
|
|
804
|
+
}
|
|
805
|
+
function bytesToHex(bytes) {
|
|
806
|
+
let hex = "";
|
|
807
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
808
|
+
return hex;
|
|
809
|
+
}
|
|
697
810
|
|
|
698
811
|
// src/internal.ts
|
|
699
812
|
function throwIfAborted(signal) {
|
|
@@ -719,6 +832,7 @@ function delay(ms, signal) {
|
|
|
719
832
|
}
|
|
720
833
|
|
|
721
834
|
// src/reshare.ts
|
|
835
|
+
var USER_BACKUP_ROLE = "user_backup";
|
|
722
836
|
var Reshare = class {
|
|
723
837
|
constructor(http, deps = {}) {
|
|
724
838
|
this.http = http;
|
|
@@ -726,6 +840,116 @@ var Reshare = class {
|
|
|
726
840
|
}
|
|
727
841
|
http;
|
|
728
842
|
deps;
|
|
843
|
+
/**
|
|
844
|
+
* Rotate the wallet onto a NEW committee, keeping its address, and drive this device's whole half of
|
|
845
|
+
* the ceremony (#488).
|
|
846
|
+
*
|
|
847
|
+
* The client is not a bystander here: the platform holds ONE share and cannot reach the threshold, so
|
|
848
|
+
* without this device's deal there is no ceremony at all.
|
|
849
|
+
*
|
|
850
|
+
* 1. Open the ceremony with the committee asked for (an empty request re-issues the current one).
|
|
851
|
+
* 2. DEAL from this device's current-epoch share — pure and local; every sub-share leaves sealed.
|
|
852
|
+
* 3. For each CLIENT-held new holder — identified by custody KIND, never by role name — fetch its
|
|
853
|
+
* material and assemble its new-epoch core locally, refusing any share that reproduces a different
|
|
854
|
+
* public key.
|
|
855
|
+
* 4. Acknowledge each one. The `user_backup` holder's ack carries its new share re-sealed under the
|
|
856
|
+
* user's EXISTING recovery code, so the wallet's no-lock-out property survives the rotation; the
|
|
857
|
+
* last acknowledgement drives the backend's atomic cutover.
|
|
858
|
+
*
|
|
859
|
+
* The assembled cores are BARE (no aux): the wallet is `reshareAuxPending` until aux-completion, which
|
|
860
|
+
* is {@link complete}'s job — and which re-assembles from the material itself, so nothing here needs
|
|
861
|
+
* to be kept on the device between the two. Nothing here can leave the device worse off — the old-epoch share is
|
|
862
|
+
* untouched, and a ceremony that fails cuts nothing over.
|
|
863
|
+
*/
|
|
864
|
+
async rotate(walletId, params = {}, options = {}) {
|
|
865
|
+
const { mpc, shareStore } = this.deps;
|
|
866
|
+
if (!mpc || !shareStore) {
|
|
867
|
+
throw new WaaskeyError("Driving a reshare requires a device MPC core and a share store \u2014 pass `mpc` and `shareStore` to `new Waaskey(...)`.", "device_core_required");
|
|
868
|
+
}
|
|
869
|
+
if (!mpc.runReshareDeal || !mpc.runReshareAssemble) {
|
|
870
|
+
throw new WaaskeyError("The configured MPC core does not support reshare \u2014 use a client-wasm built with the `reshare` feature.", "unsupported");
|
|
871
|
+
}
|
|
872
|
+
const { signal } = options;
|
|
873
|
+
throwIfAborted(signal);
|
|
874
|
+
const wallet = await this.http.request("GET", `/v1/wallets/${walletId}`, void 0, signal);
|
|
875
|
+
if (!wallet.publicKey) {
|
|
876
|
+
throw new WaaskeyError("The wallet has no public key yet \u2014 only an ACTIVE wallet can be reshared.", "reshare_failed", { details: { walletId } });
|
|
877
|
+
}
|
|
878
|
+
const share = await shareStore.get(epochShareKey(walletId, wallet.keyEpoch));
|
|
879
|
+
if (!share) {
|
|
880
|
+
throw new WaaskeyError(`This device holds no share for wallet "${walletId}" at epoch ${wallet.keyEpoch} \u2014 it cannot deal the reshare.`, "reshare_failed");
|
|
881
|
+
}
|
|
882
|
+
throwIfAborted(signal);
|
|
883
|
+
const ceremony = await this.http.request("POST", `/v1/wallets/${walletId}/reshare-embedded`, params, signal);
|
|
884
|
+
const encKeypair = await getOrCreateDeviceEncKeypair(shareStore);
|
|
885
|
+
const curve = toMpcCurve(ceremony.curve);
|
|
886
|
+
const clientHolders = ceremony.newParties.filter((_role, i) => isClientCustody(ceremony.newCustodyKinds[i]));
|
|
887
|
+
try {
|
|
888
|
+
throwIfAborted(signal);
|
|
889
|
+
const dealt = await mpc.runReshareDeal({
|
|
890
|
+
curve,
|
|
891
|
+
share,
|
|
892
|
+
quorumIndices: ceremony.quorumIndices,
|
|
893
|
+
newPreimages: ceremony.newPreimages,
|
|
894
|
+
newThreshold: ceremony.newThreshold,
|
|
895
|
+
recipientPubkeys: ceremony.recipientPubkeys
|
|
896
|
+
});
|
|
897
|
+
await this.http.request(
|
|
898
|
+
"POST",
|
|
899
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/deal`,
|
|
900
|
+
{ commitments: dealt.commitments, sealedSubShares: dealt.sealedSubShares },
|
|
901
|
+
signal
|
|
902
|
+
);
|
|
903
|
+
let { status } = ceremony;
|
|
904
|
+
for (const role of clientHolders) {
|
|
905
|
+
throwIfAborted(signal);
|
|
906
|
+
const material = await this.http.request(
|
|
907
|
+
"GET",
|
|
908
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/assembly?role=${encodeURIComponent(role)}`,
|
|
909
|
+
void 0,
|
|
910
|
+
signal
|
|
911
|
+
);
|
|
912
|
+
const assembled = await mpc.runReshareAssemble({
|
|
913
|
+
curve,
|
|
914
|
+
newPosition: material.newPosition,
|
|
915
|
+
newPreimages: material.newPreimages,
|
|
916
|
+
newThreshold: material.newThreshold,
|
|
917
|
+
wallet: material.wallet,
|
|
918
|
+
commitments: material.commitments,
|
|
919
|
+
subShares: material.subShares,
|
|
920
|
+
encryptionSecret: encKeypair.secretHex
|
|
921
|
+
});
|
|
922
|
+
assertPublicKey(assembled.sharedPublicKey, wallet.publicKey, walletId);
|
|
923
|
+
const body = role === USER_BACKUP_ROLE ? { backupCiphertext: await this.sealBackup(assembled.core, params.recoveryCode) } : {};
|
|
924
|
+
const ack = await this.http.request(
|
|
925
|
+
"POST",
|
|
926
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/assembled?role=${encodeURIComponent(role)}`,
|
|
927
|
+
body,
|
|
928
|
+
signal
|
|
929
|
+
);
|
|
930
|
+
({ status } = ack);
|
|
931
|
+
}
|
|
932
|
+
this.deps.analytics?.track("wallet.reshared", { walletId, curve: ceremony.curve });
|
|
933
|
+
return { walletId, ceremonyId: ceremony.id, status, keyEpoch: ceremony.keyEpoch, assembledRoles: clientHolders, sharedPublicKey: wallet.publicKey };
|
|
934
|
+
} catch (cause) {
|
|
935
|
+
if (cause instanceof WaaskeyError) throw cause;
|
|
936
|
+
throw new WaaskeyError("The reshare ceremony failed.", "reshare_failed", { cause });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Re-seal the new `user_backup` core under the user's EXISTING recovery code. The code never leaves
|
|
941
|
+
* the device — only the ciphertext does — which is the same trust model registration has, and the
|
|
942
|
+
* reason the server cannot check WHICH code sealed it.
|
|
943
|
+
*/
|
|
944
|
+
async sealBackup(core, recoveryCode) {
|
|
945
|
+
if (!recoveryCode) {
|
|
946
|
+
throw new WaaskeyError(
|
|
947
|
+
"This committee keeps a `user_backup` holder, so its new share must be re-sealed under the wallet existing recovery code \u2014 pass `recoveryCode` to `reshare.rotate(...)`.",
|
|
948
|
+
"reshare_failed"
|
|
949
|
+
);
|
|
950
|
+
}
|
|
951
|
+
return sealWithPassword(recoveryCode, core);
|
|
952
|
+
}
|
|
729
953
|
/**
|
|
730
954
|
* Complete this device's share for a device-retaining reshare and make the wallet signable on the
|
|
731
955
|
* device under the new epoch.
|
|
@@ -756,6 +980,7 @@ var Reshare = class {
|
|
|
756
980
|
if (!wallet.publicKey) {
|
|
757
981
|
throw new WaaskeyError("The wallet has no public key yet \u2014 it must be an ACTIVE reshared wallet to complete on device.", "reshare_failed", { details: { walletId } });
|
|
758
982
|
}
|
|
983
|
+
const encKeypair = await getOrCreateDeviceEncKeypair(shareStore);
|
|
759
984
|
throwIfAborted(signal);
|
|
760
985
|
let completed;
|
|
761
986
|
try {
|
|
@@ -766,7 +991,8 @@ var Reshare = class {
|
|
|
766
991
|
newThreshold: material.newThreshold,
|
|
767
992
|
wallet: material.wallet,
|
|
768
993
|
commitments: material.commitments,
|
|
769
|
-
subShares: material.subShares
|
|
994
|
+
subShares: material.subShares,
|
|
995
|
+
encryptionSecret: encKeypair.secretHex
|
|
770
996
|
});
|
|
771
997
|
assertPublicKey(assembled.sharedPublicKey, wallet.publicKey, walletId);
|
|
772
998
|
throwIfAborted(signal);
|
|
@@ -796,6 +1022,9 @@ var Reshare = class {
|
|
|
796
1022
|
function epochShareKey(walletId, keyEpoch) {
|
|
797
1023
|
return keyEpoch <= 1 ? walletId : `${walletId}@epoch-${keyEpoch}`;
|
|
798
1024
|
}
|
|
1025
|
+
function isClientCustody(kind) {
|
|
1026
|
+
return kind !== void 0 && kind !== "platform_signer" && kind !== "platform_recovery";
|
|
1027
|
+
}
|
|
799
1028
|
function assertPublicKey(actual, expected, walletId) {
|
|
800
1029
|
if (actual !== expected) {
|
|
801
1030
|
throw new WaaskeyError("The reshared share reproduced a DIFFERENT public key than the wallet \u2014 aborting (device share not stored).", "reshare_pubkey_mismatch", {
|
|
@@ -870,47 +1099,6 @@ function deserializeEddsaShare(blob) {
|
|
|
870
1099
|
}
|
|
871
1100
|
return { keyPackage: parsed.keyPackage, publicKeyPackage: parsed.publicKeyPackage };
|
|
872
1101
|
}
|
|
873
|
-
var DEVICE_ENC_STORE_KEY = "__waaskey_device_enc_v1__";
|
|
874
|
-
async function getOrCreateDeviceEncKeypair(shareStore) {
|
|
875
|
-
const existing = await shareStore.get(DEVICE_ENC_STORE_KEY);
|
|
876
|
-
if (existing !== null) return parseDeviceEncKeypair(existing);
|
|
877
|
-
const keypair = generateDeviceEncKeypair();
|
|
878
|
-
await shareStore.put(DEVICE_ENC_STORE_KEY, JSON.stringify(keypair));
|
|
879
|
-
return keypair;
|
|
880
|
-
}
|
|
881
|
-
function generateDeviceEncKeypair() {
|
|
882
|
-
const secret = ed25519_js.x25519.utils.randomSecretKey();
|
|
883
|
-
const publicKey = ed25519_js.x25519.getPublicKey(secret);
|
|
884
|
-
if (isAllZero(publicKey)) {
|
|
885
|
-
throw new WaaskeyError("Generated an all-zero X25519 encryption public key \u2014 refusing to seal keygen packages to it.", "keygen_failed");
|
|
886
|
-
}
|
|
887
|
-
return { secretHex: bytesToHex(secret), publicHex: bytesToHex(publicKey) };
|
|
888
|
-
}
|
|
889
|
-
function parseDeviceEncKeypair(blob) {
|
|
890
|
-
let parsed;
|
|
891
|
-
try {
|
|
892
|
-
parsed = JSON.parse(blob);
|
|
893
|
-
} catch (cause) {
|
|
894
|
-
throw new WaaskeyError("Stored device encryption keypair is corrupt \u2014 it is not valid JSON.", "keygen_failed", { cause });
|
|
895
|
-
}
|
|
896
|
-
if (!isHex32(parsed.secretHex) || !isHex32(parsed.publicHex)) {
|
|
897
|
-
throw new WaaskeyError("Stored device encryption keypair is malformed \u2014 expected 32-byte (64 hex) secret + public keys.", "keygen_failed", {
|
|
898
|
-
details: { keys: Object.keys(parsed) }
|
|
899
|
-
});
|
|
900
|
-
}
|
|
901
|
-
return { secretHex: parsed.secretHex, publicHex: parsed.publicHex };
|
|
902
|
-
}
|
|
903
|
-
function isHex32(v) {
|
|
904
|
-
return typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
|
|
905
|
-
}
|
|
906
|
-
function isAllZero(bytes) {
|
|
907
|
-
return bytes.every((byte) => byte === 0);
|
|
908
|
-
}
|
|
909
|
-
function bytesToHex(bytes) {
|
|
910
|
-
let hex = "";
|
|
911
|
-
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
912
|
-
return hex;
|
|
913
|
-
}
|
|
914
1102
|
|
|
915
1103
|
// src/session-sign.ts
|
|
916
1104
|
function toDeviceSignParams(ceremony, share, digest) {
|
|
@@ -1397,7 +1585,7 @@ function normalizeDigest(digest) {
|
|
|
1397
1585
|
// src/wallets.ts
|
|
1398
1586
|
var BACKUP_REGISTER_ATTEMPTS = 3;
|
|
1399
1587
|
var BACKUP_REGISTER_BACKOFF_MS = 200;
|
|
1400
|
-
var
|
|
1588
|
+
var USER_BACKUP_ROLE2 = "user_backup";
|
|
1401
1589
|
var DEFAULT_ACTIVATION_TIMEOUT_MS = 6e4;
|
|
1402
1590
|
var DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
1403
1591
|
var Wallets = class {
|
|
@@ -1709,7 +1897,7 @@ var Wallets = class {
|
|
|
1709
1897
|
*/
|
|
1710
1898
|
async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
|
|
1711
1899
|
const extras = ceremony.additionalParties ?? [];
|
|
1712
|
-
const unsupported = extras.filter((party) => party.role !==
|
|
1900
|
+
const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE2);
|
|
1713
1901
|
if (unsupported.length > 0) {
|
|
1714
1902
|
throw new WaaskeyError(
|
|
1715
1903
|
`The keygen ceremony carries an additional client party this SDK cannot drive (${unsupported.map((party) => party.role).join(", ")}) \u2014 refusing to keygen, since leaving a party unjoined would hang the ceremony.`,
|
|
@@ -1717,7 +1905,7 @@ var Wallets = class {
|
|
|
1717
1905
|
{ details: { roles: unsupported.map((party) => party.role) } }
|
|
1718
1906
|
);
|
|
1719
1907
|
}
|
|
1720
|
-
const userBackupParty = extras.find((party) => party.role ===
|
|
1908
|
+
const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE2);
|
|
1721
1909
|
if (!userBackupParty) {
|
|
1722
1910
|
const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
|
|
1723
1911
|
throwIfAborted(signal);
|
|
@@ -1837,8 +2025,8 @@ var Wallets = class {
|
|
|
1837
2025
|
const { signal } = options;
|
|
1838
2026
|
throwIfAborted(signal);
|
|
1839
2027
|
const digest = normalizeDigest(params.digest);
|
|
1840
|
-
const
|
|
1841
|
-
const share = await restoreUserBackupShare(
|
|
2028
|
+
const backup = await this.fetchUserBackup(walletId, params, signal);
|
|
2029
|
+
const share = await restoreUserBackupShare(backup, params);
|
|
1842
2030
|
throwIfAborted(signal);
|
|
1843
2031
|
const body = { digest };
|
|
1844
2032
|
if (params.chainId !== void 0) body["chainId"] = params.chainId;
|
|
@@ -1857,13 +2045,15 @@ var Wallets = class {
|
|
|
1857
2045
|
return signature;
|
|
1858
2046
|
}
|
|
1859
2047
|
/**
|
|
1860
|
-
* Retrieve the sealed `user_backup`
|
|
1861
|
-
*
|
|
1862
|
-
*
|
|
2048
|
+
* Retrieve the sealed `user_backup` backup via the recovery gate ({@link fetchRecoveryBackup}) — the
|
|
2049
|
+
* ciphertext AND its key wraps (#510), since a backup registered with a passkey is an envelope and
|
|
2050
|
+
* the wraps are what open it. Maps a "no recovery share registered" (404) to the actionable
|
|
2051
|
+
* `share_not_found` — this wallet has no client-held `user_backup` backup to co-sign with (its
|
|
2052
|
+
* device-loss recovery is the custodial path instead).
|
|
1863
2053
|
*/
|
|
1864
|
-
async
|
|
2054
|
+
async fetchUserBackup(walletId, params, signal) {
|
|
1865
2055
|
try {
|
|
1866
|
-
return await
|
|
2056
|
+
return await fetchRecoveryBackup(this.http, walletId, params, signal);
|
|
1867
2057
|
} catch (cause) {
|
|
1868
2058
|
if (cause instanceof WaaskeyError && cause.code === "not_found") {
|
|
1869
2059
|
throw new WaaskeyError(
|
|
@@ -1985,12 +2175,12 @@ var Wallets = class {
|
|
|
1985
2175
|
}
|
|
1986
2176
|
}
|
|
1987
2177
|
};
|
|
1988
|
-
async function restoreUserBackupShare(
|
|
2178
|
+
async function restoreUserBackupShare(backup, opener) {
|
|
1989
2179
|
let blob;
|
|
1990
2180
|
try {
|
|
1991
|
-
blob = await
|
|
2181
|
+
blob = await openRecoveryBlob(backup, opener);
|
|
1992
2182
|
} catch (cause) {
|
|
1993
|
-
throw new WaaskeyError("Could not open the user_backup backup \u2014 wrong recovery code?", "invalid_recovery_code", { cause });
|
|
2183
|
+
throw new WaaskeyError("Could not open the user_backup backup \u2014 wrong recovery code or passkey?", "invalid_recovery_code", { cause });
|
|
1994
2184
|
}
|
|
1995
2185
|
return deserializeShare(blob).keyShare;
|
|
1996
2186
|
}
|
|
@@ -2112,13 +2302,12 @@ function isReadyMemberSignCeremony(ceremony) {
|
|
|
2112
2302
|
}
|
|
2113
2303
|
|
|
2114
2304
|
// src/client.ts
|
|
2115
|
-
var DEFAULT_BASE_URL = "https://api.waaskey.com";
|
|
2116
2305
|
var Waaskey = class {
|
|
2117
2306
|
/** The `wallets` resource. */
|
|
2118
2307
|
wallets;
|
|
2119
2308
|
/** The `recovery` resource — multi-factor, client-encrypted wallet recovery. */
|
|
2120
2309
|
recovery;
|
|
2121
|
-
/** The `reshare` resource — device
|
|
2310
|
+
/** The `reshare` resource — this device's half of a committee rotation (#488) and the aux-completion that follows (#318). */
|
|
2122
2311
|
reshare;
|
|
2123
2312
|
/** The `balances` resource — client-side balance reads from a chain provider (no backend). */
|
|
2124
2313
|
balances;
|
|
@@ -2134,7 +2323,10 @@ var Waaskey = class {
|
|
|
2134
2323
|
if (!options?.apiKey) {
|
|
2135
2324
|
throw new Error("Waaskey: `apiKey` is required.");
|
|
2136
2325
|
}
|
|
2137
|
-
|
|
2326
|
+
if (!options.baseUrl) {
|
|
2327
|
+
throw new Error("Waaskey: `baseUrl` is required \u2014 pass the URL of your Waaskey API (there is no default).");
|
|
2328
|
+
}
|
|
2329
|
+
const http = new HttpClient(options.apiKey, options.baseUrl, options.fetch);
|
|
2138
2330
|
const analytics = new Analytics(resolveSink(options.analytics, http));
|
|
2139
2331
|
this.auth = new Auth(http);
|
|
2140
2332
|
this.members = new Members(http);
|
|
@@ -2169,6 +2361,15 @@ function resolveSink(analytics, http) {
|
|
|
2169
2361
|
return analytics ?? new HttpAnalyticsSink(http);
|
|
2170
2362
|
}
|
|
2171
2363
|
|
|
2364
|
+
// src/passkey/recovery-factor.ts
|
|
2365
|
+
function passkeyFactorEnrollment(credentialId) {
|
|
2366
|
+
return { type: "passkey", credential: credentialId };
|
|
2367
|
+
}
|
|
2368
|
+
async function passkeyFactorVerification(challenge, options = {}) {
|
|
2369
|
+
const assertion = await getSigningAssertion(challenge, options);
|
|
2370
|
+
return { type: "passkey", token: JSON.stringify(assertion) };
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2172
2373
|
// src/mpc/wasm-core.ts
|
|
2173
2374
|
var WasmMpcCore = class {
|
|
2174
2375
|
constructor(load) {
|
|
@@ -2234,7 +2435,10 @@ var WasmMpcCore = class {
|
|
|
2234
2435
|
new_threshold: params.newThreshold,
|
|
2235
2436
|
wallet: params.wallet,
|
|
2236
2437
|
commitments: params.commitments,
|
|
2237
|
-
sub_shares: params.subShares
|
|
2438
|
+
sub_shares: params.subShares,
|
|
2439
|
+
// The core opens the SEALED sub-shares with this before it can verify or assemble anything;
|
|
2440
|
+
// omitting it (as this did until #488) makes the call fail on its own parameter parse.
|
|
2441
|
+
encryption_secret: params.encryptionSecret
|
|
2238
2442
|
})
|
|
2239
2443
|
);
|
|
2240
2444
|
if (!raw || typeof raw.core_json !== "string") {
|
|
@@ -2242,6 +2446,26 @@ var WasmMpcCore = class {
|
|
|
2242
2446
|
}
|
|
2243
2447
|
return { core: raw.core_json, sharedPublicKey: decodePublicKey(raw.shared_public_key_json) };
|
|
2244
2448
|
}
|
|
2449
|
+
async runReshareDeal(params) {
|
|
2450
|
+
const wasm = await this.init();
|
|
2451
|
+
if (!wasm.reshareDeal) {
|
|
2452
|
+
throw new Error(reshareFeatureHint("reshareDeal"));
|
|
2453
|
+
}
|
|
2454
|
+
const raw = await wasm.reshareDeal(
|
|
2455
|
+
JSON.stringify({
|
|
2456
|
+
curve: params.curve,
|
|
2457
|
+
share: JSON.parse(params.share),
|
|
2458
|
+
quorum_indices: params.quorumIndices,
|
|
2459
|
+
new_preimages: params.newPreimages,
|
|
2460
|
+
new_threshold: params.newThreshold,
|
|
2461
|
+
recipient_pubkeys: params.recipientPubkeys
|
|
2462
|
+
})
|
|
2463
|
+
);
|
|
2464
|
+
if (!raw || !Array.isArray(raw.sealed_sub_shares_hex)) {
|
|
2465
|
+
throw new Error("client-wasm returned an unexpected reshareDeal result");
|
|
2466
|
+
}
|
|
2467
|
+
return { commitments: raw.commitments_json, sealedSubShares: raw.sealed_sub_shares_hex };
|
|
2468
|
+
}
|
|
2245
2469
|
async runCompleteReshare(params) {
|
|
2246
2470
|
const wasm = await this.init();
|
|
2247
2471
|
if (!wasm.completeReshare) {
|
|
@@ -2942,6 +3166,10 @@ exports.isPasskeySupported = isPasskeySupported;
|
|
|
2942
3166
|
exports.isPrfSupported = isPrfSupported;
|
|
2943
3167
|
exports.loadClientWasm = loadClientWasm;
|
|
2944
3168
|
exports.memberShareKey = memberShareKey;
|
|
3169
|
+
exports.openRecoveryBackup = openRecoveryBackup;
|
|
3170
|
+
exports.passkeyFactorEnrollment = passkeyFactorEnrollment;
|
|
3171
|
+
exports.passkeyFactorVerification = passkeyFactorVerification;
|
|
3172
|
+
exports.sealRecoveryBackup = sealRecoveryBackup;
|
|
2945
3173
|
exports.userBackupPendingKey = userBackupPendingKey;
|
|
2946
3174
|
exports.validateCustodyPolicy = validateCustodyPolicy;
|
|
2947
3175
|
exports.verifyWasmIntegrity = verifyWasmIntegrity;
|