@organigram/passkey-wallet 0.1.6
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/.organigram-docs-cache/passkey-wallet/api-extractor.json +35 -0
- package/.organigram-docs-cache/passkey-wallet/api-model/passkey-wallet.api.json +3166 -0
- package/.organigram-docs-cache/passkey-wallet/entry/index.d.ts +9 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/05732508 +0 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/166b6dbf +0 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/41c20e23 +0 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/7111ea58 +0 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-1822dfbab9819e9f6a93485fe3da5dbf3745311c +1 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-45a65cfdc7a50c53817c1dad3954ae3d2d2c35fb +1 -0
- package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-fee5290b05398fe9e3b878deefd22383744f1526 +1 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/createorganigrampasskeywalletinput.yml +17 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/identitypasskeycapabilities.yml +16 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeycapabilities.yml +18 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeyprovider.yml +16 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeyprovideractions.yml +20 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyprfunavailableerror.yml +28 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyproviderevent.yml +11 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyproviderlistener.yml +11 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyregistrationresult.yml +15 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultdecrypterror.yml +28 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultenvelopedata.yml +18 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultenvelopeinput.yml +19 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletapiclient.yml +33 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletcapabilities.yml +11 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletlogincapabilities.yml +14 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletregistrationcapabilities.yml +18 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletvaultpayload.yml +18 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/unlockedpasskeywallet.yml +21 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet.yml +870 -0
- package/.organigram-docs-cache/passkey-wallet/yaml/toc.yml +43 -0
- package/.turbo/turbo-build.log +6 -0
- package/.turbo/turbo-docs$colon$generate.log +118 -0
- package/.turbo/turbo-lint.log +13 -0
- package/.turbo/turbo-test.log +152 -0
- package/.turbo/turbo-typecheck.log +5 -0
- package/CHANGELOG.md +8 -0
- package/LICENSE.md +21 -0
- package/README.md +427 -0
- package/__mocks__/organigram-js.ts +19 -0
- package/__mocks__/wagmi.ts +1 -0
- package/dist/crypto.d.ts +39 -0
- package/dist/crypto.js +95 -0
- package/dist/eip1193.d.ts +24 -0
- package/dist/eip1193.js +103 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +13 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/rainbowkit.d.ts +14 -0
- package/dist/rainbowkit.js +128 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +17 -0
- package/dist/vault.d.ts +10 -0
- package/dist/vault.js +41 -0
- package/dist/wallet.d.ts +15 -0
- package/dist/wallet.js +30 -0
- package/dist/webauthn-client.d.ts +73 -0
- package/dist/webauthn-client.js +234 -0
- package/dist/webauthn-server.d.ts +62 -0
- package/dist/webauthn-server.js +133 -0
- package/jest.config.ts +32 -0
- package/package.json +61 -0
- package/src/crypto.test.ts +69 -0
- package/src/crypto.ts +193 -0
- package/src/eip1193.ts +169 -0
- package/src/errors.ts +16 -0
- package/src/index.ts +4 -0
- package/src/rainbowkit.test.ts +42 -0
- package/src/rainbowkit.ts +171 -0
- package/src/types.ts +82 -0
- package/src/vault.test.ts +23 -0
- package/src/vault.ts +77 -0
- package/src/wallet.ts +61 -0
- package/src/webauthn-client.test.ts +103 -0
- package/src/webauthn-client.ts +430 -0
- package/src/webauthn-server.test.ts +43 -0
- package/src/webauthn-server.ts +258 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +27 -0
package/README.md
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Organigram Passkey Wallet
|
|
2
|
+
|
|
3
|
+
**The ultimate web3 experience: unlock wallets and sign transactions with passkeys in a
|
|
4
|
+
breeze.**
|
|
5
|
+
|
|
6
|
+
Organigram Passkey Wallet is a lightweight, headless, EIP-1193 wallet powered by
|
|
7
|
+
WebAuthn passkeys. It gives users the day-to-day experience they expect from
|
|
8
|
+
modern passwordless apps while preserving the core property that matters in
|
|
9
|
+
web3: fully and safely owning a standard Ethereum address.
|
|
10
|
+
|
|
11
|
+
No cumbersome smart account deployment. No per-network account ceremony. No
|
|
12
|
+
custodial key service. No protocol lock-in. Just a standard EOA, encrypted
|
|
13
|
+
client-side, unlocked with rotative passkeys, and exposed through a wallet interface the
|
|
14
|
+
Ethereum ecosystem already understands.
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add @organigram/passkey-wallet
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Web3 identity, solved
|
|
21
|
+
|
|
22
|
+
Most web3 onboarding still asks users to understand browser extensions, seed
|
|
23
|
+
phrases, hardware devices, account abstraction, gas sponsorship, recovery
|
|
24
|
+
contracts, network-specific deployments, signer permissions... before they have
|
|
25
|
+
even used the product. Organigram Passkey Wallet takes a simpler path.
|
|
26
|
+
|
|
27
|
+
The user creates a wallet in the browser. The wallet is a normal Ethereum EOA
|
|
28
|
+
derived from a recovery phrase. That recovery phrase is encrypted locally with a
|
|
29
|
+
key derived from a WebAuthn passkey PRF output, then stored only as an encrypted
|
|
30
|
+
vault envelope. When the user returns, their passkey unlocks the vault and the
|
|
31
|
+
EOA signs messages or transactions like any other wallet.
|
|
32
|
+
|
|
33
|
+
The result is a wallet experience that feels native to the web:
|
|
34
|
+
|
|
35
|
+
- passkeys for everyday unlocks;
|
|
36
|
+
- standard EIP-1193 semantics for dapps;
|
|
37
|
+
- one address across every EVM network;
|
|
38
|
+
- multiple backup passkeys for the same address;
|
|
39
|
+
- seed phrase export for disaster recovery;
|
|
40
|
+
- no onchain deployment required;
|
|
41
|
+
- no offchain signing relay or hosted signer to trust.
|
|
42
|
+
|
|
43
|
+
## Highlights
|
|
44
|
+
|
|
45
|
+
### Unlock and sign with passkeys
|
|
46
|
+
|
|
47
|
+
Users unlock the wallet with a WebAuthn passkey. Once unlocked, the wallet signs
|
|
48
|
+
messages, typed data, and transactions with the underlying EOA.
|
|
49
|
+
|
|
50
|
+
### Fully-fledged EIP-1193 wallet
|
|
51
|
+
|
|
52
|
+
The wallet exposes the same interface dapps already expect from injected wallets,
|
|
53
|
+
WalletConnect wallets, and RainbowKit connectors. It supports account
|
|
54
|
+
requests, chain switching, message signing, typed data signing, and transaction
|
|
55
|
+
submission like any other standard wallet.
|
|
56
|
+
|
|
57
|
+
### Multiple backup keys, one address
|
|
58
|
+
|
|
59
|
+
Users can add additional passkeys for the same address. A backup phone, laptop,
|
|
60
|
+
password manager, or security key can unlock the same encrypted wallet vault.
|
|
61
|
+
|
|
62
|
+
### No smart-account deployment burden
|
|
63
|
+
|
|
64
|
+
The address is a standard EOA, so it exists everywhere EVM accounts exist. There
|
|
65
|
+
is no need to deploy account contracts across networks before the user can start.
|
|
66
|
+
|
|
67
|
+
### Seed phrase recovery
|
|
68
|
+
|
|
69
|
+
Passkeys optimize daily use. Seed phrase export preserves portability and gives
|
|
70
|
+
users a disaster recovery path if every passkey is lost.
|
|
71
|
+
|
|
72
|
+
### Headless and open source
|
|
73
|
+
|
|
74
|
+
The package provides wallet, WebAuthn, vault, provider, and RainbowKit building
|
|
75
|
+
blocks. You own the product surface. No hosted signer, no mandatory backend
|
|
76
|
+
service, no strings attached.
|
|
77
|
+
|
|
78
|
+
## What You Get
|
|
79
|
+
|
|
80
|
+
Passkey Wallet is built for production UX:
|
|
81
|
+
|
|
82
|
+
- users unlock and sign with passkeys;
|
|
83
|
+
- backup passkeys can be added to the same address;
|
|
84
|
+
- compromised or obsolete passkeys can be rotated out by the host app;
|
|
85
|
+
- recovery phrases can be exported for disaster recovery;
|
|
86
|
+
- the same EOA works on every supported EVM chain without deploying anything;
|
|
87
|
+
- the package is headless, so product teams own the UI;
|
|
88
|
+
- all sensitive wallet material is encrypted client-side before persistence.
|
|
89
|
+
|
|
90
|
+
It is also intentionally boring where it should be boring: the address is a
|
|
91
|
+
standard EOA, the dapp integration is EIP-1193, the RainbowKit integration is a
|
|
92
|
+
wallet descriptor, and the WebAuthn server helpers are explicit about
|
|
93
|
+
registration, authentication, challenges, origins, RP IDs, counters, and vault
|
|
94
|
+
envelopes.
|
|
95
|
+
|
|
96
|
+
## Architecture
|
|
97
|
+
|
|
98
|
+
```txt
|
|
99
|
+
Browser
|
|
100
|
+
WebAuthn passkey with PRF
|
|
101
|
+
|
|
|
102
|
+
v
|
|
103
|
+
HKDF-derived vault key
|
|
104
|
+
|
|
|
105
|
+
v
|
|
106
|
+
AES-GCM encrypted wallet vault envelope
|
|
107
|
+
|
|
|
108
|
+
v
|
|
109
|
+
Recovery phrase -> EOA -> EIP-1193 provider
|
|
110
|
+
|
|
111
|
+
Server
|
|
112
|
+
WebAuthn challenges
|
|
113
|
+
Credential public keys
|
|
114
|
+
Credential counters
|
|
115
|
+
Encrypted vault envelopes
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
In practice, the flow is:
|
|
119
|
+
|
|
120
|
+
1. Generate a recovery phrase in the browser.
|
|
121
|
+
2. Derive a standard Ethereum EOA from that phrase.
|
|
122
|
+
3. Create a WebAuthn passkey with `residentKey: "required"`,
|
|
123
|
+
`userVerification: "required"`, and the `prf` extension.
|
|
124
|
+
4. Derive a vault encryption key from the PRF output and a random salt.
|
|
125
|
+
5. Encrypt the wallet vault with AES-GCM.
|
|
126
|
+
6. Store only the credential metadata and encrypted vault envelope server-side.
|
|
127
|
+
7. On unlock, verify WebAuthn, recover the PRF output, decrypt the envelope, and
|
|
128
|
+
expose the EOA through EIP-1193.
|
|
129
|
+
|
|
130
|
+
The private wallet material is generated and decrypted in the browser. The server
|
|
131
|
+
stores only WebAuthn verification material and encrypted vault envelopes. The
|
|
132
|
+
server never needs the seed phrase, the private key, or the decrypted user vault.
|
|
133
|
+
|
|
134
|
+
The passkey unlocks the local vault. The EOA signs Ethereum messages and
|
|
135
|
+
transactions. Those are separate responsibilities, which keeps the integration
|
|
136
|
+
simple and compatible with existing web3 infrastructure.
|
|
137
|
+
|
|
138
|
+
## Package Exports
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import {
|
|
142
|
+
buildPasskeyWalletCapabilities,
|
|
143
|
+
buildEmailPasskeyCapabilities
|
|
144
|
+
} from '@organigram/passkey-wallet'
|
|
145
|
+
|
|
146
|
+
import {
|
|
147
|
+
createFetchPasskeyWalletApiClient,
|
|
148
|
+
unlockOrCreatePasskeyWallet,
|
|
149
|
+
registerAdditionalPasskeyCredential,
|
|
150
|
+
exportPasskeyWalletRecoveryPhrase
|
|
151
|
+
} from '@organigram/passkey-wallet/webauthn-client'
|
|
152
|
+
|
|
153
|
+
import {
|
|
154
|
+
createPasskeyRegistrationOptions,
|
|
155
|
+
createPasskeyAuthenticationOptions,
|
|
156
|
+
verifyPasskeyRegistration,
|
|
157
|
+
verifyPasskeyAuthentication
|
|
158
|
+
} from '@organigram/passkey-wallet/webauthn-server'
|
|
159
|
+
|
|
160
|
+
import {
|
|
161
|
+
createOrganigramPasskeyWallet,
|
|
162
|
+
isOrganigramPasskeyConnector
|
|
163
|
+
} from '@organigram/passkey-wallet/rainbowkit'
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The package is split by runtime boundary:
|
|
167
|
+
|
|
168
|
+
- `@organigram/passkey-wallet` exposes shared wallet, vault, and capability
|
|
169
|
+
types.
|
|
170
|
+
- `@organigram/passkey-wallet/webauthn-client` runs in the browser and performs
|
|
171
|
+
WebAuthn ceremonies, PRF extraction, vault encryption, vault decryption, and
|
|
172
|
+
wallet unlock.
|
|
173
|
+
- `@organigram/passkey-wallet/webauthn-server` runs on the server and verifies
|
|
174
|
+
WebAuthn registration/authentication responses.
|
|
175
|
+
- `@organigram/passkey-wallet/eip1193` exposes the provider implementation.
|
|
176
|
+
- `@organigram/passkey-wallet/rainbowkit` exposes a RainbowKit wallet adapter.
|
|
177
|
+
|
|
178
|
+
## Quick Start
|
|
179
|
+
|
|
180
|
+
Create a browser API client:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import {
|
|
184
|
+
createFetchPasskeyWalletApiClient,
|
|
185
|
+
unlockOrCreatePasskeyWallet
|
|
186
|
+
} from '@organigram/passkey-wallet/webauthn-client'
|
|
187
|
+
import { buildPasskeyWalletCapabilities } from '@organigram/passkey-wallet'
|
|
188
|
+
|
|
189
|
+
const api = createFetchPasskeyWalletApiClient('/api/auth/passkey')
|
|
190
|
+
|
|
191
|
+
const wallet = await unlockOrCreatePasskeyWallet({
|
|
192
|
+
api,
|
|
193
|
+
capabilities: buildPasskeyWalletCapabilities(),
|
|
194
|
+
targetChainId: 1
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const signature = await wallet.account.signMessage({
|
|
198
|
+
message: 'gm'
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
That is the headless primitive. Most apps will wrap it in the RainbowKit adapter
|
|
203
|
+
below so the passkey wallet appears next to other connection methods.
|
|
204
|
+
|
|
205
|
+
## RainbowKit Integration
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { connectorsForWallets } from '@rainbow-me/rainbowkit'
|
|
209
|
+
import { createConfig, http } from 'wagmi'
|
|
210
|
+
import { mainnet, sepolia } from 'wagmi/chains'
|
|
211
|
+
import { createOrganigramPasskeyWallet } from '@organigram/passkey-wallet/rainbowkit'
|
|
212
|
+
import {
|
|
213
|
+
createFetchPasskeyWalletApiClient,
|
|
214
|
+
unlockOrCreatePasskeyWallet,
|
|
215
|
+
registerAdditionalPasskeyCredential,
|
|
216
|
+
exportPasskeyWalletRecoveryPhrase
|
|
217
|
+
} from '@organigram/passkey-wallet/webauthn-client'
|
|
218
|
+
|
|
219
|
+
const api = createFetchPasskeyWalletApiClient('/api/auth/passkey')
|
|
220
|
+
|
|
221
|
+
const passkeyWallet = createOrganigramPasskeyWallet({
|
|
222
|
+
unlockOrCreatePasskeyWallet: async input =>
|
|
223
|
+
await unlockOrCreatePasskeyWallet({
|
|
224
|
+
api,
|
|
225
|
+
capabilities: input.capabilities,
|
|
226
|
+
targetChainId: input.targetChainId
|
|
227
|
+
}),
|
|
228
|
+
registerAdditionalPasskeyCredential: async input =>
|
|
229
|
+
await registerAdditionalPasskeyCredential({
|
|
230
|
+
api,
|
|
231
|
+
wallet: input.wallet,
|
|
232
|
+
name: input.name
|
|
233
|
+
}),
|
|
234
|
+
exportPasskeyWalletRecoveryPhrase: async input =>
|
|
235
|
+
await exportPasskeyWalletRecoveryPhrase({
|
|
236
|
+
api,
|
|
237
|
+
expectedAddress: input.expectedAddress
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
export const wagmiConfig = createConfig({
|
|
242
|
+
chains: [mainnet, sepolia],
|
|
243
|
+
connectors: connectorsForWallets(
|
|
244
|
+
[
|
|
245
|
+
{
|
|
246
|
+
groupName: 'Recommended',
|
|
247
|
+
wallets: [() => passkeyWallet]
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
{
|
|
251
|
+
appName: 'Your App',
|
|
252
|
+
projectId: 'walletconnect-project-id'
|
|
253
|
+
}
|
|
254
|
+
),
|
|
255
|
+
transports: {
|
|
256
|
+
[mainnet.id]: http(),
|
|
257
|
+
[sepolia.id]: http()
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
From the dapp's perspective, this behaves like a wallet. Calling
|
|
263
|
+
`personal_sign`, `eth_signTypedData_v4`, or `eth_sendTransaction` goes through
|
|
264
|
+
the unlocked EOA account behind the passkey vault.
|
|
265
|
+
|
|
266
|
+
## Server Responsibilities
|
|
267
|
+
|
|
268
|
+
This package does not hide persistence behind a hosted service. Your app owns the
|
|
269
|
+
server boundary.
|
|
270
|
+
|
|
271
|
+
At minimum, store:
|
|
272
|
+
|
|
273
|
+
- short-lived WebAuthn challenges;
|
|
274
|
+
- credential IDs;
|
|
275
|
+
- credential public keys;
|
|
276
|
+
- credential transports;
|
|
277
|
+
- signature counters;
|
|
278
|
+
- the wallet address linked to each credential;
|
|
279
|
+
- encrypted vault envelopes for each credential.
|
|
280
|
+
|
|
281
|
+
Typical endpoints:
|
|
282
|
+
|
|
283
|
+
```txt
|
|
284
|
+
POST /api/auth/passkey/register/options
|
|
285
|
+
POST /api/auth/passkey/register/verify
|
|
286
|
+
POST /api/auth/passkey/unlock/options
|
|
287
|
+
POST /api/auth/passkey/unlock/verify
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
The server helpers generate WebAuthn options and verify responses, but your app
|
|
291
|
+
decides how to persist credentials, consume challenges, enforce origin/RP ID
|
|
292
|
+
rules, and link credentials to users.
|
|
293
|
+
|
|
294
|
+
## Suggested Data Model
|
|
295
|
+
|
|
296
|
+
The package does not require a specific database, but a production integration
|
|
297
|
+
usually needs records shaped like this:
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
type PasskeyCredential = {
|
|
301
|
+
credentialId: string
|
|
302
|
+
userAddress: `0x${string}`
|
|
303
|
+
publicKey: string
|
|
304
|
+
transports: string[]
|
|
305
|
+
signCount: number
|
|
306
|
+
createdAt: Date
|
|
307
|
+
updatedAt: Date
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
type PasskeyVaultEnvelope = {
|
|
311
|
+
credentialId: string
|
|
312
|
+
userAddress: `0x${string}`
|
|
313
|
+
encryptedMnemonic: string
|
|
314
|
+
salt: string
|
|
315
|
+
nonce: string
|
|
316
|
+
algorithm: string
|
|
317
|
+
keyVersion: number
|
|
318
|
+
createdAt: Date
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
type PasskeyChallenge = {
|
|
322
|
+
challenge: string
|
|
323
|
+
type: 'registration' | 'authentication'
|
|
324
|
+
expiresAt: Date
|
|
325
|
+
consumedAt?: Date
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Use short challenge TTLs, consume challenges exactly once, and enforce uniqueness
|
|
330
|
+
for credential IDs.
|
|
331
|
+
|
|
332
|
+
## Backup Keys And Rotation
|
|
333
|
+
|
|
334
|
+
A passkey wallet should never depend on a single authenticator.
|
|
335
|
+
|
|
336
|
+
Organigram Passkey Wallet supports adding another passkey to the same wallet by
|
|
337
|
+
re-encrypting the same vault payload for a new credential. The address does not
|
|
338
|
+
change. The user can keep using the same EOA while adding a laptop passkey, a
|
|
339
|
+
phone passkey, a password manager passkey, or a hardware security key.
|
|
340
|
+
|
|
341
|
+
Rotation is the same idea in reverse: remove obsolete credentials only when
|
|
342
|
+
another recovery method remains. The package gives you the wallet and envelope
|
|
343
|
+
building blocks; your product should decide the UX policy for deletion,
|
|
344
|
+
confirmation, and minimum backup requirements.
|
|
345
|
+
|
|
346
|
+
## Recovery
|
|
347
|
+
|
|
348
|
+
Passkeys make daily wallet use simple, but users still need an escape hatch.
|
|
349
|
+
|
|
350
|
+
Organigram Passkey Wallet keeps the wallet portable by deriving the EOA from a
|
|
351
|
+
standard recovery phrase. If every passkey is lost, the user can recover with the
|
|
352
|
+
exported seed phrase in any compatible wallet. That is the difference between
|
|
353
|
+
passwordless convenience and platform lock-in.
|
|
354
|
+
|
|
355
|
+
The recovery phrase should always be exported before the user loses all passkeys.
|
|
356
|
+
The package provides a helper to export the recovery phrase after a successful
|
|
357
|
+
WebAuthn ceremony, but cannot do so after the access to the vault is lost.
|
|
358
|
+
|
|
359
|
+
## Security Model
|
|
360
|
+
|
|
361
|
+
Organigram Passkey Wallet is non-custodial by design.
|
|
362
|
+
|
|
363
|
+
The browser creates the wallet. The browser encrypts the vault. The browser
|
|
364
|
+
decrypts the vault after a successful WebAuthn ceremony. The server stores
|
|
365
|
+
encrypted envelopes and verifies that WebAuthn responses are valid for the
|
|
366
|
+
expected challenge, origin, RP ID, and credential public key.
|
|
367
|
+
|
|
368
|
+
This model assumes:
|
|
369
|
+
|
|
370
|
+
- WebAuthn PRF support is required for the passkey vault flow;
|
|
371
|
+
- the host app serves the wallet UI from a trustworthy origin;
|
|
372
|
+
- the server correctly expires and consumes challenges;
|
|
373
|
+
- the server verifies credential counters and user verification;
|
|
374
|
+
- recovery phrase export is treated as a sensitive operation;
|
|
375
|
+
- backup passkey deletion never leaves the user without a recovery method;
|
|
376
|
+
- a compromised browser session can access unlocked wallet material while it is
|
|
377
|
+
in memory.
|
|
378
|
+
|
|
379
|
+
The package avoids custodial recovery, hosted signing, and smart-account
|
|
380
|
+
dependency by keeping the encrypted EOA vault offchain and user-portable.
|
|
381
|
+
|
|
382
|
+
## Browser Compatibility
|
|
383
|
+
|
|
384
|
+
The passkey vault depends on the WebAuthn `prf` extension. Browsers or password
|
|
385
|
+
managers that do not return PRF output cannot unlock the encrypted vault.
|
|
386
|
+
|
|
387
|
+
Apps should present a clear fallback when PRF is unavailable:
|
|
388
|
+
|
|
389
|
+
- retry with another passkey provider;
|
|
390
|
+
- use a platform authenticator that supports PRF;
|
|
391
|
+
- connect an external crypto wallet instead.
|
|
392
|
+
|
|
393
|
+
## What This Is Not
|
|
394
|
+
|
|
395
|
+
Organigram Passkey Wallet is not a smart account framework. It does not require
|
|
396
|
+
4337 infrastructure, paymasters, bundlers, module registries, or per-chain
|
|
397
|
+
deployments.
|
|
398
|
+
|
|
399
|
+
It is not a custodial wallet. The server does not receive the recovery phrase or
|
|
400
|
+
private key in clear text.
|
|
401
|
+
|
|
402
|
+
It is not a hosted wallet service. The package is open source and headless; the
|
|
403
|
+
host app owns persistence, authentication policy, UI, and deployment.
|
|
404
|
+
|
|
405
|
+
It is not a replacement for hardware wallets in high-assurance custody flows. It
|
|
406
|
+
is designed for the safest mainstream web UX possible while staying portable and
|
|
407
|
+
EVM-compatible.
|
|
408
|
+
|
|
409
|
+
## Philosophy
|
|
410
|
+
|
|
411
|
+
The safest web3 UX is the one users can actually complete.
|
|
412
|
+
|
|
413
|
+
Organigram Passkey Wallet keeps the cryptographic model understandable:
|
|
414
|
+
|
|
415
|
+
- the wallet is an EOA;
|
|
416
|
+
- the unlock method is a passkey;
|
|
417
|
+
- the backup is another passkey;
|
|
418
|
+
- the disaster recovery path is a seed phrase;
|
|
419
|
+
- the dapp interface is EIP-1193;
|
|
420
|
+
- the server stores encrypted envelopes, not keys.
|
|
421
|
+
|
|
422
|
+
That is the whole point: the most frictionless, simplest, and safest UX possible
|
|
423
|
+
for web3, without strings attached.
|
|
424
|
+
|
|
425
|
+
## License
|
|
426
|
+
|
|
427
|
+
MIT
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const createEncryptionKeyVersion = (): number => 1
|
|
2
|
+
|
|
3
|
+
export const generateUserEncryptionKeyPair = async (): Promise<CryptoKeyPair> =>
|
|
4
|
+
await crypto.subtle.generateKey(
|
|
5
|
+
{
|
|
6
|
+
name: 'ECDH',
|
|
7
|
+
namedCurve: 'P-256'
|
|
8
|
+
},
|
|
9
|
+
true,
|
|
10
|
+
['deriveKey']
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
export const exportUserPrivateKey = async (
|
|
14
|
+
keyPair: CryptoKeyPair
|
|
15
|
+
): Promise<JsonWebKey> => await crypto.subtle.exportKey('jwk', keyPair.privateKey)
|
|
16
|
+
|
|
17
|
+
export const exportUserPublicKey = async (
|
|
18
|
+
keyPair: CryptoKeyPair
|
|
19
|
+
): Promise<JsonWebKey> => await crypto.subtle.exportKey('jwk', keyPair.publicKey)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const createConnector = (factory: unknown): unknown => factory
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export declare const passkeyVaultAlgorithm = "AES-GCM-HKDF-SHA-256";
|
|
2
|
+
export declare const passkeyVaultKeyVersion = 1;
|
|
3
|
+
export type PasskeyVaultEnvelopeData = {
|
|
4
|
+
algorithm: typeof passkeyVaultAlgorithm;
|
|
5
|
+
keyVersion: typeof passkeyVaultKeyVersion;
|
|
6
|
+
ciphertext: string;
|
|
7
|
+
salt: string;
|
|
8
|
+
nonce: string;
|
|
9
|
+
};
|
|
10
|
+
type DerivePasskeyVaultKeyInput = {
|
|
11
|
+
prfOutput: Uint8Array;
|
|
12
|
+
salt: Uint8Array;
|
|
13
|
+
};
|
|
14
|
+
type EncryptPasskeyVaultSecretInput = {
|
|
15
|
+
plaintext: string;
|
|
16
|
+
key: CryptoKey;
|
|
17
|
+
salt: Uint8Array;
|
|
18
|
+
nonce?: Uint8Array;
|
|
19
|
+
};
|
|
20
|
+
type DecryptPasskeyVaultSecretInput = {
|
|
21
|
+
envelope: PasskeyVaultEnvelopeData;
|
|
22
|
+
key: CryptoKey;
|
|
23
|
+
};
|
|
24
|
+
type PrfSupportCredential = {
|
|
25
|
+
getClientExtensionResults: () => {
|
|
26
|
+
prf?: {
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export declare const bytesToBase64Url: (bytes: Uint8Array) => string;
|
|
32
|
+
export declare const base64UrlToBytes: (value: string) => Uint8Array;
|
|
33
|
+
export declare const derivePasskeyVaultKey: ({ prfOutput, salt }: DerivePasskeyVaultKeyInput) => Promise<CryptoKey>;
|
|
34
|
+
export declare const encryptPasskeyVaultSecret: ({ plaintext, key, salt, nonce }: EncryptPasskeyVaultSecretInput) => Promise<PasskeyVaultEnvelopeData>;
|
|
35
|
+
export declare const decryptPasskeyVaultSecret: ({ envelope, key }: DecryptPasskeyVaultSecretInput) => Promise<string>;
|
|
36
|
+
export declare const isPasskeyPrfSupported: ({ credential }: {
|
|
37
|
+
credential: PrfSupportCredential | null;
|
|
38
|
+
}) => Promise<boolean>;
|
|
39
|
+
export {};
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
export const passkeyVaultAlgorithm = 'AES-GCM-HKDF-SHA-256';
|
|
2
|
+
export const passkeyVaultKeyVersion = 1;
|
|
3
|
+
const textEncoder = new TextEncoder();
|
|
4
|
+
const textDecoder = new TextDecoder();
|
|
5
|
+
const getCrypto = () => {
|
|
6
|
+
if (globalThis.crypto == null) {
|
|
7
|
+
throw new Error('WebCrypto is not available in this environment.');
|
|
8
|
+
}
|
|
9
|
+
return globalThis.crypto;
|
|
10
|
+
};
|
|
11
|
+
const toArrayBuffer = (bytes) => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
12
|
+
export const bytesToBase64Url = (bytes) => bytesToBase64(bytes)
|
|
13
|
+
.replaceAll('+', '-')
|
|
14
|
+
.replaceAll('/', '_')
|
|
15
|
+
.replaceAll('=', '');
|
|
16
|
+
export const base64UrlToBytes = (value) => {
|
|
17
|
+
const normalized = value.replaceAll('-', '+').replaceAll('_', '/');
|
|
18
|
+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
|
|
19
|
+
return base64ToBytes(padded);
|
|
20
|
+
};
|
|
21
|
+
const bytesToBase64 = (bytes) => {
|
|
22
|
+
if (typeof Buffer !== 'undefined') {
|
|
23
|
+
return Buffer.from(bytes).toString('base64');
|
|
24
|
+
}
|
|
25
|
+
let binary = '';
|
|
26
|
+
bytes.forEach(byte => {
|
|
27
|
+
binary += String.fromCharCode(byte);
|
|
28
|
+
});
|
|
29
|
+
return btoa(binary);
|
|
30
|
+
};
|
|
31
|
+
const base64ToBytes = (value) => {
|
|
32
|
+
if (typeof Buffer !== 'undefined') {
|
|
33
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
34
|
+
}
|
|
35
|
+
const binary = atob(value);
|
|
36
|
+
const bytes = new Uint8Array(binary.length);
|
|
37
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
38
|
+
bytes[index] = binary.charCodeAt(index);
|
|
39
|
+
}
|
|
40
|
+
return bytes;
|
|
41
|
+
};
|
|
42
|
+
export const derivePasskeyVaultKey = async ({ prfOutput, salt }) => {
|
|
43
|
+
if (prfOutput.byteLength !== 32) {
|
|
44
|
+
throw new Error('Passkey PRF output must be 32 bytes.');
|
|
45
|
+
}
|
|
46
|
+
if (salt.byteLength !== 32) {
|
|
47
|
+
throw new Error('Passkey vault salt must be 32 bytes.');
|
|
48
|
+
}
|
|
49
|
+
const crypto = getCrypto();
|
|
50
|
+
const baseKey = await crypto.subtle.importKey('raw', toArrayBuffer(prfOutput), 'HKDF', false, ['deriveKey']);
|
|
51
|
+
return await crypto.subtle.deriveKey({
|
|
52
|
+
name: 'HKDF',
|
|
53
|
+
hash: 'SHA-256',
|
|
54
|
+
salt: toArrayBuffer(salt),
|
|
55
|
+
info: textEncoder.encode('organigram-passkey-wallet-v1')
|
|
56
|
+
}, baseKey, {
|
|
57
|
+
name: 'AES-GCM',
|
|
58
|
+
length: 256
|
|
59
|
+
}, false, ['encrypt', 'decrypt']);
|
|
60
|
+
};
|
|
61
|
+
export const encryptPasskeyVaultSecret = async ({ plaintext, key, salt, nonce = getCrypto().getRandomValues(new Uint8Array(12)) }) => {
|
|
62
|
+
if (plaintext.trim() === '') {
|
|
63
|
+
throw new Error('Passkey wallet vault plaintext cannot be empty.');
|
|
64
|
+
}
|
|
65
|
+
const ciphertext = await getCrypto().subtle.encrypt({
|
|
66
|
+
name: 'AES-GCM',
|
|
67
|
+
iv: toArrayBuffer(nonce)
|
|
68
|
+
}, key, textEncoder.encode(plaintext));
|
|
69
|
+
return {
|
|
70
|
+
algorithm: passkeyVaultAlgorithm,
|
|
71
|
+
keyVersion: passkeyVaultKeyVersion,
|
|
72
|
+
ciphertext: bytesToBase64Url(new Uint8Array(ciphertext)),
|
|
73
|
+
salt: bytesToBase64Url(salt),
|
|
74
|
+
nonce: bytesToBase64Url(nonce)
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
export const decryptPasskeyVaultSecret = async ({ envelope, key }) => {
|
|
78
|
+
try {
|
|
79
|
+
if (envelope.algorithm !== passkeyVaultAlgorithm) {
|
|
80
|
+
throw new Error('Unsupported passkey wallet vault algorithm.');
|
|
81
|
+
}
|
|
82
|
+
if (envelope.keyVersion !== passkeyVaultKeyVersion) {
|
|
83
|
+
throw new Error('Unsupported passkey wallet vault key version.');
|
|
84
|
+
}
|
|
85
|
+
const plaintext = await getCrypto().subtle.decrypt({
|
|
86
|
+
name: 'AES-GCM',
|
|
87
|
+
iv: toArrayBuffer(base64UrlToBytes(envelope.nonce))
|
|
88
|
+
}, key, toArrayBuffer(base64UrlToBytes(envelope.ciphertext)));
|
|
89
|
+
return textDecoder.decode(plaintext);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
throw new Error('Unable to decrypt passkey wallet.', { cause: error });
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
export const isPasskeyPrfSupported = async ({ credential }) => credential?.getClientExtensionResults().prf?.enabled === true;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Chain, EIP1193RequestFn } from 'viem';
|
|
2
|
+
import type { PasskeyProviderEvent, PasskeyProviderListener, PasskeyRegistrationResult, UnlockedPasskeyWallet } from './types';
|
|
3
|
+
export type OrganigramPasskeyProvider = {
|
|
4
|
+
request: EIP1193RequestFn;
|
|
5
|
+
on: (event: PasskeyProviderEvent, listener: PasskeyProviderListener) => void;
|
|
6
|
+
removeListener: (event: PasskeyProviderEvent, listener: PasskeyProviderListener) => void;
|
|
7
|
+
};
|
|
8
|
+
export type OrganigramPasskeyProviderActions = {
|
|
9
|
+
registerAdditionalPasskeyCredential: (input: {
|
|
10
|
+
wallet: UnlockedPasskeyWallet;
|
|
11
|
+
name?: string;
|
|
12
|
+
}) => Promise<PasskeyRegistrationResult>;
|
|
13
|
+
exportPasskeyWalletRecoveryPhrase: (input: {
|
|
14
|
+
expectedAddress: `0x${string}`;
|
|
15
|
+
}) => Promise<string>;
|
|
16
|
+
};
|
|
17
|
+
export declare const createPasskeyWalletProvider: ({ wallet, chain, rpcUrl, switchChain, actions }: {
|
|
18
|
+
wallet: UnlockedPasskeyWallet;
|
|
19
|
+
chain: Chain;
|
|
20
|
+
rpcUrl: string;
|
|
21
|
+
switchChain: (chainId: number) => Chain;
|
|
22
|
+
actions: OrganigramPasskeyProviderActions;
|
|
23
|
+
}) => OrganigramPasskeyProvider;
|
|
24
|
+
export declare const normalizePasskeyWalletAddress: (address: `0x${string}`) => `0x${string}`;
|