@waaskey/sdk 0.3.1 → 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.cjs CHANGED
@@ -628,7 +628,7 @@ async function buildRecoveryRegistration(params) {
628
628
  const recoveryCode = params.recoveryCode ?? generateRecoveryCode();
629
629
  const ciphertext = await sealWithPassword(recoveryCode, params.share);
630
630
  const factors = [
631
- { type: "recovery_code", credentialHash: await sha256Hex(recoveryCode) },
631
+ { type: "recovery_code", credential: await sha256Hex(recoveryCode) },
632
632
  { type: "totp", credential: params.totpSecret },
633
633
  { type: "email_otp", credential: params.email },
634
634
  ...params.extraFactors ?? []
@@ -656,7 +656,7 @@ async function registerRecoveryShare(http, walletId, params, signal) {
656
656
  return { recoveryCode, share };
657
657
  }
658
658
  async function hashRecoveryFactors(verifications) {
659
- return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && v.token !== void 0 ? { type: v.type, credentialHash: await sha256Hex(v.token) } : v));
659
+ return Promise.all(verifications.map(async (v) => v.type === "recovery_code" && typeof v.token === "string" ? { type: v.type, token: await sha256Hex(v.token) } : v));
660
660
  }
661
661
  function generateRecoveryCode() {
662
662
  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
@@ -912,6 +912,51 @@ function bytesToHex(bytes) {
912
912
  return hex;
913
913
  }
914
914
 
915
+ // src/session-sign.ts
916
+ function toDeviceSignParams(ceremony, share, digest) {
917
+ const { roles, participants, signerPosition } = ceremony;
918
+ if (participants.length !== 2 || roles.length !== 2 || signerPosition < 0 || signerPosition > 1) {
919
+ throw new WaaskeyError(
920
+ `Unexpected sign descriptor: the quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${participants.length} participants, ${roles.length} roles, signerPosition ${signerPosition}).`,
921
+ "sign_failed",
922
+ { details: { roles, participants, signerPosition } }
923
+ );
924
+ }
925
+ const peerPosition = 1 - signerPosition;
926
+ return {
927
+ curve: toMpcCurve(ceremony.curve),
928
+ relayUrl: ceremony.relayUrl,
929
+ sessionId: ceremony.sessionId,
930
+ role: roles[signerPosition],
931
+ peerRole: roles[peerPosition],
932
+ partyIndex: signerPosition,
933
+ peerPartyIndex: peerPosition,
934
+ relayToken: ceremony.relayToken,
935
+ share,
936
+ participants,
937
+ signerPosition,
938
+ digest
939
+ };
940
+ }
941
+ function toSessionSignParams(session, share, digest) {
942
+ return toDeviceSignParams(
943
+ {
944
+ curve: session.curve,
945
+ relayUrl: session.relayUrl,
946
+ sessionId: session.sessionId,
947
+ roles: sessionRoles(session),
948
+ participants: session.participants,
949
+ signerPosition: session.signerPosition,
950
+ relayToken: session.relayToken
951
+ },
952
+ share,
953
+ digest
954
+ );
955
+ }
956
+ function sessionRoles(session) {
957
+ return session.signerPosition === 0 ? [session.role, session.peerRole] : [session.peerRole, session.role];
958
+ }
959
+
915
960
  // src/share-blob.ts
916
961
  function serializeShare(keygen, relayUrl) {
917
962
  return JSON.stringify({
@@ -978,6 +1023,7 @@ async function getSigningAssertion(challenge, options = {}) {
978
1023
  }
979
1024
 
980
1025
  // src/wallet.ts
1026
+ var DEVICE_ROLE = "device";
981
1027
  var Wallet = class {
982
1028
  constructor(http, data, analytics, device = {}) {
983
1029
  this.http = http;
@@ -1052,56 +1098,106 @@ var Wallet = class {
1052
1098
  */
1053
1099
  async sign(digest, options = {}) {
1054
1100
  const message = normalizeDigest(digest);
1055
- const body = { message };
1056
- await this.attachStepUp(body, "sign", options);
1057
- const coSign = this.startDeviceCoSign(message);
1058
- const post = this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1059
- const [res] = coSign === void 0 ? [await post] : await Promise.all([post, coSign.catch(() => void 0)]);
1101
+ const quorum = this.signQuorum();
1102
+ const devicePosition = quorum?.indexOf(DEVICE_ROLE) ?? -1;
1103
+ if (devicePosition < 0) {
1104
+ const body = { message };
1105
+ await this.attachStepUp(body, "sign", options);
1106
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/sign`, body, options.signal);
1107
+ this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1108
+ return res.signature;
1109
+ }
1110
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "signing");
1111
+ const startBody = { digest: message };
1112
+ await this.attachStepUp(startBody, "sign", options);
1113
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/sign-session`, startBody, options.signal);
1114
+ const { signature } = await this.runDeviceSign(mpc, this.toSignCeremony(session, quorum), keyShare, message);
1060
1115
  this.analytics?.track("wallet.signed", { walletId: this.id, curve: this.data.curve });
1061
- return res.signature;
1116
+ return signature;
1062
1117
  }
1063
1118
  /**
1064
- * Start this device's half of a secp sign ceremony when the wallet's sign quorum requires it.
1065
- * The quorum is the first `threshold` roles of the wallet's party list (#292) — for the default
1066
- * `[device, server, recovery]`/2 that is `[device, server]`, so the device MUST be online and
1067
- * co-signing. Returns `undefined` when the quorum is platform-only (or the wallet is not secp) —
1068
- * the POST then completes alone, unchanged. A device-present quorum without the device deps or
1069
- * stored share fails fast with a typed error instead of a guaranteed server-side timeout.
1119
+ * The roles that will actually sign the first `threshold` of the wallet's party list (#292),
1120
+ * mirroring the signer's own derivation. `undefined` when the topology is unknown (a legacy
1121
+ * wallet record), which callers treat as "let the server decide".
1070
1122
  */
1071
- startDeviceCoSign(digest) {
1123
+ signQuorum() {
1072
1124
  if (this.data.curve === "ed25519") return void 0;
1073
1125
  const roles = this.data.parties;
1074
1126
  const { threshold } = this.data;
1075
1127
  if (!Array.isArray(roles) || typeof threshold !== "number") return void 0;
1076
- const quorum = roles.slice(0, threshold);
1077
- const pos = quorum.indexOf("device");
1078
- if (pos < 0) return void 0;
1128
+ return roles.slice(0, threshold);
1129
+ }
1130
+ /**
1131
+ * Everything this device needs to join a ceremony, resolved BEFORE one is started (#89): the MPC
1132
+ * core, the routed-sign capability a >2-party quorum needs, and the stored key share.
1133
+ *
1134
+ * Each failure is a typed error thrown straight to the caller, and the ordering is the point: a
1135
+ * device that cannot co-sign must never leave the platform party waiting on the relay for a
1136
+ * counterpart that will never arrive. That wait ends at the party-runner timeout (~210s) and
1137
+ * reaches the caller as an opaque 5xx — minutes after a knowable, local cause.
1138
+ */
1139
+ async loadDeviceParty(quorum, action) {
1079
1140
  const { mpc, shareStore } = this.device;
1080
1141
  if (!mpc || !shareStore) {
1081
1142
  throw new WaaskeyError(
1082
- `This wallet's sign quorum [${quorum.join(", ")}] includes the device, so signing requires the device MPC core and share store \u2014 pass \`mpc\` and \`shareStore\` to \`new Waaskey(...)\`.`,
1143
+ `This wallet's signing quorum [${quorum.join(", ")}] includes the device, so ${action} requires the device MPC core and share store \u2014 pass \`mpc\` and \`shareStore\` to \`new Waaskey(...)\`.`,
1083
1144
  "device_core_required"
1084
1145
  );
1085
1146
  }
1086
- return (async () => {
1087
- const blob = await shareStore.get(this.id);
1088
- if (!blob) {
1089
- throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the sign quorum.`, "share_not_found");
1090
- }
1091
- const { keyShare, relayUrl } = deserializeShare(blob);
1092
- if (!relayUrl) {
1093
- throw new WaaskeyError("The stored device share predates relay-URL persistence \u2014 re-create the wallet (or recover) to enable device-present signing.", "share_not_found");
1094
- }
1095
- const participants = quorum.map((_role, index) => index);
1096
- const base = { curve: toMpcCurve(this.data.curve), relayUrl, sessionId: this.id, share: keyShare, participants, signerPosition: pos, digest };
1097
- if (quorum.length === 2) {
1098
- return mpc.runSign({ ...base, role: quorum[pos], peerRole: quorum[1 - pos], partyIndex: pos, peerPartyIndex: 1 - pos });
1099
- }
1100
- if (!mpc.runMemberSign) {
1101
- throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1102
- }
1103
- return mpc.runMemberSign({ ...base, roles: quorum });
1104
- })();
1147
+ if (quorum.length > 2 && !mpc.runMemberSign) {
1148
+ throw new WaaskeyError(`This wallet's sign quorum has ${quorum.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`, "unsupported");
1149
+ }
1150
+ const blob = await shareStore.get(this.id);
1151
+ if (!blob) {
1152
+ throw new WaaskeyError(`No stored device share for wallet ${this.id} \u2014 this device cannot join the signing quorum.`, "share_not_found");
1153
+ }
1154
+ const { keyShare } = deserializeShare(blob);
1155
+ return { mpc, keyShare };
1156
+ }
1157
+ /**
1158
+ * Normalize a sign-session descriptor into the shared {@link DeviceCeremony}. The 2-party roster
1159
+ * comes from the descriptor's own `role`/`peerRole` (authoritative the relay token is bound to
1160
+ * that role); a larger quorum carries no server-sent roster, so the wallet's own party slice — the
1161
+ * same slice the signer derives — supplies it.
1162
+ */
1163
+ toSignCeremony(session, quorum) {
1164
+ return {
1165
+ curve: session.curve,
1166
+ relayUrl: session.relayUrl,
1167
+ sessionId: session.sessionId,
1168
+ roles: session.participants.length === 2 ? sessionRoles(session) : quorum,
1169
+ participants: session.participants,
1170
+ signerPosition: session.signerPosition,
1171
+ relayToken: session.relayToken
1172
+ };
1173
+ }
1174
+ /**
1175
+ * Run this device's half of a started ceremony. A 2-party quorum uses the plain single-peer
1176
+ * transport; anything larger MUST be roster-routed, or the transport attributes every inbound
1177
+ * message to the one configured peer and the protocol aborts on the third party's first message
1178
+ * (waas-core#131).
1179
+ */
1180
+ runDeviceSign(mpc, ceremony, keyShare, digest) {
1181
+ if (ceremony.participants.length === 2) {
1182
+ return mpc.runSign(toDeviceSignParams(ceremony, keyShare, digest));
1183
+ }
1184
+ if (!mpc.runMemberSign) {
1185
+ throw new WaaskeyError(
1186
+ `This wallet's sign quorum has ${ceremony.participants.length} parties, which needs an MPC core with routed (member-ceremony) sign support.`,
1187
+ "unsupported"
1188
+ );
1189
+ }
1190
+ return mpc.runMemberSign({
1191
+ curve: toMpcCurve(ceremony.curve),
1192
+ relayUrl: ceremony.relayUrl,
1193
+ sessionId: ceremony.sessionId,
1194
+ relayToken: ceremony.relayToken,
1195
+ roles: ceremony.roles,
1196
+ share: keyShare,
1197
+ participants: ceremony.participants,
1198
+ signerPosition: ceremony.signerPosition,
1199
+ digest
1200
+ });
1105
1201
  }
1106
1202
  /**
1107
1203
  * Send a transaction from this wallet. The platform builds the chain-specific transaction
@@ -1121,19 +1217,60 @@ var Wallet = class {
1121
1217
  if (this.data.curve === "ed25519") {
1122
1218
  return this.sendEd25519(params, options);
1123
1219
  }
1220
+ const quorum = this.signQuorum();
1221
+ if ((quorum?.indexOf(DEVICE_ROLE) ?? -1) >= 0) {
1222
+ return this.sendWithDevice(params, options, quorum);
1223
+ }
1124
1224
  const body = { ...params };
1125
1225
  await this.attachStepUp(body, "send", options);
1126
1226
  const res = await this.http.request("POST", `/v1/wallets/${this.id}/send`, body, options.signal);
1127
1227
  this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1128
1228
  return res;
1129
1229
  }
1230
+ /**
1231
+ * Device-co-signed send for a secp wallet — the cggmp24 counterpart of {@link sendEd25519}:
1232
+ *
1233
+ * 1. **START** (`POST …/send-session`): the platform runs the transfer gates, builds the unsigned
1234
+ * tx, puts its own party on the relay in the background, and returns the 32-byte digest plus
1235
+ * the relay coordination. It does NOT wait for the ceremony.
1236
+ * 2. **CO-SIGN**: this device runs its half over the relay with its stored share; cggmp24 hands
1237
+ * the completed signature to both parties.
1238
+ * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the platform verifies the signature
1239
+ * (recovers to the wallet key AND equals its own party's) and embeds it into the wire tx.
1240
+ *
1241
+ * Every device-side precondition is resolved BEFORE the START (see {@link loadDeviceParty}), so a
1242
+ * device that cannot co-sign costs nothing: no tx is built and no platform party is left waiting.
1243
+ *
1244
+ * **A co-sign that fails after a successful START rejects with its typed error.** START is not the
1245
+ * commit point — it yields an *unsigned* tx and a pending session, and nothing broadcastable exists
1246
+ * until ASSEMBLE returns a `signedTx` — so there is no result to salvage by swallowing the failure,
1247
+ * and no fallback to `POST /send` (the platform cannot reach the threshold on this wallet alone, so
1248
+ * a retry there would only hang). The backend expires the abandoned session and fails the tx row.
1249
+ */
1250
+ async sendWithDevice(params, options, quorum) {
1251
+ const { signal } = options;
1252
+ const { mpc, keyShare } = await this.loadDeviceParty(quorum, "sending");
1253
+ throwIfAborted(signal);
1254
+ const startBody = { ...params };
1255
+ await this.attachStepUp(startBody, "send", options);
1256
+ throwIfAborted(signal);
1257
+ const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1258
+ const digest = sendPayload(session, "digest");
1259
+ throwIfAborted(signal);
1260
+ const { signature } = await this.runDeviceSign(mpc, toSendCeremony(session), keyShare, digest);
1261
+ throwIfAborted(signal);
1262
+ const assemble = { signature };
1263
+ const res = await this.http.request("POST", `/v1/wallets/${this.id}/send-session/${session.txId}/assemble`, assemble, signal);
1264
+ this.analytics?.track("wallet.sent", { walletId: this.id, chain: params.chainId });
1265
+ return res;
1266
+ }
1130
1267
  /**
1131
1268
  * Device-co-signed send for an ed25519 (FROST) wallet (#110) — the browser holds the device FROST
1132
1269
  * share and co-signs 2-party with the backend `server` party over the relay:
1133
1270
  *
1134
1271
  * 1. **START** (`POST …/send-session`): the backend builds the unsigned tx (chain adapter), starts
1135
1272
  * its server FROST party on the relay in the background, and returns the raw `message` bytes to
1136
- * sign + the relay coordination ({@link EddsaSendSession}).
1273
+ * sign + the relay coordination ({@link SendSessionResponse}).
1137
1274
  * 2. **CO-SIGN**: the device runs `signEddsa` over the relay with its stored `{keyPackage,
1138
1275
  * publicKeyPackage}` share; the two parties aggregate the RFC 8032 signature (returned locally).
1139
1276
  * 3. **ASSEMBLE** (`POST …/send-session/:txId/assemble`): the backend embeds the aggregated
@@ -1168,6 +1305,7 @@ var Wallet = class {
1168
1305
  await this.attachStepUp(startBody, "send", options);
1169
1306
  throwIfAborted(signal);
1170
1307
  const session = await this.http.request("POST", `/v1/wallets/${this.id}/send-session`, startBody, signal);
1308
+ const message = sendPayload(session, "message");
1171
1309
  throwIfAborted(signal);
1172
1310
  let signature;
1173
1311
  try {
@@ -1179,7 +1317,7 @@ var Wallet = class {
1179
1317
  keyPackage,
1180
1318
  publicKeyPackage,
1181
1319
  participants: session.participants,
1182
- message: session.message,
1320
+ message,
1183
1321
  relayToken: session.relayToken
1184
1322
  }));
1185
1323
  } catch (cause) {
@@ -1228,6 +1366,26 @@ async function attachPasskeyStepUp(http, walletId, body, operation, options) {
1228
1366
  body["passkeyAssertion"] = await getSigningAssertion(challenge, { credentialId: options.passkeyCredentialId });
1229
1367
  body["passkeyChallengeId"] = challengeId;
1230
1368
  }
1369
+ function toSendCeremony(session) {
1370
+ return {
1371
+ curve: session.curve,
1372
+ relayUrl: session.relayUrl,
1373
+ sessionId: session.sessionId,
1374
+ roles: session.roles,
1375
+ participants: session.participants,
1376
+ signerPosition: session.signerPosition,
1377
+ relayToken: session.relayToken
1378
+ };
1379
+ }
1380
+ function sendPayload(session, field) {
1381
+ const payload = session[field];
1382
+ if (!payload) {
1383
+ throw new WaaskeyError(`The send session did not return the \`${field}\` this wallet's curve (${session.curve}) signs.`, "sign_failed", {
1384
+ details: { txId: session.txId, curve: session.curve }
1385
+ });
1386
+ }
1387
+ return payload;
1388
+ }
1231
1389
  function normalizeDigest(digest) {
1232
1390
  const hex = digest.startsWith("0x") || digest.startsWith("0X") ? digest.slice(2) : digest;
1233
1391
  if (!/^[0-9a-fA-F]{64}$/.test(hex)) {
@@ -1690,7 +1848,7 @@ var Wallets = class {
1690
1848
  throwIfAborted(signal);
1691
1849
  let signature;
1692
1850
  try {
1693
- ({ signature } = await mpc.runSign(toUserBackupSignParams(session, share, digest)));
1851
+ ({ signature } = await mpc.runSign(toSessionSignParams(session, share, digest)));
1694
1852
  } catch (cause) {
1695
1853
  if (cause instanceof WaaskeyError) throw cause;
1696
1854
  throw new WaaskeyError("The user_backup device recover-sign ceremony failed.", "sign_failed", { cause });
@@ -1836,29 +1994,6 @@ async function restoreUserBackupShare(ciphertext, recoveryCode) {
1836
1994
  }
1837
1995
  return deserializeShare(blob).keyShare;
1838
1996
  }
1839
- function toUserBackupSignParams(session, share, digest) {
1840
- if (session.participants.length !== 2 || session.signerPosition < 0 || session.signerPosition > 1) {
1841
- throw new WaaskeyError(
1842
- `Unexpected recover-sign descriptor: the {server, user_backup} quorum must be exactly 2 parties with signerPosition in {0, 1} (got ${session.participants.length} participants, signerPosition ${session.signerPosition}).`,
1843
- "sign_failed",
1844
- { details: { participants: session.participants, signerPosition: session.signerPosition } }
1845
- );
1846
- }
1847
- return {
1848
- curve: toMpcCurve(session.curve),
1849
- relayUrl: session.relayUrl,
1850
- sessionId: session.sessionId,
1851
- role: session.role,
1852
- peerRole: session.peerRole,
1853
- partyIndex: session.signerPosition,
1854
- peerPartyIndex: 1 - session.signerPosition,
1855
- relayToken: session.relayToken,
1856
- share,
1857
- participants: session.participants,
1858
- signerPosition: session.signerPosition,
1859
- digest
1860
- };
1861
- }
1862
1997
  function memberShareKey(walletId, membershipId) {
1863
1998
  return `${walletId}@member-${membershipId}`;
1864
1999
  }
@@ -1875,7 +2010,17 @@ function parseBackupPayload(stored, walletId) {
1875
2010
  if (typeof parsed.ciphertext !== "string" || !Array.isArray(parsed.factors)) {
1876
2011
  throw new WaaskeyError(`The pending user_backup backup for wallet "${walletId}" is malformed.`, "backup_failed", { details: { walletId, keys: Object.keys(parsed) } });
1877
2012
  }
1878
- return { ciphertext: parsed.ciphertext, factors: parsed.factors };
2013
+ return { ciphertext: parsed.ciphertext, factors: parsed.factors.map((factor) => normalizeStoredFactor(factor, walletId)) };
2014
+ }
2015
+ function normalizeStoredFactor(factor, walletId) {
2016
+ const { type, credential, credentialHash } = factor ?? {};
2017
+ const value = typeof credential === "string" && credential !== "" ? credential : credentialHash;
2018
+ if (typeof type !== "string" || type === "" || typeof value !== "string" || value === "") {
2019
+ 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", {
2020
+ details: { walletId, factorKeys: Object.keys(factor ?? {}) }
2021
+ });
2022
+ }
2023
+ return { type, credential: value };
1879
2024
  }
1880
2025
  function membershipIdFromRole(role) {
1881
2026
  const prefix = "member:";
@@ -2332,7 +2477,7 @@ function decodePublicKey(sharedPublicKeyJson) {
2332
2477
 
2333
2478
  // src/mpc/load-wasm.ts
2334
2479
  var CLIENT_WASM_PACKAGE = "@waaskey/client-wasm";
2335
- var CLIENT_WASM_VERSION = "0.2.1";
2480
+ var CLIENT_WASM_VERSION = "0.2.2";
2336
2481
  async function verifyWasmIntegrity(bytes, expectedSha384) {
2337
2482
  if (!expectedSha384 || !expectedSha384.startsWith("sha384-")) {
2338
2483
  throw new Error("Waaskey: an expected SHA-384 integrity hash (sha384-<base64>) is required to load the wasm MPC core.");