@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/README.md +3 -1
- package/dist/index.cjs +508 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +538 -119
- package/dist/index.d.ts +538 -119
- package/dist/index.js +505 -136
- package/dist/index.js.map +1 -1
- package/dist/node.d.ts +60 -2
- package/dist/node.js +24 -1
- package/dist/node.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -517,6 +517,17 @@ async function deriveKey(secret, salt) {
|
|
|
517
517
|
"decrypt"
|
|
518
518
|
]);
|
|
519
519
|
}
|
|
520
|
+
async function deriveKeyFromBytes(material, salt, info) {
|
|
521
|
+
const s = subtle();
|
|
522
|
+
const baseKey = await s.importKey("raw", material, "HKDF", false, ["deriveKey"]);
|
|
523
|
+
return s.deriveKey(
|
|
524
|
+
{ name: "HKDF", hash: "SHA-256", salt, info: new TextEncoder().encode(info) },
|
|
525
|
+
baseKey,
|
|
526
|
+
{ name: "AES-GCM", length: AES_KEY_BITS },
|
|
527
|
+
false,
|
|
528
|
+
["encrypt", "decrypt"]
|
|
529
|
+
);
|
|
530
|
+
}
|
|
520
531
|
async function seal(key, plaintext) {
|
|
521
532
|
const iv = randomBytes(IV_BYTES);
|
|
522
533
|
const data = new TextEncoder().encode(plaintext);
|
|
@@ -559,6 +570,60 @@ function base64ToBytes(base64) {
|
|
|
559
570
|
return bytes;
|
|
560
571
|
}
|
|
561
572
|
|
|
573
|
+
// src/recovery-envelope.ts
|
|
574
|
+
var KEK_INFO = "waaskey-backup-kek-v1";
|
|
575
|
+
var DEK_INFO = "waaskey-backup-dek-v1";
|
|
576
|
+
async function sealRecoveryBackup(share, recoveryCode, passkey) {
|
|
577
|
+
const dataKeyBytes = randomBytes(32);
|
|
578
|
+
const dataKey = await deriveKeyFromBytes(dataKeyBytes, new Uint8Array(0), DEK_INFO);
|
|
579
|
+
const ciphertext = await seal(dataKey, share);
|
|
580
|
+
const codeSalt = freshSalt();
|
|
581
|
+
const keyWraps = [
|
|
582
|
+
{
|
|
583
|
+
method: "passphrase",
|
|
584
|
+
wrapped: await seal(await deriveKey(recoveryCode, codeSalt), bytesToBase64(dataKeyBytes)),
|
|
585
|
+
salt: bytesToBase64(codeSalt)
|
|
586
|
+
}
|
|
587
|
+
];
|
|
588
|
+
if (passkey) {
|
|
589
|
+
const prfSalt = base64ToBytes(passkey.salt);
|
|
590
|
+
keyWraps.push({
|
|
591
|
+
method: "passkey_prf",
|
|
592
|
+
wrapped: await seal(await deriveKeyFromBytes(base64ToBytes(passkey.secret), prfSalt, KEK_INFO), bytesToBase64(dataKeyBytes)),
|
|
593
|
+
salt: passkey.salt,
|
|
594
|
+
credentialId: passkey.credentialId
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return { ciphertext, keyWraps };
|
|
598
|
+
}
|
|
599
|
+
async function openRecoveryBackup(envelope, opener) {
|
|
600
|
+
const prfWrap = envelope.keyWraps.find((w) => w.method === "passkey_prf");
|
|
601
|
+
if (opener.passkeySecret !== void 0 && prfWrap) {
|
|
602
|
+
const kek = await deriveKeyFromBytes(base64ToBytes(opener.passkeySecret), base64ToBytes(prfWrap.salt), KEK_INFO);
|
|
603
|
+
return openWith(envelope.ciphertext, prfWrap, kek, "passkey");
|
|
604
|
+
}
|
|
605
|
+
const codeWrap = envelope.keyWraps.find((w) => w.method === "passphrase");
|
|
606
|
+
if (opener.recoveryCode !== void 0 && codeWrap) {
|
|
607
|
+
const kek = await deriveKey(opener.recoveryCode, base64ToBytes(codeWrap.salt));
|
|
608
|
+
return openWith(envelope.ciphertext, codeWrap, kek, "recovery code");
|
|
609
|
+
}
|
|
610
|
+
const enrolled = envelope.keyWraps.map((w) => w.method).join(", ") || "none";
|
|
611
|
+
throw new WaaskeyError(`No key available to open this backup \u2014 it is wrapped for [${enrolled}] and none of those was supplied.`, "recovery_failed");
|
|
612
|
+
}
|
|
613
|
+
async function openWith(ciphertext, wrap, kek, label) {
|
|
614
|
+
let dataKeyBytes;
|
|
615
|
+
try {
|
|
616
|
+
dataKeyBytes = base64ToBytes(await open(kek, wrap.wrapped));
|
|
617
|
+
} catch (cause) {
|
|
618
|
+
throw new WaaskeyError(`Could not unwrap the backup key with the ${label} \u2014 wrong ${label}?`, "recovery_failed", { cause });
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
return await open(await deriveKeyFromBytes(dataKeyBytes, new Uint8Array(0), DEK_INFO), ciphertext);
|
|
622
|
+
} catch (cause) {
|
|
623
|
+
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 });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
562
627
|
// src/recovery.ts
|
|
563
628
|
var MIN_FACTORS = 3;
|
|
564
629
|
var Recovery = class {
|
|
@@ -596,7 +661,7 @@ var Recovery = class {
|
|
|
596
661
|
{ challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
|
|
597
662
|
options.signal
|
|
598
663
|
);
|
|
599
|
-
const share = await this.decrypt(res
|
|
664
|
+
const share = await this.decrypt(res, params);
|
|
600
665
|
await this.deps.shareStore?.put(walletId, share);
|
|
601
666
|
this.deps.analytics?.track("wallet.recovered", { walletId });
|
|
602
667
|
return { share, refreshedAt: res.refreshedAt };
|
|
@@ -611,22 +676,17 @@ var Recovery = class {
|
|
|
611
676
|
* device-loss so a leaked backup can't be replayed against a still-valid old share.
|
|
612
677
|
*/
|
|
613
678
|
async retrieveShare(walletId, params, options = {}) {
|
|
614
|
-
|
|
615
|
-
return this.decrypt(ciphertext, params.recoveryCode);
|
|
679
|
+
return this.decrypt(await fetchRecoveryBackup(this.http, walletId, params, options.signal), params);
|
|
616
680
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
return await openWithPassword(recoveryCode, ciphertext);
|
|
620
|
-
} catch (cause) {
|
|
621
|
-
throw new WaaskeyError("Could not decrypt the recovery share \u2014 wrong recovery code?", "recovery_failed", { cause });
|
|
622
|
-
}
|
|
681
|
+
decrypt(backup, params) {
|
|
682
|
+
return openRecoveryBlob(backup, params);
|
|
623
683
|
}
|
|
624
684
|
};
|
|
625
685
|
async function buildRecoveryRegistration(params) {
|
|
626
686
|
const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
|
|
627
|
-
const
|
|
687
|
+
const backup = params.passkey ? await sealRecoveryBackup(params.share, recoveryCode, params.passkey) : { ciphertext: await sealWithPassword(recoveryCode, params.share), keyWraps: void 0 };
|
|
628
688
|
const factors = [
|
|
629
|
-
{ type: "recovery_code",
|
|
689
|
+
{ type: "recovery_code", credential: await sha256Hex(recoveryCode) },
|
|
630
690
|
{ type: "totp", credential: params.totpSecret },
|
|
631
691
|
{ type: "email_otp", credential: params.email },
|
|
632
692
|
...params.extraFactors ?? []
|
|
@@ -634,19 +694,31 @@ async function buildRecoveryRegistration(params) {
|
|
|
634
694
|
if (factors.length < MIN_FACTORS) {
|
|
635
695
|
throw new WaaskeyError(`Recovery requires at least ${MIN_FACTORS} factors.`, "validation");
|
|
636
696
|
}
|
|
637
|
-
return { recoveryCode, payload: { ciphertext, factors } };
|
|
697
|
+
return { recoveryCode, payload: { ciphertext: backup.ciphertext, factors, ...backup.keyWraps ? { keyWraps: backup.keyWraps } : {} } };
|
|
638
698
|
}
|
|
639
699
|
function postRecoveryRegistration(http, walletId, payload, signal) {
|
|
640
700
|
return http.request("POST", `/v1/wallets/${walletId}/recovery/register`, payload, signal);
|
|
641
701
|
}
|
|
642
|
-
async function
|
|
643
|
-
|
|
702
|
+
async function fetchRecoveryBackup(http, walletId, params, signal) {
|
|
703
|
+
return http.request(
|
|
644
704
|
"POST",
|
|
645
705
|
`/v1/wallets/${walletId}/recovery/verify`,
|
|
646
706
|
{ challengeId: params.challengeId, verifications: await hashRecoveryFactors(params.verifications) },
|
|
647
707
|
signal
|
|
648
708
|
);
|
|
649
|
-
|
|
709
|
+
}
|
|
710
|
+
async function openRecoveryBlob(backup, opener) {
|
|
711
|
+
if (backup.keyWraps?.length) {
|
|
712
|
+
return openRecoveryBackup({ ciphertext: backup.ciphertext, keyWraps: backup.keyWraps }, opener);
|
|
713
|
+
}
|
|
714
|
+
if (opener.recoveryCode === void 0) {
|
|
715
|
+
throw new WaaskeyError("This backup predates passkey wrapping and can only be opened with the recovery code.", "recovery_failed");
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
return await openWithPassword(opener.recoveryCode, backup.ciphertext);
|
|
719
|
+
} catch (cause) {
|
|
720
|
+
throw new WaaskeyError("Could not decrypt the recovery share \u2014 wrong recovery code?", "recovery_failed", { cause });
|
|
721
|
+
}
|
|
650
722
|
}
|
|
651
723
|
async function registerRecoveryShare(http, walletId, params, signal) {
|
|
652
724
|
const { recoveryCode, payload } = await buildRecoveryRegistration(params);
|
|
@@ -654,7 +726,7 @@ async function registerRecoveryShare(http, walletId, params, signal) {
|
|
|
654
726
|
return { recoveryCode, share };
|
|
655
727
|
}
|
|
656
728
|
async function hashRecoveryFactors(verifications) {
|
|
657
|
-
return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && v.token
|
|
729
|
+
return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && typeof v.token === "string" ? { type: v.type, token: await sha256Hex(v.token) } : v));
|
|
658
730
|
}
|
|
659
731
|
function generateRecoveryCode() {
|
|
660
732
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
@@ -692,6 +764,47 @@ function toMpcCurve(curve) {
|
|
|
692
764
|
}
|
|
693
765
|
return curve;
|
|
694
766
|
}
|
|
767
|
+
var DEVICE_ENC_STORE_KEY = "__waaskey_device_enc_v1__";
|
|
768
|
+
async function getOrCreateDeviceEncKeypair(shareStore) {
|
|
769
|
+
const existing = await shareStore.get(DEVICE_ENC_STORE_KEY);
|
|
770
|
+
if (existing !== null) return parseDeviceEncKeypair(existing);
|
|
771
|
+
const keypair = generateDeviceEncKeypair();
|
|
772
|
+
await shareStore.put(DEVICE_ENC_STORE_KEY, JSON.stringify(keypair));
|
|
773
|
+
return keypair;
|
|
774
|
+
}
|
|
775
|
+
function generateDeviceEncKeypair() {
|
|
776
|
+
const secret = x25519.utils.randomSecretKey();
|
|
777
|
+
const publicKey = x25519.getPublicKey(secret);
|
|
778
|
+
if (isAllZero(publicKey)) {
|
|
779
|
+
throw new WaaskeyError("Generated an all-zero X25519 encryption public key \u2014 refusing to seal keygen packages to it.", "keygen_failed");
|
|
780
|
+
}
|
|
781
|
+
return { secretHex: bytesToHex(secret), publicHex: bytesToHex(publicKey) };
|
|
782
|
+
}
|
|
783
|
+
function parseDeviceEncKeypair(blob) {
|
|
784
|
+
let parsed;
|
|
785
|
+
try {
|
|
786
|
+
parsed = JSON.parse(blob);
|
|
787
|
+
} catch (cause) {
|
|
788
|
+
throw new WaaskeyError("Stored device encryption keypair is corrupt \u2014 it is not valid JSON.", "keygen_failed", { cause });
|
|
789
|
+
}
|
|
790
|
+
if (!isHex32(parsed.secretHex) || !isHex32(parsed.publicHex)) {
|
|
791
|
+
throw new WaaskeyError("Stored device encryption keypair is malformed \u2014 expected 32-byte (64 hex) secret + public keys.", "keygen_failed", {
|
|
792
|
+
details: { keys: Object.keys(parsed) }
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
return { secretHex: parsed.secretHex, publicHex: parsed.publicHex };
|
|
796
|
+
}
|
|
797
|
+
function isHex32(v) {
|
|
798
|
+
return typeof v === "string" && /^[0-9a-fA-F]{64}$/.test(v);
|
|
799
|
+
}
|
|
800
|
+
function isAllZero(bytes) {
|
|
801
|
+
return bytes.every((byte) => byte === 0);
|
|
802
|
+
}
|
|
803
|
+
function bytesToHex(bytes) {
|
|
804
|
+
let hex = "";
|
|
805
|
+
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
806
|
+
return hex;
|
|
807
|
+
}
|
|
695
808
|
|
|
696
809
|
// src/internal.ts
|
|
697
810
|
function throwIfAborted(signal) {
|
|
@@ -717,6 +830,7 @@ function delay(ms, signal) {
|
|
|
717
830
|
}
|
|
718
831
|
|
|
719
832
|
// src/reshare.ts
|
|
833
|
+
var USER_BACKUP_ROLE = "user_backup";
|
|
720
834
|
var Reshare = class {
|
|
721
835
|
constructor(http, deps = {}) {
|
|
722
836
|
this.http = http;
|
|
@@ -724,6 +838,116 @@ var Reshare = class {
|
|
|
724
838
|
}
|
|
725
839
|
http;
|
|
726
840
|
deps;
|
|
841
|
+
/**
|
|
842
|
+
* Rotate the wallet onto a NEW committee, keeping its address, and drive this device's whole half of
|
|
843
|
+
* the ceremony (#488).
|
|
844
|
+
*
|
|
845
|
+
* The client is not a bystander here: the platform holds ONE share and cannot reach the threshold, so
|
|
846
|
+
* without this device's deal there is no ceremony at all.
|
|
847
|
+
*
|
|
848
|
+
* 1. Open the ceremony with the committee asked for (an empty request re-issues the current one).
|
|
849
|
+
* 2. DEAL from this device's current-epoch share — pure and local; every sub-share leaves sealed.
|
|
850
|
+
* 3. For each CLIENT-held new holder — identified by custody KIND, never by role name — fetch its
|
|
851
|
+
* material and assemble its new-epoch core locally, refusing any share that reproduces a different
|
|
852
|
+
* public key.
|
|
853
|
+
* 4. Acknowledge each one. The `user_backup` holder's ack carries its new share re-sealed under the
|
|
854
|
+
* user's EXISTING recovery code, so the wallet's no-lock-out property survives the rotation; the
|
|
855
|
+
* last acknowledgement drives the backend's atomic cutover.
|
|
856
|
+
*
|
|
857
|
+
* The assembled cores are BARE (no aux): the wallet is `reshareAuxPending` until aux-completion, which
|
|
858
|
+
* is {@link complete}'s job — and which re-assembles from the material itself, so nothing here needs
|
|
859
|
+
* to be kept on the device between the two. Nothing here can leave the device worse off — the old-epoch share is
|
|
860
|
+
* untouched, and a ceremony that fails cuts nothing over.
|
|
861
|
+
*/
|
|
862
|
+
async rotate(walletId, params = {}, options = {}) {
|
|
863
|
+
const { mpc, shareStore } = this.deps;
|
|
864
|
+
if (!mpc || !shareStore) {
|
|
865
|
+
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");
|
|
866
|
+
}
|
|
867
|
+
if (!mpc.runReshareDeal || !mpc.runReshareAssemble) {
|
|
868
|
+
throw new WaaskeyError("The configured MPC core does not support reshare \u2014 use a client-wasm built with the `reshare` feature.", "unsupported");
|
|
869
|
+
}
|
|
870
|
+
const { signal } = options;
|
|
871
|
+
throwIfAborted(signal);
|
|
872
|
+
const wallet = await this.http.request("GET", `/v1/wallets/${walletId}`, void 0, signal);
|
|
873
|
+
if (!wallet.publicKey) {
|
|
874
|
+
throw new WaaskeyError("The wallet has no public key yet \u2014 only an ACTIVE wallet can be reshared.", "reshare_failed", { details: { walletId } });
|
|
875
|
+
}
|
|
876
|
+
const share = await shareStore.get(epochShareKey(walletId, wallet.keyEpoch));
|
|
877
|
+
if (!share) {
|
|
878
|
+
throw new WaaskeyError(`This device holds no share for wallet "${walletId}" at epoch ${wallet.keyEpoch} \u2014 it cannot deal the reshare.`, "reshare_failed");
|
|
879
|
+
}
|
|
880
|
+
throwIfAborted(signal);
|
|
881
|
+
const ceremony = await this.http.request("POST", `/v1/wallets/${walletId}/reshare-embedded`, params, signal);
|
|
882
|
+
const encKeypair = await getOrCreateDeviceEncKeypair(shareStore);
|
|
883
|
+
const curve = toMpcCurve(ceremony.curve);
|
|
884
|
+
const clientHolders = ceremony.newParties.filter((_role, i) => isClientCustody(ceremony.newCustodyKinds[i]));
|
|
885
|
+
try {
|
|
886
|
+
throwIfAborted(signal);
|
|
887
|
+
const dealt = await mpc.runReshareDeal({
|
|
888
|
+
curve,
|
|
889
|
+
share,
|
|
890
|
+
quorumIndices: ceremony.quorumIndices,
|
|
891
|
+
newPreimages: ceremony.newPreimages,
|
|
892
|
+
newThreshold: ceremony.newThreshold,
|
|
893
|
+
recipientPubkeys: ceremony.recipientPubkeys
|
|
894
|
+
});
|
|
895
|
+
await this.http.request(
|
|
896
|
+
"POST",
|
|
897
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/deal`,
|
|
898
|
+
{ commitments: dealt.commitments, sealedSubShares: dealt.sealedSubShares },
|
|
899
|
+
signal
|
|
900
|
+
);
|
|
901
|
+
let { status } = ceremony;
|
|
902
|
+
for (const role of clientHolders) {
|
|
903
|
+
throwIfAborted(signal);
|
|
904
|
+
const material = await this.http.request(
|
|
905
|
+
"GET",
|
|
906
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/assembly?role=${encodeURIComponent(role)}`,
|
|
907
|
+
void 0,
|
|
908
|
+
signal
|
|
909
|
+
);
|
|
910
|
+
const assembled = await mpc.runReshareAssemble({
|
|
911
|
+
curve,
|
|
912
|
+
newPosition: material.newPosition,
|
|
913
|
+
newPreimages: material.newPreimages,
|
|
914
|
+
newThreshold: material.newThreshold,
|
|
915
|
+
wallet: material.wallet,
|
|
916
|
+
commitments: material.commitments,
|
|
917
|
+
subShares: material.subShares,
|
|
918
|
+
encryptionSecret: encKeypair.secretHex
|
|
919
|
+
});
|
|
920
|
+
assertPublicKey(assembled.sharedPublicKey, wallet.publicKey, walletId);
|
|
921
|
+
const body = role === USER_BACKUP_ROLE ? { backupCiphertext: await this.sealBackup(assembled.core, params.recoveryCode) } : {};
|
|
922
|
+
const ack = await this.http.request(
|
|
923
|
+
"POST",
|
|
924
|
+
`/v1/wallets/${walletId}/reshare-drop/${ceremony.id}/assembled?role=${encodeURIComponent(role)}`,
|
|
925
|
+
body,
|
|
926
|
+
signal
|
|
927
|
+
);
|
|
928
|
+
({ status } = ack);
|
|
929
|
+
}
|
|
930
|
+
this.deps.analytics?.track("wallet.reshared", { walletId, curve: ceremony.curve });
|
|
931
|
+
return { walletId, ceremonyId: ceremony.id, status, keyEpoch: ceremony.keyEpoch, assembledRoles: clientHolders, sharedPublicKey: wallet.publicKey };
|
|
932
|
+
} catch (cause) {
|
|
933
|
+
if (cause instanceof WaaskeyError) throw cause;
|
|
934
|
+
throw new WaaskeyError("The reshare ceremony failed.", "reshare_failed", { cause });
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
/**
|
|
938
|
+
* Re-seal the new `user_backup` core under the user's EXISTING recovery code. The code never leaves
|
|
939
|
+
* the device — only the ciphertext does — which is the same trust model registration has, and the
|
|
940
|
+
* reason the server cannot check WHICH code sealed it.
|
|
941
|
+
*/
|
|
942
|
+
async sealBackup(core, recoveryCode) {
|
|
943
|
+
if (!recoveryCode) {
|
|
944
|
+
throw new WaaskeyError(
|
|
945
|
+
"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(...)`.",
|
|
946
|
+
"reshare_failed"
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
return sealWithPassword(recoveryCode, core);
|
|
950
|
+
}
|
|
727
951
|
/**
|
|
728
952
|
* Complete this device's share for a device-retaining reshare and make the wallet signable on the
|
|
729
953
|
* device under the new epoch.
|
|
@@ -754,6 +978,7 @@ var Reshare = class {
|
|
|
754
978
|
if (!wallet.publicKey) {
|
|
755
979
|
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 } });
|
|
756
980
|
}
|
|
981
|
+
const encKeypair = await getOrCreateDeviceEncKeypair(shareStore);
|
|
757
982
|
throwIfAborted(signal);
|
|
758
983
|
let completed;
|
|
759
984
|
try {
|
|
@@ -764,7 +989,8 @@ var Reshare = class {
|
|
|
764
989
|
newThreshold: material.newThreshold,
|
|
765
990
|
wallet: material.wallet,
|
|
766
991
|
commitments: material.commitments,
|
|
767
|
-
subShares: material.subShares
|
|
992
|
+
subShares: material.subShares,
|
|
993
|
+
encryptionSecret: encKeypair.secretHex
|
|
768
994
|
});
|
|
769
995
|
assertPublicKey(assembled.sharedPublicKey, wallet.publicKey, walletId);
|
|
770
996
|
throwIfAborted(signal);
|
|
@@ -794,6 +1020,9 @@ var Reshare = class {
|
|
|
794
1020
|
function epochShareKey(walletId, keyEpoch) {
|
|
795
1021
|
return keyEpoch <= 1 ? walletId : `${walletId}@epoch-${keyEpoch}`;
|
|
796
1022
|
}
|
|
1023
|
+
function isClientCustody(kind) {
|
|
1024
|
+
return kind !== void 0 && kind !== "platform_signer" && kind !== "platform_recovery";
|
|
1025
|
+
}
|
|
797
1026
|
function assertPublicKey(actual, expected, walletId) {
|
|
798
1027
|
if (actual !== expected) {
|
|
799
1028
|
throw new WaaskeyError("The reshared share reproduced a DIFFERENT public key than the wallet \u2014 aborting (device share not stored).", "reshare_pubkey_mismatch", {
|
|
@@ -868,46 +1097,50 @@ function deserializeEddsaShare(blob) {
|
|
|
868
1097
|
}
|
|
869
1098
|
return { keyPackage: parsed.keyPackage, publicKeyPackage: parsed.publicKeyPackage };
|
|
870
1099
|
}
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
const publicKey = x25519.getPublicKey(secret);
|
|
882
|
-
if (isAllZero(publicKey)) {
|
|
883
|
-
throw new WaaskeyError("Generated an all-zero X25519 encryption public key \u2014 refusing to seal keygen packages to it.", "keygen_failed");
|
|
884
|
-
}
|
|
885
|
-
return { secretHex: bytesToHex(secret), publicHex: bytesToHex(publicKey) };
|
|
886
|
-
}
|
|
887
|
-
function parseDeviceEncKeypair(blob) {
|
|
888
|
-
let parsed;
|
|
889
|
-
try {
|
|
890
|
-
parsed = JSON.parse(blob);
|
|
891
|
-
} catch (cause) {
|
|
892
|
-
throw new WaaskeyError("Stored device encryption keypair is corrupt \u2014 it is not valid JSON.", "keygen_failed", { cause });
|
|
893
|
-
}
|
|
894
|
-
if (!isHex32(parsed.secretHex) || !isHex32(parsed.publicHex)) {
|
|
895
|
-
throw new WaaskeyError("Stored device encryption keypair is malformed \u2014 expected 32-byte (64 hex) secret + public keys.", "keygen_failed", {
|
|
896
|
-
details: { keys: Object.keys(parsed) }
|
|
897
|
-
});
|
|
1100
|
+
|
|
1101
|
+
// src/session-sign.ts
|
|
1102
|
+
function toDeviceSignParams(ceremony, share, digest) {
|
|
1103
|
+
const { roles, participants, signerPosition } = ceremony;
|
|
1104
|
+
if (participants.length !== 2 || roles.length !== 2 || signerPosition < 0 || signerPosition > 1) {
|
|
1105
|
+
throw new WaaskeyError(
|
|
1106
|
+
`Unexpected sign descriptor: the quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${participants.length} participants, ${roles.length} roles, signerPosition ${signerPosition}).`,
|
|
1107
|
+
"sign_failed",
|
|
1108
|
+
{ details: { roles, participants, signerPosition } }
|
|
1109
|
+
);
|
|
898
1110
|
}
|
|
899
|
-
|
|
1111
|
+
const peerPosition = 1 - signerPosition;
|
|
1112
|
+
return {
|
|
1113
|
+
curve: toMpcCurve(ceremony.curve),
|
|
1114
|
+
relayUrl: ceremony.relayUrl,
|
|
1115
|
+
sessionId: ceremony.sessionId,
|
|
1116
|
+
role: roles[signerPosition],
|
|
1117
|
+
peerRole: roles[peerPosition],
|
|
1118
|
+
partyIndex: signerPosition,
|
|
1119
|
+
peerPartyIndex: peerPosition,
|
|
1120
|
+
relayToken: ceremony.relayToken,
|
|
1121
|
+
share,
|
|
1122
|
+
participants,
|
|
1123
|
+
signerPosition,
|
|
1124
|
+
digest
|
|
1125
|
+
};
|
|
900
1126
|
}
|
|
901
|
-
function
|
|
902
|
-
return
|
|
1127
|
+
function toSessionSignParams(session, share, digest) {
|
|
1128
|
+
return toDeviceSignParams(
|
|
1129
|
+
{
|
|
1130
|
+
curve: session.curve,
|
|
1131
|
+
relayUrl: session.relayUrl,
|
|
1132
|
+
sessionId: session.sessionId,
|
|
1133
|
+
roles: sessionRoles(session),
|
|
1134
|
+
participants: session.participants,
|
|
1135
|
+
signerPosition: session.signerPosition,
|
|
1136
|
+
relayToken: session.relayToken
|
|
1137
|
+
},
|
|
1138
|
+
share,
|
|
1139
|
+
digest
|
|
1140
|
+
);
|
|
903
1141
|
}
|
|
904
|
-
function
|
|
905
|
-
return
|
|
906
|
-
}
|
|
907
|
-
function bytesToHex(bytes) {
|
|
908
|
-
let hex = "";
|
|
909
|
-
for (const byte of bytes) hex += byte.toString(16).padStart(2, "0");
|
|
910
|
-
return hex;
|
|
1142
|
+
function sessionRoles(session) {
|
|
1143
|
+
return session.signerPosition === 0 ? [session.role, session.peerRole] : [session.peerRole, session.role];
|
|
911
1144
|
}
|
|
912
1145
|
|
|
913
1146
|
// src/share-blob.ts
|
|
@@ -976,6 +1209,7 @@ async function getSigningAssertion(challenge, options = {}) {
|
|
|
976
1209
|
}
|
|
977
1210
|
|
|
978
1211
|
// src/wallet.ts
|
|
1212
|
+
var DEVICE_ROLE = "device";
|
|
979
1213
|
var Wallet = class {
|
|
980
1214
|
constructor(http, data, analytics, device = {}) {
|
|
981
1215
|
this.http = http;
|
|
@@ -1050,56 +1284,106 @@ var Wallet = class {
|
|
|
1050
1284
|
*/
|
|
1051
1285
|
async sign(digest, options = {}) {
|
|
1052
1286
|
const message = normalizeDigest(digest);
|
|
1053
|
-
const
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1287
|
+
const quorum = this.signQuorum();
|
|
1288
|
+
const devicePosition = quorum?.indexOf(DEVICE_ROLE) ?? -1;
|
|
1289
|
+
if (devicePosition < 0) {
|
|
1290
|
+
const body = { message };
|
|
1291
|
+
await this.attachStepUp(body, "sign", options);
|
|
1292
|
+
const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
|
|
1293
|
+
this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
|
|
1294
|
+
return res.signature;
|
|
1295
|
+
}
|
|
1296
|
+
const { mpc, keyShare } = await this.loadDeviceParty(quorum, "signing");
|
|
1297
|
+
const startBody = { digest: message };
|
|
1298
|
+
await this.attachStepUp(startBody, "sign", options);
|
|
1299
|
+
const session = await this.http.request("POST", `/v1/wallets/${this.id}/sign-session`, startBody, options.signal);
|
|
1300
|
+
const { signature } = await this.runDeviceSign(mpc, this.toSignCeremony(session, quorum), keyShare, message);
|
|
1058
1301
|
this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
|
|
1059
|
-
return
|
|
1302
|
+
return signature;
|
|
1060
1303
|
}
|
|
1061
1304
|
/**
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
1065
|
-
* co-signing. Returns `undefined` when the quorum is platform-only (or the wallet is not secp) —
|
|
1066
|
-
* the POST then completes alone, unchanged. A device-present quorum without the device deps or
|
|
1067
|
-
* stored share fails fast with a typed error instead of a guaranteed server-side timeout.
|
|
1305
|
+
* The roles that will actually sign — the first `threshold` of the wallet's party list (#292),
|
|
1306
|
+
* mirroring the signer's own derivation. `undefined` when the topology is unknown (a legacy
|
|
1307
|
+
* wallet record), which callers treat as "let the server decide".
|
|
1068
1308
|
*/
|
|
1069
|
-
|
|
1309
|
+
signQuorum() {
|
|
1070
1310
|
if (this.data.curve === "ed25519") return void 0;
|
|
1071
1311
|
const roles = this.data.parties;
|
|
1072
1312
|
const { threshold } = this.data;
|
|
1073
1313
|
if (!Array.isArray(roles) || typeof threshold !== "number") return void 0;
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1314
|
+
return roles.slice(0, threshold);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Everything this device needs to join a ceremony, resolved BEFORE one is started (#89): the MPC
|
|
1318
|
+
* core, the routed-sign capability a >2-party quorum needs, and the stored key share.
|
|
1319
|
+
*
|
|
1320
|
+
* Each failure is a typed error thrown straight to the caller, and the ordering is the point: a
|
|
1321
|
+
* device that cannot co-sign must never leave the platform party waiting on the relay for a
|
|
1322
|
+
* counterpart that will never arrive. That wait ends at the party-runner timeout (~210s) and
|
|
1323
|
+
* reaches the caller as an opaque 5xx — minutes after a knowable, local cause.
|
|
1324
|
+
*/
|
|
1325
|
+
async loadDeviceParty(quorum, action) {
|
|
1077
1326
|
const { mpc, shareStore } = this.device;
|
|
1078
1327
|
if (!mpc || !shareStore) {
|
|
1079
1328
|
throw new WaaskeyError(
|
|
1080
|
-
`This wallet's
|
|
1329
|
+
`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(...)\`.`,
|
|
1081
1330
|
"device_core_required"
|
|
1082
1331
|
);
|
|
1083
1332
|
}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1333
|
+
if (quorum.length > 2 && !mpc.runMemberSign) {
|
|
1334
|
+
throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
|
|
1335
|
+
}
|
|
1336
|
+
const blob = await shareStore.get(this.id);
|
|
1337
|
+
if (!blob) {
|
|
1338
|
+
throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the signing quorum.`, "share_not_found");
|
|
1339
|
+
}
|
|
1340
|
+
const { keyShare } = deserializeShare(blob);
|
|
1341
|
+
return { mpc, keyShare };
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Normalize a sign-session descriptor into the shared {@link DeviceCeremony}. The 2-party roster
|
|
1345
|
+
* comes from the descriptor's own `role`/`peerRole` (authoritative — the relay token is bound to
|
|
1346
|
+
* that role); a larger quorum carries no server-sent roster, so the wallet's own party slice — the
|
|
1347
|
+
* same slice the signer derives — supplies it.
|
|
1348
|
+
*/
|
|
1349
|
+
toSignCeremony(session, quorum) {
|
|
1350
|
+
return {
|
|
1351
|
+
curve: session.curve,
|
|
1352
|
+
relayUrl: session.relayUrl,
|
|
1353
|
+
sessionId: session.sessionId,
|
|
1354
|
+
roles: session.participants.length === 2 ? sessionRoles(session) : quorum,
|
|
1355
|
+
participants: session.participants,
|
|
1356
|
+
signerPosition: session.signerPosition,
|
|
1357
|
+
relayToken: session.relayToken
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Run this device's half of a started ceremony. A 2-party quorum uses the plain single-peer
|
|
1362
|
+
* transport; anything larger MUST be roster-routed, or the transport attributes every inbound
|
|
1363
|
+
* message to the one configured peer and the protocol aborts on the third party's first message
|
|
1364
|
+
* (waas-core#131).
|
|
1365
|
+
*/
|
|
1366
|
+
runDeviceSign(mpc, ceremony, keyShare, digest) {
|
|
1367
|
+
if (ceremony.participants.length === 2) {
|
|
1368
|
+
return mpc.runSign(toDeviceSignParams(ceremony, keyShare, digest));
|
|
1369
|
+
}
|
|
1370
|
+
if (!mpc.runMemberSign) {
|
|
1371
|
+
throw new WaaskeyError(
|
|
1372
|
+
`This wallet's sign quorum has ${ceremony.participants.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`,
|
|
1373
|
+
"unsupported"
|
|
1374
|
+
);
|
|
1375
|
+
}
|
|
1376
|
+
return mpc.runMemberSign({
|
|
1377
|
+
curve: toMpcCurve(ceremony.curve),
|
|
1378
|
+
relayUrl: ceremony.relayUrl,
|
|
1379
|
+
sessionId: ceremony.sessionId,
|
|
1380
|
+
relayToken: ceremony.relayToken,
|
|
1381
|
+
roles: ceremony.roles,
|
|
1382
|
+
share: keyShare,
|
|
1383
|
+
participants: ceremony.participants,
|
|
1384
|
+
signerPosition: ceremony.signerPosition,
|
|
1385
|
+
digest
|
|
1386
|
+
});
|
|
1103
1387
|
}
|
|
1104
1388
|
/**
|
|
1105
1389
|
* Send a transaction from this wallet. The platform builds the chain-specific transaction
|
|
@@ -1119,19 +1403,60 @@ var Wallet = class {
|
|
|
1119
1403
|
if (this.data.curve === "ed25519") {
|
|
1120
1404
|
return this.sendEd25519(params, options);
|
|
1121
1405
|
}
|
|
1406
|
+
const quorum = this.signQuorum();
|
|
1407
|
+
if ((quorum?.indexOf(DEVICE_ROLE) ?? -1) >= 0) {
|
|
1408
|
+
return this.sendWithDevice(params, options, quorum);
|
|
1409
|
+
}
|
|
1122
1410
|
const body = { ...params };
|
|
1123
1411
|
await this.attachStepUp(body, "send", options);
|
|
1124
1412
|
const res = await this.http.request("POST", `/v1/wallets/${this.id}/send`, body, options.signal);
|
|
1125
1413
|
this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
|
|
1126
1414
|
return res;
|
|
1127
1415
|
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Device-co-signed send for a secp wallet — the cggmp24 counterpart of {@link sendEd25519}:
|
|
1418
|
+
*
|
|
1419
|
+
* 1. **START** (`POST …/send-session`): the platform runs the transfer gates, builds the unsigned
|
|
1420
|
+
* tx, puts its own party on the relay in the background, and returns the 32-byte digest plus
|
|
1421
|
+
* the relay coordination. It does NOT wait for the ceremony.
|
|
1422
|
+
* 2. **CO-SIGN**: this device runs its half over the relay with its stored share; cggmp24 hands
|
|
1423
|
+
* the completed signature to both parties.
|
|
1424
|
+
* 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the platform verifies the signature
|
|
1425
|
+
* (recovers to the wallet key AND equals its own party's) and embeds it into the wire tx.
|
|
1426
|
+
*
|
|
1427
|
+
* Every device-side precondition is resolved BEFORE the START (see {@link loadDeviceParty}), so a
|
|
1428
|
+
* device that cannot co-sign costs nothing: no tx is built and no platform party is left waiting.
|
|
1429
|
+
*
|
|
1430
|
+
* **A co-sign that fails after a successful START rejects with its typed error.** START is not the
|
|
1431
|
+
* commit point — it yields an *unsigned* tx and a pending session, and nothing broadcastable exists
|
|
1432
|
+
* until ASSEMBLE returns a `signedTx` — so there is no result to salvage by swallowing the failure,
|
|
1433
|
+
* and no fallback to `POST /send` (the platform cannot reach the threshold on this wallet alone, so
|
|
1434
|
+
* a retry there would only hang). The backend expires the abandoned session and fails the tx row.
|
|
1435
|
+
*/
|
|
1436
|
+
async sendWithDevice(params, options, quorum) {
|
|
1437
|
+
const { signal } = options;
|
|
1438
|
+
const { mpc, keyShare } = await this.loadDeviceParty(quorum, "sending");
|
|
1439
|
+
throwIfAborted(signal);
|
|
1440
|
+
const startBody = { ...params };
|
|
1441
|
+
await this.attachStepUp(startBody, "send", options);
|
|
1442
|
+
throwIfAborted(signal);
|
|
1443
|
+
const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
|
|
1444
|
+
const digest = sendPayload(session, "digest");
|
|
1445
|
+
throwIfAborted(signal);
|
|
1446
|
+
const { signature } = await this.runDeviceSign(mpc, toSendCeremony(session), keyShare, digest);
|
|
1447
|
+
throwIfAborted(signal);
|
|
1448
|
+
const assemble = { signature };
|
|
1449
|
+
const res = await this.http.request("POST", `/v1/wallets/${this.id}/send-session/${session.txId}/assemble`, assemble, signal);
|
|
1450
|
+
this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
|
|
1451
|
+
return res;
|
|
1452
|
+
}
|
|
1128
1453
|
/**
|
|
1129
1454
|
* Device-co-signed send for an ed25519 (FROST) wallet (#110) — the browser holds the device FROST
|
|
1130
1455
|
* share and co-signs 2-party with the backend `server` party over the relay:
|
|
1131
1456
|
*
|
|
1132
1457
|
* 1. **START** (`POST …/send-session`): the backend builds the unsigned tx (chain adapter), starts
|
|
1133
1458
|
* its server FROST party on the relay in the background, and returns the raw `message` bytes to
|
|
1134
|
-
* sign + the relay coordination ({@link
|
|
1459
|
+
* sign + the relay coordination ({@link SendSessionResponse}).
|
|
1135
1460
|
* 2. **CO-SIGN**: the device runs `signEddsa` over the relay with its stored `{keyPackage,
|
|
1136
1461
|
* publicKeyPackage}` share; the two parties aggregate the RFC 8032 signature (returned locally).
|
|
1137
1462
|
* 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the backend embeds the aggregated
|
|
@@ -1166,6 +1491,7 @@ var Wallet = class {
|
|
|
1166
1491
|
await this.attachStepUp(startBody, "send", options);
|
|
1167
1492
|
throwIfAborted(signal);
|
|
1168
1493
|
const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
|
|
1494
|
+
const message = sendPayload(session, "message");
|
|
1169
1495
|
throwIfAborted(signal);
|
|
1170
1496
|
let signature;
|
|
1171
1497
|
try {
|
|
@@ -1177,7 +1503,7 @@ var Wallet = class {
|
|
|
1177
1503
|
keyPackage,
|
|
1178
1504
|
publicKeyPackage,
|
|
1179
1505
|
participants: session.participants,
|
|
1180
|
-
message
|
|
1506
|
+
message,
|
|
1181
1507
|
relayToken: session.relayToken
|
|
1182
1508
|
}));
|
|
1183
1509
|
} catch (cause) {
|
|
@@ -1226,6 +1552,26 @@ async function attachPasskeyStepUp(http, walletId, body, operation, options) {
|
|
|
1226
1552
|
body["passkeyAssertion"] = await getSigningAssertion(challenge, { credentialId: options.passkeyCredentialId });
|
|
1227
1553
|
body["passkeyChallengeId"] = challengeId;
|
|
1228
1554
|
}
|
|
1555
|
+
function toSendCeremony(session) {
|
|
1556
|
+
return {
|
|
1557
|
+
curve: session.curve,
|
|
1558
|
+
relayUrl: session.relayUrl,
|
|
1559
|
+
sessionId: session.sessionId,
|
|
1560
|
+
roles: session.roles,
|
|
1561
|
+
participants: session.participants,
|
|
1562
|
+
signerPosition: session.signerPosition,
|
|
1563
|
+
relayToken: session.relayToken
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
function sendPayload(session, field) {
|
|
1567
|
+
const payload = session[field];
|
|
1568
|
+
if (!payload) {
|
|
1569
|
+
throw new WaaskeyError(`The send session did not return the \`${field}\` this wallet's curve (${session.curve}) signs.`, "sign_failed", {
|
|
1570
|
+
details: { txId: session.txId, curve: session.curve }
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
return payload;
|
|
1574
|
+
}
|
|
1229
1575
|
function normalizeDigest(digest) {
|
|
1230
1576
|
const hex = digest.startsWith("0x") || digest.startsWith("0X") ? digest.slice(2) : digest;
|
|
1231
1577
|
if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
|
|
@@ -1237,7 +1583,7 @@ function normalizeDigest(digest) {
|
|
|
1237
1583
|
// src/wallets.ts
|
|
1238
1584
|
var BACKUP_REGISTER_ATTEMPTS = 3;
|
|
1239
1585
|
var BACKUP_REGISTER_BACKOFF_MS = 200;
|
|
1240
|
-
var
|
|
1586
|
+
var USER_BACKUP_ROLE2 = "user_backup";
|
|
1241
1587
|
var DEFAULT_ACTIVATION_TIMEOUT_MS = 6e4;
|
|
1242
1588
|
var DEFAULT_POLL_INTERVAL_MS = 1e3;
|
|
1243
1589
|
var Wallets = class {
|
|
@@ -1549,7 +1895,7 @@ var Wallets = class {
|
|
|
1549
1895
|
*/
|
|
1550
1896
|
async runSecpKeygen(mpc, shareStore, primePool, walletId, ceremony, curve, backup, signal) {
|
|
1551
1897
|
const extras = ceremony.additionalParties ?? [];
|
|
1552
|
-
const unsupported = extras.filter((party) => party.role !==
|
|
1898
|
+
const unsupported = extras.filter((party) => party.role !== USER_BACKUP_ROLE2);
|
|
1553
1899
|
if (unsupported.length > 0) {
|
|
1554
1900
|
throw new WaaskeyError(
|
|
1555
1901
|
`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.`,
|
|
@@ -1557,7 +1903,7 @@ var Wallets = class {
|
|
|
1557
1903
|
{ details: { roles: unsupported.map((party) => party.role) } }
|
|
1558
1904
|
);
|
|
1559
1905
|
}
|
|
1560
|
-
const userBackupParty = extras.find((party) => party.role ===
|
|
1906
|
+
const userBackupParty = extras.find((party) => party.role === USER_BACKUP_ROLE2);
|
|
1561
1907
|
if (!userBackupParty) {
|
|
1562
1908
|
const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
|
|
1563
1909
|
throwIfAborted(signal);
|
|
@@ -1677,8 +2023,8 @@ var Wallets = class {
|
|
|
1677
2023
|
const { signal } = options;
|
|
1678
2024
|
throwIfAborted(signal);
|
|
1679
2025
|
const digest = normalizeDigest(params.digest);
|
|
1680
|
-
const
|
|
1681
|
-
const share = await restoreUserBackupShare(
|
|
2026
|
+
const backup = await this.fetchUserBackup(walletId, params, signal);
|
|
2027
|
+
const share = await restoreUserBackupShare(backup, params);
|
|
1682
2028
|
throwIfAborted(signal);
|
|
1683
2029
|
const body = { digest };
|
|
1684
2030
|
if (params.chainId !== void 0) body["chainId"] = params.chainId;
|
|
@@ -1688,7 +2034,7 @@ var Wallets = class {
|
|
|
1688
2034
|
throwIfAborted(signal);
|
|
1689
2035
|
let signature;
|
|
1690
2036
|
try {
|
|
1691
|
-
({ signature } = await mpc.runSign(
|
|
2037
|
+
({ signature } = await mpc.runSign(toSessionSignParams(session, share, digest)));
|
|
1692
2038
|
} catch (cause) {
|
|
1693
2039
|
if (cause instanceof WaaskeyError) throw cause;
|
|
1694
2040
|
throw new WaaskeyError("The user_backup device recover-sign ceremony failed.", "sign_failed", { cause });
|
|
@@ -1697,13 +2043,15 @@ var Wallets = class {
|
|
|
1697
2043
|
return signature;
|
|
1698
2044
|
}
|
|
1699
2045
|
/**
|
|
1700
|
-
* Retrieve the sealed `user_backup`
|
|
1701
|
-
*
|
|
1702
|
-
*
|
|
2046
|
+
* Retrieve the sealed `user_backup` backup via the recovery gate ({@link fetchRecoveryBackup}) — the
|
|
2047
|
+
* ciphertext AND its key wraps (#510), since a backup registered with a passkey is an envelope and
|
|
2048
|
+
* the wraps are what open it. Maps a "no recovery share registered" (404) to the actionable
|
|
2049
|
+
* `share_not_found` — this wallet has no client-held `user_backup` backup to co-sign with (its
|
|
2050
|
+
* device-loss recovery is the custodial path instead).
|
|
1703
2051
|
*/
|
|
1704
|
-
async
|
|
2052
|
+
async fetchUserBackup(walletId, params, signal) {
|
|
1705
2053
|
try {
|
|
1706
|
-
return await
|
|
2054
|
+
return await fetchRecoveryBackup(this.http, walletId, params, signal);
|
|
1707
2055
|
} catch (cause) {
|
|
1708
2056
|
if (cause instanceof WaaskeyError && cause.code === "not_found") {
|
|
1709
2057
|
throw new WaaskeyError(
|
|
@@ -1825,38 +2173,15 @@ var Wallets = class {
|
|
|
1825
2173
|
}
|
|
1826
2174
|
}
|
|
1827
2175
|
};
|
|
1828
|
-
async function restoreUserBackupShare(
|
|
2176
|
+
async function restoreUserBackupShare(backup, opener) {
|
|
1829
2177
|
let blob;
|
|
1830
2178
|
try {
|
|
1831
|
-
blob = await
|
|
2179
|
+
blob = await openRecoveryBlob(backup, opener);
|
|
1832
2180
|
} catch (cause) {
|
|
1833
|
-
throw new WaaskeyError("Could not open the user_backup backup \u2014 wrong recovery code?", "invalid_recovery_code", { cause });
|
|
2181
|
+
throw new WaaskeyError("Could not open the user_backup backup \u2014 wrong recovery code or passkey?", "invalid_recovery_code", { cause });
|
|
1834
2182
|
}
|
|
1835
2183
|
return deserializeShare(blob).keyShare;
|
|
1836
2184
|
}
|
|
1837
|
-
function toUserBackupSignParams(session, share, digest) {
|
|
1838
|
-
if (session.participants.length !== 2 || session.signerPosition < 0 || session.signerPosition > 1) {
|
|
1839
|
-
throw new WaaskeyError(
|
|
1840
|
-
`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}).`,
|
|
1841
|
-
"sign_failed",
|
|
1842
|
-
{ details: { participants: session.participants, signerPosition: session.signerPosition } }
|
|
1843
|
-
);
|
|
1844
|
-
}
|
|
1845
|
-
return {
|
|
1846
|
-
curve: toMpcCurve(session.curve),
|
|
1847
|
-
relayUrl: session.relayUrl,
|
|
1848
|
-
sessionId: session.sessionId,
|
|
1849
|
-
role: session.role,
|
|
1850
|
-
peerRole: session.peerRole,
|
|
1851
|
-
partyIndex: session.signerPosition,
|
|
1852
|
-
peerPartyIndex: 1 - session.signerPosition,
|
|
1853
|
-
relayToken: session.relayToken,
|
|
1854
|
-
share,
|
|
1855
|
-
participants: session.participants,
|
|
1856
|
-
signerPosition: session.signerPosition,
|
|
1857
|
-
digest
|
|
1858
|
-
};
|
|
1859
|
-
}
|
|
1860
2185
|
function memberShareKey(walletId, membershipId) {
|
|
1861
2186
|
return `${walletId}@member-${membershipId}`;
|
|
1862
2187
|
}
|
|
@@ -1873,7 +2198,17 @@ function parseBackupPayload(stored, walletId) {
|
|
|
1873
2198
|
if (typeof parsed.ciphertext !== "string" || !Array.isArray(parsed.factors)) {
|
|
1874
2199
|
throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed.`, "backup_failed", { details: { walletId, keys: Object.keys(parsed) } });
|
|
1875
2200
|
}
|
|
1876
|
-
return { ciphertext: parsed.ciphertext, factors: parsed.factors };
|
|
2201
|
+
return { ciphertext: parsed.ciphertext, factors: parsed.factors.map((factor) => normalizeStoredFactor(factor, walletId)) };
|
|
2202
|
+
}
|
|
2203
|
+
function normalizeStoredFactor(factor, walletId) {
|
|
2204
|
+
const { type, credential, credentialHash } = factor ?? {};
|
|
2205
|
+
const value = typeof credential === "string" && credential !== "" ? credential : credentialHash;
|
|
2206
|
+
if (typeof type !== "string" || type === "" || typeof value !== "string" || value === "") {
|
|
2207
|
+
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", {
|
|
2208
|
+
details: { walletId, factorKeys: Object.keys(factor ?? {}) }
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
return { type, credential: value };
|
|
1877
2212
|
}
|
|
1878
2213
|
function membershipIdFromRole(role) {
|
|
1879
2214
|
const prefix = "member:";
|
|
@@ -1965,13 +2300,12 @@ function isReadyMemberSignCeremony(ceremony) {
|
|
|
1965
2300
|
}
|
|
1966
2301
|
|
|
1967
2302
|
// src/client.ts
|
|
1968
|
-
var DEFAULT_BASE_URL = "https://api.waaskey.com";
|
|
1969
2303
|
var Waaskey = class {
|
|
1970
2304
|
/** The `wallets` resource. */
|
|
1971
2305
|
wallets;
|
|
1972
2306
|
/** The `recovery` resource — multi-factor, client-encrypted wallet recovery. */
|
|
1973
2307
|
recovery;
|
|
1974
|
-
/** The `reshare` resource — device
|
|
2308
|
+
/** The `reshare` resource — this device's half of a committee rotation (#488) and the aux-completion that follows (#318). */
|
|
1975
2309
|
reshare;
|
|
1976
2310
|
/** The `balances` resource — client-side balance reads from a chain provider (no backend). */
|
|
1977
2311
|
balances;
|
|
@@ -1987,7 +2321,10 @@ var Waaskey = class {
|
|
|
1987
2321
|
if (!options?.apiKey) {
|
|
1988
2322
|
throw new Error("Waaskey: `apiKey` is required.");
|
|
1989
2323
|
}
|
|
1990
|
-
|
|
2324
|
+
if (!options.baseUrl) {
|
|
2325
|
+
throw new Error("Waaskey: `baseUrl` is required \u2014 pass the URL of your Waaskey API (there is no default).");
|
|
2326
|
+
}
|
|
2327
|
+
const http = new HttpClient(options.apiKey, options.baseUrl, options.fetch);
|
|
1991
2328
|
const analytics = new Analytics(resolveSink(options.analytics, http));
|
|
1992
2329
|
this.auth = new Auth(http);
|
|
1993
2330
|
this.members = new Members(http);
|
|
@@ -2022,6 +2359,15 @@ function resolveSink(analytics, http) {
|
|
|
2022
2359
|
return analytics ?? new HttpAnalyticsSink(http);
|
|
2023
2360
|
}
|
|
2024
2361
|
|
|
2362
|
+
// src/passkey/recovery-factor.ts
|
|
2363
|
+
function passkeyFactorEnrollment(credentialId) {
|
|
2364
|
+
return { type: "passkey", credential: credentialId };
|
|
2365
|
+
}
|
|
2366
|
+
async function passkeyFactorVerification(challenge, options = {}) {
|
|
2367
|
+
const assertion = await getSigningAssertion(challenge, options);
|
|
2368
|
+
return { type: "passkey", token: JSON.stringify(assertion) };
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2025
2371
|
// src/mpc/wasm-core.ts
|
|
2026
2372
|
var WasmMpcCore = class {
|
|
2027
2373
|
constructor(load) {
|
|
@@ -2087,7 +2433,10 @@ var WasmMpcCore = class {
|
|
|
2087
2433
|
new_threshold: params.newThreshold,
|
|
2088
2434
|
wallet: params.wallet,
|
|
2089
2435
|
commitments: params.commitments,
|
|
2090
|
-
sub_shares: params.subShares
|
|
2436
|
+
sub_shares: params.subShares,
|
|
2437
|
+
// The core opens the SEALED sub-shares with this before it can verify or assemble anything;
|
|
2438
|
+
// omitting it (as this did until #488) makes the call fail on its own parameter parse.
|
|
2439
|
+
encryption_secret: params.encryptionSecret
|
|
2091
2440
|
})
|
|
2092
2441
|
);
|
|
2093
2442
|
if (!raw || typeof raw.core_json !== "string") {
|
|
@@ -2095,6 +2444,26 @@ var WasmMpcCore = class {
|
|
|
2095
2444
|
}
|
|
2096
2445
|
return { core: raw.core_json, sharedPublicKey: decodePublicKey(raw.shared_public_key_json) };
|
|
2097
2446
|
}
|
|
2447
|
+
async runReshareDeal(params) {
|
|
2448
|
+
const wasm = await this.init();
|
|
2449
|
+
if (!wasm.reshareDeal) {
|
|
2450
|
+
throw new Error(reshareFeatureHint("reshareDeal"));
|
|
2451
|
+
}
|
|
2452
|
+
const raw = await wasm.reshareDeal(
|
|
2453
|
+
JSON.stringify({
|
|
2454
|
+
curve: params.curve,
|
|
2455
|
+
share: JSON.parse(params.share),
|
|
2456
|
+
quorum_indices: params.quorumIndices,
|
|
2457
|
+
new_preimages: params.newPreimages,
|
|
2458
|
+
new_threshold: params.newThreshold,
|
|
2459
|
+
recipient_pubkeys: params.recipientPubkeys
|
|
2460
|
+
})
|
|
2461
|
+
);
|
|
2462
|
+
if (!raw || !Array.isArray(raw.sealed_sub_shares_hex)) {
|
|
2463
|
+
throw new Error("client-wasm returned an unexpected reshareDeal result");
|
|
2464
|
+
}
|
|
2465
|
+
return { commitments: raw.commitments_json, sealedSubShares: raw.sealed_sub_shares_hex };
|
|
2466
|
+
}
|
|
2098
2467
|
async runCompleteReshare(params) {
|
|
2099
2468
|
const wasm = await this.init();
|
|
2100
2469
|
if (!wasm.completeReshare) {
|
|
@@ -2330,7 +2699,7 @@ function decodePublicKey(sharedPublicKeyJson) {
|
|
|
2330
2699
|
|
|
2331
2700
|
// src/mpc/load-wasm.ts
|
|
2332
2701
|
var CLIENT_WASM_PACKAGE = "@waaskey/client-wasm";
|
|
2333
|
-
var CLIENT_WASM_VERSION = "0.2.
|
|
2702
|
+
var CLIENT_WASM_VERSION = "0.2.2";
|
|
2334
2703
|
async function verifyWasmIntegrity(bytes, expectedSha384) {
|
|
2335
2704
|
if (!expectedSha384 || !expectedSha384.startsWith("sha384-")) {
|
|
2336
2705
|
throw new Error("Waaskey: an expected SHA-384 integrity hash (sha384-<base64>) is required to load the wasm MPC core.");
|
|
@@ -2762,6 +3131,6 @@ function prfOutputToSecret(prfResult) {
|
|
|
2762
3131
|
return btoa(binary);
|
|
2763
3132
|
}
|
|
2764
3133
|
|
|
2765
|
-
export { Analytics, Auth, Balances, CLIENT_WASM_VERSION, EncryptedShareStore, EvmRpcProvider, HttpAnalyticsSink, IndexedDbKeyValueStore, Members, MemoryKeyValueStore, MemoryPrimeStore, Onramp, PasskeyPrfSecretProvider, PrimePool, Recovery, Reshare, Waaskey, WaaskeyError, Wallet, Wallets, WasmMpcCore, broadcast, createVerifiedClientWasmLoader, epochShareKey, formatUnits, generateRecoveryCode, getSigningAssertion, isNonCustodial, isPasskeyAssertionSupported, isPasskeySupported, isPrfSupported, loadClientWasm, memberShareKey, userBackupPendingKey, validateCustodyPolicy, verifyWasmIntegrity };
|
|
3134
|
+
export { Analytics, Auth, Balances, CLIENT_WASM_VERSION, EncryptedShareStore, EvmRpcProvider, HttpAnalyticsSink, IndexedDbKeyValueStore, Members, MemoryKeyValueStore, MemoryPrimeStore, Onramp, PasskeyPrfSecretProvider, PrimePool, Recovery, Reshare, Waaskey, WaaskeyError, Wallet, Wallets, WasmMpcCore, broadcast, createVerifiedClientWasmLoader, epochShareKey, formatUnits, generateRecoveryCode, getSigningAssertion, isNonCustodial, isPasskeyAssertionSupported, isPasskeySupported, isPrfSupported, loadClientWasm, memberShareKey, openRecoveryBackup, passkeyFactorEnrollment, passkeyFactorVerification, sealRecoveryBackup, userBackupPendingKey, validateCustodyPolicy, verifyWasmIntegrity };
|
|
2766
3135
|
//# sourceMappingURL=index.js.map
|
|
2767
3136
|
//# sourceMappingURL=index.js.map
|