@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/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
|
+
})
|