@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.
Files changed (79) hide show
  1. package/.organigram-docs-cache/passkey-wallet/api-extractor.json +35 -0
  2. package/.organigram-docs-cache/passkey-wallet/api-model/passkey-wallet.api.json +3166 -0
  3. package/.organigram-docs-cache/passkey-wallet/entry/index.d.ts +9 -0
  4. package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/05732508 +0 -0
  5. package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/166b6dbf +0 -0
  6. package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/41c20e23 +0 -0
  7. package/.organigram-docs-cache/passkey-wallet/tmp/node-compile-cache/v24.12.0-arm64-cf738c9d-501/7111ea58 +0 -0
  8. package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-1822dfbab9819e9f6a93485fe3da5dbf3745311c +1 -0
  9. package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-45a65cfdc7a50c53817c1dad3954ae3d2d2c35fb +1 -0
  10. package/.organigram-docs-cache/passkey-wallet/tmp/tsx-501/17844-fee5290b05398fe9e3b878deefd22383744f1526 +1 -0
  11. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/createorganigrampasskeywalletinput.yml +17 -0
  12. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/identitypasskeycapabilities.yml +16 -0
  13. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeycapabilities.yml +18 -0
  14. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeyprovider.yml +16 -0
  15. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/organigrampasskeyprovideractions.yml +20 -0
  16. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyprfunavailableerror.yml +28 -0
  17. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyproviderevent.yml +11 -0
  18. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyproviderlistener.yml +11 -0
  19. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyregistrationresult.yml +15 -0
  20. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultdecrypterror.yml +28 -0
  21. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultenvelopedata.yml +18 -0
  22. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeyvaultenvelopeinput.yml +19 -0
  23. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletapiclient.yml +33 -0
  24. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletcapabilities.yml +11 -0
  25. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletlogincapabilities.yml +14 -0
  26. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletregistrationcapabilities.yml +18 -0
  27. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/passkeywalletvaultpayload.yml +18 -0
  28. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet/unlockedpasskeywallet.yml +21 -0
  29. package/.organigram-docs-cache/passkey-wallet/yaml/passkey-wallet.yml +870 -0
  30. package/.organigram-docs-cache/passkey-wallet/yaml/toc.yml +43 -0
  31. package/.turbo/turbo-build.log +6 -0
  32. package/.turbo/turbo-docs$colon$generate.log +118 -0
  33. package/.turbo/turbo-lint.log +13 -0
  34. package/.turbo/turbo-test.log +152 -0
  35. package/.turbo/turbo-typecheck.log +5 -0
  36. package/CHANGELOG.md +8 -0
  37. package/LICENSE.md +21 -0
  38. package/README.md +427 -0
  39. package/__mocks__/organigram-js.ts +19 -0
  40. package/__mocks__/wagmi.ts +1 -0
  41. package/dist/crypto.d.ts +39 -0
  42. package/dist/crypto.js +95 -0
  43. package/dist/eip1193.d.ts +24 -0
  44. package/dist/eip1193.js +103 -0
  45. package/dist/errors.d.ts +7 -0
  46. package/dist/errors.js +13 -0
  47. package/dist/index.d.ts +4 -0
  48. package/dist/index.js +4 -0
  49. package/dist/rainbowkit.d.ts +14 -0
  50. package/dist/rainbowkit.js +128 -0
  51. package/dist/types.d.ts +45 -0
  52. package/dist/types.js +17 -0
  53. package/dist/vault.d.ts +10 -0
  54. package/dist/vault.js +41 -0
  55. package/dist/wallet.d.ts +15 -0
  56. package/dist/wallet.js +30 -0
  57. package/dist/webauthn-client.d.ts +73 -0
  58. package/dist/webauthn-client.js +234 -0
  59. package/dist/webauthn-server.d.ts +62 -0
  60. package/dist/webauthn-server.js +133 -0
  61. package/jest.config.ts +32 -0
  62. package/package.json +61 -0
  63. package/src/crypto.test.ts +69 -0
  64. package/src/crypto.ts +193 -0
  65. package/src/eip1193.ts +169 -0
  66. package/src/errors.ts +16 -0
  67. package/src/index.ts +4 -0
  68. package/src/rainbowkit.test.ts +42 -0
  69. package/src/rainbowkit.ts +171 -0
  70. package/src/types.ts +82 -0
  71. package/src/vault.test.ts +23 -0
  72. package/src/vault.ts +77 -0
  73. package/src/wallet.ts +61 -0
  74. package/src/webauthn-client.test.ts +103 -0
  75. package/src/webauthn-client.ts +430 -0
  76. package/src/webauthn-server.test.ts +43 -0
  77. package/src/webauthn-server.ts +258 -0
  78. package/tsconfig.build.json +15 -0
  79. package/tsconfig.json +27 -0
package/src/vault.ts ADDED
@@ -0,0 +1,77 @@
1
+ import {
2
+ createEncryptionKeyVersion,
3
+ exportUserPrivateKey,
4
+ exportUserPublicKey,
5
+ generateUserEncryptionKeyPair
6
+ } from '@organigram/js'
7
+
8
+ export type PasskeyWalletVaultPayload = {
9
+ version: 1
10
+ recoveryPhrase: string
11
+ userEncryptionPrivateKey: JsonWebKey
12
+ userEncryptionPublicKey: JsonWebKey
13
+ userEncryptionKeyVersion: number
14
+ }
15
+
16
+ export const serializePasskeyWalletVaultPayload = ({
17
+ recoveryPhrase,
18
+ userEncryptionPrivateKey,
19
+ userEncryptionPublicKey,
20
+ userEncryptionKeyVersion
21
+ }: Omit<PasskeyWalletVaultPayload, 'version'>): string =>
22
+ JSON.stringify({
23
+ version: 1,
24
+ recoveryPhrase,
25
+ userEncryptionPrivateKey,
26
+ userEncryptionPublicKey,
27
+ userEncryptionKeyVersion
28
+ } satisfies PasskeyWalletVaultPayload)
29
+
30
+ export const parsePasskeyWalletVaultPayload = (
31
+ plaintext: string
32
+ ): PasskeyWalletVaultPayload => {
33
+ const payload = JSON.parse(plaintext) as Partial<PasskeyWalletVaultPayload>
34
+ if (payload.version !== 1) {
35
+ throw new Error('Unsupported passkey wallet vault payload version.')
36
+ }
37
+ if (
38
+ typeof payload.recoveryPhrase !== 'string' ||
39
+ payload.recoveryPhrase.trim() === ''
40
+ ) {
41
+ throw new Error('Passkey wallet vault is missing its recovery phrase.')
42
+ }
43
+ if (payload.userEncryptionPrivateKey == null) {
44
+ throw new Error('Passkey wallet vault is missing its IPFS private key.')
45
+ }
46
+ if (payload.userEncryptionPublicKey == null) {
47
+ throw new Error('Passkey wallet vault is missing its IPFS public key.')
48
+ }
49
+ if (
50
+ !Number.isInteger(payload.userEncryptionKeyVersion) ||
51
+ payload.userEncryptionKeyVersion == null
52
+ ) {
53
+ throw new Error('Passkey wallet vault has an invalid IPFS key version.')
54
+ }
55
+
56
+ return payload as PasskeyWalletVaultPayload
57
+ }
58
+
59
+ export const createPasskeyWalletVaultPayload = async (
60
+ recoveryPhrase: string
61
+ ): Promise<PasskeyWalletVaultPayload> => {
62
+ const userEncryptionKeyPair = await generateUserEncryptionKeyPair()
63
+ const userEncryptionPrivateKey = await exportUserPrivateKey(
64
+ userEncryptionKeyPair
65
+ )
66
+ const userEncryptionPublicKey = await exportUserPublicKey(
67
+ userEncryptionKeyPair
68
+ )
69
+
70
+ return {
71
+ version: 1,
72
+ recoveryPhrase,
73
+ userEncryptionPrivateKey,
74
+ userEncryptionPublicKey,
75
+ userEncryptionKeyVersion: createEncryptionKeyVersion()
76
+ }
77
+ }
package/src/wallet.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { english, generateMnemonic, mnemonicToAccount } from 'viem/accounts'
2
+
3
+ import type {
4
+ OrganigramPasskeyCapabilities,
5
+ UnlockedPasskeyWallet
6
+ } from './types'
7
+ import {
8
+ createPasskeyWalletVaultPayload,
9
+ type PasskeyWalletVaultPayload
10
+ } from './vault'
11
+
12
+ export const unlockedPasskeyWalletTtlMs = 15 * 60 * 1000
13
+
14
+ export const createUnlockedPasskeyWallet = ({
15
+ address,
16
+ credentialId,
17
+ vaultPayload,
18
+ now = Date.now()
19
+ }: {
20
+ address: `0x${string}`
21
+ credentialId: string
22
+ vaultPayload: PasskeyWalletVaultPayload
23
+ now?: number
24
+ }): UnlockedPasskeyWallet => {
25
+ const account = mnemonicToAccount(vaultPayload.recoveryPhrase)
26
+
27
+ return {
28
+ address,
29
+ account,
30
+ recoveryPhrase: vaultPayload.recoveryPhrase,
31
+ userEncryptionPrivateKey: vaultPayload.userEncryptionPrivateKey,
32
+ userEncryptionPublicKey: vaultPayload.userEncryptionPublicKey,
33
+ userEncryptionKeyVersion: vaultPayload.userEncryptionKeyVersion,
34
+ credentialId,
35
+ expiresAt: now + unlockedPasskeyWalletTtlMs
36
+ }
37
+ }
38
+
39
+ export const createNewPasskeyWalletVault = async ({
40
+ capabilities
41
+ }: {
42
+ capabilities: OrganigramPasskeyCapabilities
43
+ }): Promise<{
44
+ address: `0x${string}`
45
+ vaultPayload: PasskeyWalletVaultPayload
46
+ }> => {
47
+ const recoveryPhrase = generateMnemonic(english, 128)
48
+ const vaultPayload = await createPasskeyWalletVaultPayload(recoveryPhrase)
49
+ const account = mnemonicToAccount(recoveryPhrase)
50
+
51
+ return {
52
+ address: account.address,
53
+ vaultPayload: {
54
+ ...vaultPayload,
55
+ recoveryPhrase:
56
+ capabilities.method === 'register'
57
+ ? vaultPayload.recoveryPhrase
58
+ : vaultPayload.recoveryPhrase
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+
4
+ import {
5
+ hydratePasskeyPrfOptions,
6
+ isPasskeyCredentialUnavailableError
7
+ } from './webauthn-client'
8
+
9
+ const webauthnClientPath = resolve('src/webauthn-client.ts')
10
+ const walletPath = resolve('src/wallet.ts')
11
+
12
+ describe('passkey WebAuthn client helpers', () => {
13
+ it('hydrates serialized WebAuthn PRF extension inputs before browser ceremonies', () => {
14
+ const serializedFirst = Object.fromEntries(
15
+ Array.from({ length: 32 }, (_, index) => [String(index), index])
16
+ )
17
+ const options = hydratePasskeyPrfOptions({
18
+ challenge: 'challenge',
19
+ user: {
20
+ id: 'user',
21
+ name: 'user@example.com',
22
+ displayName: 'user@example.com'
23
+ },
24
+ pubKeyCredParams: [],
25
+ extensions: {
26
+ prf: {
27
+ eval: {
28
+ first: serializedFirst
29
+ },
30
+ evalByCredential: {
31
+ credential: {
32
+ first: Array.from({ length: 32 }, (_, index) => 31 - index)
33
+ }
34
+ }
35
+ }
36
+ }
37
+ })
38
+
39
+ const prf = (options.extensions as {
40
+ prf?: {
41
+ eval?: { first?: unknown }
42
+ evalByCredential?: Record<string, { first?: unknown }>
43
+ }
44
+ }).prf
45
+
46
+ expect(prf?.eval?.first).toBeInstanceOf(Uint8Array)
47
+ expect(Array.from(prf?.eval?.first as Uint8Array)).toEqual(
48
+ Array.from({ length: 32 }, (_, index) => index)
49
+ )
50
+ expect(prf?.evalByCredential?.credential.first).toBeInstanceOf(Uint8Array)
51
+ expect(
52
+ Array.from(prf?.evalByCredential?.credential.first as Uint8Array)
53
+ ).toEqual(Array.from({ length: 32 }, (_, index) => 31 - index))
54
+ })
55
+
56
+ it('creates passkey wallets from a mnemonic recovery phrase, not a raw private key', () => {
57
+ const source = readFileSync(walletPath, 'utf8')
58
+
59
+ expect(source).toMatch(/generateMnemonic/)
60
+ expect(source).toMatch(/mnemonicToAccount/)
61
+ expect(source).toMatch(/english/)
62
+ expect(source).not.toMatch(/generatePrivateKey/)
63
+ })
64
+
65
+ it('exports a recovery phrase for mnemonic-backed passkey wallets', () => {
66
+ const source = readFileSync(webauthnClientPath, 'utf8')
67
+
68
+ expect(source).toMatch(/exportPasskeyWalletRecoveryPhrase/)
69
+ expect(source).toMatch(/recoveryPhrase/)
70
+ expect(source).not.toMatch(/exportPasskeyWalletPrivateKey/)
71
+ })
72
+
73
+ it('shows a user-facing message when a passkey provider does not return PRF output', () => {
74
+ const source = readFileSync(webauthnClientPath, 'utf8')
75
+ const errorsSource = readFileSync(resolve('src/errors.ts'), 'utf8')
76
+
77
+ expect(source).toMatch(/PasskeyPrfUnavailableError/)
78
+ expect(errorsSource).toMatch(
79
+ /This password manager is not compatible with Organigram's passkey wallet\. Please use a different password manager to unlock your wallet\./
80
+ )
81
+ expect(source).not.toMatch(/Ce gestionnaire/)
82
+ expect(errorsSource).not.toMatch(/Ce gestionnaire/)
83
+ expect(source).not.toMatch(/WebAuthn PRF is not supported/)
84
+ })
85
+
86
+ it('recognizes browser errors for missing passkey credentials', () => {
87
+ expect(
88
+ isPasskeyCredentialUnavailableError(
89
+ new DOMException('The operation was not allowed.', 'NotAllowedError')
90
+ )
91
+ ).toBe(true)
92
+ expect(
93
+ isPasskeyCredentialUnavailableError(
94
+ new Error('No credentials are available for this authenticator.')
95
+ )
96
+ ).toBe(true)
97
+ expect(
98
+ isPasskeyCredentialUnavailableError(
99
+ new Error('Passkey wallet envelope does not match its address.')
100
+ )
101
+ ).toBe(false)
102
+ })
103
+ })
@@ -0,0 +1,430 @@
1
+ import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
2
+
3
+ import {
4
+ base64UrlToBytes,
5
+ decryptPasskeyVaultSecret,
6
+ derivePasskeyVaultKey,
7
+ encryptPasskeyVaultSecret,
8
+ type PasskeyVaultEnvelopeData
9
+ } from './crypto'
10
+ import { PasskeyPrfUnavailableError } from './errors'
11
+ import type {
12
+ OrganigramPasskeyCapabilities,
13
+ PasskeyRegistrationResult,
14
+ UnlockedPasskeyWallet
15
+ } from './types'
16
+ import { createNewPasskeyWalletVault, createUnlockedPasskeyWallet } from './wallet'
17
+ import {
18
+ parsePasskeyWalletVaultPayload,
19
+ serializePasskeyWalletVaultPayload,
20
+ type PasskeyWalletVaultPayload
21
+ } from './vault'
22
+
23
+ export {
24
+ parsePasskeyWalletVaultPayload,
25
+ serializePasskeyWalletVaultPayload
26
+ }
27
+ export type { OrganigramPasskeyCapabilities, UnlockedPasskeyWallet } from './types'
28
+
29
+ type PasskeyRegisterOptionsResponse = {
30
+ options: Parameters<typeof startRegistration>[0]['optionsJSON']
31
+ }
32
+
33
+ type PasskeyUnlockOptionsResponse = {
34
+ options: Parameters<typeof startAuthentication>[0]['optionsJSON']
35
+ hasCredentials: boolean
36
+ }
37
+
38
+ type PasskeyUnlockVerifyResponse = {
39
+ address: `0x${string}`
40
+ credentialId: string
41
+ envelope: PasskeyVaultEnvelopeData
42
+ }
43
+
44
+ type PasskeyExtensionResults = {
45
+ prf?: {
46
+ results?: {
47
+ first?: string | ArrayBuffer
48
+ }
49
+ }
50
+ }
51
+
52
+ type PasskeyPrfValue =
53
+ | string
54
+ | ArrayBuffer
55
+ | ArrayBufferView
56
+ | number[]
57
+ | Record<string, number>
58
+
59
+ export type PasskeyWalletApiClient = {
60
+ registerOptions: (input: {
61
+ address: `0x${string}`
62
+ email?: string | null
63
+ name?: string | null
64
+ }) => Promise<PasskeyRegisterOptionsResponse>
65
+ registerVerify: (input: {
66
+ response: unknown
67
+ envelope: {
68
+ address: `0x${string}`
69
+ encryptedMnemonic: string
70
+ salt: string
71
+ nonce: string
72
+ algorithm: string
73
+ keyVersion: number
74
+ }
75
+ }) => Promise<PasskeyRegistrationResult>
76
+ unlockOptions: () => Promise<PasskeyUnlockOptionsResponse>
77
+ unlockVerify: (input: {
78
+ response: unknown
79
+ }) => Promise<PasskeyUnlockVerifyResponse>
80
+ }
81
+
82
+ const readJson = async <T>(response: Response): Promise<T> => {
83
+ const body = (await response.json().catch(() => null)) as
84
+ | ({ error?: string } & T)
85
+ | null
86
+
87
+ if (!response.ok) {
88
+ throw new Error(
89
+ body?.error ?? `Request failed with status ${response.status}`
90
+ )
91
+ }
92
+
93
+ return body as T
94
+ }
95
+
96
+ const postJson = async <T>(
97
+ path: string,
98
+ body: Record<string, unknown>
99
+ ): Promise<T> => {
100
+ const response = await fetch(path, {
101
+ method: 'POST',
102
+ headers: {
103
+ 'Content-Type': 'application/json'
104
+ },
105
+ body: JSON.stringify(body)
106
+ })
107
+
108
+ return await readJson<T>(response)
109
+ }
110
+
111
+ export const createFetchPasskeyWalletApiClient = (
112
+ basePath = '/api/auth/passkey'
113
+ ): PasskeyWalletApiClient => ({
114
+ registerOptions: async input =>
115
+ await postJson<PasskeyRegisterOptionsResponse>(
116
+ `${basePath}/register/options`,
117
+ input
118
+ ),
119
+ registerVerify: async input =>
120
+ await postJson<PasskeyRegistrationResult>(
121
+ `${basePath}/register/verify`,
122
+ input
123
+ ),
124
+ unlockOptions: async () =>
125
+ await postJson<PasskeyUnlockOptionsResponse>(
126
+ `${basePath}/unlock/options`,
127
+ {}
128
+ ),
129
+ unlockVerify: async input =>
130
+ await postJson<PasskeyUnlockVerifyResponse>(
131
+ `${basePath}/unlock/verify`,
132
+ input
133
+ )
134
+ })
135
+
136
+ const hydrateSerializedPrfValue = (value: unknown): unknown => {
137
+ if (value == null) return value
138
+ if (typeof value === 'string') return base64UrlToBytes(value)
139
+ if (value instanceof ArrayBuffer) return new Uint8Array(value)
140
+ if (ArrayBuffer.isView(value)) {
141
+ return new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
142
+ }
143
+ if (Array.isArray(value)) return new Uint8Array(value)
144
+ if (typeof value === 'object') {
145
+ const entries = Object.entries(value as Record<string, unknown>)
146
+ if (
147
+ entries.every(
148
+ ([key, entryValue]) =>
149
+ /^\d+$/.test(key) && typeof entryValue === 'number'
150
+ )
151
+ ) {
152
+ return new Uint8Array(
153
+ entries
154
+ .sort(([left], [right]) => Number(left) - Number(right))
155
+ .map(([, entryValue]) => entryValue as number)
156
+ )
157
+ }
158
+ }
159
+
160
+ return value
161
+ }
162
+
163
+ const hydratePrfValueSet = <T extends { first?: unknown; second?: unknown }>(
164
+ valueSet: T | undefined
165
+ ): T | undefined => {
166
+ if (valueSet == null) return valueSet
167
+
168
+ return {
169
+ ...valueSet,
170
+ first: hydrateSerializedPrfValue(valueSet.first) as PasskeyPrfValue,
171
+ ...(valueSet.second != null
172
+ ? {
173
+ second: hydrateSerializedPrfValue(valueSet.second) as PasskeyPrfValue
174
+ }
175
+ : {})
176
+ }
177
+ }
178
+
179
+ export const hydratePasskeyPrfOptions = <T extends { extensions?: unknown }>(
180
+ options: T
181
+ ): T => {
182
+ const extensions = options.extensions as
183
+ | {
184
+ prf?: {
185
+ eval?: { first?: unknown; second?: unknown }
186
+ evalByCredential?: Record<
187
+ string,
188
+ { first?: unknown; second?: unknown }
189
+ >
190
+ }
191
+ }
192
+ | undefined
193
+ const prf = extensions?.prf
194
+ if (prf == null) return options
195
+
196
+ return {
197
+ ...options,
198
+ extensions: {
199
+ ...extensions,
200
+ prf: {
201
+ ...prf,
202
+ ...(prf.eval != null ? { eval: hydratePrfValueSet(prf.eval) } : {}),
203
+ ...(prf.evalByCredential != null
204
+ ? {
205
+ evalByCredential: Object.fromEntries(
206
+ Object.entries(prf.evalByCredential).map(
207
+ ([credentialId, valueSet]) => [
208
+ credentialId,
209
+ hydratePrfValueSet(valueSet)
210
+ ]
211
+ )
212
+ )
213
+ }
214
+ : {})
215
+ }
216
+ }
217
+ }
218
+ }
219
+
220
+ const getPasskeyPrfOutput = (response: unknown): Uint8Array => {
221
+ const first = (
222
+ response as {
223
+ clientExtensionResults?: PasskeyExtensionResults
224
+ }
225
+ ).clientExtensionResults?.prf?.results?.first
226
+ if (first == null || first === '') {
227
+ throw new PasskeyPrfUnavailableError()
228
+ }
229
+
230
+ return typeof first === 'string'
231
+ ? base64UrlToBytes(first)
232
+ : new Uint8Array(first)
233
+ }
234
+
235
+ export async function registerPasskeyCredentialEnvelope({
236
+ api,
237
+ address,
238
+ vaultPayload,
239
+ email,
240
+ name
241
+ }: {
242
+ api: PasskeyWalletApiClient
243
+ address: `0x${string}`
244
+ vaultPayload: PasskeyWalletVaultPayload
245
+ email?: string | null
246
+ name?: string | null
247
+ }): Promise<PasskeyRegistrationResult> {
248
+ const { options } = await api.registerOptions({
249
+ address,
250
+ email,
251
+ name
252
+ })
253
+ const registrationResponse = await startRegistration({
254
+ optionsJSON: hydratePasskeyPrfOptions(options)
255
+ })
256
+ const prfOutput = getPasskeyPrfOutput(registrationResponse)
257
+ const salt = crypto.getRandomValues(new Uint8Array(32))
258
+ const key = await derivePasskeyVaultKey({
259
+ prfOutput,
260
+ salt
261
+ })
262
+ const envelope = await encryptPasskeyVaultSecret({
263
+ plaintext: serializePasskeyWalletVaultPayload(vaultPayload),
264
+ key,
265
+ salt
266
+ })
267
+
268
+ return await api.registerVerify({
269
+ response: registrationResponse,
270
+ envelope: {
271
+ address,
272
+ encryptedMnemonic: envelope.ciphertext,
273
+ salt: envelope.salt,
274
+ nonce: envelope.nonce,
275
+ algorithm: envelope.algorithm,
276
+ keyVersion: envelope.keyVersion
277
+ }
278
+ })
279
+ }
280
+
281
+ export const registerPasskeyWallet = async ({
282
+ api,
283
+ capabilities
284
+ }: {
285
+ api: PasskeyWalletApiClient
286
+ capabilities: OrganigramPasskeyCapabilities
287
+ }): Promise<UnlockedPasskeyWallet> => {
288
+ const { address, vaultPayload } = await createNewPasskeyWalletVault({
289
+ capabilities
290
+ })
291
+ const credential = await registerPasskeyCredentialEnvelope({
292
+ api,
293
+ address,
294
+ vaultPayload,
295
+ email: capabilities.identity?.email ?? null,
296
+ name: capabilities.name ?? capabilities.identity?.email ?? null
297
+ })
298
+
299
+ return createUnlockedPasskeyWallet({
300
+ address: credential.address,
301
+ credentialId: credential.credentialId,
302
+ vaultPayload
303
+ })
304
+ }
305
+
306
+ export const unlockPasskeyWallet = async ({
307
+ api
308
+ }: {
309
+ api: PasskeyWalletApiClient
310
+ }): Promise<UnlockedPasskeyWallet | null> => {
311
+ const { options, hasCredentials } = await api.unlockOptions()
312
+ if (!hasCredentials) return null
313
+
314
+ const authenticationResponse = await startAuthentication({
315
+ optionsJSON: hydratePasskeyPrfOptions(options)
316
+ })
317
+ const prfOutput = getPasskeyPrfOutput(authenticationResponse)
318
+ const result = await api.unlockVerify({
319
+ response: authenticationResponse
320
+ })
321
+ const key = await derivePasskeyVaultKey({
322
+ prfOutput,
323
+ salt: base64UrlToBytes(result.envelope.salt)
324
+ })
325
+ const vaultPlaintext = await decryptPasskeyVaultSecret({
326
+ envelope: result.envelope,
327
+ key
328
+ })
329
+ const vaultPayload = parsePasskeyWalletVaultPayload(vaultPlaintext)
330
+ const wallet = createUnlockedPasskeyWallet({
331
+ address: result.address,
332
+ credentialId: result.credentialId,
333
+ vaultPayload
334
+ })
335
+
336
+ if (wallet.account.address.toLowerCase() !== result.address.toLowerCase()) {
337
+ throw new Error('Passkey wallet envelope does not match its address.')
338
+ }
339
+
340
+ return wallet
341
+ }
342
+
343
+ export const exportPasskeyWalletRecoveryPhrase = async ({
344
+ api,
345
+ expectedAddress
346
+ }: {
347
+ api: PasskeyWalletApiClient
348
+ expectedAddress: `0x${string}`
349
+ }): Promise<string> => {
350
+ const wallet = await unlockPasskeyWallet({ api })
351
+ if (wallet == null) {
352
+ throw new Error('No passkey wallet is available to export.')
353
+ }
354
+ if (wallet.address.toLowerCase() !== expectedAddress.toLowerCase()) {
355
+ throw new Error('Selected passkey does not unlock this wallet.')
356
+ }
357
+
358
+ return wallet.recoveryPhrase
359
+ }
360
+
361
+ export const registerAdditionalPasskeyCredential = async ({
362
+ api,
363
+ wallet,
364
+ name
365
+ }: {
366
+ api: PasskeyWalletApiClient
367
+ wallet: UnlockedPasskeyWallet
368
+ name?: string
369
+ }): Promise<PasskeyRegistrationResult> =>
370
+ await registerPasskeyCredentialEnvelope({
371
+ api,
372
+ address: wallet.address,
373
+ vaultPayload: {
374
+ version: 1,
375
+ recoveryPhrase: wallet.recoveryPhrase,
376
+ userEncryptionPrivateKey: wallet.userEncryptionPrivateKey,
377
+ userEncryptionPublicKey: wallet.userEncryptionPublicKey,
378
+ userEncryptionKeyVersion: wallet.userEncryptionKeyVersion
379
+ },
380
+ name: name ?? 'Backup passkey'
381
+ })
382
+
383
+ export const isPasskeyCredentialUnavailableError = (error: unknown): boolean => {
384
+ if (typeof error !== 'object' || error == null) return false
385
+
386
+ const { name, message } = error as { name?: unknown; message?: unknown }
387
+ const normalizedName = typeof name === 'string' ? name.toLowerCase() : ''
388
+ const normalizedMessage =
389
+ typeof message === 'string' ? message.toLowerCase() : ''
390
+
391
+ return (
392
+ normalizedName === 'notallowederror' ||
393
+ normalizedName === 'invalidstateerror' ||
394
+ normalizedMessage.includes('no credentials') ||
395
+ normalizedMessage.includes('not allowed') ||
396
+ normalizedMessage.includes('could not be completed')
397
+ )
398
+ }
399
+
400
+ export const unlockOrCreatePasskeyWallet = async ({
401
+ api,
402
+ capabilities
403
+ }: {
404
+ api: PasskeyWalletApiClient
405
+ capabilities: OrganigramPasskeyCapabilities
406
+ targetChainId: number
407
+ }): Promise<UnlockedPasskeyWallet> => {
408
+ if (typeof window === 'undefined' || window.PublicKeyCredential == null) {
409
+ throw new Error('Passkeys are not available in this browser.')
410
+ }
411
+
412
+ if (capabilities.method !== 'register') {
413
+ try {
414
+ const wallet = await unlockPasskeyWallet({ api })
415
+ if (wallet != null) return wallet
416
+ } catch (error) {
417
+ if (!isPasskeyCredentialUnavailableError(error)) {
418
+ throw error
419
+ }
420
+ }
421
+ }
422
+
423
+ return await registerPasskeyWallet({
424
+ api,
425
+ capabilities: {
426
+ ...capabilities,
427
+ method: 'register'
428
+ }
429
+ })
430
+ }
@@ -0,0 +1,43 @@
1
+ import {
2
+ passkeyChallengeTtlMs,
3
+ validatePasskeyVaultEnvelopeInput
4
+ } from './webauthn-server'
5
+ import { readFileSync } from 'node:fs'
6
+ import { resolve } from 'node:path'
7
+
8
+ const serverPath = resolve('src/webauthn-server.ts')
9
+
10
+ describe('passkey WebAuthn server helpers', () => {
11
+ it('uses discoverable authentication without sending the global credential list', () => {
12
+ const source = readFileSync(serverPath, 'utf8')
13
+
14
+ expect(source).toMatch(/const isDiscoverableAuthentication = credentials\.length === 0/)
15
+ expect(source).toMatch(/isDiscoverableAuthentication\s+\?\s+\{\s*eval:/)
16
+ expect(source).toMatch(/\.\.\.\(!isDiscoverableAuthentication\s+\?\s+\{\s*allowCredentials:/)
17
+ })
18
+
19
+ it('requires discoverable credentials for passkey wallet registration', () => {
20
+ const source = readFileSync(serverPath, 'utf8')
21
+
22
+ expect(source).toMatch(/residentKey: 'required'/)
23
+ })
24
+
25
+ it('validates passkey vault envelopes without requiring a database', () => {
26
+ expect(
27
+ validatePasskeyVaultEnvelopeInput({
28
+ address: '0x0000000000000000000000000000000000000001',
29
+ encryptedMnemonic: 'ciphertext',
30
+ salt: 'salt',
31
+ nonce: 'nonce',
32
+ algorithm: 'AES-GCM-HKDF-SHA-256',
33
+ keyVersion: 1
34
+ })
35
+ ).toMatchObject({
36
+ address: '0x0000000000000000000000000000000000000001'
37
+ })
38
+ })
39
+
40
+ it('keeps passkey challenges short-lived', () => {
41
+ expect(passkeyChallengeTtlMs).toBe(5 * 60 * 1000)
42
+ })
43
+ })