@waaskey/sdk 0.2.1 → 0.3.1

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.d.cts CHANGED
@@ -212,6 +212,12 @@ interface DeviceKeygenParams extends CeremonyParams {
212
212
  * this device's private aux material and are NEVER sent to the server.
213
213
  */
214
214
  pregeneratedPrimes?: string;
215
+ /**
216
+ * The FULL party roster (`roles[i]` = relay role of protocol index `i`), when the backend
217
+ * provides it (waas-core#131). Required to route a `parties > 2` ceremony — the plain 2-party
218
+ * transport cannot attribute messages from more than one peer.
219
+ */
220
+ roles?: string[];
215
221
  }
216
222
  /** Result of the device half of keygen — the device's share never leaves the device. */
217
223
  interface DeviceKeygenResult {
@@ -603,24 +609,6 @@ interface VerifiedWasmLoaderOptions {
603
609
  * ```
604
610
  */
605
611
  declare function createVerifiedClientWasmLoader(options: VerifiedWasmLoaderOptions): ClientWasmLoader;
606
- /**
607
- * Convenience loader for the Waaskey client-wasm engine — dynamically imports `@waaskey/client-wasm`
608
- * (the wasm-pack/bundler build, auto-initialized on import) and returns it as a {@link ClientWasmModule}:
609
- *
610
- * ```ts
611
- * import { Waaskey, WasmMpcCore, loadClientWasm } from '@waaskey/sdk';
612
- * const waaskey = new Waaskey({ apiKey, mpc: new WasmMpcCore(loadClientWasm), shareStore });
613
- * ```
614
- *
615
- * ⚠️ **No integrity verification (issue #40).** A bundler-target build instantiates the wasm on
616
- * import, so its bytes cannot be checked first. For production — where a registry compromise or
617
- * dependency-confusion attack on `@waaskey/client-wasm` could exfiltrate the plaintext device share
618
- * — use {@link createVerifiedClientWasmLoader} with a vendored `.wasm` and the pinned SHA-384 instead.
619
- *
620
- * `@waaskey/client-wasm` is an OPTIONAL peer dependency pinned to {@link CLIENT_WASM_VERSION}: install
621
- * it alongside the SDK in apps that create wallets. The indirect import specifier keeps it out of
622
- * static module resolution — so a missing install fails here with an actionable message, not at build.
623
- */
624
612
  declare const loadClientWasm: ClientWasmLoader;
625
613
 
626
614
  /**
@@ -1100,6 +1088,12 @@ interface WalletCeremony {
1100
1088
  threshold: number;
1101
1089
  /** Short-lived relay token (JWT) the device presents to join this keygen session; present only when relay auth is enabled. */
1102
1090
  relayToken?: string;
1091
+ /**
1092
+ * The FULL party roster: `roles[i]` is the relay role of protocol index `i` (waas-core#131).
1093
+ * Needed by the routed transport whenever `parties > 2` (e.g. `['device', 'server', 'recovery']`);
1094
+ * older backends omit it — the SDK then reconstructs the standard 3-party topology.
1095
+ */
1096
+ roles?: string[];
1103
1097
  /**
1104
1098
  * The FROST DKG round-2 encryption roster — **ed25519 keygen only** (#114, backend `IWalletCeremony`).
1105
1099
  * One X25519 encryption PUBLIC key (32-byte hex) per party, in `parties`/protocol-index order, so
@@ -2093,6 +2087,15 @@ declare class Wallet {
2093
2087
  * pre-built assertion via `options.passkeyAssertion` (with its `options.passkeyChallengeId`).
2094
2088
  */
2095
2089
  sign(digest: string, options?: SignOptions): Promise<string>;
2090
+ /**
2091
+ * Start this device's half of a secp sign ceremony when the wallet's sign quorum requires it.
2092
+ * The quorum is the first `threshold` roles of the wallet's party list (#292) — for the default
2093
+ * `[device, server, recovery]`/2 that is `[device, server]`, so the device MUST be online and
2094
+ * co-signing. Returns `undefined` when the quorum is platform-only (or the wallet is not secp) —
2095
+ * the POST then completes alone, unchanged. A device-present quorum without the device deps or
2096
+ * stored share fails fast with a typed error instead of a guaranteed server-side timeout.
2097
+ */
2098
+ private startDeviceCoSign;
2096
2099
  /**
2097
2100
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
2098
2101
  * and co-signs it with the 2-of-3 MPC quorum, returning the **signed raw transaction**.
@@ -2381,6 +2384,13 @@ declare class Wallets {
2381
2384
  * `[device, user_backup]` ceremony — one place owns the error contract so both paths stay identical.
2382
2385
  */
2383
2386
  private runKeygenParties;
2387
+ /**
2388
+ * Run ONE client keygen party over the right transport. A 2-party ceremony uses the plain
2389
+ * single-peer path; anything larger MUST be roster-routed — the 2-party transport attributes
2390
+ * every inbound message to the one configured peer, so a third party's very first message
2391
+ * aborts the protocol with "route received message" (waas-core#131).
2392
+ */
2393
+ private runKeygenParty;
2384
2394
  /**
2385
2395
  * Run the device half of an ed25519 (FROST) keygen (#110) and seal the resulting `{keyPackage,
2386
2396
  * publicKeyPackage}` share — the EdDSA counterpart of the cggmp24 `runKeygen` branch in {@link create}.
package/dist/index.d.ts CHANGED
@@ -212,6 +212,12 @@ interface DeviceKeygenParams extends CeremonyParams {
212
212
  * this device's private aux material and are NEVER sent to the server.
213
213
  */
214
214
  pregeneratedPrimes?: string;
215
+ /**
216
+ * The FULL party roster (`roles[i]` = relay role of protocol index `i`), when the backend
217
+ * provides it (waas-core#131). Required to route a `parties > 2` ceremony — the plain 2-party
218
+ * transport cannot attribute messages from more than one peer.
219
+ */
220
+ roles?: string[];
215
221
  }
216
222
  /** Result of the device half of keygen — the device's share never leaves the device. */
217
223
  interface DeviceKeygenResult {
@@ -603,24 +609,6 @@ interface VerifiedWasmLoaderOptions {
603
609
  * ```
604
610
  */
605
611
  declare function createVerifiedClientWasmLoader(options: VerifiedWasmLoaderOptions): ClientWasmLoader;
606
- /**
607
- * Convenience loader for the Waaskey client-wasm engine — dynamically imports `@waaskey/client-wasm`
608
- * (the wasm-pack/bundler build, auto-initialized on import) and returns it as a {@link ClientWasmModule}:
609
- *
610
- * ```ts
611
- * import { Waaskey, WasmMpcCore, loadClientWasm } from '@waaskey/sdk';
612
- * const waaskey = new Waaskey({ apiKey, mpc: new WasmMpcCore(loadClientWasm), shareStore });
613
- * ```
614
- *
615
- * ⚠️ **No integrity verification (issue #40).** A bundler-target build instantiates the wasm on
616
- * import, so its bytes cannot be checked first. For production — where a registry compromise or
617
- * dependency-confusion attack on `@waaskey/client-wasm` could exfiltrate the plaintext device share
618
- * — use {@link createVerifiedClientWasmLoader} with a vendored `.wasm` and the pinned SHA-384 instead.
619
- *
620
- * `@waaskey/client-wasm` is an OPTIONAL peer dependency pinned to {@link CLIENT_WASM_VERSION}: install
621
- * it alongside the SDK in apps that create wallets. The indirect import specifier keeps it out of
622
- * static module resolution — so a missing install fails here with an actionable message, not at build.
623
- */
624
612
  declare const loadClientWasm: ClientWasmLoader;
625
613
 
626
614
  /**
@@ -1100,6 +1088,12 @@ interface WalletCeremony {
1100
1088
  threshold: number;
1101
1089
  /** Short-lived relay token (JWT) the device presents to join this keygen session; present only when relay auth is enabled. */
1102
1090
  relayToken?: string;
1091
+ /**
1092
+ * The FULL party roster: `roles[i]` is the relay role of protocol index `i` (waas-core#131).
1093
+ * Needed by the routed transport whenever `parties > 2` (e.g. `['device', 'server', 'recovery']`);
1094
+ * older backends omit it — the SDK then reconstructs the standard 3-party topology.
1095
+ */
1096
+ roles?: string[];
1103
1097
  /**
1104
1098
  * The FROST DKG round-2 encryption roster — **ed25519 keygen only** (#114, backend `IWalletCeremony`).
1105
1099
  * One X25519 encryption PUBLIC key (32-byte hex) per party, in `parties`/protocol-index order, so
@@ -2093,6 +2087,15 @@ declare class Wallet {
2093
2087
  * pre-built assertion via `options.passkeyAssertion` (with its `options.passkeyChallengeId`).
2094
2088
  */
2095
2089
  sign(digest: string, options?: SignOptions): Promise<string>;
2090
+ /**
2091
+ * Start this device's half of a secp sign ceremony when the wallet's sign quorum requires it.
2092
+ * The quorum is the first `threshold` roles of the wallet's party list (#292) — for the default
2093
+ * `[device, server, recovery]`/2 that is `[device, server]`, so the device MUST be online and
2094
+ * co-signing. Returns `undefined` when the quorum is platform-only (or the wallet is not secp) —
2095
+ * the POST then completes alone, unchanged. A device-present quorum without the device deps or
2096
+ * stored share fails fast with a typed error instead of a guaranteed server-side timeout.
2097
+ */
2098
+ private startDeviceCoSign;
2096
2099
  /**
2097
2100
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
2098
2101
  * and co-signs it with the 2-of-3 MPC quorum, returning the **signed raw transaction**.
@@ -2381,6 +2384,13 @@ declare class Wallets {
2381
2384
  * `[device, user_backup]` ceremony — one place owns the error contract so both paths stay identical.
2382
2385
  */
2383
2386
  private runKeygenParties;
2387
+ /**
2388
+ * Run ONE client keygen party over the right transport. A 2-party ceremony uses the plain
2389
+ * single-peer path; anything larger MUST be roster-routed — the 2-party transport attributes
2390
+ * every inbound message to the one configured peer, so a third party's very first message
2391
+ * aborts the protocol with "route received message" (waas-core#131).
2392
+ */
2393
+ private runKeygenParty;
2384
2394
  /**
2385
2395
  * Run the device half of an ed25519 (FROST) keygen (#110) and seal the resulting `{keyPackage,
2386
2396
  * publicKeyPackage}` share — the EdDSA counterpart of the cggmp24 `runKeygen` branch in {@link create}.
package/dist/index.js CHANGED
@@ -910,6 +910,32 @@ function bytesToHex(bytes) {
910
910
  return hex;
911
911
  }
912
912
 
913
+ // src/share-blob.ts
914
+ function serializeShare(keygen, relayUrl) {
915
+ return JSON.stringify({
916
+ keyShare: keygen.keyShare,
917
+ auxInfo: keygen.auxInfo,
918
+ sharedPublicKey: keygen.sharedPublicKey,
919
+ ...relayUrl === void 0 ? {} : { relayUrl }
920
+ });
921
+ }
922
+ function deserializeShare(blob) {
923
+ let parsed;
924
+ try {
925
+ parsed = JSON.parse(blob);
926
+ } catch (cause) {
927
+ throw new WaaskeyError("Stored device share is corrupt \u2014 it is not valid JSON.", "share_not_found", { cause });
928
+ }
929
+ if (typeof parsed.keyShare !== "string") {
930
+ throw new WaaskeyError("Stored device share is malformed \u2014 it is missing its KeyShare.", "share_not_found", { details: { keys: Object.keys(parsed) } });
931
+ }
932
+ return {
933
+ keyShare: parsed.keyShare,
934
+ sharedPublicKey: typeof parsed.sharedPublicKey === "string" ? parsed.sharedPublicKey : void 0,
935
+ relayUrl: typeof parsed.relayUrl === "string" ? parsed.relayUrl : void 0
936
+ };
937
+ }
938
+
913
939
  // src/passkey/assertion.ts
914
940
  function isPasskeyAssertionSupported() {
915
941
  return typeof globalThis.navigator !== "undefined" && typeof globalThis.navigator.credentials !== "undefined";
@@ -1026,10 +1052,55 @@ var Wallet = class {
1026
1052
  const message = normalizeDigest(digest);
1027
1053
  const body = { message };
1028
1054
  await this.attachStepUp(body, "sign", options);
1029
- const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1055
+ const coSign = this.startDeviceCoSign(message);
1056
+ const post = this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1057
+ const [res] = coSign === void 0 ? [await post] : await Promise.all([post, coSign.catch(() => void 0)]);
1030
1058
  this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1031
1059
  return res.signature;
1032
1060
  }
1061
+ /**
1062
+ * Start this device's half of a secp sign ceremony when the wallet's sign quorum requires it.
1063
+ * The quorum is the first `threshold` roles of the wallet's party list (#292) — for the default
1064
+ * `[device, server, recovery]`/2 that is `[device, server]`, so the device MUST be online and
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.
1068
+ */
1069
+ startDeviceCoSign(digest) {
1070
+ if (this.data.curve === "ed25519") return void 0;
1071
+ const roles = this.data.parties;
1072
+ const { threshold } = this.data;
1073
+ if (!Array.isArray(roles) || typeof threshold !== "number") return void 0;
1074
+ const quorum = roles.slice(0, threshold);
1075
+ const pos = quorum.indexOf("device");
1076
+ if (pos < 0) return void 0;
1077
+ const { mpc, shareStore } = this.device;
1078
+ if (!mpc || !shareStore) {
1079
+ throw new WaaskeyError(
1080
+ `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(...)\`.`,
1081
+ "device_core_required"
1082
+ );
1083
+ }
1084
+ return (async () => {
1085
+ const blob = await shareStore.get(this.id);
1086
+ if (!blob) {
1087
+ throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the sign quorum.`, "share_not_found");
1088
+ }
1089
+ const { keyShare, relayUrl } = deserializeShare(blob);
1090
+ if (!relayUrl) {
1091
+ 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");
1092
+ }
1093
+ const participants = quorum.map((_role, index) => index);
1094
+ const base = { curve: toMpcCurve(this.data.curve), relayUrl, sessionId: this.id, share: keyShare, participants, signerPosition: pos, digest };
1095
+ if (quorum.length === 2) {
1096
+ return mpc.runSign({ ...base, role: quorum[pos], peerRole: quorum[1 - pos], partyIndex: pos, peerPartyIndex: 1 - pos });
1097
+ }
1098
+ if (!mpc.runMemberSign) {
1099
+ throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1100
+ }
1101
+ return mpc.runMemberSign({ ...base, roles: quorum });
1102
+ })();
1103
+ }
1033
1104
  /**
1034
1105
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
1035
1106
  * and co-signs it with the 2-of-3 MPC quorum, returning the **signed raw transaction**.
@@ -1491,7 +1562,7 @@ var Wallets = class {
1491
1562
  const pregeneratedPrimes = primePool ? await primePool.take(curve) : void 0;
1492
1563
  throwIfAborted(signal);
1493
1564
  const [keygen] = await this.runKeygenParties(mpc, [{ ...ceremony, curve, pregeneratedPrimes }]);
1494
- await shareStore.put(walletId, serializeShare(keygen));
1565
+ await shareStore.put(walletId, serializeShare(keygen, ceremony.relayUrl));
1495
1566
  return;
1496
1567
  }
1497
1568
  if (!backup) {
@@ -1506,7 +1577,7 @@ var Wallets = class {
1506
1577
  { ...ceremony, curve, pregeneratedPrimes: devicePrimes },
1507
1578
  { ...userBackupParty, curve, pregeneratedPrimes: backupPrimes }
1508
1579
  ]);
1509
- await shareStore.put(walletId, serializeShare(deviceKeygen));
1580
+ await shareStore.put(walletId, serializeShare(deviceKeygen, ceremony.relayUrl));
1510
1581
  throwIfAborted(signal);
1511
1582
  const { payload } = await buildRecoveryRegistration({ share: serializeShare(backupKeygen), ...backup });
1512
1583
  await shareStore.put(userBackupPendingKey(walletId), JSON.stringify(payload));
@@ -1651,12 +1722,39 @@ var Wallets = class {
1651
1722
  */
1652
1723
  async runKeygenParties(mpc, params) {
1653
1724
  try {
1654
- return await Promise.all(params.map((party) => mpc.runKeygen(party)));
1725
+ return await Promise.all(params.map((party) => this.runKeygenParty(mpc, party, params)));
1655
1726
  } catch (cause) {
1656
1727
  if (cause instanceof WaaskeyError) throw cause;
1657
1728
  throw new WaaskeyError("The device keygen ceremony failed.", "keygen_failed", { cause });
1658
1729
  }
1659
1730
  }
1731
+ /**
1732
+ * Run ONE client keygen party over the right transport. A 2-party ceremony uses the plain
1733
+ * single-peer path; anything larger MUST be roster-routed — the 2-party transport attributes
1734
+ * every inbound message to the one configured peer, so a third party's very first message
1735
+ * aborts the protocol with "route received message" (waas-core#131).
1736
+ */
1737
+ runKeygenParty(mpc, party, batch) {
1738
+ if (party.parties <= 2) {
1739
+ return mpc.runKeygen(party);
1740
+ }
1741
+ if (!mpc.runMemberKeygen) {
1742
+ throw new WaaskeyError(
1743
+ `This keygen ceremony has ${party.parties} parties, which needs an MPC core with routed (member-ceremony) keygen support \u2014 use a client-wasm build exposing keygenMember.`,
1744
+ "unsupported"
1745
+ );
1746
+ }
1747
+ return mpc.runMemberKeygen({
1748
+ curve: party.curve,
1749
+ relayUrl: party.relayUrl,
1750
+ sessionId: party.sessionId,
1751
+ roles: ceremonyRoster(party, batch),
1752
+ partyIndex: party.partyIndex,
1753
+ threshold: party.threshold,
1754
+ relayToken: party.relayToken,
1755
+ pregeneratedPrimes: party.pregeneratedPrimes
1756
+ });
1757
+ }
1660
1758
  /**
1661
1759
  * Run the device half of an ed25519 (FROST) keygen (#110) and seal the resulting `{keyPackage,
1662
1760
  * publicKeyPackage}` share — the EdDSA counterpart of the cggmp24 `runKeygen` branch in {@link create}.
@@ -1727,21 +1825,6 @@ var Wallets = class {
1727
1825
  }
1728
1826
  }
1729
1827
  };
1730
- function serializeShare(keygen) {
1731
- return JSON.stringify({ keyShare: keygen.keyShare, auxInfo: keygen.auxInfo, sharedPublicKey: keygen.sharedPublicKey });
1732
- }
1733
- function deserializeShare(blob) {
1734
- let parsed;
1735
- try {
1736
- parsed = JSON.parse(blob);
1737
- } catch (cause) {
1738
- throw new WaaskeyError("Stored device share is corrupt \u2014 it is not valid JSON.", "share_not_found", { cause });
1739
- }
1740
- if (typeof parsed.keyShare !== "string") {
1741
- throw new WaaskeyError("Stored device share is malformed \u2014 it is missing its KeyShare.", "share_not_found", { details: { keys: Object.keys(parsed) } });
1742
- }
1743
- return { keyShare: parsed.keyShare, sharedPublicKey: typeof parsed.sharedPublicKey === "string" ? parsed.sharedPublicKey : void 0 };
1744
- }
1745
1828
  async function restoreUserBackupShare(ciphertext, recoveryCode) {
1746
1829
  let blob;
1747
1830
  try {
@@ -1799,6 +1882,38 @@ function membershipIdFromRole(role) {
1799
1882
  }
1800
1883
  return role.slice(prefix.length);
1801
1884
  }
1885
+ function ceremonyRoster(party, batch) {
1886
+ if (party.roles) {
1887
+ if (party.roles.length !== party.parties) {
1888
+ throw new WaaskeyError(`The ceremony roster names ${party.roles.length} parties but the ceremony has ${party.parties}.`, "validation");
1889
+ }
1890
+ return party.roles;
1891
+ }
1892
+ const roles = new Array(party.parties).fill(void 0);
1893
+ const put = (index, role) => {
1894
+ if (role === void 0) return;
1895
+ if (index < 0 || index >= party.parties) {
1896
+ throw new WaaskeyError(`Ceremony party index ${index} is out of range for a ${party.parties}-party ceremony.`, "validation");
1897
+ }
1898
+ if (roles[index] !== void 0 && roles[index] !== role) {
1899
+ throw new WaaskeyError(`Conflicting ceremony roles for party index ${index}: "${roles[index]}" vs "${role}".`, "validation");
1900
+ }
1901
+ roles[index] = role;
1902
+ };
1903
+ for (const entry of batch) {
1904
+ put(entry.partyIndex, entry.role ?? "device");
1905
+ put(entry.peerPartyIndex, entry.peerRole ?? "server");
1906
+ }
1907
+ const gaps = roles.reduce((acc, role, index) => role === void 0 ? [...acc, index] : acc, []);
1908
+ if (gaps.length > 1) {
1909
+ throw new WaaskeyError(
1910
+ `The ${party.parties}-party keygen ceremony leaves ${gaps.length} party slots unnamed \u2014 this backend must supply the full ceremony roster (roles).`,
1911
+ "validation"
1912
+ );
1913
+ }
1914
+ if (gaps.length === 1) roles[gaps[0]] = "recovery";
1915
+ return roles;
1916
+ }
1802
1917
  function buildMemberRoster(shareholders, parties) {
1803
1918
  const roles = new Array(parties).fill(void 0);
1804
1919
  for (const shareholder of shareholders) {
@@ -2258,14 +2373,30 @@ function createVerifiedClientWasmLoader(options) {
2258
2373
  return mod;
2259
2374
  };
2260
2375
  }
2376
+ var clientWasm;
2261
2377
  var loadClientWasm = async () => {
2378
+ return clientWasm ??= importAndInit();
2379
+ };
2380
+ async function importAndInit() {
2381
+ let mod;
2262
2382
  try {
2263
- const mod = await import(CLIENT_WASM_PACKAGE);
2264
- return mod;
2383
+ mod = await import(CLIENT_WASM_PACKAGE);
2265
2384
  } catch (cause) {
2266
2385
  throw new Error("Waaskey: creating a wallet needs the wasm engine \u2014 install '@waaskey/client-wasm' alongside '@waaskey/sdk'.", { cause });
2267
2386
  }
2268
- };
2387
+ const init = mod["default"] ?? mod["init"];
2388
+ if (typeof init === "function") {
2389
+ try {
2390
+ await init();
2391
+ } catch (cause) {
2392
+ throw new Error(
2393
+ "Waaskey: the wasm engine failed to initialize. In the browser this loader fetches the `.wasm` next to the module \u2014 serve it, or pass a loader built with `createVerifiedClientWasmLoader({ wasmUrl, expectedSha384 })`. On Node use `NodeWorkerMpcCore` from `@waaskey/sdk/node`, which loads the binary from disk in a worker thread.",
2394
+ { cause }
2395
+ );
2396
+ }
2397
+ }
2398
+ return mod;
2399
+ }
2269
2400
  function toBase64(bytes) {
2270
2401
  let binary = "";
2271
2402
  for (const byte of bytes) binary += String.fromCharCode(byte);