From 5e178750e43f15b2cd8180ea1ab97d6d65f79434 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 15:06:16 -0300 Subject: [PATCH 01/21] Add passkey authentication support for web wallet using WebAuthn PRF extension --- docs/cloud-wallet.md | 87 ++++++++++++++- packages/core/src/cloud-wallet.ts | 162 +++++++++++++++++++++++++++ packages/web/example.html | 90 ++++++++++++++- packages/web/src/index.js | 26 +++++ packages/web/src/passkey.js | 179 ++++++++++++++++++++++++++++++ 5 files changed, 537 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/passkey.js diff --git a/docs/cloud-wallet.md b/docs/cloud-wallet.md index 8bdba0f2..9263ea65 100644 --- a/docs/cloud-wallet.md +++ b/docs/cloud-wallet.md @@ -191,7 +191,7 @@ The Truvera Cloud Wallet supports multiple authentication methods to unlock the 1. **Mnemonic-based authentication**: The traditional recovery phrase approach 2. **Biometric authentication**: Using fingerprints, facial recognition, or other biometric data -3. **Future extensions**: Can be extended to support passkeys and other authentication methods +3. **Passkey authentication**: Using WebAuthn passkeys with the PRF extension for browser-based wallets ### How multi-key authentication works @@ -272,6 +272,91 @@ The authentication process: 4. Decrypts the master key 5. Uses the master key to access the CloudWalletVault +#### Passkey authentication + +Passkeys provide a passwordless authentication method for web wallets using the WebAuthn PRF extension to derive deterministic key material. This approach works the same way as biometric authentication — the passkey PRF output is used to encrypt/decrypt the master key in the KeyMappingVault. + +**Browser requirements**: Chrome 116+, Safari 18+ (macOS Sequoia / iOS 18), Edge 116+. + +##### Step 1: Register a passkey and enroll + +```js +import { + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + enrollUserWithPasskey, +} from '@docknetwork/wallet-sdk-web'; + +const identifier = 'user@example.com'; + +// Check browser support +const { webauthn } = await checkPasskeySupport(); +if (!webauthn) { + throw new Error('WebAuthn not supported'); +} + +// Register a passkey (prompts user for biometric/PIN) +const { credentialId, prfSupported } = await registerPasskey(identifier); +if (!prfSupported) { + throw new Error('PRF extension not supported. Use Chrome 116+ or Safari 18+.'); +} + +// Extract PRF key material (second WebAuthn ceremony, required on Safari) +const prfOutput = await getPasskeyPRFKey(credentialId, identifier); + +// Enroll: generates a new masterKey, encrypts it with the passkey-derived key +const { masterKey, mnemonic } = await enrollUserWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, identifier +); + +// Store credentialId for future authentication +localStorage.setItem('walletCredentialId', credentialIdToBase64url(credentialId)); + +// IMPORTANT: Prompt the user to save the mnemonic for recovery +``` + +##### Step 2: Authenticate with passkey + +```js +import { + getPasskeyPRFKey, + base64urlToCredentialId, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, +} from '@docknetwork/wallet-sdk-web'; + +const identifier = 'user@example.com'; +const credentialId = base64urlToCredentialId(localStorage.getItem('walletCredentialId')); + +// Get PRF key material (prompts user for biometric/PIN) +const prfOutput = await getPasskeyPRFKey(credentialId, identifier); + +// Method 1: Get the master key directly +const masterKey = await authenticateWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, identifier +); + +// Method 2: Initialize cloud wallet in one step +const cloudWallet = await initializeCloudWalletWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, identifier, dataStore +); +``` + +The passkey authentication process: +1. Performs a WebAuthn assertion with the PRF extension to extract deterministic key material +2. Derives vault access keys from the PRF output using HKDF +3. Accesses the KeyMappingVault and finds the encrypted master key +4. Derives the decryption key from the PRF output +5. Decrypts and returns the master key + +**Security notes**: +- The `credentialId` is a public identifier, safe to store in localStorage +- PRF output and derived keys exist only in memory during a session +- Passkeys may sync across devices via iCloud Keychain or Google Password Manager +- If a passkey is lost, the user can recover using their mnemonic phrase + ### Wallet recovery This architecture allows solution developers to design the recovery mechanism that makes sense for your use case. diff --git a/packages/core/src/cloud-wallet.ts b/packages/core/src/cloud-wallet.ts index dcb23136..0fc95745 100644 --- a/packages/core/src/cloud-wallet.ts +++ b/packages/core/src/cloud-wallet.ts @@ -15,6 +15,7 @@ import { utilCryptoService } from '@docknetwork/wallet-sdk-wasm/src/services/uti export const SYNC_MARKER_TYPE = 'SyncMarkerDocument'; export const MNEMONIC_WORD_COUNT = 12; export const KEY_MAPPING_TYPE = 'KeyMappingDocument'; +export const PASSKEY_KEY_MAPPING_TYPE = 'PasskeyKeyMappingDocument'; const MASTER_KEY_SUFFIX = 'master-key'; /** @@ -269,6 +270,167 @@ export async function initializeCloudWalletWithBiometrics( }); } +/** + * Derives EDV keys from passkey PRF output for the KeyMappingVault + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier as additional entropy (email, phone number, etc.) + * @returns Keys for accessing the KeyMappingVault + */ +export async function derivePasskeyVaultKeys( + prfOutput: Uint8Array, + identifier: string +): Promise<{ hmacKey: string; agreementKey: string; verificationKey: string }> { + const seedBuffer = deriveBiometricKey(Buffer.from(prfOutput), identifier); + return edvService.deriveKeys(new Uint8Array(seedBuffer)); +} + +/** + * Generates a key for encrypting/decrypting the master key from passkey PRF output + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier as salt (email, phone number, etc.) + * @returns Encryption key and IV for AES encryption + */ +export async function derivePasskeyEncryptionKey( + prfOutput: Uint8Array, + identifier: string +): Promise<{ key: Buffer; iv: Buffer }> { + return edvService.deriveBiometricEncryptionKey(Buffer.from(prfOutput), identifier); +} + +/** + * Initializes the KeyMappingVault using passkey PRF output + * @param edvUrl URL for the edv + * @param authKey Auth key for the edv + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier (email, phone number, etc.) + * @returns Initialized EDV service + */ +export async function initializePasskeyKeyMappingVault( + edvUrl: string, + authKey: string, + prfOutput: Uint8Array, + identifier: string +): Promise { + const { + hmacKey, + agreementKey, + verificationKey + } = await derivePasskeyVaultKeys(prfOutput, identifier); + + const keyMappingEdvService = edvService; + await keyMappingEdvService.initialize({ + hmacKey, + agreementKey, + verificationKey, + edvUrl, + authKey + }); + + return keyMappingEdvService; +} + +/** + * Enrolls a user by creating a passkey-protected master key in the KeyMappingVault + * @param edvUrl URL for the edv + * @param authKey Auth key for the edv + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier (email, phone number, etc.) + * @returns The master key and mnemonic for backup + */ +export async function enrollUserWithPasskey( + edvUrl: string, + authKey: string, + prfOutput: Uint8Array, + identifier: string +): Promise<{ masterKey: Uint8Array; mnemonic: string }> { + const keyMappingEdv = await initializePasskeyKeyMappingVault( + edvUrl, + authKey, + prfOutput, + identifier + ); + const { mnemonic, masterKey } = await generateCloudWalletMasterKey(); + const { key: encryptionKey, iv } = await derivePasskeyEncryptionKey(prfOutput, identifier); + const encryptedMasterKey = await encryptMasterKey(masterKey, encryptionKey, iv); + + const encryptedData = { + data: Array.from(encryptedMasterKey), + iv: Array.from(iv) + }; + + const contentId = `${await keyMappingEdv.getController()}#${MASTER_KEY_SUFFIX}`; + + await keyMappingEdv.insert({ + document: { + content: { + id: contentId, + type: PASSKEY_KEY_MAPPING_TYPE, + encryptedKey: encryptedData + } + } + }); + + return { masterKey, mnemonic }; +} + +/** + * Authenticates a user with passkey PRF output and identifier + * @param edvUrl URL for the edv + * @param authKey Auth key for the edv + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier (email, phone number, etc.) + * @returns The decrypted master key for CloudWalletVault + */ +export async function authenticateWithPasskey( + edvUrl: string, + authKey: string, + prfOutput: Uint8Array, + identifier: string +): Promise { + const keyMappingEdv = await initializePasskeyKeyMappingVault( + edvUrl, + authKey, + prfOutput, + identifier + ); + const { key: decryptionKey } = await derivePasskeyEncryptionKey(prfOutput, identifier); + + const contentId = `${await keyMappingEdv.getController()}#${MASTER_KEY_SUFFIX}`; + + return getKeyMappingMasterKey(keyMappingEdv, contentId, decryptionKey); +} + +/** + * Initializes the Cloud Wallet using passkey authentication + * @param edvUrl Cloud wallet vault URL + * @param authKey Cloud wallet auth key + * @param prfOutput 32-byte PRF output from WebAuthn assertion + * @param identifier User's identifier (email, phone number, etc.) + * @param dataStore Optional data store for the wallet + * @returns Initialized cloud wallet + */ +export async function initializeCloudWalletWithPasskey( + edvUrl: string, + authKey: string, + prfOutput: Uint8Array, + identifier: string, + dataStore?: any +): Promise { + const masterKey = await authenticateWithPasskey( + edvUrl, + authKey, + prfOutput, + identifier + ); + + return initializeCloudWallet({ + dataStore, + edvUrl, + authKey, + masterKey + }); +} + interface QueuedOperation { operation: () => Promise; resolve: (value: any) => void; diff --git a/packages/web/example.html b/packages/web/example.html index 38707547..dd5aef26 100644 --- a/packages/web/example.html +++ b/packages/web/example.html @@ -9,23 +9,101 @@ - + - \ No newline at end of file + diff --git a/packages/web/src/index.js b/packages/web/src/index.js index c8561ce5..d0195648 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -13,6 +13,9 @@ import { initializeCloudWallet, generateCloudWalletMasterKey, recoverCloudWalletMasterKey, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, } from '@docknetwork/wallet-sdk-core/src/cloud-wallet'; import {createWallet} from '@docknetwork/wallet-sdk-core/src/wallet'; import {createCredentialProvider} from '@docknetwork/wallet-sdk-core/src/credential-provider'; @@ -20,6 +23,13 @@ import {createDIDProvider} from '@docknetwork/wallet-sdk-core/src/did-provider'; import {createMessageProvider} from '@docknetwork/wallet-sdk-core/src/message-provider'; import {createVerificationController} from '@docknetwork/wallet-sdk-core/src/verification-controller'; import {blockchainService} from '@docknetwork/wallet-sdk-wasm/src/services/blockchain'; +import { + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, +} from './passkey'; /** * Initializes the Dock Wallet SDK with the provided configuration. @@ -444,6 +454,14 @@ export { generateCloudWalletMasterKey, recoverCloudWalletMasterKey, createVerificationController, + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, }; export default { @@ -457,4 +475,12 @@ export default { generateCloudWalletMasterKey, recoverCloudWalletMasterKey, createVerificationController, + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, }; diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js new file mode 100644 index 00000000..82051e8c --- /dev/null +++ b/packages/web/src/passkey.js @@ -0,0 +1,179 @@ +/** + * @module passkey + * @description WebAuthn passkey helpers for deriving wallet keys using the PRF extension. + * Provides browser-side functions for registering passkeys and extracting deterministic + * key material via the WebAuthn PRF extension (Chrome 116+, Safari 18+). + */ + +/** + * Checks if the browser supports WebAuthn and the PRF extension. + * Note: PRF support can only be fully confirmed during credential creation. + * @returns {Promise<{webauthn: boolean, prf: boolean}>} + */ +export async function checkPasskeySupport() { + if ( + typeof window === 'undefined' || + !window.PublicKeyCredential + ) { + return {webauthn: false, prf: false}; + } + + // PRF support is confirmed during registration via getClientExtensionResults().prf.enabled + // We can only report WebAuthn availability at detection time + return {webauthn: true, prf: true}; +} + +/** + * Computes a deterministic PRF salt from an identifier. + * @param {string} identifier - User's identifier (email, phone number, etc.) + * @returns {Promise} 32-byte SHA-256 hash to use as PRF salt + */ +async function computePRFSalt(identifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(`truvera-wallet-prf-salt:${identifier}`); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return new Uint8Array(hashBuffer); +} + +/** + * Converts a Uint8Array to a base64url string for storage. + * @param {Uint8Array} bytes + * @returns {string} + */ +export function credentialIdToBase64url(bytes) { + const base64 = btoa(String.fromCharCode(...bytes)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +/** + * Converts a base64url string back to a Uint8Array. + * @param {string} base64url + * @returns {Uint8Array} + */ +export function base64urlToCredentialId(base64url) { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const binary = atob(base64 + padding); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} + +/** + * Registers a new passkey credential with PRF extension support. + * Safari 18 does not support PRF during creation, so PRF output is only + * available during subsequent authentication assertions. + * + * @param {string} identifier - User's identifier (email, phone number, etc.) + * @param {string} [rpName='Truvera Wallet'] - Relying party display name + * @param {string} [rpId] - Relying party ID (defaults to current hostname) + * @returns {Promise<{credentialId: Uint8Array, prfSupported: boolean}>} + * @throws {Error} If WebAuthn is not supported or user cancels + */ +export async function registerPasskey(identifier, rpName, rpId) { + if (!window.PublicKeyCredential) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const credential = await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { + name: rpName || 'Truvera Wallet', + id: rpId || window.location.hostname, + }, + user: { + id: new TextEncoder().encode(identifier), + name: identifier, + displayName: identifier, + }, + pubKeyCredParams: [ + {alg: -7, type: 'public-key'}, // ES256 + {alg: -257, type: 'public-key'}, // RS256 + ], + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required', + }, + extensions: { + prf: {}, + }, + }, + }); + + const extensionResults = credential.getClientExtensionResults(); + const prfSupported = extensionResults.prf?.enabled === true; + + return { + credentialId: new Uint8Array(credential.rawId), + prfSupported, + }; +} + +/** + * Performs a WebAuthn assertion with the PRF extension to extract deterministic + * key material from the passkey. + * + * When credentialId is provided, the browser uses it directly (no picker shown). + * When omitted, the browser shows a passkey picker for discoverable credentials, + * enabling cross-device usage (e.g., same passkey synced via iCloud Keychain). + * + * @param {string} identifier - User's identifier (email, phone number, etc.) + * @param {Object} [options] - Optional parameters + * @param {Uint8Array} [options.credentialId] - The credential ID from registration (omit to show passkey picker) + * @param {string} [options.rpId] - Relying party ID (defaults to current hostname) + * @returns {Promise<{prfOutput: Uint8Array, credentialId: Uint8Array}>} PRF output and the credential ID used + * @throws {Error} If PRF extension is not supported or returns no result + */ +export async function getPasskeyPRFKey(identifier, options = {}) { + if (!window.PublicKeyCredential) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const salt = await computePRFSalt(identifier); + + const publicKeyOptions = { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId: options.rpId || window.location.hostname, + userVerification: 'required', + extensions: { + prf: { + eval: { + first: salt, + }, + }, + }, + }; + + // When credentialId is provided, skip the passkey picker + // When omitted, browser shows discoverable credential picker (cross-device friendly) + if (options.credentialId) { + // Ensure credentialId is a proper ArrayBuffer (WebAuthn requires BufferSource) + const id = options.credentialId instanceof ArrayBuffer + ? options.credentialId + : new Uint8Array(options.credentialId).buffer; + + publicKeyOptions.allowCredentials = [ + { + id, + type: 'public-key', + }, + ]; + } + + const assertion = await navigator.credentials.get({ + publicKey: publicKeyOptions, + }); + + const prfResults = assertion.getClientExtensionResults().prf?.results; + + if (!prfResults || !prfResults.first) { + throw new Error( + 'PRF extension not supported by this authenticator. ' + + 'Passkey-based wallet access requires Chrome 116+ or Safari 18+.' + ); + } + + return { + prfOutput: new Uint8Array(prfResults.first), + credentialId: new Uint8Array(assertion.rawId), + }; +} From 1628bdccd470c9a4f888432930871be73906bc3a Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 15:37:54 -0300 Subject: [PATCH 02/21] Simplify passkey API with unified passkey config and update documentation --- docs/cloud-wallet.md | 143 +++++++++------ packages/web/README.md | 129 +++++++++++++- packages/web/example-passkey.html | 32 ++++ packages/web/example.html | 80 +-------- packages/web/src/index.js | 282 +++++++++++++++++++++++++----- packages/web/src/passkey.js | 20 ++- 6 files changed, 508 insertions(+), 178 deletions(-) create mode 100644 packages/web/example-passkey.html diff --git a/docs/cloud-wallet.md b/docs/cloud-wallet.md index 9263ea65..1103c6e5 100644 --- a/docs/cloud-wallet.md +++ b/docs/cloud-wallet.md @@ -11,7 +11,7 @@ The Truvera Cloud Wallet service hosts individual wallets for each user. The use Once initialized, the Cloud Wallet service automatically synchronizes documents between the EDV and the wallet application, allowing you to add, update, and remove credentials without dealing with the synchronization logic. -Each holder's individual cloud wallet is accessed using a key in the holder's possession. This key can be stored in the local storage of a wallet application, or derived from a biometric of the holder's. A recovery mnemonic can be used to recover a lost master key. +Each holder's individual cloud wallet is accessed using a key in the holder's possession. This key can be stored in the local storage of a wallet application, derived from a biometric of the holder's, or derived from a WebAuthn passkey using the PRF extension. A recovery mnemonic can be used to recover a lost master key. ## Usage example @@ -274,88 +274,119 @@ The authentication process: #### Passkey authentication -Passkeys provide a passwordless authentication method for web wallets using the WebAuthn PRF extension to derive deterministic key material. This approach works the same way as biometric authentication — the passkey PRF output is used to encrypt/decrypt the master key in the KeyMappingVault. +Passkeys provide a passwordless authentication method for web wallets using the WebAuthn PRF (Pseudo-Random Function) extension. The PRF extension extracts deterministic 32-byte key material from a passkey — same passkey + same salt always produces the same bytes. This key material is used to encrypt/decrypt the master key in the KeyMappingVault, following the same pattern as biometric authentication. **Browser requirements**: Chrome 116+, Safari 18+ (macOS Sequoia / iOS 18), Edge 116+. -##### Step 1: Register a passkey and enroll +##### Quick start with the web SDK + +The simplest way to use passkeys is through the web SDK's `initialize` method with `passkey: true`. This handles enrollment, authentication, and localStorage management automatically: ```js -import { - checkPasskeySupport, - registerPasskey, - getPasskeyPRFKey, - credentialIdToBase64url, - enrollUserWithPasskey, -} from '@docknetwork/wallet-sdk-web'; +import TruveraWebWallet from '@docknetwork/wallet-sdk-web'; -const identifier = 'user@example.com'; +const wallet = await TruveraWebWallet.initialize({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + networkId: 'testnet', + passkey: true, +}); -// Check browser support -const { webauthn } = await checkPasskeySupport(); -if (!webauthn) { - throw new Error('WebAuthn not supported'); +// wallet.mnemonic is only present on first enrollment +if (wallet.mnemonic) { + // Prompt the user to save their recovery phrase + showRecoveryDialog(wallet.mnemonic); } +``` -// Register a passkey (prompts user for biometric/PIN) -const { credentialId, prfSupported } = await registerPasskey(identifier); -if (!prfSupported) { - throw new Error('PRF extension not supported. Use Chrome 116+ or Safari 18+.'); -} +On first visit this registers a passkey, generates a master key, encrypts it, and stores it in the vault. On return visits it authenticates with a single biometric/PIN prompt. -// Extract PRF key material (second WebAuthn ceremony, required on Safari) -const prfOutput = await getPasskeyPRFKey(credentialId, identifier); +For more control, pass an options object: -// Enroll: generates a new masterKey, encrypts it with the passkey-derived key -const { masterKey, mnemonic } = await enrollUserWithPasskey( - EDV_URL, EDV_AUTH_KEY, prfOutput, identifier -); +```js +const wallet = await TruveraWebWallet.initialize({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + networkId: 'testnet', + passkey: { + identifier: 'user@example.com', // Key derivation salt (defaults to hostname) + storageKey: 'my-app-passkey', // Custom localStorage key + rpName: 'My Application', // WebAuthn relying party name + }, +}); +``` -// Store credentialId for future authentication -localStorage.setItem('walletCredentialId', credentialIdToBase64url(credentialId)); +##### Using the core SDK directly -// IMPORTANT: Prompt the user to save the mnemonic for recovery -``` +For non-web environments or when you need full control over the WebAuthn ceremony, use the core SDK functions directly. These receive the PRF output as a parameter — the WebAuthn ceremony is handled separately. -##### Step 2: Authenticate with passkey +**Step 1: Register a passkey and enroll** ```js import { + checkPasskeySupport, + registerPasskey, getPasskeyPRFKey, - base64urlToCredentialId, - authenticateWithPasskey, - initializeCloudWalletWithPasskey, + enrollPasskey, } from '@docknetwork/wallet-sdk-web'; -const identifier = 'user@example.com'; -const credentialId = base64urlToCredentialId(localStorage.getItem('walletCredentialId')); +// Option A: High-level — handles register + PRF + vault in one call +const { mnemonic, credentialId } = await enrollPasskey({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + identifier: 'user@example.com', +}); -// Get PRF key material (prompts user for biometric/PIN) -const prfOutput = await getPasskeyPRFKey(credentialId, identifier); +// Option B: Low-level — full control over each step +const support = await checkPasskeySupport(); +const { credentialId, prfSupported } = await registerPasskey('user@example.com'); +const { prfOutput } = await getPasskeyPRFKey('user@example.com', { credentialId }); -// Method 1: Get the master key directly -const masterKey = await authenticateWithPasskey( - EDV_URL, EDV_AUTH_KEY, prfOutput, identifier +import { enrollUserWithPasskey } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet'; +const { masterKey, mnemonic } = await enrollUserWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, 'user@example.com' ); +``` -// Method 2: Initialize cloud wallet in one step -const cloudWallet = await initializeCloudWalletWithPasskey( - EDV_URL, EDV_AUTH_KEY, prfOutput, identifier, dataStore +**Step 2: Authenticate with passkey** + +```js +import { authenticateWithPasskey } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet'; +import { getPasskeyPRFKey } from '@docknetwork/wallet-sdk-web'; + +const { prfOutput } = await getPasskeyPRFKey('user@example.com', { credentialId }); + +const masterKey = await authenticateWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, 'user@example.com' ); ``` +##### How it works + The passkey authentication process: 1. Performs a WebAuthn assertion with the PRF extension to extract deterministic key material -2. Derives vault access keys from the PRF output using HKDF -3. Accesses the KeyMappingVault and finds the encrypted master key +2. Derives vault access keys from the PRF output using HKDF (SHA-256, 32 bytes) +3. Initializes the KeyMappingVault and finds the encrypted master key 4. Derives the decryption key from the PRF output -5. Decrypts and returns the master key +5. Decrypts the master key using AES-GCM + +##### Cross-device support + +Passkeys sync automatically across devices via platform credential managers: +- **Apple**: iCloud Keychain syncs passkeys across all Apple devices with the same Apple ID +- **Google**: Google Password Manager syncs across Chrome on Android and desktop +- **Cross-platform**: QR code scanning enables cross-ecosystem authentication (e.g., using an iPhone passkey to authenticate on a Windows PC) + +The `credentialId` can be omitted when calling `getPasskeyPRFKey` — the browser will show a passkey picker for discoverable credentials, enabling cross-device usage without pre-stored state. + +##### Security considerations -**Security notes**: - The `credentialId` is a public identifier, safe to store in localStorage -- PRF output and derived keys exist only in memory during a session -- Passkeys may sync across devices via iCloud Keychain or Google Password Manager +- PRF output and derived encryption keys exist only in memory during a session — never persisted +- The PRF salt is deterministic (`SHA-256("truvera-wallet-prf-salt:" + identifier)`) and acts as a domain separator +- WebAuthn ceremonies require user interaction (biometric/PIN), providing natural rate limiting - If a passkey is lost, the user can recover using their mnemonic phrase +- Passkeys synced via iCloud or Google Password Manager extend the security boundary to the sync provider ### Wallet recovery @@ -367,6 +398,18 @@ Alternatively, one or more recovery keys can be stored in the KeyMappingVault. A If a biometrically derived key can no longer be generated, then a recovery key should be used to enroll a new biometric. Any biometric-bound credentials will need to be reissued with the new biometric. +For passkey-based wallets, the recovery mnemonic is returned during enrollment. If the passkey is lost (e.g., device replaced, credential deleted), the user can recover their wallet using the mnemonic and optionally enroll a new passkey: + +```js +// Recover with mnemonic, then re-enroll with a new passkey +const wallet = await TruveraWebWallet.initialize({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + networkId: 'testnet', + mnemonic: savedMnemonic, +}); +``` + ## Organizational wallets diff --git a/packages/web/README.md b/packages/web/README.md index 866a1bef..2a0ff0e1 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -21,11 +21,19 @@ The SDK can be used via a global variable (Script Tag) or imported as an ES Modu > [!IMPORTANT] > This SDK is designed for **browser-side use only**. -1. **Client-Side Only**: Your wallet keys (mnemonic/master key) decrypt your data locally in the browser. **Never** send these keys to a server or store them where they can be accessed by third parties. +1. **Client-Side Only**: Your wallet keys (mnemonic/master key/passkey) decrypt your data locally in the browser. **Never** send these keys to a server or store them where they can be accessed by third parties. 2. **No Server-Side Operations**: Do not use this SDK to initialize wallets or process keys on a backend server. Server-side handling of user keys creates significant security risks and breaks the non-custodial model. 3. **End-to-End Encryption**: User data stored in the Cloud Wallet (EDV) is fully encrypted. The decryption key exists *only* in the user's browser session. 4. **Authentication vs Encryption**: The `edvAuthKey` is strictly for authenticating the client with the storage server. It does **not** grant access to the encrypted data content; only the user's keys can do that. You can request an `edvAuthKey` by contacting Truvera support at [docs.truvera.io/support](https://docs.truvera.io/support). +The SDK supports three authentication methods: + +| Method | Use case | +|---|---| +| **Passkey** | Passwordless, hardware-backed authentication via WebAuthn PRF extension. Zero-config with `passkey: true`. Requires Chrome 116+, Safari 18+, or Edge 116+. | +| **Mnemonic** | Traditional 12-word BIP39 recovery phrase. Best for backup/recovery. | +| **Master key** | Pre-derived 32-byte key. For advanced integrations where key management is handled externally. | + ### 1. Script Tag (Global) When loaded via ` + + + + + diff --git a/packages/web/example.html b/packages/web/example.html index dd5aef26..9d6725e0 100644 --- a/packages/web/example.html +++ b/packages/web/example.html @@ -4,7 +4,7 @@ - Truvera Web Wallet Example + Truvera Web Wallet - Mnemonic Example @@ -12,9 +12,6 @@ diff --git a/packages/web/src/index.js b/packages/web/src/index.js index d0195648..8136ff0d 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -1,10 +1,16 @@ /** - * @fileoverview Dock Wallet SDK for Web + * @fileoverview Truvera Wallet SDK for Web * * This module provides a simplified interface for initializing and working with - * the Dock Wallet SDK in web environments. It includes functions for managing + * the Truvera Wallet SDK in web environments. It includes functions for managing * DIDs, credentials, presentations, and cloud wallet synchronization. * + * Supports three authentication methods: + * - **Mnemonic**: BIP39 recovery phrase (12 words) + * - **Master key**: Pre-derived 32-byte Uint8Array + * - **Passkey**: WebAuthn passkey with PRF extension — zero-config with `passkey: true`, + * or customizable with passkey options (identifier, storageKey, rpName, etc.) + * * @module @docknetwork/wallet-sdk-web */ @@ -32,20 +38,34 @@ import { } from './passkey'; /** - * Initializes the Dock Wallet SDK with the provided configuration. + * Initializes the Truvera Wallet SDK with the provided configuration. + * + * Sets up all wallet providers and establishes a connection to the cloud wallet + * for synchronization. Supports three authentication methods — provide exactly one: + * + * - **Mnemonic**: `mnemonic` — a BIP39 recovery phrase + * - **Master key**: `masterKey` — a pre-derived Uint8Array key + * - **Passkey**: `passkey` — WebAuthn passkey with PRF extension (Chrome 116+, Safari 18+) * - * This function sets up all necessary wallet providers and establishes a connection - * to the cloud wallet for synchronization. It requires either a master key or mnemonic - * for wallet recovery, but not both. + * When using passkeys, the SDK handles enrollment and authentication automatically. + * On first use it registers a passkey, generates a master key, and returns a recovery + * mnemonic. On subsequent visits it authenticates with the stored passkey silently. * * @async * @param {Object} config - Configuration object for wallet initialization * @param {string} config.edvUrl - The Encrypted Data Vault (EDV) URL for cloud storage * @param {string} config.edvAuthKey - Authentication key for accessing the EDV - * @param {string} [config.masterKey] - Master key for wallet access (required if mnemonic is not provided) - * @param {string} [config.mnemonic] - BIP39 mnemonic for wallet recovery (required if masterKey is not provided) * @param {string} config.networkId - Network identifier ('testnet' or 'mainnet') * @param {string} [config.databasePath='truvera-web-wallet'] - Optional path for the local database + * @param {Uint8Array} [config.masterKey] - Pre-derived master key for wallet access + * @param {string} [config.mnemonic] - BIP39 mnemonic for wallet recovery + * @param {boolean|Object} [config.passkey] - Passkey authentication configuration. + * Pass `true` for zero-config (auto-enroll/authenticate using defaults), or an object: + * @param {string} [config.passkey.identifier] - User identifier for key derivation salt (defaults to hostname) + * @param {string} [config.passkey.credentialId] - Base64url-encoded credential ID for direct auth (skips localStorage) + * @param {string} [config.passkey.storageKey='truvera-wallet-passkey'] - Custom localStorage key for enrollment data + * @param {string} [config.passkey.rpName='Truvera Wallet'] - WebAuthn relying party display name + * @param {string} [config.passkey.rpId] - WebAuthn relying party ID (defaults to hostname) * * @returns {Promise} Initialized wallet object with the following properties: * @returns {Object} returns.wallet - Core wallet instance @@ -57,15 +77,13 @@ import { * @returns {Function} returns.addCredential - Function to import a credential from an offer URI * @returns {Function} returns.getDID - Function to get the default DID * @returns {Function} returns.createPresentation - Function to create a presentation with optional auto-selection + * @returns {string} [returns.mnemonic] - Recovery mnemonic phrase (only present on first passkey enrollment) * - * @throws {Error} When neither masterKey nor mnemonic is provided - * @throws {Error} When both masterKey and mnemonic are provided - * @throws {Error} When edvUrl is not provided - * @throws {Error} When edvAuthKey is not provided - * @throws {Error} When networkId is not provided + * @throws {Error} When no authentication method is provided + * @throws {Error} When multiple authentication methods are combined + * @throws {Error} When edvUrl or edvAuthKey is missing * @throws {Error} When networkId is not 'testnet' or 'mainnet' - * @throws {Error} When wallet initialization fails - * @throws {Error} When cloud wallet initialization fails + * @throws {Error} When passkey PRF extension is not supported by the browser * * @example * // Initialize with mnemonic @@ -77,15 +95,120 @@ import { * }); * * @example - * // Initialize with master key + * // Initialize with passkey — zero config, auto enroll/authenticate + * const wallet = await initialize({ + * edvUrl: 'https://edv.example.com', + * edvAuthKey: 'your-auth-key', + * networkId: 'testnet', + * passkey: true, + * }); + * if (wallet.mnemonic) { + * console.log('Save your recovery phrase:', wallet.mnemonic); + * } + * + * @example + * // Initialize with passkey — custom options + * const wallet = await initialize({ + * edvUrl: 'https://edv.example.com', + * edvAuthKey: 'your-auth-key', + * networkId: 'testnet', + * passkey: { + * identifier: 'user@example.com', + * storageKey: 'my-app-passkey', + * rpName: 'My App', + * }, + * }); + * + * @example + * // Initialize with passkey — direct auth, no localStorage * const wallet = await initialize({ * edvUrl: 'https://edv.example.com', * edvAuthKey: 'your-auth-key', - * masterKey: 'your-master-key', - * networkId: 'mainnet', - * databasePath: 'custom-wallet-db' + * networkId: 'testnet', + * passkey: { + * credentialId: 'base64url-encoded-credential-id', + * identifier: 'user@example.com', + * }, * }); */ +const DEFAULT_PASSKEY_STORAGE_KEY = 'truvera-wallet-passkey'; + +/** + * Resolves passkey options from the various input forms. + * @param {boolean|Object} passkey - true or options object + * @returns {Object} Resolved options with defaults applied + */ +function resolvePasskeyOptions(passkey) { + const opts = typeof passkey === 'object' ? passkey : {}; + return { + identifier: opts.identifier || window.location.hostname, + rpId: opts.rpId || window.location.hostname, + rpName: opts.rpName || 'Truvera Wallet', + storageKey: opts.storageKey || DEFAULT_PASSKEY_STORAGE_KEY, + credentialId: opts.credentialId || null, + }; +} + +/** + * Checks if a passkey has been enrolled on this device. + * + * @param {string} [storageKey] - Custom localStorage key (defaults to 'truvera-wallet-passkey') + * @returns {boolean} True if a passkey is enrolled + */ +function isPasskeyEnrolled(storageKey) { + try { + return localStorage.getItem(storageKey || DEFAULT_PASSKEY_STORAGE_KEY) !== null; + } catch { + return false; + } +} + +/** + * Enrolls a new passkey and creates a passkey-protected wallet. + * Handles the full WebAuthn flow: registration, PRF extraction, and master key enrollment. + * Stores enrollment metadata in localStorage for future authentication. + * + * @async + * @param {Object} config - Configuration object + * @param {string} config.edvUrl - The Encrypted Data Vault (EDV) URL + * @param {string} config.edvAuthKey - Authentication key for accessing the EDV + * @param {string} [config.identifier] - User's identifier (defaults to current hostname) + * @param {string} [config.rpName='Truvera Wallet'] - Relying party display name + * @param {string} [config.rpId] - Relying party ID (defaults to current hostname) + * @param {string} [config.storageKey='truvera-wallet-passkey'] - Custom localStorage key + * @returns {Promise<{mnemonic: string, credentialId: string}>} Recovery mnemonic and base64url-encoded credential ID + * @throws {Error} If WebAuthn or PRF is not supported + */ +async function enrollPasskey({edvUrl, edvAuthKey, identifier, rpName, rpId, storageKey}) { + const resolved = resolvePasskeyOptions({identifier, rpName, rpId, storageKey}); + + const support = await checkPasskeySupport(); + if (!support.webauthn) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const {credentialId, prfSupported} = await registerPasskey(resolved.identifier, resolved.rpName, resolved.rpId); + + if (!prfSupported) { + throw new Error( + 'PRF extension not supported by this authenticator. Requires Chrome 116+ or Safari 18+.' + ); + } + + const {prfOutput} = await getPasskeyPRFKey(resolved.identifier, {credentialId, rpId: resolved.rpId}); + + const {mnemonic} = await enrollUserWithPasskey(edvUrl, edvAuthKey, prfOutput, resolved.identifier); + + const credentialIdBase64url = credentialIdToBase64url(credentialId); + + localStorage.setItem(resolved.storageKey, JSON.stringify({ + credentialId: credentialIdBase64url, + identifier: resolved.identifier, + })); + + return {mnemonic, credentialId: credentialIdBase64url}; +} + async function initialize({ edvUrl, edvAuthKey, @@ -93,35 +216,73 @@ async function initialize({ mnemonic, networkId, databasePath, + passkey, }) { - // Validate required parameters - if (!masterKey && !mnemonic) { + if (!edvUrl) { throw new Error( - 'Initialization failed: Either masterKey or mnemonic must be provided for wallet access', + 'Initialization failed: edvUrl is required. Please provide a valid Encrypted Data Vault URL', ); } - if (masterKey && mnemonic) { + if (!edvAuthKey) { throw new Error( - 'Initialization failed: Cannot provide both masterKey and mnemonic. Please use only one authentication method', + 'Initialization failed: edvAuthKey is required for EDV authentication', ); } - if (!edvUrl) { + if (networkId !== 'testnet' && networkId !== 'mainnet') { throw new Error( - 'Initialization failed: edvUrl is required. Please provide a valid Encrypted Data Vault URL', + 'Initialization failed: networkId is required. Must be either "testnet" or "mainnet"', ); } - if (!edvAuthKey) { + let passkeyMnemonic; + + if (passkey) { + if (masterKey || mnemonic) { + throw new Error( + 'Initialization failed: Cannot combine passkey with masterKey or mnemonic', + ); + } + + const resolved = resolvePasskeyOptions(passkey); + let {credentialId} = resolved; + const {identifier, rpId, storageKey} = resolved; + + // No explicit credentialId — check localStorage for stored enrollment + if (!credentialId) { + if (!isPasskeyEnrolled(storageKey)) { + // First time: enroll automatically + const result = await enrollPasskey({ + edvUrl, + edvAuthKey, + identifier, + rpName: resolved.rpName, + rpId, + storageKey, + }); + passkeyMnemonic = result.mnemonic; + } + + const stored = JSON.parse(localStorage.getItem(storageKey)); + credentialId = stored.credentialId; + } + + const prfOptions = credentialId + ? {credentialId: base64urlToCredentialId(credentialId), rpId} + : {rpId}; + + const {prfOutput} = await getPasskeyPRFKey(identifier, prfOptions); + masterKey = await authenticateWithPasskey(edvUrl, edvAuthKey, prfOutput, identifier); + } else if (!masterKey && !mnemonic) { throw new Error( - 'Initialization failed: edvAuthKey is required for EDV authentication', + 'Initialization failed: Provide one of masterKey, mnemonic, or passkey for wallet access', ); } - if (networkId !== 'testnet' && networkId !== 'mainnet') { + if (masterKey && mnemonic) { throw new Error( - 'Initialization failed: networkId is required. Must be either "testnet" or "mainnet"', + 'Initialization failed: Cannot provide both masterKey and mnemonic. Please use only one authentication method', ); } @@ -190,7 +351,7 @@ async function initialize({ throw new Error(`Failed to initialize wallet providers: ${error.message}`); } - return { + const result = { wallet, messageProvider, cloudWallet, @@ -398,53 +559,87 @@ async function initialize({ }; }, }; + + if (passkeyMnemonic) { + result.mnemonic = passkeyMnemonic; + } + + return result; } /** - * Dock Wallet SDK - Web Module + * Truvera Wallet SDK - Web Module * * Provides a comprehensive SDK for building decentralized identity wallets in web applications. - * Includes high-level initialization functions and low-level building blocks for custom implementations. + * Supports three authentication methods: mnemonic phrases, master keys, and WebAuthn passkeys. + * + * @namespace TruveraWebWallet * - * @namespace DockWalletSDK + * @property {Function} initialize - Initialize a wallet with mnemonic, masterKey, or passkey authentication + * + * @property {Function} enrollPasskey - Explicitly enroll a new passkey (register + PRF + vault storage) + * @property {Function} isPasskeyEnrolled - Check if a passkey is enrolled in localStorage + * + * @property {Function} checkPasskeySupport - Check browser WebAuthn/PRF support + * @property {Function} registerPasskey - Register a new WebAuthn credential + * @property {Function} getPasskeyPRFKey - Extract PRF key material from a passkey assertion + * @property {Function} credentialIdToBase64url - Encode a credential ID for storage + * @property {Function} base64urlToCredentialId - Decode a stored credential ID + * @property {Function} enrollUserWithPasskey - Low-level: encrypt and store masterKey with passkey-derived key + * @property {Function} authenticateWithPasskey - Low-level: decrypt masterKey from vault with passkey-derived key + * @property {Function} initializeCloudWalletWithPasskey - Low-level: full wallet init from passkey auth * - * @property {Function} initialize - High-level function to initialize a complete wallet with all providers * @property {Function} createDataStore - Create a data store for wallet data persistence * @property {Function} createWallet - Create a core wallet instance * @property {Function} createCredentialProvider - Create a provider for credential management * @property {Function} createDIDProvider - Create a provider for DID operations * @property {Function} createMessageProvider - Create a provider for wallet messaging * @property {Function} initializeCloudWallet - Initialize cloud wallet synchronization - * @property {Function} generateCloudWalletMasterKey - Generate a new master key for cloud wallet + * @property {Function} generateCloudWalletMasterKey - Generate a new master key with BIP39 mnemonic * @property {Function} recoverCloudWalletMasterKey - Recover master key from a BIP39 mnemonic * @property {Function} createVerificationController - Create a controller for verification presentations * * @example - * // Quick start - use the high-level initialize function + * // Passkey wallet — zero config, auto enroll/authenticate + * import WalletSDK from '@docknetwork/wallet-sdk-web'; + * + * const wallet = await WalletSDK.initialize({ + * edvUrl: 'https://edv.dock.io', + * edvAuthKey: 'your-auth-key', + * networkId: 'testnet', + * passkey: true, + * }); + * if (wallet.mnemonic) { + * console.log('Save your recovery phrase:', wallet.mnemonic); + * } + * + * @example + * // Mnemonic wallet * import WalletSDK from '@docknetwork/wallet-sdk-web'; * * const wallet = await WalletSDK.initialize({ * edvUrl: 'https://edv.dock.io', * edvAuthKey: 'your-auth-key', * mnemonic: 'your mnemonic phrase...', - * networkId: 'testnet' + * networkId: 'testnet', * }); * * @example - * // Advanced usage - build custom wallet with individual components + * // Advanced: build custom wallet with individual components * import WalletSDK from '@docknetwork/wallet-sdk-web'; * * const dataStore = await WalletSDK.createDataStore({ * databasePath: 'my-wallet', - * defaultNetwork: 'mainnet' + * defaultNetwork: 'mainnet', * }); - * * const wallet = await WalletSDK.createWallet({ dataStore }); * const didProvider = WalletSDK.createDIDProvider({ wallet }); - * // ... configure additional providers as needed */ export { initialize, + + enrollPasskey, + isPasskeyEnrolled, createDataStore, createWallet, createCredentialProvider, @@ -466,6 +661,9 @@ export { export default { initialize, + + enrollPasskey, + isPasskeyEnrolled, createDataStore, createWallet, createCredentialProvider, diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index 82051e8c..044c5ac0 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -1,8 +1,22 @@ /** * @module passkey - * @description WebAuthn passkey helpers for deriving wallet keys using the PRF extension. - * Provides browser-side functions for registering passkeys and extracting deterministic - * key material via the WebAuthn PRF extension (Chrome 116+, Safari 18+). + * @description Low-level WebAuthn passkey helpers for the Truvera Wallet SDK. + * + * These functions handle the browser-side WebAuthn ceremonies for registering passkeys + * and extracting deterministic key material via the PRF (Pseudo-Random Function) extension. + * The PRF output is a 32-byte secret derived from the passkey's internal key and a + * deterministic salt — same passkey + same salt always produces the same bytes. + * + * For most use cases, prefer the high-level `initialize({ passkey: true })` API in + * the main SDK module. Use these helpers directly only when you need full control + * over the WebAuthn ceremony flow. + * + * Browser requirements: + * - Chrome 116+ (PRF supported during both create and get) + * - Safari 18+ / macOS Sequoia / iOS 18 (PRF supported during get only) + * - Edge 116+ (Chromium-based, same as Chrome) + * + * @see {@link module:@docknetwork/wallet-sdk-web} for the high-level passkey API */ /** From 434184bd38bacdda8980cc2b373bb79ccf5be2b4 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 15:49:39 -0300 Subject: [PATCH 03/21] Fix prettier formatting --- packages/web/src/index.js | 59 +++++++++++++++++++++++++++++-------- packages/web/src/passkey.js | 18 +++++------ 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index 8136ff0d..1ccbb1fd 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -157,7 +157,9 @@ function resolvePasskeyOptions(passkey) { */ function isPasskeyEnrolled(storageKey) { try { - return localStorage.getItem(storageKey || DEFAULT_PASSKEY_STORAGE_KEY) !== null; + return ( + localStorage.getItem(storageKey || DEFAULT_PASSKEY_STORAGE_KEY) !== null + ); } catch { return false; } @@ -179,32 +181,59 @@ function isPasskeyEnrolled(storageKey) { * @returns {Promise<{mnemonic: string, credentialId: string}>} Recovery mnemonic and base64url-encoded credential ID * @throws {Error} If WebAuthn or PRF is not supported */ -async function enrollPasskey({edvUrl, edvAuthKey, identifier, rpName, rpId, storageKey}) { - const resolved = resolvePasskeyOptions({identifier, rpName, rpId, storageKey}); +async function enrollPasskey({ + edvUrl, + edvAuthKey, + identifier, + rpName, + rpId, + storageKey, +}) { + const resolved = resolvePasskeyOptions({ + identifier, + rpName, + rpId, + storageKey, + }); const support = await checkPasskeySupport(); if (!support.webauthn) { throw new Error('WebAuthn is not supported in this browser'); } - const {credentialId, prfSupported} = await registerPasskey(resolved.identifier, resolved.rpName, resolved.rpId); + const {credentialId, prfSupported} = await registerPasskey( + resolved.identifier, + resolved.rpName, + resolved.rpId, + ); if (!prfSupported) { throw new Error( - 'PRF extension not supported by this authenticator. Requires Chrome 116+ or Safari 18+.' + 'PRF extension not supported by this authenticator. Requires Chrome 116+ or Safari 18+.', ); } - const {prfOutput} = await getPasskeyPRFKey(resolved.identifier, {credentialId, rpId: resolved.rpId}); + const {prfOutput} = await getPasskeyPRFKey(resolved.identifier, { + credentialId, + rpId: resolved.rpId, + }); - const {mnemonic} = await enrollUserWithPasskey(edvUrl, edvAuthKey, prfOutput, resolved.identifier); + const {mnemonic} = await enrollUserWithPasskey( + edvUrl, + edvAuthKey, + prfOutput, + resolved.identifier, + ); const credentialIdBase64url = credentialIdToBase64url(credentialId); - localStorage.setItem(resolved.storageKey, JSON.stringify({ - credentialId: credentialIdBase64url, - identifier: resolved.identifier, - })); + localStorage.setItem( + resolved.storageKey, + JSON.stringify({ + credentialId: credentialIdBase64url, + identifier: resolved.identifier, + }), + ); return {mnemonic, credentialId: credentialIdBase64url}; } @@ -273,7 +302,12 @@ async function initialize({ : {rpId}; const {prfOutput} = await getPasskeyPRFKey(identifier, prfOptions); - masterKey = await authenticateWithPasskey(edvUrl, edvAuthKey, prfOutput, identifier); + masterKey = await authenticateWithPasskey( + edvUrl, + edvAuthKey, + prfOutput, + identifier, + ); } else if (!masterKey && !mnemonic) { throw new Error( 'Initialization failed: Provide one of masterKey, mnemonic, or passkey for wallet access', @@ -637,7 +671,6 @@ async function initialize({ */ export { initialize, - enrollPasskey, isPasskeyEnrolled, createDataStore, diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index 044c5ac0..a8e09762 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -25,10 +25,7 @@ * @returns {Promise<{webauthn: boolean, prf: boolean}>} */ export async function checkPasskeySupport() { - if ( - typeof window === 'undefined' || - !window.PublicKeyCredential - ) { + if (typeof window === 'undefined' || !window.PublicKeyCredential) { return {webauthn: false, prf: false}; } @@ -100,8 +97,8 @@ export async function registerPasskey(identifier, rpName, rpId) { displayName: identifier, }, pubKeyCredParams: [ - {alg: -7, type: 'public-key'}, // ES256 - {alg: -257, type: 'public-key'}, // RS256 + {alg: -7, type: 'public-key'}, // ES256 + {alg: -257, type: 'public-key'}, // RS256 ], authenticatorSelection: { residentKey: 'required', @@ -161,9 +158,10 @@ export async function getPasskeyPRFKey(identifier, options = {}) { // When omitted, browser shows discoverable credential picker (cross-device friendly) if (options.credentialId) { // Ensure credentialId is a proper ArrayBuffer (WebAuthn requires BufferSource) - const id = options.credentialId instanceof ArrayBuffer - ? options.credentialId - : new Uint8Array(options.credentialId).buffer; + const id = + options.credentialId instanceof ArrayBuffer + ? options.credentialId + : new Uint8Array(options.credentialId).buffer; publicKeyOptions.allowCredentials = [ { @@ -182,7 +180,7 @@ export async function getPasskeyPRFKey(identifier, options = {}) { if (!prfResults || !prfResults.first) { throw new Error( 'PRF extension not supported by this authenticator. ' + - 'Passkey-based wallet access requires Chrome 116+ or Safari 18+.' + 'Passkey-based wallet access requires Chrome 116+ or Safari 18+.', ); } From 228cf64c5375492e539bc8921eda13259de16773 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 15:52:29 -0300 Subject: [PATCH 04/21] Fix no-div-regex lint warning in base64url helper --- packages/web/src/passkey.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index a8e09762..771aa9ad 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -53,7 +53,7 @@ async function computePRFSalt(identifier) { */ export function credentialIdToBase64url(bytes) { const base64 = btoa(String.fromCharCode(...bytes)); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, ''); } /** From ce8045a9d206a7233f5015fafc682df25c5f82e2 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 16:00:39 -0300 Subject: [PATCH 05/21] Fix unit test expected error message for passkey support --- packages/web/src/index.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index ff087396..ac588492 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -171,7 +171,9 @@ describe('WalletSDK initialize', () => { mnemonic: undefined, masterKey: undefined, }), - ).rejects.toThrow('Either masterKey or mnemonic must be provided'); + ).rejects.toThrow( + 'Provide one of masterKey, mnemonic, or passkey for wallet access', + ); }); it('should throw when both masterKey and mnemonic are provided', async () => { From 78529c7f6d58cc6ac9569fedc080c43d4cd9f997 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Fri, 3 Apr 2026 16:16:46 -0300 Subject: [PATCH 06/21] Add unit tests for passkey authentication --- packages/core/src/cloud-wallet.test.js | 217 +++++++++++++++ packages/web/src/index.test.js | 154 +++++++++++ packages/web/src/passkey.test.js | 351 +++++++++++++++++++++++++ 3 files changed, 722 insertions(+) create mode 100644 packages/web/src/passkey.test.js diff --git a/packages/core/src/cloud-wallet.test.js b/packages/core/src/cloud-wallet.test.js index 51d7edf3..1e6640f4 100644 --- a/packages/core/src/cloud-wallet.test.js +++ b/packages/core/src/cloud-wallet.test.js @@ -3,17 +3,52 @@ const { generateCloudWalletMasterKey, recoverCloudWalletMasterKey, initializeCloudWallet, + derivePasskeyVaultKeys, + derivePasskeyEncryptionKey, + initializePasskeyKeyMappingVault, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, + PASSKEY_KEY_MAPPING_TYPE, } = require('./cloud-wallet'); const mockInitializeFromMasterKey = jest.fn().mockResolvedValue(undefined); const mockInitializeFromMnemonic = jest.fn().mockResolvedValue(undefined); const mockFind = jest.fn().mockResolvedValue({documents: []}); +const mockDeriveBiometricKey = jest.fn().mockReturnValue(Buffer.alloc(32)); +const mockDeriveKeys = jest.fn().mockResolvedValue({ + hmacKey: 'mock-hmac', + agreementKey: 'mock-agreement', + verificationKey: 'mock-verification', +}); +const mockInitialize = jest.fn().mockResolvedValue(undefined); +const mockDeriveBiometricEncryptionKey = jest.fn().mockResolvedValue({ + key: Buffer.alloc(32), + iv: Buffer.alloc(16), +}); +const mockEncryptMasterKey = jest + .fn() + .mockResolvedValue(new Uint8Array([99, 99])); +const mockDecryptMasterKey = jest + .fn() + .mockResolvedValue(new Uint8Array([1, 2, 3])); +const mockGetController = jest.fn().mockResolvedValue('mock-controller'); +const mockInsert = jest.fn().mockResolvedValue(undefined); jest.mock('@docknetwork/wallet-sdk-wasm/src/services/edv', () => ({ edvService: { initializeFromMasterKey: (...args) => mockInitializeFromMasterKey(...args), initializeFromMnemonic: (...args) => mockInitializeFromMnemonic(...args), find: (...args) => mockFind(...args), + deriveBiometricKey: (...args) => mockDeriveBiometricKey(...args), + deriveKeys: (...args) => mockDeriveKeys(...args), + initialize: (...args) => mockInitialize(...args), + deriveBiometricEncryptionKey: (...args) => + mockDeriveBiometricEncryptionKey(...args), + encryptMasterKey: (...args) => mockEncryptMasterKey(...args), + decryptMasterKey: (...args) => mockDecryptMasterKey(...args), + getController: (...args) => mockGetController(...args), + insert: (...args) => mockInsert(...args), }, })); @@ -149,4 +184,186 @@ describe('cloud-wallet', () => { ).rejects.toThrow('Either masterKey or mnemonic is required'); }); }); + + describe('passkey functions', () => { + const edvUrl = 'https://edv.example.com'; + const authKey = 'test-auth-key'; + const prfOutput = new Uint8Array(32).fill(42); + const identifier = 'user@example.com'; + + describe('derivePasskeyVaultKeys', () => { + it('should derive vault keys from PRF output and identifier', async () => { + const result = await derivePasskeyVaultKeys(prfOutput, identifier); + + expect(mockDeriveBiometricKey).toHaveBeenCalledWith( + expect.any(Buffer), + identifier, + ); + expect(mockDeriveKeys).toHaveBeenCalled(); + expect(result).toEqual({ + hmacKey: 'mock-hmac', + agreementKey: 'mock-agreement', + verificationKey: 'mock-verification', + }); + }); + + it('should convert prfOutput to Buffer before calling deriveBiometricKey', async () => { + await derivePasskeyVaultKeys(prfOutput, identifier); + + const bufferArg = mockDeriveBiometricKey.mock.calls[0][0]; + expect(Buffer.isBuffer(bufferArg)).toBe(true); + expect(bufferArg).toEqual(Buffer.from(prfOutput)); + }); + }); + + describe('derivePasskeyEncryptionKey', () => { + it('should derive encryption key from PRF output and identifier', async () => { + const result = await derivePasskeyEncryptionKey(prfOutput, identifier); + + expect(mockDeriveBiometricEncryptionKey).toHaveBeenCalledWith( + expect.any(Buffer), + identifier, + ); + expect(result).toHaveProperty('key'); + expect(result).toHaveProperty('iv'); + }); + }); + + describe('initializePasskeyKeyMappingVault', () => { + it('should initialize the EDV with derived vault keys', async () => { + await initializePasskeyKeyMappingVault( + edvUrl, + authKey, + prfOutput, + identifier, + ); + + expect(mockInitialize).toHaveBeenCalledWith({ + hmacKey: 'mock-hmac', + agreementKey: 'mock-agreement', + verificationKey: 'mock-verification', + edvUrl, + authKey, + }); + }); + }); + + describe('enrollUserWithPasskey', () => { + beforeEach(() => { + mockMnemonicGenerate.mockResolvedValue('mock mnemonic phrase'); + mockMnemonicToMiniSecret.mockResolvedValue( + new Uint8Array([1, 2, 3, 4]), + ); + }); + + it('should generate a master key and store it encrypted in the vault', async () => { + const result = await enrollUserWithPasskey( + edvUrl, + authKey, + prfOutput, + identifier, + ); + + expect(result.mnemonic).toBe('mock mnemonic phrase'); + expect(result.masterKey).toEqual(new Uint8Array([1, 2, 3, 4])); + }); + + it('should encrypt the master key before storing', async () => { + await enrollUserWithPasskey(edvUrl, authKey, prfOutput, identifier); + + expect(mockEncryptMasterKey).toHaveBeenCalledWith( + new Uint8Array([1, 2, 3, 4]), + expect.any(Buffer), + expect.any(Buffer), + ); + }); + + it('should insert document with PASSKEY_KEY_MAPPING_TYPE', async () => { + await enrollUserWithPasskey(edvUrl, authKey, prfOutput, identifier); + + expect(mockInsert).toHaveBeenCalledWith({ + document: { + content: { + id: 'mock-controller#master-key', + type: PASSKEY_KEY_MAPPING_TYPE, + encryptedKey: expect.objectContaining({ + data: expect.any(Array), + iv: expect.any(Array), + }), + }, + }, + }); + }); + }); + + describe('authenticateWithPasskey', () => { + it('should initialize vault and retrieve decrypted master key', async () => { + mockFind.mockResolvedValueOnce({ + documents: [ + { + content: { + id: 'mock-controller#master-key', + encryptedKey: { + data: [99, 99], + iv: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + }, + }, + ], + }); + + const result = await authenticateWithPasskey( + edvUrl, + authKey, + prfOutput, + identifier, + ); + + expect(mockInitialize).toHaveBeenCalled(); + expect(mockDecryptMasterKey).toHaveBeenCalled(); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('should throw when no key mapping document is found', async () => { + mockFind.mockResolvedValueOnce({documents: []}); + + await expect( + authenticateWithPasskey(edvUrl, authKey, prfOutput, identifier), + ).rejects.toThrow('Authentication failed: Invalid identifier'); + }); + }); + + describe('initializeCloudWalletWithPasskey', () => { + it('should authenticate and initialize cloud wallet', async () => { + mockFind.mockResolvedValueOnce({ + documents: [ + { + content: { + id: 'mock-controller#master-key', + encryptedKey: { + data: [99, 99], + iv: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + }, + }, + }, + ], + }); + + const dataStore = createMockDataStore(); + await initializeCloudWalletWithPasskey( + edvUrl, + authKey, + prfOutput, + identifier, + dataStore, + ); + + expect(mockInitializeFromMasterKey).toHaveBeenCalledWith({ + masterKey: new Uint8Array([1, 2, 3]), + edvUrl, + authKey, + }); + }); + }); + }); }); diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index ac588492..a0ca7a7b 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -24,6 +24,29 @@ jest.mock('@docknetwork/wallet-sdk-core/src/cloud-wallet', () => ({ mockCallOrder.push('recoverCloudWalletMasterKey'); return 'mock-master-key'; }), + enrollUserWithPasskey: jest.fn().mockImplementation(async () => ({ + masterKey: new Uint8Array([1, 2, 3]), + mnemonic: 'mock passkey mnemonic', + })), + authenticateWithPasskey: jest + .fn() + .mockResolvedValue(new Uint8Array([1, 2, 3])), +})); + +jest.mock('./passkey', () => ({ + checkPasskeySupport: jest.fn().mockResolvedValue({webauthn: true, prf: true}), + registerPasskey: jest.fn().mockResolvedValue({ + credentialId: new Uint8Array([10, 20, 30]), + prfSupported: true, + }), + getPasskeyPRFKey: jest.fn().mockResolvedValue({ + prfOutput: new Uint8Array(32).fill(42), + credentialId: new Uint8Array([10, 20, 30]), + }), + credentialIdToBase64url: jest.fn().mockReturnValue('ChQe'), + base64urlToCredentialId: jest + .fn() + .mockReturnValue(new Uint8Array([10, 20, 30])), })); jest.mock('@docknetwork/wallet-sdk-core/src/wallet', () => ({ @@ -202,5 +225,136 @@ describe('WalletSDK initialize', () => { WalletSDK.initialize({...validConfig, networkId: 'invalid'}), ).rejects.toThrow('networkId is required'); }); + + it('should throw when passkey is combined with masterKey', async () => { + await expect( + WalletSDK.initialize({ + ...validConfig, + mnemonic: undefined, + masterKey: 'some-key', + passkey: true, + }), + ).rejects.toThrow('Cannot combine passkey with masterKey or mnemonic'); + }); + + it('should throw when passkey is combined with mnemonic', async () => { + await expect( + WalletSDK.initialize({ + ...validConfig, + passkey: true, + }), + ).rejects.toThrow('Cannot combine passkey with masterKey or mnemonic'); + }); + }); + + describe('passkey', () => { + const { + authenticateWithPasskey, + enrollUserWithPasskey, + } = require('@docknetwork/wallet-sdk-core/src/cloud-wallet'); + const { + registerPasskey, + getPasskeyPRFKey, + base64urlToCredentialId, + } = require('./passkey'); + + const passkeyConfig = { + edvUrl: 'https://edv.example.com', + edvAuthKey: 'test-auth-key', + networkId: 'testnet', + }; + + beforeEach(() => { + localStorage.clear(); + }); + + it('should enroll a new passkey on first use with passkey: true', async () => { + const result = await WalletSDK.initialize({ + ...passkeyConfig, + passkey: true, + }); + + expect(registerPasskey).toHaveBeenCalled(); + expect(enrollUserWithPasskey).toHaveBeenCalled(); + expect(result.mnemonic).toBe('mock passkey mnemonic'); + }); + + it('should store enrollment data in localStorage after enrollment', async () => { + await WalletSDK.initialize({ + ...passkeyConfig, + passkey: true, + }); + + const stored = JSON.parse(localStorage.getItem('truvera-wallet-passkey')); + expect(stored).toBeTruthy(); + expect(stored.credentialId).toBe('ChQe'); + expect(stored.identifier).toBeDefined(); + }); + + it('should authenticate with stored passkey on subsequent visits', async () => { + localStorage.setItem( + 'truvera-wallet-passkey', + JSON.stringify({ + credentialId: 'ChQe', + identifier: 'localhost', + }), + ); + + const result = await WalletSDK.initialize({ + ...passkeyConfig, + passkey: true, + }); + + expect(registerPasskey).not.toHaveBeenCalled(); + expect(enrollUserWithPasskey).not.toHaveBeenCalled(); + expect(authenticateWithPasskey).toHaveBeenCalled(); + expect(result.mnemonic).toBeUndefined(); + }); + + it('should use custom storageKey when provided', async () => { + await WalletSDK.initialize({ + ...passkeyConfig, + passkey: {storageKey: 'custom-key'}, + }); + + expect(localStorage.getItem('custom-key')).toBeTruthy(); + }); + + it('should authenticate directly when credentialId is provided', async () => { + const result = await WalletSDK.initialize({ + ...passkeyConfig, + passkey: { + credentialId: 'ChQe', + identifier: 'user@example.com', + }, + }); + + expect(registerPasskey).not.toHaveBeenCalled(); + expect(enrollUserWithPasskey).not.toHaveBeenCalled(); + expect(base64urlToCredentialId).toHaveBeenCalledWith('ChQe'); + expect(getPasskeyPRFKey).toHaveBeenCalledWith( + 'user@example.com', + expect.objectContaining({ + credentialId: expect.any(Uint8Array), + }), + ); + expect(result.mnemonic).toBeUndefined(); + }); + + it('should pass custom rpId and rpName during enrollment', async () => { + await WalletSDK.initialize({ + ...passkeyConfig, + passkey: { + rpId: 'example.com', + rpName: 'My App', + }, + }); + + expect(registerPasskey).toHaveBeenCalledWith( + expect.any(String), + 'My App', + 'example.com', + ); + }); }); }); diff --git a/packages/web/src/passkey.test.js b/packages/web/src/passkey.test.js new file mode 100644 index 00000000..753dd179 --- /dev/null +++ b/packages/web/src/passkey.test.js @@ -0,0 +1,351 @@ +import { + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, +} from './passkey'; + +describe('passkey helpers', () => { + describe('credentialIdToBase64url', () => { + it('should convert Uint8Array to base64url string', () => { + const bytes = new Uint8Array([72, 101, 108, 108, 111]); + const result = credentialIdToBase64url(bytes); + + expect(result).toBe('SGVsbG8'); + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + it('should handle bytes that produce + / = in base64', () => { + // 0xFB, 0xFF, 0xFE produces base64 with + and / + const bytes = new Uint8Array([251, 255, 254]); + const result = credentialIdToBase64url(bytes); + + expect(result).not.toContain('+'); + expect(result).not.toContain('/'); + expect(result).not.toContain('='); + }); + + it('should handle empty array', () => { + const result = credentialIdToBase64url(new Uint8Array([])); + expect(result).toBe(''); + }); + }); + + describe('base64urlToCredentialId', () => { + it('should convert base64url string back to Uint8Array', () => { + const result = base64urlToCredentialId('SGVsbG8'); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); + + it('should handle base64url characters (- and _)', () => { + // Create bytes, encode, then decode and verify roundtrip + const original = new Uint8Array([251, 255, 254]); + const encoded = credentialIdToBase64url(original); + const decoded = base64urlToCredentialId(encoded); + + expect(decoded).toEqual(original); + }); + + it('should handle padding correctly', () => { + // 1 byte needs 2 padding chars, 2 bytes need 1 padding char + const oneByteResult = base64urlToCredentialId( + credentialIdToBase64url(new Uint8Array([42])), + ); + expect(oneByteResult).toEqual(new Uint8Array([42])); + + const twoBytesResult = base64urlToCredentialId( + credentialIdToBase64url(new Uint8Array([42, 43])), + ); + expect(twoBytesResult).toEqual(new Uint8Array([42, 43])); + }); + }); + + describe('credentialId roundtrip', () => { + it('should survive encode/decode roundtrip for various lengths', () => { + const testCases = [ + new Uint8Array([]), + new Uint8Array([0]), + new Uint8Array([255]), + new Uint8Array(16).fill(128), + new Uint8Array(32).map((_, i) => i), + new Uint8Array(64).map((_, i) => i * 4), + ]; + + for (const original of testCases) { + const encoded = credentialIdToBase64url(original); + const decoded = base64urlToCredentialId(encoded); + expect(decoded).toEqual(original); + } + }); + }); + + describe('checkPasskeySupport', () => { + const originalWindow = global.window; + + afterEach(() => { + global.window = originalWindow; + }); + + it('should return false when window is undefined', async () => { + delete global.window; + const result = await checkPasskeySupport(); + expect(result).toEqual({webauthn: false, prf: false}); + }); + + it('should return false when PublicKeyCredential is not available', async () => { + global.window = {PublicKeyCredential: undefined}; + const result = await checkPasskeySupport(); + expect(result).toEqual({webauthn: false, prf: false}); + }); + + it('should return true when PublicKeyCredential is available', async () => { + global.window = {PublicKeyCredential: jest.fn()}; + const result = await checkPasskeySupport(); + expect(result).toEqual({webauthn: true, prf: true}); + }); + }); + + describe('registerPasskey', () => { + let originalNavigator; + let originalWindow; + + beforeEach(() => { + originalNavigator = global.navigator; + originalWindow = global.window; + global.window = { + PublicKeyCredential: jest.fn(), + location: {hostname: 'localhost'}, + }; + }); + + afterEach(() => { + global.navigator = originalNavigator; + global.window = originalWindow; + }); + + it('should throw when WebAuthn is not supported', async () => { + global.window = {PublicKeyCredential: undefined}; + + await expect(registerPasskey('user@test.com')).rejects.toThrow( + 'WebAuthn is not supported in this browser', + ); + }); + + it('should call navigator.credentials.create with correct options', async () => { + const mockCredential = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({prf: {enabled: true}}), + }; + + global.navigator = { + credentials: {create: jest.fn().mockResolvedValue(mockCredential)}, + }; + + await registerPasskey('user@test.com', 'My App', 'example.com'); + + const createCall = global.navigator.credentials.create.mock.calls[0][0]; + expect(createCall.publicKey.rp.name).toBe('My App'); + expect(createCall.publicKey.rp.id).toBe('example.com'); + expect(createCall.publicKey.user.name).toBe('user@test.com'); + expect(createCall.publicKey.extensions.prf).toEqual({}); + expect(createCall.publicKey.authenticatorSelection.residentKey).toBe( + 'required', + ); + }); + + it('should use defaults for rpName and rpId', async () => { + const mockCredential = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({prf: {enabled: false}}), + }; + + global.navigator = { + credentials: {create: jest.fn().mockResolvedValue(mockCredential)}, + }; + + await registerPasskey('user@test.com'); + + const createCall = global.navigator.credentials.create.mock.calls[0][0]; + expect(createCall.publicKey.rp.name).toBe('Truvera Wallet'); + expect(createCall.publicKey.rp.id).toBe('localhost'); + }); + + it('should return credentialId and prfSupported status', async () => { + const rawId = new Uint8Array([1, 2, 3, 4]).buffer; + const mockCredential = { + rawId, + getClientExtensionResults: () => ({prf: {enabled: true}}), + }; + + global.navigator = { + credentials: {create: jest.fn().mockResolvedValue(mockCredential)}, + }; + + const result = await registerPasskey('user@test.com'); + + expect(result.credentialId).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(result.prfSupported).toBe(true); + }); + + it('should report prfSupported as false when PRF is not enabled', async () => { + const mockCredential = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({}), + }; + + global.navigator = { + credentials: {create: jest.fn().mockResolvedValue(mockCredential)}, + }; + + const result = await registerPasskey('user@test.com'); + expect(result.prfSupported).toBe(false); + }); + }); + + describe('getPasskeyPRFKey', () => { + let originalNavigator; + let originalWindow; + + beforeEach(() => { + originalNavigator = global.navigator; + originalWindow = global.window; + global.window = { + PublicKeyCredential: jest.fn(), + location: {hostname: 'localhost'}, + }; + }); + + afterEach(() => { + global.navigator = originalNavigator; + global.window = originalWindow; + }); + + it('should throw when WebAuthn is not supported', async () => { + global.window = {PublicKeyCredential: undefined}; + + await expect(getPasskeyPRFKey('user@test.com')).rejects.toThrow( + 'WebAuthn is not supported in this browser', + ); + }); + + it('should call navigator.credentials.get with PRF extension', async () => { + const prfFirst = new ArrayBuffer(32); + const mockAssertion = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({ + prf: {results: {first: prfFirst}}, + }), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + await getPasskeyPRFKey('user@test.com'); + + const getCall = global.navigator.credentials.get.mock.calls[0][0]; + expect(getCall.publicKey.userVerification).toBe('required'); + expect(getCall.publicKey.extensions.prf.eval.first).toBeInstanceOf( + Uint8Array, + ); + expect(getCall.publicKey.rpId).toBe('localhost'); + }); + + it('should include allowCredentials when credentialId is provided', async () => { + const prfFirst = new ArrayBuffer(32); + const mockAssertion = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({ + prf: {results: {first: prfFirst}}, + }), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + const credentialId = new Uint8Array([10, 20, 30]); + await getPasskeyPRFKey('user@test.com', {credentialId}); + + const getCall = global.navigator.credentials.get.mock.calls[0][0]; + expect(getCall.publicKey.allowCredentials).toHaveLength(1); + expect(getCall.publicKey.allowCredentials[0].type).toBe('public-key'); + }); + + it('should not include allowCredentials when credentialId is omitted', async () => { + const prfFirst = new ArrayBuffer(32); + const mockAssertion = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({ + prf: {results: {first: prfFirst}}, + }), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + await getPasskeyPRFKey('user@test.com'); + + const getCall = global.navigator.credentials.get.mock.calls[0][0]; + expect(getCall.publicKey.allowCredentials).toBeUndefined(); + }); + + it('should return prfOutput and credentialId', async () => { + const prfBytes = new Uint8Array(32).fill(42); + const rawId = new Uint8Array([1, 2, 3]).buffer; + const mockAssertion = { + rawId, + getClientExtensionResults: () => ({ + prf: {results: {first: prfBytes.buffer}}, + }), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + const result = await getPasskeyPRFKey('user@test.com'); + + expect(result.prfOutput).toEqual(new Uint8Array(32).fill(42)); + expect(result.credentialId).toEqual(new Uint8Array([1, 2, 3])); + }); + + it('should throw when PRF results are missing', async () => { + const mockAssertion = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({}), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + await expect(getPasskeyPRFKey('user@test.com')).rejects.toThrow( + 'PRF extension not supported by this authenticator', + ); + }); + + it('should use custom rpId when provided', async () => { + const prfFirst = new ArrayBuffer(32); + const mockAssertion = { + rawId: new ArrayBuffer(16), + getClientExtensionResults: () => ({ + prf: {results: {first: prfFirst}}, + }), + }; + + global.navigator = { + credentials: {get: jest.fn().mockResolvedValue(mockAssertion)}, + }; + + await getPasskeyPRFKey('user@test.com', {rpId: 'example.com'}); + + const getCall = global.navigator.credentials.get.mock.calls[0][0]; + expect(getCall.publicKey.rpId).toBe('example.com'); + }); + }); +}); From c9e06cdb108d612040c6819c6fbd415294cfe1bd Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:03:49 -0300 Subject: [PATCH 07/21] Return prf: unknown from checkPasskeySupport since PRF can only be confirmed during ceremony --- packages/web/src/index.test.js | 4 +++- packages/web/src/passkey.js | 9 +++++---- packages/web/src/passkey.test.js | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index a0ca7a7b..259ec766 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -34,7 +34,9 @@ jest.mock('@docknetwork/wallet-sdk-core/src/cloud-wallet', () => ({ })); jest.mock('./passkey', () => ({ - checkPasskeySupport: jest.fn().mockResolvedValue({webauthn: true, prf: true}), + checkPasskeySupport: jest + .fn() + .mockResolvedValue({webauthn: true, prf: 'unknown'}), registerPasskey: jest.fn().mockResolvedValue({ credentialId: new Uint8Array([10, 20, 30]), prfSupported: true, diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index 771aa9ad..e660fdfa 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -20,9 +20,10 @@ */ /** - * Checks if the browser supports WebAuthn and the PRF extension. - * Note: PRF support can only be fully confirmed during credential creation. - * @returns {Promise<{webauthn: boolean, prf: boolean}>} + * Checks if the browser supports WebAuthn. + * PRF support cannot be determined until a credential ceremony is performed, + * so it is reported as `'unknown'` here and confirmed during registration/assertion. + * @returns {Promise<{webauthn: boolean, prf: boolean|'unknown'}>} */ export async function checkPasskeySupport() { if (typeof window === 'undefined' || !window.PublicKeyCredential) { @@ -31,7 +32,7 @@ export async function checkPasskeySupport() { // PRF support is confirmed during registration via getClientExtensionResults().prf.enabled // We can only report WebAuthn availability at detection time - return {webauthn: true, prf: true}; + return {webauthn: true, prf: 'unknown'}; } /** diff --git a/packages/web/src/passkey.test.js b/packages/web/src/passkey.test.js index 753dd179..111979e3 100644 --- a/packages/web/src/passkey.test.js +++ b/packages/web/src/passkey.test.js @@ -101,10 +101,10 @@ describe('passkey helpers', () => { expect(result).toEqual({webauthn: false, prf: false}); }); - it('should return true when PublicKeyCredential is available', async () => { + it('should return webauthn true and prf unknown when PublicKeyCredential is available', async () => { global.window = {PublicKeyCredential: jest.fn()}; const result = await checkPasskeySupport(); - expect(result).toEqual({webauthn: true, prf: true}); + expect(result).toEqual({webauthn: true, prf: 'unknown'}); }); }); From 02296071c7cb304789a3c38b7a1dab89fd127bf2 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:04:15 -0300 Subject: [PATCH 08/21] Hash user.id with SHA-256 to comply with WebAuthn 64-byte limit --- packages/web/src/passkey.js | 8 +++++++- packages/web/src/passkey.test.js | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index e660fdfa..e2253a7e 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -93,7 +93,13 @@ export async function registerPasskey(identifier, rpName, rpId) { id: rpId || window.location.hostname, }, user: { - id: new TextEncoder().encode(identifier), + // WebAuthn spec requires user.id to be max 64 bytes; hash to ensure compliance + id: new Uint8Array( + await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(identifier), + ), + ), name: identifier, displayName: identifier, }, diff --git a/packages/web/src/passkey.test.js b/packages/web/src/passkey.test.js index 111979e3..f8d3ebda 100644 --- a/packages/web/src/passkey.test.js +++ b/packages/web/src/passkey.test.js @@ -150,6 +150,8 @@ describe('passkey helpers', () => { expect(createCall.publicKey.rp.name).toBe('My App'); expect(createCall.publicKey.rp.id).toBe('example.com'); expect(createCall.publicKey.user.name).toBe('user@test.com'); + expect(createCall.publicKey.user.id).toBeInstanceOf(Uint8Array); + expect(createCall.publicKey.user.id.length).toBe(32); expect(createCall.publicKey.extensions.prf).toEqual({}); expect(createCall.publicKey.authenticatorSelection.residentKey).toBe( 'required', From b843806a4270c7a41c8633c628d38c4bc146beb3 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:04:36 -0300 Subject: [PATCH 09/21] Use chunked encoding in credentialIdToBase64url to avoid stack overflow on large IDs --- packages/web/src/passkey.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/web/src/passkey.js b/packages/web/src/passkey.js index e2253a7e..183b91b1 100644 --- a/packages/web/src/passkey.js +++ b/packages/web/src/passkey.js @@ -53,7 +53,16 @@ async function computePRFSalt(identifier) { * @returns {string} */ export function credentialIdToBase64url(bytes) { - const base64 = btoa(String.fromCharCode(...bytes)); + // Use chunked encoding to avoid call stack overflow on large credential IDs + let binary = ''; + const chunkSize = 0x8000; + + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + binary += String.fromCharCode.apply(null, chunk); + } + + const base64 = btoa(binary); return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/[=]/g, ''); } From 7f5d927a3a1b30678220eb0acb9f8fd8a50b7216 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:05:08 -0300 Subject: [PATCH 10/21] Remove prfSupported hard check during enrollment for Safari compatibility --- packages/web/src/index.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index 1ccbb1fd..19f2c7ef 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -201,18 +201,14 @@ async function enrollPasskey({ throw new Error('WebAuthn is not supported in this browser'); } - const {credentialId, prfSupported} = await registerPasskey( + // Safari 18 does not report PRF support during create, only during get. + // Skip the prfSupported check here and rely on getPasskeyPRFKey to surface errors. + const {credentialId} = await registerPasskey( resolved.identifier, resolved.rpName, resolved.rpId, ); - if (!prfSupported) { - throw new Error( - 'PRF extension not supported by this authenticator. Requires Chrome 116+ or Safari 18+.', - ); - } - const {prfOutput} = await getPasskeyPRFKey(resolved.identifier, { credentialId, rpId: resolved.rpId, From c84642f150938c0a1a2d2ab20c231df271f3c046 Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:05:56 -0300 Subject: [PATCH 11/21] Use stored identifier from localStorage and guard JSON.parse for robustness --- packages/web/src/index.js | 28 ++++++++++++++++++++++++++-- packages/web/src/index.test.js | 31 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index 19f2c7ef..2a37356a 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -272,7 +272,8 @@ async function initialize({ const resolved = resolvePasskeyOptions(passkey); let {credentialId} = resolved; - const {identifier, rpId, storageKey} = resolved; + let {identifier} = resolved; + const {rpId, storageKey} = resolved; // No explicit credentialId — check localStorage for stored enrollment if (!credentialId) { @@ -289,8 +290,31 @@ async function initialize({ passkeyMnemonic = result.mnemonic; } - const stored = JSON.parse(localStorage.getItem(storageKey)); + let stored; + try { + const storedValue = localStorage.getItem(storageKey); + if (storedValue) { + stored = JSON.parse(storedValue); + } + } catch { + // Malformed JSON or localStorage access error + } + + if (!stored || !stored.credentialId) { + throw new Error( + `Initialization failed: No valid passkey enrollment data found for key "${storageKey}". Re-enroll or provide passkey.credentialId explicitly.`, + ); + } + credentialId = stored.credentialId; + + // Use the identifier from enrollment to ensure PRF salt consistency + if ( + !resolved.identifier || + resolved.identifier === resolvePasskeyOptions(true).identifier + ) { + identifier = stored.identifier || identifier; + } } const prfOptions = credentialId diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index 259ec766..9177a8df 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -313,6 +313,37 @@ describe('WalletSDK initialize', () => { expect(result.mnemonic).toBeUndefined(); }); + it('should use stored identifier for PRF salt consistency on return visits', async () => { + localStorage.setItem( + 'truvera-wallet-passkey', + JSON.stringify({ + credentialId: 'ChQe', + identifier: 'user@example.com', + }), + ); + + await WalletSDK.initialize({ + ...passkeyConfig, + passkey: true, + }); + + expect(getPasskeyPRFKey).toHaveBeenCalledWith( + 'user@example.com', + expect.any(Object), + ); + }); + + it('should throw when localStorage has no valid enrollment data', async () => { + localStorage.setItem('truvera-wallet-passkey', 'invalid-json'); + + await expect( + WalletSDK.initialize({ + ...passkeyConfig, + passkey: {credentialId: null}, + }), + ).rejects.toThrow('No valid passkey enrollment data found'); + }); + it('should use custom storageKey when provided', async () => { await WalletSDK.initialize({ ...passkeyConfig, From b848816771ee5a5d77b44fb2b7f576de42a5da3e Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:06:20 -0300 Subject: [PATCH 12/21] Guard window.location access in resolvePasskeyOptions for non-browser contexts --- packages/web/src/index.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/web/src/index.js b/packages/web/src/index.js index 2a37356a..14c2727a 100644 --- a/packages/web/src/index.js +++ b/packages/web/src/index.js @@ -140,9 +140,21 @@ const DEFAULT_PASSKEY_STORAGE_KEY = 'truvera-wallet-passkey'; */ function resolvePasskeyOptions(passkey) { const opts = typeof passkey === 'object' ? passkey : {}; + + const hostname = + typeof window !== 'undefined' && window.location + ? window.location.hostname + : undefined; + + if (!opts.identifier && !hostname) { + throw new Error( + 'Passkey requires an identifier. Provide passkey.identifier when using passkeys outside a browser.', + ); + } + return { - identifier: opts.identifier || window.location.hostname, - rpId: opts.rpId || window.location.hostname, + identifier: opts.identifier || hostname, + rpId: opts.rpId || hostname, rpName: opts.rpName || 'Truvera Wallet', storageKey: opts.storageKey || DEFAULT_PASSKEY_STORAGE_KEY, credentialId: opts.credentialId || null, From 8ba892139e25e6977557ce77a2ddb5edce789c1b Mon Sep 17 00:00:00 2001 From: Maycon Mello Date: Wed, 8 Apr 2026 14:06:34 -0300 Subject: [PATCH 13/21] Use placeholder values for credentials in example files --- packages/web/example-passkey.html | 4 ++-- packages/web/example.html | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/web/example-passkey.html b/packages/web/example-passkey.html index 57f0b489..2e88e192 100644 --- a/packages/web/example-passkey.html +++ b/packages/web/example-passkey.html @@ -13,8 +13,8 @@ + +