@waaskey/sdk 0.3.1 → 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/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.ciphertext, params.recoveryCode);
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,22 +678,17 @@ 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
- const ciphertext = await fetchRecoveryCiphertext(this.http, walletId, params, options.signal);
617
- return this.decrypt(ciphertext, params.recoveryCode);
681
+ return this.decrypt(await fetchRecoveryBackup(this.http, walletId, params, options.signal), params);
618
682
  }
619
- async decrypt(ciphertext, recoveryCode) {
620
- try {
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 ciphertext = await sealWithPassword(recoveryCode, params.share);
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
- { type: "recovery_code", credentialHash: await sha256Hex(recoveryCode) },
691
+ { type: "recovery_code", credential: await sha256Hex(recoveryCode) },
632
692
  { type: "totp", credential: params.totpSecret },
633
693
  { type: "email_otp", credential: params.email },
634
694
  ...params.extraFactors ?? []
@@ -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 fetchRecoveryCiphertext(http, walletId, params, signal) {
645
- const res = await http.request(
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
- return res.ciphertext;
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);
@@ -656,7 +728,7 @@ async function registerRecoveryShare(http, walletId, params, signal) {
656
728
  return { recoveryCode, share };
657
729
  }
658
730
  async function hashRecoveryFactors(verifications) {
659
- return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && v.token !== void 0 ? { type: v.type, credentialHash: await sha256Hex(v.token) } : v));
731
+ return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && typeof v.token === "string" ? { type: v.type, token: await sha256Hex(v.token) } : v));
660
732
  }
661
733
  function generateRecoveryCode() {
662
734
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -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,46 +1099,50 @@ 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
- });
1102
+
1103
+ // src/session-sign.ts
1104
+ function toDeviceSignParams(ceremony, share, digest) {
1105
+ const { roles, participants, signerPosition } = ceremony;
1106
+ if (participants.length !== 2 || roles.length !== 2 || signerPosition < 0 || signerPosition > 1) {
1107
+ throw new WaaskeyError(
1108
+ `Unexpected sign descriptor: the quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${participants.length} participants, ${roles.length} roles, signerPosition ${signerPosition}).`,
1109
+ "sign_failed",
1110
+ { details: { roles, participants, signerPosition } }
1111
+ );
900
1112
  }
901
- return { secretHex: parsed.secretHex, publicHex: parsed.publicHex };
1113
+ const peerPosition = 1 - signerPosition;
1114
+ return {
1115
+ curve: toMpcCurve(ceremony.curve),
1116
+ relayUrl: ceremony.relayUrl,
1117
+ sessionId: ceremony.sessionId,
1118
+ role: roles[signerPosition],
1119
+ peerRole: roles[peerPosition],
1120
+ partyIndex: signerPosition,
1121
+ peerPartyIndex: peerPosition,
1122
+ relayToken: ceremony.relayToken,
1123
+ share,
1124
+ participants,
1125
+ signerPosition,
1126
+ digest
1127
+ };
902
1128
  }
903
- function isHex32(v) {
904
- return typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
1129
+ function toSessionSignParams(session, share, digest) {
1130
+ return toDeviceSignParams(
1131
+ {
1132
+ curve: session.curve,
1133
+ relayUrl: session.relayUrl,
1134
+ sessionId: session.sessionId,
1135
+ roles: sessionRoles(session),
1136
+ participants: session.participants,
1137
+ signerPosition: session.signerPosition,
1138
+ relayToken: session.relayToken
1139
+ },
1140
+ share,
1141
+ digest
1142
+ );
905
1143
  }
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;
1144
+ function sessionRoles(session) {
1145
+ return session.signerPosition === 0 ? [session.role, session.peerRole] : [session.peerRole, session.role];
913
1146
  }
914
1147
 
915
1148
  // src/share-blob.ts
@@ -978,6 +1211,7 @@ async function getSigningAssertion(challenge, options = {}) {
978
1211
  }
979
1212
 
980
1213
  // src/wallet.ts
1214
+ var DEVICE_ROLE = "device";
981
1215
  var Wallet = class {
982
1216
  constructor(http, data, analytics, device = {}) {
983
1217
  this.http = http;
@@ -1052,56 +1286,106 @@ var Wallet = class {
1052
1286
  */
1053
1287
  async sign(digest, options = {}) {
1054
1288
  const message = normalizeDigest(digest);
1055
- const body = { message };
1056
- await this.attachStepUp(body, "sign", options);
1057
- const coSign = this.startDeviceCoSign(message);
1058
- const post = this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1059
- const [res] = coSign === void 0 ? [await post] : await Promise.all([post, coSign.catch(() => void 0)]);
1289
+ const quorum = this.signQuorum();
1290
+ const devicePosition = quorum?.indexOf(DEVICE_ROLE) ?? -1;
1291
+ if (devicePosition < 0) {
1292
+ const body = { message };
1293
+ await this.attachStepUp(body, "sign", options);
1294
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1295
+ this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1296
+ return res.signature;
1297
+ }
1298
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "signing");
1299
+ const startBody = { digest: message };
1300
+ await this.attachStepUp(startBody, "sign", options);
1301
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/sign-session`, startBody, options.signal);
1302
+ const { signature } = await this.runDeviceSign(mpc, this.toSignCeremony(session, quorum), keyShare, message);
1060
1303
  this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1061
- return res.signature;
1304
+ return signature;
1062
1305
  }
1063
1306
  /**
1064
- * Start this device's half of a secp sign ceremony when the wallet's sign quorum requires it.
1065
- * The quorum is the first `threshold` roles of the wallet's party list (#292) — for the default
1066
- * `[device, server, recovery]`/2 that is `[device, server]`, so the device MUST be online and
1067
- * co-signing. Returns `undefined` when the quorum is platform-only (or the wallet is not secp) —
1068
- * the POST then completes alone, unchanged. A device-present quorum without the device deps or
1069
- * stored share fails fast with a typed error instead of a guaranteed server-side timeout.
1307
+ * The roles that will actually sign the first `threshold` of the wallet's party list (#292),
1308
+ * mirroring the signer's own derivation. `undefined` when the topology is unknown (a legacy
1309
+ * wallet record), which callers treat as "let the server decide".
1070
1310
  */
1071
- startDeviceCoSign(digest) {
1311
+ signQuorum() {
1072
1312
  if (this.data.curve === "ed25519") return void 0;
1073
1313
  const roles = this.data.parties;
1074
1314
  const { threshold } = this.data;
1075
1315
  if (!Array.isArray(roles) || typeof threshold !== "number") return void 0;
1076
- const quorum = roles.slice(0, threshold);
1077
- const pos = quorum.indexOf("device");
1078
- if (pos < 0) return void 0;
1316
+ return roles.slice(0, threshold);
1317
+ }
1318
+ /**
1319
+ * Everything this device needs to join a ceremony, resolved BEFORE one is started (#89): the MPC
1320
+ * core, the routed-sign capability a >2-party quorum needs, and the stored key share.
1321
+ *
1322
+ * Each failure is a typed error thrown straight to the caller, and the ordering is the point: a
1323
+ * device that cannot co-sign must never leave the platform party waiting on the relay for a
1324
+ * counterpart that will never arrive. That wait ends at the party-runner timeout (~210s) and
1325
+ * reaches the caller as an opaque 5xx — minutes after a knowable, local cause.
1326
+ */
1327
+ async loadDeviceParty(quorum, action) {
1079
1328
  const { mpc, shareStore } = this.device;
1080
1329
  if (!mpc || !shareStore) {
1081
1330
  throw new WaaskeyError(
1082
- `This wallet's sign quorum [${quorum.join(", ")}] includes the device, so signing requires the device MPC core and share store \u2014 pass \`mpc\` and \`shareStore\` to \`new Waaskey(...)\`.`,
1331
+ `This wallet's signing quorum [${quorum.join(", ")}] includes the device, so ${action} requires the device MPC core and share store \u2014 pass \`mpc\` and \`shareStore\` to \`new Waaskey(...)\`.`,
1083
1332
  "device_core_required"
1084
1333
  );
1085
1334
  }
1086
- return (async () => {
1087
- const blob = await shareStore.get(this.id);
1088
- if (!blob) {
1089
- throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the sign quorum.`, "share_not_found");
1090
- }
1091
- const { keyShare, relayUrl } = deserializeShare(blob);
1092
- if (!relayUrl) {
1093
- throw new WaaskeyError("The stored device share predates relay-URL persistence \u2014 re-create the wallet (or recover) to enable device-present signing.", "share_not_found");
1094
- }
1095
- const participants = quorum.map((_role, index) => index);
1096
- const base = { curve: toMpcCurve(this.data.curve), relayUrl, sessionId: this.id, share: keyShare, participants, signerPosition: pos, digest };
1097
- if (quorum.length === 2) {
1098
- return mpc.runSign({ ...base, role: quorum[pos], peerRole: quorum[1 - pos], partyIndex: pos, peerPartyIndex: 1 - pos });
1099
- }
1100
- if (!mpc.runMemberSign) {
1101
- throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1102
- }
1103
- return mpc.runMemberSign({ ...base, roles: quorum });
1104
- })();
1335
+ if (quorum.length > 2 && !mpc.runMemberSign) {
1336
+ throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1337
+ }
1338
+ const blob = await shareStore.get(this.id);
1339
+ if (!blob) {
1340
+ throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the signing quorum.`, "share_not_found");
1341
+ }
1342
+ const { keyShare } = deserializeShare(blob);
1343
+ return { mpc, keyShare };
1344
+ }
1345
+ /**
1346
+ * Normalize a sign-session descriptor into the shared {@link DeviceCeremony}. The 2-party roster
1347
+ * comes from the descriptor's own `role`/`peerRole` (authoritative the relay token is bound to
1348
+ * that role); a larger quorum carries no server-sent roster, so the wallet's own party slice — the
1349
+ * same slice the signer derives — supplies it.
1350
+ */
1351
+ toSignCeremony(session, quorum) {
1352
+ return {
1353
+ curve: session.curve,
1354
+ relayUrl: session.relayUrl,
1355
+ sessionId: session.sessionId,
1356
+ roles: session.participants.length === 2 ? sessionRoles(session) : quorum,
1357
+ participants: session.participants,
1358
+ signerPosition: session.signerPosition,
1359
+ relayToken: session.relayToken
1360
+ };
1361
+ }
1362
+ /**
1363
+ * Run this device's half of a started ceremony. A 2-party quorum uses the plain single-peer
1364
+ * transport; anything larger MUST be roster-routed, or the transport attributes every inbound
1365
+ * message to the one configured peer and the protocol aborts on the third party's first message
1366
+ * (waas-core#131).
1367
+ */
1368
+ runDeviceSign(mpc, ceremony, keyShare, digest) {
1369
+ if (ceremony.participants.length === 2) {
1370
+ return mpc.runSign(toDeviceSignParams(ceremony, keyShare, digest));
1371
+ }
1372
+ if (!mpc.runMemberSign) {
1373
+ throw new WaaskeyError(
1374
+ `This wallet's sign quorum has ${ceremony.participants.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`,
1375
+ "unsupported"
1376
+ );
1377
+ }
1378
+ return mpc.runMemberSign({
1379
+ curve: toMpcCurve(ceremony.curve),
1380
+ relayUrl: ceremony.relayUrl,
1381
+ sessionId: ceremony.sessionId,
1382
+ relayToken: ceremony.relayToken,
1383
+ roles: ceremony.roles,
1384
+ share: keyShare,
1385
+ participants: ceremony.participants,
1386
+ signerPosition: ceremony.signerPosition,
1387
+ digest
1388
+ });
1105
1389
  }
1106
1390
  /**
1107
1391
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
@@ -1121,19 +1405,60 @@ var Wallet = class {
1121
1405
  if (this.data.curve === "ed25519") {
1122
1406
  return this.sendEd25519(params, options);
1123
1407
  }
1408
+ const quorum = this.signQuorum();
1409
+ if ((quorum?.indexOf(DEVICE_ROLE) ?? -1) >= 0) {
1410
+ return this.sendWithDevice(params, options, quorum);
1411
+ }
1124
1412
  const body = { ...params };
1125
1413
  await this.attachStepUp(body, "send", options);
1126
1414
  const res = await this.http.request("POST", `/v1/wallets/${this.id}/send`, body, options.signal);
1127
1415
  this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1128
1416
  return res;
1129
1417
  }
1418
+ /**
1419
+ * Device-co-signed send for a secp wallet — the cggmp24 counterpart of {@link sendEd25519}:
1420
+ *
1421
+ * 1. **START** (`POST …/send-session`): the platform runs the transfer gates, builds the unsigned
1422
+ * tx, puts its own party on the relay in the background, and returns the 32-byte digest plus
1423
+ * the relay coordination. It does NOT wait for the ceremony.
1424
+ * 2. **CO-SIGN**: this device runs its half over the relay with its stored share; cggmp24 hands
1425
+ * the completed signature to both parties.
1426
+ * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the platform verifies the signature
1427
+ * (recovers to the wallet key AND equals its own party's) and embeds it into the wire tx.
1428
+ *
1429
+ * Every device-side precondition is resolved BEFORE the START (see {@link loadDeviceParty}), so a
1430
+ * device that cannot co-sign costs nothing: no tx is built and no platform party is left waiting.
1431
+ *
1432
+ * **A co-sign that fails after a successful START rejects with its typed error.** START is not the
1433
+ * commit point — it yields an *unsigned* tx and a pending session, and nothing broadcastable exists
1434
+ * until ASSEMBLE returns a `signedTx` — so there is no result to salvage by swallowing the failure,
1435
+ * and no fallback to `POST /send` (the platform cannot reach the threshold on this wallet alone, so
1436
+ * a retry there would only hang). The backend expires the abandoned session and fails the tx row.
1437
+ */
1438
+ async sendWithDevice(params, options, quorum) {
1439
+ const { signal } = options;
1440
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "sending");
1441
+ throwIfAborted(signal);
1442
+ const startBody = { ...params };
1443
+ await this.attachStepUp(startBody, "send", options);
1444
+ throwIfAborted(signal);
1445
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1446
+ const digest = sendPayload(session, "digest");
1447
+ throwIfAborted(signal);
1448
+ const { signature } = await this.runDeviceSign(mpc, toSendCeremony(session), keyShare, digest);
1449
+ throwIfAborted(signal);
1450
+ const assemble = { signature };
1451
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/send-session/${session.txId}/assemble`, assemble, signal);
1452
+ this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1453
+ return res;
1454
+ }
1130
1455
  /**
1131
1456
  * Device-co-signed send for an ed25519 (FROST) wallet (#110) — the browser holds the device FROST
1132
1457
  * share and co-signs 2-party with the backend `server` party over the relay:
1133
1458
  *
1134
1459
  * 1. **START** (`POST …/send-session`): the backend builds the unsigned tx (chain adapter), starts
1135
1460
  * its server FROST party on the relay in the background, and returns the raw `message` bytes to
1136
- * sign + the relay coordination ({@link EddsaSendSession}).
1461
+ * sign + the relay coordination ({@link SendSessionResponse}).
1137
1462
  * 2. **CO-SIGN**: the device runs `signEddsa` over the relay with its stored `{keyPackage,
1138
1463
  * publicKeyPackage}` share; the two parties aggregate the RFC 8032 signature (returned locally).
1139
1464
  * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the backend embeds the aggregated
@@ -1168,6 +1493,7 @@ var Wallet = class {
1168
1493
  await this.attachStepUp(startBody, "send", options);
1169
1494
  throwIfAborted(signal);
1170
1495
  const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1496
+ const message = sendPayload(session, "message");
1171
1497
  throwIfAborted(signal);
1172
1498
  let signature;
1173
1499
  try {
@@ -1179,7 +1505,7 @@ var Wallet = class {
1179
1505
  keyPackage,
1180
1506
  publicKeyPackage,
1181
1507
  participants: session.participants,
1182
- message: session.message,
1508
+ message,
1183
1509
  relayToken: session.relayToken
1184
1510
  }));
1185
1511
  } catch (cause) {
@@ -1228,6 +1554,26 @@ async function attachPasskeyStepUp(http, walletId, body, operation, options) {
1228
1554
  body["passkeyAssertion"] = await getSigningAssertion(challenge, { credentialId: options.passkeyCredentialId });
1229
1555
  body["passkeyChallengeId"] = challengeId;
1230
1556
  }
1557
+ function toSendCeremony(session) {
1558
+ return {
1559
+ curve: session.curve,
1560
+ relayUrl: session.relayUrl,
1561
+ sessionId: session.sessionId,
1562
+ roles: session.roles,
1563
+ participants: session.participants,
1564
+ signerPosition: session.signerPosition,
1565
+ relayToken: session.relayToken
1566
+ };
1567
+ }
1568
+ function sendPayload(session, field) {
1569
+ const payload = session[field];
1570
+ if (!payload) {
1571
+ throw new WaaskeyError(`The send session did not return the \`${field}\` this wallet's curve (${session.curve}) signs.`, "sign_failed", {
1572
+ details: { txId: session.txId, curve: session.curve }
1573
+ });
1574
+ }
1575
+ return payload;
1576
+ }
1231
1577
  function normalizeDigest(digest) {
1232
1578
  const hex = digest.startsWith("0x") || digest.startsWith("0X") ? digest.slice(2) : digest;
1233
1579
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
@@ -1239,7 +1585,7 @@ function normalizeDigest(digest) {
1239
1585
  // src/wallets.ts
1240
1586
  var BACKUP_REGISTER_ATTEMPTS = 3;
1241
1587
  var BACKUP_REGISTER_BACKOFF_MS = 200;
1242
- var USER_BACKUP_ROLE = "user_backup";
1588
+ var USER_BACKUP_ROLE2 = "user_backup";
1243
1589
  var DEFAULT_ACTIVATION_TIMEOUT_MS = 6e4;
1244
1590
  var DEFAULT_POLL_INTERVAL_MS = 1e3;
1245
1591
  var Wallets = class {
@@ -1551,7 +1897,7 @@ var Wallets = class {
1551
1897
  */
1552
1898
  async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
1553
1899
  const extras = ceremony.additionalParties ?? [];
1554
- const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE);
1900
+ const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE2);
1555
1901
  if (unsupported.length > 0) {
1556
1902
  throw new WaaskeyError(
1557
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.`,
@@ -1559,7 +1905,7 @@ var Wallets = class {
1559
1905
  { details: { roles: unsupported.map((party) => party.role) } }
1560
1906
  );
1561
1907
  }
1562
- const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE);
1908
+ const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE2);
1563
1909
  if (!userBackupParty) {
1564
1910
  const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
1565
1911
  throwIfAborted(signal);
@@ -1679,8 +2025,8 @@ var Wallets = class {
1679
2025
  const { signal } = options;
1680
2026
  throwIfAborted(signal);
1681
2027
  const digest = normalizeDigest(params.digest);
1682
- const ciphertext = await this.fetchUserBackupCiphertext(walletId, params, signal);
1683
- const share = await restoreUserBackupShare(ciphertext, params.recoveryCode);
2028
+ const backup = await this.fetchUserBackup(walletId, params, signal);
2029
+ const share = await restoreUserBackupShare(backup, params);
1684
2030
  throwIfAborted(signal);
1685
2031
  const body = { digest };
1686
2032
  if (params.chainId !== void 0) body["chainId"] = params.chainId;
@@ -1690,7 +2036,7 @@ var Wallets = class {
1690
2036
  throwIfAborted(signal);
1691
2037
  let signature;
1692
2038
  try {
1693
- ({ signature } = await mpc.runSign(toUserBackupSignParams(session, share, digest)));
2039
+ ({ signature } = await mpc.runSign(toSessionSignParams(session, share, digest)));
1694
2040
  } catch (cause) {
1695
2041
  if (cause instanceof WaaskeyError) throw cause;
1696
2042
  throw new WaaskeyError("The user_backup device recover-sign ceremony failed.", "sign_failed", { cause });
@@ -1699,13 +2045,15 @@ var Wallets = class {
1699
2045
  return signature;
1700
2046
  }
1701
2047
  /**
1702
- * Retrieve the sealed `user_backup` ciphertext via the recovery gate ({@link fetchRecoveryCiphertext}),
1703
- * mapping a "no recovery share registered" (404) to the actionable `share_not_found` this wallet has no
1704
- * client-held `user_backup` backup to co-sign with (its device-loss recovery is the custodial path instead).
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).
1705
2053
  */
1706
- async fetchUserBackupCiphertext(walletId, params, signal) {
2054
+ async fetchUserBackup(walletId, params, signal) {
1707
2055
  try {
1708
- return await fetchRecoveryCiphertext(this.http, walletId, params, signal);
2056
+ return await fetchRecoveryBackup(this.http, walletId, params, signal);
1709
2057
  } catch (cause) {
1710
2058
  if (cause instanceof WaaskeyError && cause.code === "not_found") {
1711
2059
  throw new WaaskeyError(
@@ -1827,38 +2175,15 @@ var Wallets = class {
1827
2175
  }
1828
2176
  }
1829
2177
  };
1830
- async function restoreUserBackupShare(ciphertext, recoveryCode) {
2178
+ async function restoreUserBackupShare(backup, opener) {
1831
2179
  let blob;
1832
2180
  try {
1833
- blob = await openWithPassword(recoveryCode, ciphertext);
2181
+ blob = await openRecoveryBlob(backup, opener);
1834
2182
  } catch (cause) {
1835
- 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 });
1836
2184
  }
1837
2185
  return deserializeShare(blob).keyShare;
1838
2186
  }
1839
- function toUserBackupSignParams(session, share, digest) {
1840
- if (session.participants.length !== 2 || session.signerPosition < 0 || session.signerPosition > 1) {
1841
- throw new WaaskeyError(
1842
- `Unexpected recover-sign descriptor: the {server, user_backup} quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${session.participants.length} participants, signerPosition ${session.signerPosition}).`,
1843
- "sign_failed",
1844
- { details: { participants: session.participants, signerPosition: session.signerPosition } }
1845
- );
1846
- }
1847
- return {
1848
- curve: toMpcCurve(session.curve),
1849
- relayUrl: session.relayUrl,
1850
- sessionId: session.sessionId,
1851
- role: session.role,
1852
- peerRole: session.peerRole,
1853
- partyIndex: session.signerPosition,
1854
- peerPartyIndex: 1 - session.signerPosition,
1855
- relayToken: session.relayToken,
1856
- share,
1857
- participants: session.participants,
1858
- signerPosition: session.signerPosition,
1859
- digest
1860
- };
1861
- }
1862
2187
  function memberShareKey(walletId, membershipId) {
1863
2188
  return `${walletId}@member-${membershipId}`;
1864
2189
  }
@@ -1875,7 +2200,17 @@ function parseBackupPayload(stored, walletId) {
1875
2200
  if (typeof parsed.ciphertext !== "string" || !Array.isArray(parsed.factors)) {
1876
2201
  throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed.`, "backup_failed", { details: { walletId, keys: Object.keys(parsed) } });
1877
2202
  }
1878
- return { ciphertext: parsed.ciphertext, factors: parsed.factors };
2203
+ return { ciphertext: parsed.ciphertext, factors: parsed.factors.map((factor) => normalizeStoredFactor(factor, walletId)) };
2204
+ }
2205
+ function normalizeStoredFactor(factor, walletId) {
2206
+ const { type, credential, credentialHash } = factor ?? {};
2207
+ const value = typeof credential === "string" && credential !== "" ? credential : credentialHash;
2208
+ if (typeof type !== "string" || type === "" || typeof value !== "string" || value === "") {
2209
+ throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed \u2014 a factor entry is missing its type or credential.`, "backup_failed", {
2210
+ details: { walletId, factorKeys: Object.keys(factor ?? {}) }
2211
+ });
2212
+ }
2213
+ return { type, credential: value };
1879
2214
  }
1880
2215
  function membershipIdFromRole(role) {
1881
2216
  const prefix = "member:";
@@ -1967,13 +2302,12 @@ function isReadyMemberSignCeremony(ceremony) {
1967
2302
  }
1968
2303
 
1969
2304
  // src/client.ts
1970
- var DEFAULT_BASE_URL = "https://api.waaskey.com";
1971
2305
  var Waaskey = class {
1972
2306
  /** The `wallets` resource. */
1973
2307
  wallets;
1974
2308
  /** The `recovery` resource — multi-factor, client-encrypted wallet recovery. */
1975
2309
  recovery;
1976
- /** The `reshare` resource — device-side completion of a device-retaining reshare (#318). */
2310
+ /** The `reshare` resource — this device's half of a committee rotation (#488) and the aux-completion that follows (#318). */
1977
2311
  reshare;
1978
2312
  /** The `balances` resource — client-side balance reads from a chain provider (no backend). */
1979
2313
  balances;
@@ -1989,7 +2323,10 @@ var Waaskey = class {
1989
2323
  if (!options?.apiKey) {
1990
2324
  throw new Error("Waaskey: `apiKey` is required.");
1991
2325
  }
1992
- const http = new HttpClient(options.apiKey, options.baseUrl ?? DEFAULT_BASE_URL, options.fetch);
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);
1993
2330
  const analytics = new Analytics(resolveSink(options.analytics, http));
1994
2331
  this.auth = new Auth(http);
1995
2332
  this.members = new Members(http);
@@ -2024,6 +2361,15 @@ function resolveSink(analytics, http) {
2024
2361
  return analytics ?? new HttpAnalyticsSink(http);
2025
2362
  }
2026
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
+
2027
2373
  // src/mpc/wasm-core.ts
2028
2374
  var WasmMpcCore = class {
2029
2375
  constructor(load) {
@@ -2089,7 +2435,10 @@ var WasmMpcCore = class {
2089
2435
  new_threshold: params.newThreshold,
2090
2436
  wallet: params.wallet,
2091
2437
  commitments: params.commitments,
2092
- 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
2093
2442
  })
2094
2443
  );
2095
2444
  if (!raw || typeof raw.core_json !== "string") {
@@ -2097,6 +2446,26 @@ var WasmMpcCore = class {
2097
2446
  }
2098
2447
  return { core: raw.core_json, sharedPublicKey: decodePublicKey(raw.shared_public_key_json) };
2099
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
+ }
2100
2469
  async runCompleteReshare(params) {
2101
2470
  const wasm = await this.init();
2102
2471
  if (!wasm.completeReshare) {
@@ -2332,7 +2701,7 @@ function decodePublicKey(sharedPublicKeyJson) {
2332
2701
 
2333
2702
  // src/mpc/load-wasm.ts
2334
2703
  var CLIENT_WASM_PACKAGE = "@waaskey/client-wasm";
2335
- var CLIENT_WASM_VERSION = "0.2.1";
2704
+ var CLIENT_WASM_VERSION = "0.2.2";
2336
2705
  async function verifyWasmIntegrity(bytes, expectedSha384) {
2337
2706
  if (!expectedSha384 || !expectedSha384.startsWith("sha384-")) {
2338
2707
  throw new Error("Waaskey: an expected SHA-384 integrity hash (sha384-<base64>) is required to load the wasm MPC core.");
@@ -2797,6 +3166,10 @@ exports.isPasskeySupported = isPasskeySupported;
2797
3166
  exports.isPrfSupported = isPrfSupported;
2798
3167
  exports.loadClientWasm = loadClientWasm;
2799
3168
  exports.memberShareKey = memberShareKey;
3169
+ exports.openRecoveryBackup = openRecoveryBackup;
3170
+ exports.passkeyFactorEnrollment = passkeyFactorEnrollment;
3171
+ exports.passkeyFactorVerification = passkeyFactorVerification;
3172
+ exports.sealRecoveryBackup = sealRecoveryBackup;
2800
3173
  exports.userBackupPendingKey = userBackupPendingKey;
2801
3174
  exports.validateCustodyPolicy = validateCustodyPolicy;
2802
3175
  exports.verifyWasmIntegrity = verifyWasmIntegrity;