@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 +127 -18
- package/dist/index.d.ts +84 -5
- package/dist/index.js +135 -28
- package/dist/index.js.map +1 -1
- package/dist/{source.generated-CISa6YOa.js → source.generated-BdYdygOX.js} +2 -2
- package/dist/{source.generated-CISa6YOa.js.map → source.generated-BdYdygOX.js.map} +1 -1
- package/package.json +3 -3
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
|
|
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
|
|
47
|
-
| `confirm` | `() => 'presence'` | How to confirm each signature while a session is unlocked
|
|
48
|
-
| `
|
|
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
|
|
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
|
-
/**
|
|
29
|
-
|
|
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:
|
|
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-
|
|
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.
|
|
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
|
|
415
|
-
* -
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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
|
|
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 ?
|
|
468
|
-
|
|
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}
|
|
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
|
-
|
|
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.
|
|
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.
|
|
788
|
+
this.finishRequest();
|
|
682
789
|
}
|
|
683
790
|
};
|
|
684
791
|
/**
|