@txnlab/use-wallet-passkey 5.2.0-next.3 → 5.2.0-next.5

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 CHANGED
@@ -5,7 +5,31 @@
5
5
 
6
6
  Passkey adapter for [use-wallet](https://github.com/TxnLab/use-wallet), the Algorand wallet integration library.
7
7
 
8
- It derives a **Falcon-1024 (post-quantum) account** from a passkey's WebAuthn PRF output. Users need no extension, app or seed phrase: their passkey provider (iCloud Keychain, Google Password Manager, 1Password, Windows Hello or a FIDO2 security key) stores and syncs the credential.
8
+ It derives an **app-scoped Falcon-1024 (post-quantum) account** from a passkey's WebAuthn PRF output. Users need no extension, app or seed phrase: their passkey provider (iCloud Keychain, Google Password Manager, 1Password, Windows Hello or a FIDO2 security key) stores and syncs the credential.
9
+
10
+ The account belongs to the site that creates it. Read the security model before you ship it.
11
+
12
+ ## Security model
13
+
14
+ The key is derived inside your page, so your site is part of the account's trust boundary:
15
+
16
+ - **Your site can take the key.** The passkey only works on its domain (the RP ID), and any script running there can derive the account's key with one verified passkey prompt, which looks the same as signing in. That includes your own code, an XSS bug, a compromised dependency or third-party script, a takeover of your hosting, deploy or DNS accounts, a re-registered domain, and every subdomain when `rpId` is a parent domain.
17
+ - **The library can't protect users from the site.** It runs in your page, so nothing in it can stop the site itself from taking the key. Users have to trust your site the way they'd trust an app that holds a balance for them.
18
+ - **Other sites can't reach it.** Browsers bind a passkey to its domain, so another site can't derive this account, and this account gives no access to the user's other wallets.
19
+ - **An unlocked session signs without a new key prompt.** Page scripts can request signatures through the adapter: silently if `confirm` returns `'silent'`, otherwise behind a passkey prompt. Getting the key itself always takes a fully verified prompt.
20
+ - **Post-quantum is bounded by the passkey's custody.** The PRF itself is symmetric, but synced passkeys are protected by each provider's classical encryption, and the CTAP2 channel uses P-256. Hardware security keys that don't sync close more of that gap.
21
+ - **Only public data is stored** (credential ID, public key, address). The private key lives in a worker, never on the page's main thread, and is dropped on lock.
22
+
23
+ Tell users plainly whose account it is, for example: _"This account is created by and for example.com. Keep only what you need here, and move larger amounts to a wallet you control."_ For the same reason, the adapter names the account after its domain (`Passkey · example.com`).
24
+
25
+ ### Best practices
26
+
27
+ - Present it as a starting account: keep balances small, and offer both [moving to a wallet](#moving-to-a-wallet) and the [recovery phrase](#recovery-phrase).
28
+ - Set a strict [Content Security Policy](#content-security-policy) and load as few third-party scripts as you can.
29
+ - Keep `rpId` narrow: the default hostname, unless you need to share the account across subdomains.
30
+ - Protect the domain and the accounts behind it: registrar lock, auto-renew, and two-factor authentication on your registrar, DNS, hosting and deploy accounts.
31
+ - Choose `confirm` and the session timeouts deliberately, or [let users choose](#user-preferences). With `'silent'`, any script on your page can sign while the session is unlocked, so it's exactly as safe as your page. Asking for `'presence'` or `'verify'` above an amount you pick is a middle ground.
32
+ - Show what's being signed in your own UI before you call `signTransactions`. The passkey prompt can only show your site's name.
9
33
 
10
34
  ## Installation
11
35
 
@@ -37,15 +61,17 @@ Call `connect`, `signTransactions` and `signData` directly from the click handle
37
61
 
38
62
  ### Options
39
63
 
40
- | Option | Default | |
41
- | ---------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
42
- | `rpId` | `location.hostname` | The domain the passkey, and so the account, is bound to. Set a parent domain (`example.com`) to share one account across its subdomains. |
43
- | `rpName` | the RP ID | The name shown in passkey prompts |
44
- | `context` | `''` | Separates this app's account from others on the same domain. No `:`; changing it changes the account. |
45
- | `userName` | `<rpName> account` | The name new passkeys are saved under |
46
- | `session` | `false` | Keep the key unlocked between signatures (see below). `true` or `{ idleTimeoutMs }`; the default timeout is 15 minutes, and `0` disables it. |
47
- | `confirm` | `() => 'presence'` | How to confirm each signature while a session is unlocked |
48
- | `keyStore` | built-in worker | Replace where the key is held (`PasskeyKeyStore`) |
64
+ | Option | Default | |
65
+ | ---------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
66
+ | `rpId` | `location.hostname` | The domain the passkey, and so the account, is bound to. Set a parent domain (`example.com`) to share one account across its subdomains. |
67
+ | `rpName` | the RP ID | The name shown in passkey prompts |
68
+ | `context` | `''` | Separates this app's account from others on the same domain. No `:`; changing it changes the account. |
69
+ | `userName` | `<rpName> account` | The name new passkeys are saved under |
70
+ | `session` | `false` | Keep the key unlocked between signatures (see below). `true` or `{ idleTimeoutMs, maxAgeMs, lockWhenHidden }`. |
71
+ | `confirm` | `() => 'presence'` | How to confirm each signature while a session is unlocked |
72
+ | `onLock` | none | Called with a reason when an unlocked session locks (see below) |
73
+ | `onUnlock` | none | Called when a locked session unlocks (see below) |
74
+ | `keyStore` | built-in worker | Replace where the key is held (`PasskeyKeyStore`) |
49
75
 
50
76
  The factory also accepts an optional `metadata` option to override the wallet's display name and icon.
51
77
 
@@ -53,15 +79,23 @@ The factory also accepts an optional `metadata` option to override the wallet's
53
79
 
54
80
  Every passkey prompt that derives the key requires full user verification (biometric, PIN or system password), because the PRF output depends on it. Without `session`, every signing request runs one.
55
81
 
56
- With `session`, only the first request after connecting or reloading does. The key then stays in a dedicated worker until the idle timeout, `disconnect` or a page reload, and `confirm` decides how each later request is approved:
82
+ With `session`, only the first request after connecting or reloading does. The key then stays in a dedicated worker until the session locks, and `confirm` decides how each later request is approved:
57
83
 
58
84
  - `'silent'`: no prompt
59
85
  - `'presence'`: a passkey prompt with `userVerification: 'preferred'`, which some providers satisfy with a tap
60
86
  - `'verify'`: a passkey prompt with full user verification
61
87
 
88
+ A session locks on `disconnect`, on a page reload, and when any of these options says so:
89
+
90
+ - `idleTimeoutMs`: after this long without a signature. Default: 15 minutes.
91
+ - `maxAgeMs`: this long after it unlocked, however active it is. Default: 1 hour.
92
+ - `lockWhenHidden`: when the page is hidden, e.g. the user switches tabs or apps. A signing request in progress finishes first. Default: `false`, because on phones, leaving the browser to copy an address means one more verified prompt. Pass a function to follow a setting the user can change.
93
+
94
+ `0` turns off either timeout.
95
+
62
96
  ```typescript
63
97
  passkey({
64
- session: { idleTimeoutMs: 10 * 60 * 1000 },
98
+ session: { idleTimeoutMs: 10 * 60 * 1000, maxAgeMs: 30 * 60 * 1000, lockWhenHidden: true },
65
99
  confirm: (request) =>
66
100
  request.type === 'transactions' && request.transactions.some((txn) => txn.type === 'pay')
67
101
  ? 'verify'
@@ -71,6 +105,49 @@ passkey({
71
105
 
72
106
  `confirm` must be synchronous: awaiting before the prompt would lose the user's tap on iOS.
73
107
 
108
+ ### Unlocking and locking
109
+
110
+ After a page reload, the first signature runs the PRF ceremony. On iOS, that prompt only appears if `signTransactions` is called straight from the user's tap. If your flow awaits something first, such as fetching a quote or building the group, unlock the session on an earlier tap instead:
111
+
112
+ ```typescript
113
+ const wallet = manager.getWallet('passkey') as PasskeyAdapter
114
+
115
+ wallet.isUnlocked // false after a reload or a lock
116
+ await wallet.unlock() // call from a click handler: one verified prompt
117
+ wallet.lock() // drops the key; the account stays connected
118
+ ```
119
+
120
+ A session can also lock on its own, and a signature can unlock it. Pass `onLock` and `onUnlock` to keep your UI in step, e.g. to show an "Unlock" button:
121
+
122
+ ```typescript
123
+ passkey({
124
+ session: true,
125
+ onLock: (reason) => setUnlocked(false), // 'idle' | 'max-age' | 'hidden' | 'lock' | 'disconnect' | 'connect' | 'key-store'
126
+ onUnlock: () => setUnlocked(true)
127
+ })
128
+ ```
129
+
130
+ `onUnlock` is called once each time a locked session unlocks: on `connect`, `unlock()`, or a signature that ran the PRF ceremony. `onLock` is called once each time it locks: `'idle'` and `'max-age'` are the timeouts, `'hidden'` is `lockWhenHidden`, `'lock'` is your own `lock()` call, `'connect'` means a new `connect` replaced or dropped the session, and `'key-store'` means the key store dropped the key itself, e.g. because its worker failed. Without `session`, neither is called and `unlock()` does nothing.
131
+
132
+ ### User preferences
133
+
134
+ `confirm` runs on every request, so it can follow a setting the user changes at any time. `lockWhenHidden` can be a function for the same reason. For auto-lock settings, turn off the built-in timeouts and call `lock()` on your own schedule, starting from `onUnlock`:
135
+
136
+ ```typescript
137
+ let autoLock: ReturnType<typeof setTimeout> | undefined
138
+
139
+ passkey({
140
+ session: { idleTimeoutMs: 0, maxAgeMs: 0, lockWhenHidden: () => settings.lockWhenHidden },
141
+ confirm: () => settings.confirmation, // 'silent' | 'presence' | 'verify'
142
+ onUnlock: () => {
143
+ if (settings.autoLockMs) autoLock = setTimeout(() => wallet.lock(), settings.autoLockMs)
144
+ },
145
+ onLock: () => clearTimeout(autoLock)
146
+ })
147
+ ```
148
+
149
+ These settings decide how often honest requests prompt the user. They don't protect against a compromised page: a script that takes over your page can change them too.
150
+
74
151
  ### Recovery phrase
75
152
 
76
153
  The account is bound to your domain, so it's stranded if the domain is lost. Let users export its recovery phrase:
@@ -86,6 +163,44 @@ With a framework adapter, get the manager from `useWalletManager()`.
86
163
 
87
164
  Tell users to **import the phrase as a Falcon / quantum account**: in Lute, Add account → "Falcon25 account" → Import; in Pera, the quantum import; or `algokey pq import --scheme falcon-1024`. The same 25 words in a regular import restore a different, empty account.
88
165
 
166
+ ### Moving to a wallet
167
+
168
+ Give users a way to move to a wallet they control, such as Pera, Defly or Lute. After that, a compromise of your site can't reach their funds. There are two ways:
169
+
170
+ - **Close the account:** send the whole balance to the wallet with `closeRemainderTo`. An account that still holds assets or app opt-ins can't close, so send or opt out of those first, or rekey instead.
171
+ - **Rekey it:** keep the address, and give its signing authority to the wallet's account with `rekeyTo`. The passkey can no longer sign for it, so disconnect the passkey account afterwards. The user needs a wallet that supports rekeyed accounts to use the address. Rekeying to a regular (ed25519) account gives up post-quantum protection.
172
+
173
+ ```typescript
174
+ import algosdk from 'algosdk'
175
+
176
+ const wallet = manager.getWallet('passkey')!
177
+ const sender = wallet.activeAddress!
178
+ const params = await manager.algodClient.getTransactionParams().do()
179
+ // A Falcon signature pays 3× the minimum fee; under congestion, simulate for the fee
180
+ const suggestedParams = { ...params, flatFee: true, fee: params.minFee * 3n }
181
+
182
+ // Close: send the whole balance to the wallet
183
+ const close = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
184
+ sender,
185
+ receiver: walletAddress,
186
+ amount: 0,
187
+ closeRemainderTo: walletAddress,
188
+ suggestedParams
189
+ })
190
+
191
+ // Or rekey: keep the address, and let the wallet sign for it
192
+ const rekey = algosdk.makePaymentTxnWithSuggestedParamsFromObject({
193
+ sender,
194
+ receiver: sender,
195
+ amount: 0,
196
+ rekeyTo: walletAddress,
197
+ suggestedParams
198
+ })
199
+
200
+ const [signed] = await wallet.signTransactions([close]) // or [rekey]
201
+ await manager.algodClient.sendRawTransaction(signed!).do()
202
+ ```
203
+
89
204
  ### Content Security Policy
90
205
 
91
206
  The key runs in a module worker started from a `blob:` URL, using WebAssembly. If your page sets a CSP, allow both:
@@ -106,10 +221,4 @@ Failures throw a `PasskeyError` with a `code`: `cancelled`, `unsupported` (no We
106
221
  - No multisig participation and no raw-bytes or program-data signing.
107
222
  - `signData` supports ARC-60 authentication with scheme `falcon-1024-det`.
108
223
 
109
- ## Security
110
-
111
- - **The domain holds the keys.** Whoever controls the RP ID domain (including an XSS on it) can run a passkey prompt and recover the key with one tap.
112
- - **Post-quantum is bounded by the passkey's custody.** The PRF itself is symmetric, but synced passkeys are protected by each provider's classical encryption, and the CTAP2 channel uses P-256. Hardware security keys that don't sync close more of that gap.
113
- - Only public data is stored (credential ID, public key, address). The private key lives in a worker, never on the page's main thread, and is dropped on lock.
114
-
115
224
  ### Visit [txnlab.gitbook.io/use-wallet](https://txnlab.gitbook.io/use-wallet) for docs, guides, and examples!
package/dist/index.d.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  import algosdk from "algosdk";
2
2
  import { AdapterConstructorParams, BaseWallet, Siwa, StdSignDataResponse, StdSignMetadata, WalletAccount, WalletMetadata } from "@txnlab/use-wallet/adapter";
3
3
  import { WalletAdapterConfig, WalletFactoryOptions } from "@txnlab/use-wallet";
4
+ //#region src/worker/protocol.d.ts
5
+ /** Why the key worker locked itself */
6
+ type PasskeyTimeoutReason = 'idle' | 'max-age';
7
+ //#endregion
4
8
  //#region src/key-store.d.ts
9
+ /**
10
+ * Why the built-in key store locked itself: a timeout, or `'key-store'` when
11
+ * its worker failed
12
+ */
13
+ type WorkerKeyStoreLockReason = PasskeyTimeoutReason | 'key-store';
5
14
  /**
6
15
  * Holds the Falcon-1024 private key between a passkey's PRF ceremony and the
7
16
  * signatures it authorizes. The adapter's built-in key store runs the key in a
@@ -25,8 +34,10 @@ interface PasskeyKeyStore {
25
34
  interface WorkerKeyStoreOptions {
26
35
  /** Clear the key after this long without a signature. 0 keeps it until `lock`. */
27
36
  idleTimeoutMs?: number;
28
- /** Called when the idle timeout clears the key */
29
- onIdleLock?: () => void;
37
+ /** Clear the key this long after `unlock`, however often it signs. 0 sets no limit. */
38
+ maxAgeMs?: number;
39
+ /** Called when the key store locks itself, not on `lock` */
40
+ onLock?: (reason: WorkerKeyStoreLockReason) => void;
30
41
  /** How long to wait for the worker to load. Default: 10000 */
31
42
  startTimeoutMs?: number;
32
43
  }
@@ -73,9 +84,28 @@ type PasskeySigningRequest = {
73
84
  type: 'data';
74
85
  siwa: Siwa;
75
86
  };
87
+ /**
88
+ * Why an unlocked session locked:
89
+ * - `idle`, `max-age`: the built-in key store's timeouts
90
+ * - `hidden`: the page was hidden, with `lockWhenHidden`
91
+ * - `lock`: the app called `lock()`
92
+ * - `disconnect`: the wallet disconnected
93
+ * - `connect`: a new `connect` replaced or dropped the session
94
+ * - `key-store`: the key store dropped the key itself, e.g. its worker failed
95
+ */
96
+ type PasskeyLockReason = WorkerKeyStoreLockReason | 'hidden' | 'lock' | 'disconnect' | 'connect';
76
97
  interface PasskeySessionOptions {
77
98
  /** Lock the session after this long without a signature; 0 never locks it. Default: 15 minutes */
78
99
  idleTimeoutMs?: number;
100
+ /** Lock the session this long after it unlocks, however active it is; 0 sets no limit. Default: 1 hour */
101
+ maxAgeMs?: number;
102
+ /**
103
+ * Lock the session when the page is hidden, e.g. the user switches tabs or
104
+ * apps. A signing request in progress finishes first. Also applies to a
105
+ * custom `keyStore`. Pass a function to follow a setting the user can
106
+ * change; it's read each time the page is hidden. Default: `false`
107
+ */
108
+ lockWhenHidden?: boolean | (() => boolean);
79
109
  }
80
110
  interface PasskeyOptions {
81
111
  /**
@@ -98,8 +128,8 @@ interface PasskeyOptions {
98
128
  * Keep the key unlocked between signatures, so only the first signature
99
129
  * (or `connect`) runs a PRF ceremony with full user verification; later ones
100
130
  * are confirmed per `confirm`. The key is held in a dedicated worker until
101
- * the idle timeout, `disconnect` or a page reload. Default: `false`, a PRF
102
- * ceremony for every request.
131
+ * the idle timeout, the maximum age, `disconnect` or a page reload. Default:
132
+ * `false`, a PRF ceremony for every request.
103
133
  */
104
134
  session?: boolean | PasskeySessionOptions;
105
135
  /**
@@ -108,6 +138,17 @@ interface PasskeyOptions {
108
138
  * across an await, so this must not be async. Default: `() => 'presence'`.
109
139
  */
110
140
  confirm?: (request: PasskeySigningRequest) => PasskeyConfirmation;
141
+ /**
142
+ * Called when an unlocked session locks, so the app can show it (e.g. an
143
+ * "Unlock" button) without polling `isUnlocked`. Not called without a
144
+ * session, where the key is only held during a request.
145
+ */
146
+ onLock?: (reason: PasskeyLockReason) => void;
147
+ /**
148
+ * Called when a locked session unlocks: on `connect`, `unlock`, or a
149
+ * signature that ran the PRF ceremony. Not called without a session.
150
+ */
151
+ onUnlock?: () => void;
111
152
  /**
112
153
  * Replaces the built-in worker key store, e.g. to keep a session across page
113
154
  * reloads. The adapter treats a custom key store as a session: it never
@@ -129,6 +170,10 @@ interface PasskeyRecoveryPhrase {
129
170
  }
130
171
  export declare class PasskeyAdapter extends BaseWallet<PasskeyOptions> {
131
172
  private keyStore;
173
+ /** Signing requests in progress; hiding the page doesn't lock in the middle of one */
174
+ private activeRequests;
175
+ /** Whether the session was unlocked when `onLock`/`onUnlock` last checked */
176
+ private reportedUnlocked;
132
177
  constructor(params: AdapterConstructorParams<PasskeyOptions>);
133
178
  static defaultMetadata: WalletMetadata;
134
179
  private get rpId();
@@ -136,10 +181,23 @@ export declare class PasskeyAdapter extends BaseWallet<PasskeyOptions> {
136
181
  private get context();
137
182
  /** True when the key stays unlocked between requests */
138
183
  private get isSession();
184
+ private get sessionOptions();
185
+ private get lockWhenHidden();
139
186
  private createKeyStore;
140
187
  private getKeyStore;
141
188
  /** Drop the key after a request, unless a session keeps it */
142
189
  private release;
190
+ /** True when the page is hidden and the session should lock for it */
191
+ private isHidden;
192
+ private onVisibilityChange;
193
+ /** Finish a request: drop the key unless the session keeps it, and report the session state */
194
+ private finishRequest;
195
+ /**
196
+ * Call `onLock` or `onUnlock` once when the session changes state, with
197
+ * `reason` for a lock. Checks the key store itself, so a change is reported
198
+ * however it happened.
199
+ */
200
+ private reportSession;
143
201
  /**
144
202
  * Sign in with an existing passkey, or create one with `{ create: true }`.
145
203
  * Show both choices: a missing passkey and a cancelled prompt raise the same
@@ -151,6 +209,27 @@ export declare class PasskeyAdapter extends BaseWallet<PasskeyOptions> {
151
209
  resumeSession: () => Promise<void>;
152
210
  private matchesRecord;
153
211
  private requireRecord;
212
+ /** True when the key is unlocked, so the next signature needs no PRF ceremony */
213
+ get isUnlocked(): boolean;
214
+ /**
215
+ * Unlocks the session ahead of the next signature with one PRF ceremony
216
+ * (full user verification), e.g. from an "Unlock" button after a page
217
+ * reload, so signing needs no ceremony. Call it directly from the click
218
+ * handler: iOS drops the tap across an await. Does nothing if the session is
219
+ * already unlocked, or without `session`, where every request runs its own
220
+ * ceremony.
221
+ */
222
+ unlock: () => Promise<void>;
223
+ /**
224
+ * Drops the key. The account stays connected; the next signature (or
225
+ * `unlock`) runs a PRF ceremony.
226
+ */
227
+ lock: () => void;
228
+ /**
229
+ * Unlock the key with a PRF ceremony for the connected credential, and check
230
+ * it derives the connected account. Starts its prompt before any await.
231
+ */
232
+ private unlockKey;
154
233
  /**
155
234
  * Make sure the key is unlocked and the user approved this request. When the
156
235
  * key is locked, the PRF ceremony that unlocks it is the approval. Starts its
@@ -220,5 +299,5 @@ export declare const LOCAL_STORAGE_PASSKEY_KEY = "@txnlab/use-wallet:v5:passkey"
220
299
  export declare const WALLET_ID: "passkey";
221
300
  export declare function passkey(options?: PasskeyOptions & WalletFactoryOptions): WalletAdapterConfig;
222
301
  //#endregion
223
- export type { PasskeyConfirmation, PasskeyConnectArgs, PasskeyErrorCode, PasskeyKeyStore, PasskeyOptions, PasskeyRecoveryPhrase, PasskeySessionOptions, PasskeySigningRequest, WorkerKeyStoreOptions };
302
+ export type { PasskeyConfirmation, PasskeyConnectArgs, PasskeyErrorCode, PasskeyKeyStore, PasskeyLockReason, PasskeyOptions, PasskeyRecoveryPhrase, PasskeySessionOptions, PasskeySigningRequest, PasskeyTimeoutReason, WorkerKeyStoreLockReason, WorkerKeyStoreOptions };
224
303
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -122,7 +122,8 @@ var WorkerKeyStore = class {
122
122
  const publicKey = await this.call({
123
123
  op: "unlock",
124
124
  entropy: buffer,
125
- idleTimeoutMs: this.options.idleTimeoutMs
125
+ idleTimeoutMs: this.options.idleTimeoutMs,
126
+ maxAgeMs: this.options.maxAgeMs
126
127
  }, [buffer]);
127
128
  this.isUnlocked = true;
128
129
  return publicKey;
@@ -149,7 +150,7 @@ var WorkerKeyStore = class {
149
150
  this.pending.clear();
150
151
  }
151
152
  async start() {
152
- const { WORKER_SOURCE } = await import("./source.generated-CISa6YOa.js");
153
+ const { WORKER_SOURCE } = await import("./source.generated-BdYdygOX.js");
153
154
  const { startTimeoutMs = 1e4 } = this.options;
154
155
  const url = URL.createObjectURL(new Blob([WORKER_SOURCE], { type: "text/javascript" }));
155
156
  return new Promise((resolve, reject) => {
@@ -179,7 +180,9 @@ var WorkerKeyStore = class {
179
180
  worker.onmessage = (event) => this.handle(event.data);
180
181
  worker.onerror = (event) => {
181
182
  event.preventDefault();
183
+ const wasUnlocked = this.isUnlocked;
182
184
  this.lock();
185
+ if (wasUnlocked) this.options.onLock?.("key-store");
183
186
  };
184
187
  resolve(worker);
185
188
  };
@@ -213,7 +216,7 @@ var WorkerKeyStore = class {
213
216
  handle(message) {
214
217
  if (message.type === "locked") {
215
218
  this.lock();
216
- this.options.onIdleLock?.();
219
+ this.options.onLock?.(message.reason);
217
220
  return;
218
221
  }
219
222
  if (message.type !== "result") return;
@@ -411,11 +414,13 @@ function assertUser({ rpId, credentialId, userVerification }) {
411
414
  * provider (iCloud Keychain, Google Password Manager, 1Password, Windows
412
415
  * Hello, a security key) syncs and protects the credential.
413
416
  *
414
- * SECURITY CONSIDERATIONS:
415
- * - The account is bound to the relying party ID (by default the page's
416
- * hostname). Whoever controls that domain can run a PRF ceremony and recover
417
- * the key with one tap, and the account is stranded if the domain is lost,
418
- * so offer `exportRecoveryPhrase`.
417
+ * SECURITY MODEL: the account is app-scoped.
418
+ * - It is bound to the relying party ID (by default the page's hostname). Any
419
+ * script running there can derive the key with one verified passkey prompt
420
+ * that looks like a sign-in: the dApp itself, an XSS bug, a compromised
421
+ * dependency, or whoever takes over the domain. Other sites can't derive it.
422
+ * - The account is stranded if the domain is lost, so offer
423
+ * `exportRecoveryPhrase` and a way to move to a wallet. See the README.
419
424
  * - Only public data is persisted: the credential ID, public key, address
420
425
  * salt and address. Never the PRF output, the entropy or the private key.
421
426
  * - The private key lives in a dedicated worker, never on the main thread.
@@ -423,6 +428,7 @@ function assertUser({ rpId, credentialId, userVerification }) {
423
428
  */
424
429
  const ICON = `data:image/svg+xml;base64,${btoa(icon)}`;
425
430
  const DEFAULT_IDLE_TIMEOUT_MS = 9e5;
431
+ const DEFAULT_MAX_AGE_MS = 36e5;
426
432
  function bytesEqual(a, b) {
427
433
  return a.length === b.length && a.every((byte, i) => byte === b[i]);
428
434
  }
@@ -439,9 +445,14 @@ async function sha256(data) {
439
445
  }
440
446
  var PasskeyAdapter = class extends BaseWallet {
441
447
  keyStore = null;
448
+ /** Signing requests in progress; hiding the page doesn't lock in the middle of one */
449
+ activeRequests = 0;
450
+ /** Whether the session was unlocked when `onLock`/`onUnlock` last checked */
451
+ reportedUnlocked = false;
442
452
  constructor(params) {
443
453
  super(params);
444
454
  assertValidContext(this.context);
455
+ if (this.isSession && this.sessionOptions.lockWhenHidden && typeof document !== "undefined") document.addEventListener("visibilitychange", this.onVisibilityChange);
445
456
  }
446
457
  static defaultMetadata = {
447
458
  name: "Passkey",
@@ -460,12 +471,32 @@ var PasskeyAdapter = class extends BaseWallet {
460
471
  get isSession() {
461
472
  return !!this.options.session || !!this.options.keyStore;
462
473
  }
474
+ get sessionOptions() {
475
+ const { session } = this.options;
476
+ return typeof session === "object" ? session : {};
477
+ }
478
+ get lockWhenHidden() {
479
+ const { lockWhenHidden } = this.sessionOptions;
480
+ if (!this.isSession) return false;
481
+ if (typeof lockWhenHidden !== "function") return !!lockWhenHidden;
482
+ try {
483
+ return lockWhenHidden();
484
+ } catch (error) {
485
+ this.logger.error("Error in lockWhenHidden:", error.message);
486
+ return true;
487
+ }
488
+ }
463
489
  createKeyStore() {
464
490
  if (this.options.keyStore) return this.options.keyStore();
465
- const { session } = this.options;
491
+ const session = !!this.options.session;
492
+ const { idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS, maxAgeMs = DEFAULT_MAX_AGE_MS } = this.sessionOptions;
466
493
  return new WorkerKeyStore({
467
- idleTimeoutMs: session ? (typeof session === "object" ? session.idleTimeoutMs : void 0) ?? DEFAULT_IDLE_TIMEOUT_MS : 0,
468
- onIdleLock: () => this.logger.info("Session locked after inactivity")
494
+ idleTimeoutMs: session ? idleTimeoutMs : 0,
495
+ maxAgeMs: session ? maxAgeMs : 0,
496
+ onLock: (reason) => {
497
+ this.logger.info(`Session locked (${reason})`);
498
+ this.reportSession(reason);
499
+ }
469
500
  });
470
501
  }
471
502
  getKeyStore() {
@@ -474,7 +505,39 @@ var PasskeyAdapter = class extends BaseWallet {
474
505
  }
475
506
  /** Drop the key after a request, unless a session keeps it */
476
507
  release() {
477
- if (!this.isSession) this.keyStore?.lock();
508
+ if (!this.isSession || this.isHidden()) this.keyStore?.lock();
509
+ }
510
+ /** True when the page is hidden and the session should lock for it */
511
+ isHidden() {
512
+ return typeof document !== "undefined" && document.visibilityState === "hidden" && this.lockWhenHidden;
513
+ }
514
+ onVisibilityChange = () => {
515
+ if (this.activeRequests === 0 && this.isHidden()) {
516
+ this.keyStore?.lock();
517
+ this.reportSession("hidden");
518
+ }
519
+ };
520
+ /** Finish a request: drop the key unless the session keeps it, and report the session state */
521
+ finishRequest() {
522
+ this.activeRequests--;
523
+ this.release();
524
+ this.reportSession(this.isHidden() ? "hidden" : "key-store");
525
+ }
526
+ /**
527
+ * Call `onLock` or `onUnlock` once when the session changes state, with
528
+ * `reason` for a lock. Checks the key store itself, so a change is reported
529
+ * however it happened.
530
+ */
531
+ reportSession(reason) {
532
+ const unlocked = this.isSession && this.isUnlocked;
533
+ if (unlocked === this.reportedUnlocked) return;
534
+ this.reportedUnlocked = unlocked;
535
+ try {
536
+ if (unlocked) this.options.onUnlock?.();
537
+ else this.options.onLock?.(reason);
538
+ } catch (error) {
539
+ this.logger.error(`Error in ${unlocked ? "onUnlock" : "onLock"}:`, error.message);
540
+ }
478
541
  }
479
542
  /**
480
543
  * Sign in with an existing passkey, or create one with `{ create: true }`.
@@ -516,7 +579,7 @@ var PasskeyAdapter = class extends BaseWallet {
516
579
  };
517
580
  saveRecord(record);
518
581
  const walletAccount = {
519
- name: `${this.metadata.name} Account`,
582
+ name: `${this.metadata.name} · ${rpId}`,
520
583
  address: record.address,
521
584
  emptySignature: emptySignatureFor(record)
522
585
  };
@@ -525,11 +588,13 @@ var PasskeyAdapter = class extends BaseWallet {
525
588
  activeAccount: walletAccount
526
589
  };
527
590
  this.store.addWallet(walletState);
591
+ this.reportSession(this.isHidden() ? "hidden" : "connect");
528
592
  this.logger.info("Connected successfully", walletState);
529
593
  return [walletAccount];
530
594
  } catch (error) {
531
595
  result?.prf.fill(0);
532
596
  this.keyStore?.lock();
597
+ this.reportSession("connect");
533
598
  this.logger.error("Error connecting:", error.message);
534
599
  throw error;
535
600
  }
@@ -537,6 +602,7 @@ var PasskeyAdapter = class extends BaseWallet {
537
602
  disconnect = async () => {
538
603
  this.logger.info("Disconnecting...");
539
604
  this.keyStore?.lock();
605
+ this.reportSession("disconnect");
540
606
  this.keyStore = null;
541
607
  removeRecord();
542
608
  this.onDisconnect();
@@ -566,25 +632,64 @@ var PasskeyAdapter = class extends BaseWallet {
566
632
  if (!walletState || !record || !this.matchesRecord(record, walletState)) throw new PasskeyError("not-connected", "No passkey account is connected.");
567
633
  return record;
568
634
  }
635
+ /** True when the key is unlocked, so the next signature needs no PRF ceremony */
636
+ get isUnlocked() {
637
+ return this.keyStore?.unlocked ?? false;
638
+ }
639
+ /**
640
+ * Unlocks the session ahead of the next signature with one PRF ceremony
641
+ * (full user verification), e.g. from an "Unlock" button after a page
642
+ * reload, so signing needs no ceremony. Call it directly from the click
643
+ * handler: iOS drops the tap across an await. Does nothing if the session is
644
+ * already unlocked, or without `session`, where every request runs its own
645
+ * ceremony.
646
+ */
647
+ unlock = async () => {
648
+ const record = this.requireRecord();
649
+ if (!this.isSession || this.isUnlocked) return;
650
+ this.logger.info("Unlocking...");
651
+ this.activeRequests++;
652
+ try {
653
+ await this.unlockKey(record);
654
+ this.logger.info("Unlocked");
655
+ } catch (error) {
656
+ this.logger.error("Error unlocking:", error.message);
657
+ throw error;
658
+ } finally {
659
+ this.finishRequest();
660
+ }
661
+ };
662
+ /**
663
+ * Drops the key. The account stays connected; the next signature (or
664
+ * `unlock`) runs a PRF ceremony.
665
+ */
666
+ lock = () => {
667
+ this.keyStore?.lock();
668
+ this.reportSession("lock");
669
+ };
670
+ /**
671
+ * Unlock the key with a PRF ceremony for the connected credential, and check
672
+ * it derives the connected account. Starts its prompt before any await.
673
+ */
674
+ async unlockKey(record) {
675
+ const keyStore = this.getKeyStore();
676
+ const { prf } = await evaluatePrf({
677
+ rpId: record.rpId,
678
+ prfInput: prfInput(record.context),
679
+ credentialId: record.credentialId
680
+ });
681
+ if (!bytesEqual(await keyStore.unlock(prf), record.publicKey)) {
682
+ keyStore.lock();
683
+ throw new PasskeyError("key-mismatch", "This passkey derived a different key than the connected account. Disconnect and connect again.");
684
+ }
685
+ }
569
686
  /**
570
687
  * Make sure the key is unlocked and the user approved this request. When the
571
688
  * key is locked, the PRF ceremony that unlocks it is the approval. Starts its
572
689
  * prompt before any await.
573
690
  */
574
691
  async authorize(request, record) {
575
- const keyStore = this.getKeyStore();
576
- if (!keyStore.unlocked) {
577
- const { prf } = await evaluatePrf({
578
- rpId: record.rpId,
579
- prfInput: prfInput(record.context),
580
- credentialId: record.credentialId
581
- });
582
- if (!bytesEqual(await keyStore.unlock(prf), record.publicKey)) {
583
- keyStore.lock();
584
- throw new PasskeyError("key-mismatch", "This passkey derived a different key than the connected account. Disconnect and connect again.");
585
- }
586
- return;
587
- }
692
+ if (!this.getKeyStore().unlocked) return this.unlockKey(record);
588
693
  const confirmation = this.options.confirm?.(request) ?? "presence";
589
694
  if (confirmation === "silent") return;
590
695
  await assertUser({
@@ -614,6 +719,7 @@ var PasskeyAdapter = class extends BaseWallet {
614
719
  const record = this.requireRecord();
615
720
  const txnsToSign = isTransactionArray(txnGroup) ? this.processTxns(flattenTxnGroup(txnGroup), indexesToSign, record.address) : this.processEncodedTxns(flattenTxnGroup(txnGroup), indexesToSign, record.address);
616
721
  if (txnsToSign.length === 0) return [];
722
+ this.activeRequests++;
617
723
  try {
618
724
  this.logger.debug("Signing transactions...", { txnsToSign });
619
725
  await this.authorize({
@@ -632,7 +738,7 @@ var PasskeyAdapter = class extends BaseWallet {
632
738
  this.logger.error("Error signing transactions:", error.message);
633
739
  throw error;
634
740
  } finally {
635
- this.release();
741
+ this.finishRequest();
636
742
  }
637
743
  };
638
744
  canSignData = true;
@@ -661,6 +767,7 @@ var PasskeyAdapter = class extends BaseWallet {
661
767
  if (!siwa || typeof siwa !== "object" || canonify(siwa) !== json) throw new SignDataError("Bad JSON", 4609);
662
768
  if (siwa.domain !== location.host) throw new SignDataError("Failed Domain Auth", 4610);
663
769
  if (siwa.type !== "falcon-1024-det" || siwa.account_address !== record.address) throw new SignDataError("Invalid Signer", 4603);
770
+ this.activeRequests++;
664
771
  try {
665
772
  await this.authorize({
666
773
  type: "data",
@@ -678,7 +785,7 @@ var PasskeyAdapter = class extends BaseWallet {
678
785
  this.logger.error("Error signing data:", error.message);
679
786
  throw error;
680
787
  } finally {
681
- this.release();
788
+ this.finishRequest();
682
789
  }
683
790
  };
684
791
  /**