decane-connect-kit 2.7.2 → 2.7.4

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
@@ -44,6 +44,7 @@ __export(errors_exports, {
44
44
  NewDeviceError: () => NewDeviceError,
45
45
  OriginNotAllowedError: () => OriginNotAllowedError,
46
46
  PromptPinNotConfiguredError: () => PromptPinNotConfiguredError,
47
+ ProvisioningTimeoutError: () => ProvisioningTimeoutError,
47
48
  ReconnectRequiredError: () => ReconnectRequiredError,
48
49
  RecoveryFailedError: () => RecoveryFailedError,
49
50
  RecoveryWalletMismatchError: () => RecoveryWalletMismatchError,
@@ -55,7 +56,7 @@ __export(errors_exports, {
55
56
  WebAuthnNotSupportedError: () => WebAuthnNotSupportedError,
56
57
  WrongPasswordError: () => WrongPasswordError
57
58
  });
58
- var DecaneConnectError, InvalidApiKeyError, OriginNotAllowedError, SessionExpiredError, UnlockRequiredError, ReconnectRequiredError, UserCancelledError, WebAuthnNotSupportedError, InvalidPinError, CallSequenceError, NetworkError, ShareCorruptedError, NewDeviceError, StaleDeviceShareError, PromptPinNotConfiguredError, AttestationError, RecoveryFailedError, InvalidRecoveryFileError, WrongPasswordError, RecoveryWalletMismatchError, ExportNotAuthorizedError;
59
+ var DecaneConnectError, InvalidApiKeyError, OriginNotAllowedError, SessionExpiredError, UnlockRequiredError, ReconnectRequiredError, UserCancelledError, WebAuthnNotSupportedError, InvalidPinError, CallSequenceError, NetworkError, ShareCorruptedError, ProvisioningTimeoutError, NewDeviceError, StaleDeviceShareError, PromptPinNotConfiguredError, AttestationError, RecoveryFailedError, InvalidRecoveryFileError, WrongPasswordError, RecoveryWalletMismatchError, ExportNotAuthorizedError;
59
60
  var init_errors = __esm({
60
61
  "src/social/errors.ts"() {
61
62
  "use strict";
@@ -126,6 +127,11 @@ var init_errors = __esm({
126
127
  super("SHARE_CORRUPTED", "Stored device share is corrupted");
127
128
  }
128
129
  };
130
+ ProvisioningTimeoutError = class extends DecaneConnectError {
131
+ constructor(step) {
132
+ super("PROVISIONING_TIMEOUT", `Wallet setup timed out while ${step}. Check your connection and try again.`, true);
133
+ }
134
+ };
129
135
  NewDeviceError = class extends DecaneConnectError {
130
136
  constructor() {
131
137
  super("NEW_DEVICE", "No device share found. Re-authenticate to set up this device.");
@@ -204,6 +210,7 @@ __export(index_exports, {
204
210
  NewDeviceError: () => NewDeviceError,
205
211
  OriginNotAllowedError: () => OriginNotAllowedError,
206
212
  PromptPinNotConfiguredError: () => PromptPinNotConfiguredError,
213
+ ProvisioningTimeoutError: () => ProvisioningTimeoutError,
207
214
  ReconnectRequiredError: () => ReconnectRequiredError,
208
215
  RecoveryFailedError: () => RecoveryFailedError,
209
216
  RecoveryWalletMismatchError: () => RecoveryWalletMismatchError,
@@ -2007,6 +2014,7 @@ var ERROR_MAP = {
2007
2014
  // generic failure message.
2008
2015
  UNAUTHORIZED: ReconnectRequiredError
2009
2016
  };
2017
+ var REQUEST_TIMEOUT_MS = 3e4;
2010
2018
  var ApiClient = class {
2011
2019
  constructor(baseUrl, apiKey, appId) {
2012
2020
  this.baseUrl = baseUrl;
@@ -2015,9 +2023,12 @@ var ApiClient = class {
2015
2023
  }
2016
2024
  async _request(path, init) {
2017
2025
  let res;
2026
+ const controller = new AbortController();
2027
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
2018
2028
  try {
2019
2029
  res = await fetch(`${this.baseUrl}${path}`, {
2020
2030
  ...init,
2031
+ signal: controller.signal,
2021
2032
  headers: {
2022
2033
  "Content-Type": "application/json",
2023
2034
  "X-API-Key": this.apiKey,
@@ -2027,6 +2038,8 @@ var ApiClient = class {
2027
2038
  });
2028
2039
  } catch {
2029
2040
  throw new NetworkError();
2041
+ } finally {
2042
+ clearTimeout(timer);
2030
2043
  }
2031
2044
  if (res.status === 404 && init?.allow404) return null;
2032
2045
  if (!res.ok) {
@@ -2329,6 +2342,36 @@ function bytesToBase64(b) {
2329
2342
  function base64ToBytes(s) {
2330
2343
  return Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
2331
2344
  }
2345
+ var TEE_TIMEOUT_MS = 3e4;
2346
+ async function teeFetch(url, init) {
2347
+ const controller = new AbortController();
2348
+ const timer = setTimeout(() => controller.abort(), TEE_TIMEOUT_MS);
2349
+ try {
2350
+ return await fetch(url, { ...init, signal: controller.signal });
2351
+ } finally {
2352
+ clearTimeout(timer);
2353
+ }
2354
+ }
2355
+ var ATTESTATION_TTL_MS = 10 * 6e4;
2356
+ var attestationCache = /* @__PURE__ */ new Map();
2357
+ function attestOnce(teeUrl, verify) {
2358
+ const hit = attestationCache.get(teeUrl);
2359
+ if (hit && Date.now() - hit.at < ATTESTATION_TTL_MS) return hit.promise;
2360
+ const promise = verify().catch((e) => {
2361
+ attestationCache.delete(teeUrl);
2362
+ throw e;
2363
+ });
2364
+ attestationCache.set(teeUrl, { at: Date.now(), promise });
2365
+ return promise;
2366
+ }
2367
+ function prewarmAttestation(teeUrl) {
2368
+ void attestOnce(teeUrl, async () => {
2369
+ const nonce = crypto.getRandomValues(new Uint8Array(16));
2370
+ const nonceHex = Array.from(nonce).map((b) => b.toString(16).padStart(2, "0")).join("");
2371
+ await verifyTeeAttestation(teeUrl, nonceHex);
2372
+ }).catch(() => {
2373
+ });
2374
+ }
2332
2375
  var TeeClient = class {
2333
2376
  constructor(teeUrl) {
2334
2377
  this.teeUrl = teeUrl;
@@ -2370,9 +2413,11 @@ var TeeClient = class {
2370
2413
  }
2371
2414
  async ensureAttested() {
2372
2415
  if (this.attested) return;
2373
- const nonce = crypto.getRandomValues(new Uint8Array(16));
2374
- const nonceHex = Array.from(nonce).map((b) => b.toString(16).padStart(2, "0")).join("");
2375
- await verifyTeeAttestation(this.teeUrl, nonceHex);
2416
+ await attestOnce(this.teeUrl, async () => {
2417
+ const nonce = crypto.getRandomValues(new Uint8Array(16));
2418
+ const nonceHex = Array.from(nonce).map((b) => b.toString(16).padStart(2, "0")).join("");
2419
+ await verifyTeeAttestation(this.teeUrl, nonceHex);
2420
+ });
2376
2421
  this.attested = true;
2377
2422
  }
2378
2423
  /**
@@ -2384,7 +2429,7 @@ var TeeClient = class {
2384
2429
  */
2385
2430
  async openSession(deviceShare, jwt, requireAssertionPerSignature = false) {
2386
2431
  await this.ensureAttested();
2387
- const res = await fetch(`${this.teeUrl}/signer/session`, {
2432
+ const res = await teeFetch(`${this.teeUrl}/signer/session`, {
2388
2433
  method: "POST",
2389
2434
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2390
2435
  body: JSON.stringify({
@@ -2416,7 +2461,7 @@ var TeeClient = class {
2416
2461
  */
2417
2462
  async recoverWithShare(jwt, recoveryShare) {
2418
2463
  await this.ensureAttested();
2419
- const res = await fetch(`${this.teeUrl}/signer/recover`, {
2464
+ const res = await teeFetch(`${this.teeUrl}/signer/recover`, {
2420
2465
  method: "POST",
2421
2466
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2422
2467
  body: JSON.stringify({ recoveryShare: bytesToBase64(recoveryShare) })
@@ -2441,7 +2486,7 @@ var TeeClient = class {
2441
2486
  * require proof of fresh authenticator possession, not just an active session.
2442
2487
  */
2443
2488
  async completeAssertion(jwt) {
2444
- const res = await fetch(`${this.teeUrl}/signer/assertion-challenge`, {
2489
+ const res = await teeFetch(`${this.teeUrl}/signer/assertion-challenge`, {
2445
2490
  method: "POST",
2446
2491
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2447
2492
  body: JSON.stringify({ rpId: window.location.hostname })
@@ -2470,7 +2515,7 @@ var TeeClient = class {
2470
2515
  */
2471
2516
  async enrolAuthShare(jwt, recoveryShare) {
2472
2517
  await this.ensureAttested();
2473
- const res = await fetch(`${this.teeUrl}/signer/auth-share`, {
2518
+ const res = await teeFetch(`${this.teeUrl}/signer/auth-share`, {
2474
2519
  method: "POST",
2475
2520
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2476
2521
  body: JSON.stringify({ share: bytesToBase64(recoveryShare) })
@@ -2493,7 +2538,7 @@ var TeeClient = class {
2493
2538
  */
2494
2539
  async provisionDeviceShare(jwt) {
2495
2540
  await this.ensureAttested();
2496
- const res = await fetch(`${this.teeUrl}/signer/provision`, {
2541
+ const res = await teeFetch(`${this.teeUrl}/signer/provision`, {
2497
2542
  method: "POST",
2498
2543
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }
2499
2544
  });
@@ -2511,7 +2556,7 @@ var TeeClient = class {
2511
2556
  async rotateShares(jwt) {
2512
2557
  if (!this.handle) throw new Error("no session");
2513
2558
  const { challengeId, assertion } = await this.completeAssertion(jwt);
2514
- const res = await fetch(`${this.teeUrl}/signer/rotate`, {
2559
+ const res = await teeFetch(`${this.teeUrl}/signer/rotate`, {
2515
2560
  method: "POST",
2516
2561
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2517
2562
  body: JSON.stringify({ handle: this.handle, challengeId, assertion, origin: window.location.origin })
@@ -2528,7 +2573,7 @@ var TeeClient = class {
2528
2573
  async exportPortableBackup(jwt) {
2529
2574
  if (!this.handle) throw new Error("no session");
2530
2575
  const { challengeId, assertion } = await this.completeAssertion(jwt);
2531
- const res = await fetch(`${this.teeUrl}/signer/export-portable`, {
2576
+ const res = await teeFetch(`${this.teeUrl}/signer/export-portable`, {
2532
2577
  method: "POST",
2533
2578
  headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
2534
2579
  body: JSON.stringify({ handle: this.handle, challengeId, assertion, origin: window.location.origin })
@@ -2558,7 +2603,7 @@ var TeeClient = class {
2558
2603
  if (!jwt) throw new Error("sign requires a jwt when per-signature assertion is enabled");
2559
2604
  gate = await this.completeAssertion(jwt);
2560
2605
  }
2561
- const res = await fetch(`${this.teeUrl}/signer/sign`, {
2606
+ const res = await teeFetch(`${this.teeUrl}/signer/sign`, {
2562
2607
  method: "POST",
2563
2608
  headers: { "Content-Type": "application/json" },
2564
2609
  body: JSON.stringify({
@@ -3499,6 +3544,64 @@ async function createDecaneConnect(config) {
3499
3544
  let ethProvider = null;
3500
3545
  const persistSession = config.persistSession !== false;
3501
3546
  const serverAssistedRecovery = true;
3547
+ const RECOVERY_OFFER_TIMEOUT_MS = 5 * 6e4;
3548
+ const selfHealAttempted = /* @__PURE__ */ new Set();
3549
+ async function selfHealEnrolment(userId, jwt, share) {
3550
+ try {
3551
+ if (selfHealAttempted.has(userId)) return;
3552
+ const status = await api.getRecoveryStatus(jwt);
3553
+ if (status.enrolled !== false) return;
3554
+ selfHealAttempted.add(userId);
3555
+ await new TeeClient(teeUrl).enrolAuthShare(jwt, share);
3556
+ } catch (e) {
3557
+ events.emit("enrolment-failed");
3558
+ console.warn(
3559
+ "decane-connect-kit: this wallet is not enrolled for server-assisted recovery and the automatic repair failed. It still works on this device, but a new device will need a passkey backup or a recovery file. The SDK will try again on the next unlock.",
3560
+ e
3561
+ );
3562
+ } finally {
3563
+ zeroize(share);
3564
+ }
3565
+ }
3566
+ async function enrolInBackground(jwt, share) {
3567
+ try {
3568
+ for (let attempt = 1; attempt <= 2; attempt++) {
3569
+ try {
3570
+ await new TeeClient(teeUrl).enrolAuthShare(jwt, share);
3571
+ return;
3572
+ } catch (e) {
3573
+ if (attempt === 2) {
3574
+ events.emit("enrolment-failed");
3575
+ console.warn(
3576
+ "decane-connect-kit: could not enrol this wallet for server-assisted recovery. The wallet is fine and fully usable; it will be re-enrolled automatically on the next unlock. Until then a brand-new device would need a passkey backup or a recovery file.",
3577
+ e
3578
+ );
3579
+ }
3580
+ }
3581
+ }
3582
+ } finally {
3583
+ zeroize(share);
3584
+ }
3585
+ }
3586
+ const PROVISIONING_BUDGET_MS = 9e4;
3587
+ function withDeadline(promise, ms, step) {
3588
+ let timer;
3589
+ return Promise.race([
3590
+ promise,
3591
+ new Promise((_, reject) => {
3592
+ timer = setTimeout(() => reject(new ProvisioningTimeoutError(step)), Math.max(0, ms));
3593
+ })
3594
+ ]).finally(() => clearTimeout(timer));
3595
+ }
3596
+ function withTimeout(promise, ms) {
3597
+ let timer;
3598
+ return Promise.race([
3599
+ promise,
3600
+ new Promise((resolve) => {
3601
+ timer = setTimeout(() => resolve(null), ms);
3602
+ })
3603
+ ]).finally(() => clearTimeout(timer));
3604
+ }
3502
3605
  const SESSION_KEY = sessionPersistKey(config.apiKey);
3503
3606
  function persistLiveSession() {
3504
3607
  if (!persistSession) return;
@@ -3610,6 +3713,7 @@ async function createDecaneConnect(config) {
3610
3713
  }
3611
3714
  }
3612
3715
  async function firstTimeSetup(userId, jwt) {
3716
+ prewarmAttestation(teeUrl);
3613
3717
  let unlockMethod;
3614
3718
  let wrappingKey;
3615
3719
  let meta = {};
@@ -3639,6 +3743,8 @@ async function createDecaneConnect(config) {
3639
3743
  ({ wrappingKey, meta } = await pinSetup());
3640
3744
  unlockMethod = "pin";
3641
3745
  }
3746
+ const provisioningStartedAt = Date.now();
3747
+ const remainingBudget = () => PROVISIONING_BUDGET_MS - (Date.now() - provisioningStartedAt);
3642
3748
  const master = generateMaster();
3643
3749
  let shareSet;
3644
3750
  let evmAddress;
@@ -3670,7 +3776,7 @@ async function createDecaneConnect(config) {
3670
3776
  version: 1
3671
3777
  });
3672
3778
  const serverIv = generateRandomBytes(12);
3673
- await api.putShare(jwt, {
3779
+ await withDeadline(api.putShare(jwt, {
3674
3780
  ciphertext: shareSet.server,
3675
3781
  iv: serverIv,
3676
3782
  wrappedShareMeta: {
@@ -3680,7 +3786,7 @@ async function createDecaneConnect(config) {
3680
3786
  evmAddress,
3681
3787
  solanaAddress,
3682
3788
  tronAddress
3683
- });
3789
+ }), remainingBudget(), "storing your wallet");
3684
3790
  zeroize(shareSet.server);
3685
3791
  const addresses = { evm: evmAddress, solana: solanaAddress, tron: tronAddress };
3686
3792
  if (unlockMethod === "passkey" && meta.credentialId && meta.prfSalt) {
@@ -3696,7 +3802,11 @@ async function createDecaneConnect(config) {
3696
3802
  console.warn("decane-connect-kit: wrapped share backup failed; user can retry from settings.", e);
3697
3803
  }
3698
3804
  }
3699
- const confirmed = await session.start(shareSet.device, jwt, addresses);
3805
+ const confirmed = await withDeadline(
3806
+ session.start(shareSet.device, jwt, addresses),
3807
+ remainingBudget(),
3808
+ "opening the signing session"
3809
+ );
3700
3810
  zeroize(shareSet.device);
3701
3811
  if (confirmed.evm.toLowerCase() !== addresses.evm.toLowerCase()) {
3702
3812
  console.warn(
@@ -3707,42 +3817,49 @@ async function createDecaneConnect(config) {
3707
3817
  addresses.solana = solanaAddress = confirmed.solana;
3708
3818
  addresses.tron = tronAddress = confirmed.tron ?? evmToTronAddress2(confirmed.evm);
3709
3819
  if (serverAssistedRecovery) {
3710
- for (let attempt = 1; attempt <= 2; attempt++) {
3711
- try {
3712
- const tee = new TeeClient(teeUrl);
3713
- await tee.enrolAuthShare(jwt, shareSet.recovery);
3714
- break;
3715
- } catch (e) {
3716
- if (attempt === 2) {
3717
- console.warn(
3718
- "decane-connect-kit: could not enrol this wallet for server-assisted recovery; a recovery file will be required on a new device. This wallet will get a 409 not_enrolled from /signer/provision until it is re-enrolled.",
3719
- e
3720
- );
3721
- }
3722
- }
3723
- }
3820
+ void enrolInBackground(jwt, new Uint8Array(shareSet.recovery));
3724
3821
  }
3725
- if (config.offerRecoveryAtSignup !== false && config.onRecoveryShareOffer) {
3726
- try {
3727
- const choice = await config.onRecoveryShareOffer({ evmAddress, solanaAddress });
3728
- if (choice.wants) {
3729
- const file = await buildRecoveryShareFile(
3730
- shareSet.recovery,
3731
- choice.password,
3732
- addresses,
3733
- shareSet.version,
3734
- choice.passwordHint
3735
- );
3736
- await config.onRecoveryFileReady?.(file, recoveryShareFileName(evmAddress));
3737
- await api.markRecoveryShareSaved(jwt);
3738
- }
3739
- } catch (e) {
3740
- console.warn("decane-connect-kit: recovery share offer failed or was skipped.", e);
3741
- }
3822
+ if (config.offerRecoveryAtSignup === true && config.onRecoveryShareOffer) {
3823
+ void offerRecoveryShare(
3824
+ jwt,
3825
+ new Uint8Array(shareSet.recovery),
3826
+ { ...addresses },
3827
+ shareSet.version,
3828
+ evmAddress,
3829
+ solanaAddress
3830
+ );
3742
3831
  }
3743
3832
  zeroize(shareSet.recovery);
3744
3833
  return addresses;
3745
3834
  }
3835
+ async function offerRecoveryShare(jwt, recoveryShare, addresses, shareSetVersion, evmAddress, solanaAddress) {
3836
+ try {
3837
+ const choice = await withTimeout(
3838
+ config.onRecoveryShareOffer({ evmAddress, solanaAddress }),
3839
+ RECOVERY_OFFER_TIMEOUT_MS
3840
+ );
3841
+ if (!choice) {
3842
+ console.warn(
3843
+ `decane-connect-kit: onRecoveryShareOffer did not settle within ${RECOVERY_OFFER_TIMEOUT_MS / 6e4} minutes, so no recovery file was offered. The wallet is unaffected \u2014 it was already created and enrolled \u2014 and the user can generate a recovery share later via rotateShares(). Check that your handler always resolves or rejects.`
3844
+ );
3845
+ return;
3846
+ }
3847
+ if (!choice.wants) return;
3848
+ const file = await buildRecoveryShareFile(
3849
+ recoveryShare,
3850
+ choice.password,
3851
+ addresses,
3852
+ shareSetVersion,
3853
+ choice.passwordHint
3854
+ );
3855
+ await config.onRecoveryFileReady?.(file, recoveryShareFileName(evmAddress));
3856
+ await api.markRecoveryShareSaved(jwt);
3857
+ } catch (e) {
3858
+ console.warn("decane-connect-kit: recovery share offer failed or was skipped.", e);
3859
+ } finally {
3860
+ zeroize(recoveryShare);
3861
+ }
3862
+ }
3746
3863
  async function returningUserUnlock(userId, jwt) {
3747
3864
  const rawAddresses = await api.getAddresses(jwt);
3748
3865
  const recordedAddresses = rawAddresses.evm && rawAddresses.solana ? { evm: rawAddresses.evm, solana: rawAddresses.solana, tron: rawAddresses.tron ?? evmToTronAddress2(rawAddresses.evm) } : null;
@@ -3828,6 +3945,7 @@ async function createDecaneConnect(config) {
3828
3945
  }
3829
3946
  throw e;
3830
3947
  }
3948
+ void selfHealEnrolment(userId, jwt, new Uint8Array(deviceShare));
3831
3949
  zeroize(deviceShare);
3832
3950
  if (recordedAddresses && recordedAddresses.evm.toLowerCase() !== addresses.evm.toLowerCase()) {
3833
3951
  console.warn(
@@ -7273,6 +7391,7 @@ function useSignTypedData() {
7273
7391
  NewDeviceError,
7274
7392
  OriginNotAllowedError,
7275
7393
  PromptPinNotConfiguredError,
7394
+ ProvisioningTimeoutError,
7276
7395
  ReconnectRequiredError,
7277
7396
  RecoveryFailedError,
7278
7397
  RecoveryWalletMismatchError,