@waaskey/sdk 0.3.0 → 0.3.2

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.js CHANGED
@@ -626,7 +626,7 @@ async function buildRecoveryRegistration(params) {
626
626
  const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
627
627
  const ciphertext = await sealWithPassword(recoveryCode, params.share);
628
628
  const factors = [
629
- { type: "recovery_code", credentialHash: await sha256Hex(recoveryCode) },
629
+ { type: "recovery_code", credential: await sha256Hex(recoveryCode) },
630
630
  { type: "totp", credential: params.totpSecret },
631
631
  { type: "email_otp", credential: params.email },
632
632
  ...params.extraFactors ?? []
@@ -654,7 +654,7 @@ async function registerRecoveryShare(http, walletId, params, signal) {
654
654
  return { recoveryCode, share };
655
655
  }
656
656
  async function hashRecoveryFactors(verifications) {
657
- return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && v.token !== void 0 ? { type: v.type, credentialHash: await sha256Hex(v.token) } : v));
657
+ 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
658
  }
659
659
  function generateRecoveryCode() {
660
660
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -910,6 +910,51 @@ function bytesToHex(bytes) {
910
910
  return hex;
911
911
  }
912
912
 
913
+ // src/session-sign.ts
914
+ function toDeviceSignParams(ceremony, share, digest) {
915
+ const { roles, participants, signerPosition } = ceremony;
916
+ if (participants.length !== 2 || roles.length !== 2 || signerPosition < 0 || signerPosition > 1) {
917
+ throw new WaaskeyError(
918
+ `Unexpected sign descriptor: the quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${participants.length} participants, ${roles.length} roles, signerPosition ${signerPosition}).`,
919
+ "sign_failed",
920
+ { details: { roles, participants, signerPosition } }
921
+ );
922
+ }
923
+ const peerPosition = 1 - signerPosition;
924
+ return {
925
+ curve: toMpcCurve(ceremony.curve),
926
+ relayUrl: ceremony.relayUrl,
927
+ sessionId: ceremony.sessionId,
928
+ role: roles[signerPosition],
929
+ peerRole: roles[peerPosition],
930
+ partyIndex: signerPosition,
931
+ peerPartyIndex: peerPosition,
932
+ relayToken: ceremony.relayToken,
933
+ share,
934
+ participants,
935
+ signerPosition,
936
+ digest
937
+ };
938
+ }
939
+ function toSessionSignParams(session, share, digest) {
940
+ return toDeviceSignParams(
941
+ {
942
+ curve: session.curve,
943
+ relayUrl: session.relayUrl,
944
+ sessionId: session.sessionId,
945
+ roles: sessionRoles(session),
946
+ participants: session.participants,
947
+ signerPosition: session.signerPosition,
948
+ relayToken: session.relayToken
949
+ },
950
+ share,
951
+ digest
952
+ );
953
+ }
954
+ function sessionRoles(session) {
955
+ return session.signerPosition === 0 ? [session.role, session.peerRole] : [session.peerRole, session.role];
956
+ }
957
+
913
958
  // src/share-blob.ts
914
959
  function serializeShare(keygen, relayUrl) {
915
960
  return JSON.stringify({
@@ -976,6 +1021,7 @@ async function getSigningAssertion(challenge, options = {}) {
976
1021
  }
977
1022
 
978
1023
  // src/wallet.ts
1024
+ var DEVICE_ROLE = "device";
979
1025
  var Wallet = class {
980
1026
  constructor(http, data, analytics, device = {}) {
981
1027
  this.http = http;
@@ -1050,56 +1096,106 @@ var Wallet = class {
1050
1096
  */
1051
1097
  async sign(digest, options = {}) {
1052
1098
  const message = normalizeDigest(digest);
1053
- const body = { message };
1054
- await this.attachStepUp(body, "sign", options);
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)]);
1099
+ const quorum = this.signQuorum();
1100
+ const devicePosition = quorum?.indexOf(DEVICE_ROLE) ?? -1;
1101
+ if (devicePosition < 0) {
1102
+ const body = { message };
1103
+ await this.attachStepUp(body, "sign", options);
1104
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1105
+ this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1106
+ return res.signature;
1107
+ }
1108
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "signing");
1109
+ const startBody = { digest: message };
1110
+ await this.attachStepUp(startBody, "sign", options);
1111
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/sign-session`, startBody, options.signal);
1112
+ const { signature } = await this.runDeviceSign(mpc, this.toSignCeremony(session, quorum), keyShare, message);
1058
1113
  this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1059
- return res.signature;
1114
+ return signature;
1060
1115
  }
1061
1116
  /**
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.
1117
+ * The roles that will actually sign the first `threshold` of the wallet's party list (#292),
1118
+ * mirroring the signer's own derivation. `undefined` when the topology is unknown (a legacy
1119
+ * wallet record), which callers treat as "let the server decide".
1068
1120
  */
1069
- startDeviceCoSign(digest) {
1121
+ signQuorum() {
1070
1122
  if (this.data.curve === "ed25519") return void 0;
1071
1123
  const roles = this.data.parties;
1072
1124
  const { threshold } = this.data;
1073
1125
  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;
1126
+ return roles.slice(0, threshold);
1127
+ }
1128
+ /**
1129
+ * Everything this device needs to join a ceremony, resolved BEFORE one is started (#89): the MPC
1130
+ * core, the routed-sign capability a >2-party quorum needs, and the stored key share.
1131
+ *
1132
+ * Each failure is a typed error thrown straight to the caller, and the ordering is the point: a
1133
+ * device that cannot co-sign must never leave the platform party waiting on the relay for a
1134
+ * counterpart that will never arrive. That wait ends at the party-runner timeout (~210s) and
1135
+ * reaches the caller as an opaque 5xx — minutes after a knowable, local cause.
1136
+ */
1137
+ async loadDeviceParty(quorum, action) {
1077
1138
  const { mpc, shareStore } = this.device;
1078
1139
  if (!mpc || !shareStore) {
1079
1140
  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(...)\`.`,
1141
+ `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
1142
  "device_core_required"
1082
1143
  );
1083
1144
  }
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
- })();
1145
+ if (quorum.length > 2 && !mpc.runMemberSign) {
1146
+ throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1147
+ }
1148
+ const blob = await shareStore.get(this.id);
1149
+ if (!blob) {
1150
+ throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the signing quorum.`, "share_not_found");
1151
+ }
1152
+ const { keyShare } = deserializeShare(blob);
1153
+ return { mpc, keyShare };
1154
+ }
1155
+ /**
1156
+ * Normalize a sign-session descriptor into the shared {@link DeviceCeremony}. The 2-party roster
1157
+ * comes from the descriptor's own `role`/`peerRole` (authoritative the relay token is bound to
1158
+ * that role); a larger quorum carries no server-sent roster, so the wallet's own party slice — the
1159
+ * same slice the signer derives — supplies it.
1160
+ */
1161
+ toSignCeremony(session, quorum) {
1162
+ return {
1163
+ curve: session.curve,
1164
+ relayUrl: session.relayUrl,
1165
+ sessionId: session.sessionId,
1166
+ roles: session.participants.length === 2 ? sessionRoles(session) : quorum,
1167
+ participants: session.participants,
1168
+ signerPosition: session.signerPosition,
1169
+ relayToken: session.relayToken
1170
+ };
1171
+ }
1172
+ /**
1173
+ * Run this device's half of a started ceremony. A 2-party quorum uses the plain single-peer
1174
+ * transport; anything larger MUST be roster-routed, or the transport attributes every inbound
1175
+ * message to the one configured peer and the protocol aborts on the third party's first message
1176
+ * (waas-core#131).
1177
+ */
1178
+ runDeviceSign(mpc, ceremony, keyShare, digest) {
1179
+ if (ceremony.participants.length === 2) {
1180
+ return mpc.runSign(toDeviceSignParams(ceremony, keyShare, digest));
1181
+ }
1182
+ if (!mpc.runMemberSign) {
1183
+ throw new WaaskeyError(
1184
+ `This wallet's sign quorum has ${ceremony.participants.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`,
1185
+ "unsupported"
1186
+ );
1187
+ }
1188
+ return mpc.runMemberSign({
1189
+ curve: toMpcCurve(ceremony.curve),
1190
+ relayUrl: ceremony.relayUrl,
1191
+ sessionId: ceremony.sessionId,
1192
+ relayToken: ceremony.relayToken,
1193
+ roles: ceremony.roles,
1194
+ share: keyShare,
1195
+ participants: ceremony.participants,
1196
+ signerPosition: ceremony.signerPosition,
1197
+ digest
1198
+ });
1103
1199
  }
1104
1200
  /**
1105
1201
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
@@ -1119,19 +1215,60 @@ var Wallet = class {
1119
1215
  if (this.data.curve === "ed25519") {
1120
1216
  return this.sendEd25519(params, options);
1121
1217
  }
1218
+ const quorum = this.signQuorum();
1219
+ if ((quorum?.indexOf(DEVICE_ROLE) ?? -1) >= 0) {
1220
+ return this.sendWithDevice(params, options, quorum);
1221
+ }
1122
1222
  const body = { ...params };
1123
1223
  await this.attachStepUp(body, "send", options);
1124
1224
  const res = await this.http.request("POST", `/v1/wallets/${this.id}/send`, body, options.signal);
1125
1225
  this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1126
1226
  return res;
1127
1227
  }
1228
+ /**
1229
+ * Device-co-signed send for a secp wallet — the cggmp24 counterpart of {@link sendEd25519}:
1230
+ *
1231
+ * 1. **START** (`POST …/send-session`): the platform runs the transfer gates, builds the unsigned
1232
+ * tx, puts its own party on the relay in the background, and returns the 32-byte digest plus
1233
+ * the relay coordination. It does NOT wait for the ceremony.
1234
+ * 2. **CO-SIGN**: this device runs its half over the relay with its stored share; cggmp24 hands
1235
+ * the completed signature to both parties.
1236
+ * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the platform verifies the signature
1237
+ * (recovers to the wallet key AND equals its own party's) and embeds it into the wire tx.
1238
+ *
1239
+ * Every device-side precondition is resolved BEFORE the START (see {@link loadDeviceParty}), so a
1240
+ * device that cannot co-sign costs nothing: no tx is built and no platform party is left waiting.
1241
+ *
1242
+ * **A co-sign that fails after a successful START rejects with its typed error.** START is not the
1243
+ * commit point — it yields an *unsigned* tx and a pending session, and nothing broadcastable exists
1244
+ * until ASSEMBLE returns a `signedTx` — so there is no result to salvage by swallowing the failure,
1245
+ * and no fallback to `POST /send` (the platform cannot reach the threshold on this wallet alone, so
1246
+ * a retry there would only hang). The backend expires the abandoned session and fails the tx row.
1247
+ */
1248
+ async sendWithDevice(params, options, quorum) {
1249
+ const { signal } = options;
1250
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "sending");
1251
+ throwIfAborted(signal);
1252
+ const startBody = { ...params };
1253
+ await this.attachStepUp(startBody, "send", options);
1254
+ throwIfAborted(signal);
1255
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1256
+ const digest = sendPayload(session, "digest");
1257
+ throwIfAborted(signal);
1258
+ const { signature } = await this.runDeviceSign(mpc, toSendCeremony(session), keyShare, digest);
1259
+ throwIfAborted(signal);
1260
+ const assemble = { signature };
1261
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/send-session/${session.txId}/assemble`, assemble, signal);
1262
+ this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1263
+ return res;
1264
+ }
1128
1265
  /**
1129
1266
  * Device-co-signed send for an ed25519 (FROST) wallet (#110) — the browser holds the device FROST
1130
1267
  * share and co-signs 2-party with the backend `server` party over the relay:
1131
1268
  *
1132
1269
  * 1. **START** (`POST …/send-session`): the backend builds the unsigned tx (chain adapter), starts
1133
1270
  * its server FROST party on the relay in the background, and returns the raw `message` bytes to
1134
- * sign + the relay coordination ({@link EddsaSendSession}).
1271
+ * sign + the relay coordination ({@link SendSessionResponse}).
1135
1272
  * 2. **CO-SIGN**: the device runs `signEddsa` over the relay with its stored `{keyPackage,
1136
1273
  * publicKeyPackage}` share; the two parties aggregate the RFC 8032 signature (returned locally).
1137
1274
  * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the backend embeds the aggregated
@@ -1166,6 +1303,7 @@ var Wallet = class {
1166
1303
  await this.attachStepUp(startBody, "send", options);
1167
1304
  throwIfAborted(signal);
1168
1305
  const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1306
+ const message = sendPayload(session, "message");
1169
1307
  throwIfAborted(signal);
1170
1308
  let signature;
1171
1309
  try {
@@ -1177,7 +1315,7 @@ var Wallet = class {
1177
1315
  keyPackage,
1178
1316
  publicKeyPackage,
1179
1317
  participants: session.participants,
1180
- message: session.message,
1318
+ message,
1181
1319
  relayToken: session.relayToken
1182
1320
  }));
1183
1321
  } catch (cause) {
@@ -1226,6 +1364,26 @@ async function attachPasskeyStepUp(http, walletId, body, operation, options) {
1226
1364
  body["passkeyAssertion"] = await getSigningAssertion(challenge, { credentialId: options.passkeyCredentialId });
1227
1365
  body["passkeyChallengeId"] = challengeId;
1228
1366
  }
1367
+ function toSendCeremony(session) {
1368
+ return {
1369
+ curve: session.curve,
1370
+ relayUrl: session.relayUrl,
1371
+ sessionId: session.sessionId,
1372
+ roles: session.roles,
1373
+ participants: session.participants,
1374
+ signerPosition: session.signerPosition,
1375
+ relayToken: session.relayToken
1376
+ };
1377
+ }
1378
+ function sendPayload(session, field) {
1379
+ const payload = session[field];
1380
+ if (!payload) {
1381
+ throw new WaaskeyError(`The send session did not return the \`${field}\` this wallet's curve (${session.curve}) signs.`, "sign_failed", {
1382
+ details: { txId: session.txId, curve: session.curve }
1383
+ });
1384
+ }
1385
+ return payload;
1386
+ }
1229
1387
  function normalizeDigest(digest) {
1230
1388
  const hex = digest.startsWith("0x") || digest.startsWith("0X") ? digest.slice(2) : digest;
1231
1389
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
@@ -1688,7 +1846,7 @@ var Wallets = class {
1688
1846
  throwIfAborted(signal);
1689
1847
  let signature;
1690
1848
  try {
1691
- ({ signature } = await mpc.runSign(toUserBackupSignParams(session, share, digest)));
1849
+ ({ signature } = await mpc.runSign(toSessionSignParams(session, share, digest)));
1692
1850
  } catch (cause) {
1693
1851
  if (cause instanceof WaaskeyError) throw cause;
1694
1852
  throw new WaaskeyError("The user_backup device recover-sign ceremony failed.", "sign_failed", { cause });
@@ -1834,29 +1992,6 @@ async function restoreUserBackupShare(ciphertext, recoveryCode) {
1834
1992
  }
1835
1993
  return deserializeShare(blob).keyShare;
1836
1994
  }
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
1995
  function memberShareKey(walletId, membershipId) {
1861
1996
  return `${walletId}@member-${membershipId}`;
1862
1997
  }
@@ -1873,7 +2008,17 @@ function parseBackupPayload(stored, walletId) {
1873
2008
  if (typeof parsed.ciphertext !== "string" || !Array.isArray(parsed.factors)) {
1874
2009
  throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed.`, "backup_failed", { details: { walletId, keys: Object.keys(parsed) } });
1875
2010
  }
1876
- return { ciphertext: parsed.ciphertext, factors: parsed.factors };
2011
+ return { ciphertext: parsed.ciphertext, factors: parsed.factors.map((factor) => normalizeStoredFactor(factor, walletId)) };
2012
+ }
2013
+ function normalizeStoredFactor(factor, walletId) {
2014
+ const { type, credential, credentialHash } = factor ?? {};
2015
+ const value = typeof credential === "string" && credential !== "" ? credential : credentialHash;
2016
+ if (typeof type !== "string" || type === "" || typeof value !== "string" || value === "") {
2017
+ 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", {
2018
+ details: { walletId, factorKeys: Object.keys(factor ?? {}) }
2019
+ });
2020
+ }
2021
+ return { type, credential: value };
1877
2022
  }
1878
2023
  function membershipIdFromRole(role) {
1879
2024
  const prefix = "member:";
@@ -2330,7 +2475,7 @@ function decodePublicKey(sharedPublicKeyJson) {
2330
2475
 
2331
2476
  // src/mpc/load-wasm.ts
2332
2477
  var CLIENT_WASM_PACKAGE = "@waaskey/client-wasm";
2333
- var CLIENT_WASM_VERSION = "0.2.1";
2478
+ var CLIENT_WASM_VERSION = "0.2.2";
2334
2479
  async function verifyWasmIntegrity(bytes, expectedSha384) {
2335
2480
  if (!expectedSha384 || !expectedSha384.startsWith("sha384-")) {
2336
2481
  throw new Error("Waaskey: an expected SHA-384 integrity hash (sha384-<base64>) is required to load the wasm MPC core.");
@@ -2373,14 +2518,30 @@ function createVerifiedClientWasmLoader(options) {
2373
2518
  return mod;
2374
2519
  };
2375
2520
  }
2521
+ var clientWasm;
2376
2522
  var loadClientWasm = async () => {
2523
+ return clientWasm ??= importAndInit();
2524
+ };
2525
+ async function importAndInit() {
2526
+ let mod;
2377
2527
  try {
2378
- const mod = await import(CLIENT_WASM_PACKAGE);
2379
- return mod;
2528
+ mod = await import(CLIENT_WASM_PACKAGE);
2380
2529
  } catch (cause) {
2381
2530
  throw new Error("Waaskey: creating a wallet needs the wasm engine \u2014 install '@waaskey/client-wasm' alongside '@waaskey/sdk'.", { cause });
2382
2531
  }
2383
- };
2532
+ const init = mod["default"] ?? mod["init"];
2533
+ if (typeof init === "function") {
2534
+ try {
2535
+ await init();
2536
+ } catch (cause) {
2537
+ throw new Error(
2538
+ "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.",
2539
+ { cause }
2540
+ );
2541
+ }
2542
+ }
2543
+ return mod;
2544
+ }
2384
2545
  function toBase64(bytes) {
2385
2546
  let binary = "";
2386
2547
  for (const byte of bytes) binary += String.fromCharCode(byte);