diff --git a/docs/cloud-wallet.md b/docs/cloud-wallet.md index 8bdba0f2..91973cab 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 @@ -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,122 @@ 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 (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+. The PRF extension is a built-in WebAuthn capability — no browser extensions or password manager add-ons are required. It is natively supported by platform authenticators (Touch ID, Windows Hello, Android biometrics) and synced passkey providers (iCloud Keychain, Google Password Manager). + +##### 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 TruveraWebWallet from '@docknetwork/wallet-sdk-web'; + +const wallet = await TruveraWebWallet.initialize({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + networkId: 'testnet', + passkey: true, +}); + +// wallet.mnemonic is only present on first enrollment +if (wallet.mnemonic) { + // Prompt the user to save their recovery phrase + showRecoveryDialog(wallet.mnemonic); +} +``` + +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. + +For more control, pass an options object: + +```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 + }, +}); +``` + +##### Using the core SDK directly + +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 1: Register a passkey and enroll** + +```js +import { + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + enrollPasskey, +} from '@docknetwork/wallet-sdk-web'; + +// Option A: High-level — handles register + PRF + vault in one call +const { mnemonic, passkeyCredentialId } = await enrollPasskey({ + edvUrl: EDV_URL, + edvAuthKey: EDV_AUTH_KEY, + identifier: 'user@example.com', +}); + +// 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 }); + +import { enrollUserWithPasskey } from '@docknetwork/wallet-sdk-core/lib/cloud-wallet'; +const { masterKey, mnemonic } = await enrollUserWithPasskey( + EDV_URL, EDV_AUTH_KEY, prfOutput, 'user@example.com' +); +``` + +**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 (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 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 + +- The `passkeyCredentialId` is a public identifier, safe to store in localStorage +- 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 This architecture allows solution developers to design the recovery mechanism that makes sense for your use case. @@ -282,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/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/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/README.md b/packages/web/README.md index 866a1bef..6aec88b1 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 ` + - \ No newline at end of file + diff --git a/packages/web/examples/passkey.html b/packages/web/examples/passkey.html new file mode 100644 index 00000000..6f7b1670 --- /dev/null +++ b/packages/web/examples/passkey.html @@ -0,0 +1,32 @@ + + + + + + + Truvera Web Wallet - Passkey Example + + + + + + + + + diff --git a/packages/web/src/index.js b/packages/web/src/index.js index c8561ce5..a2283398 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 */ @@ -13,6 +19,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,22 +29,43 @@ 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. + * 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.passkeyCredentialId] - Base64url-encoded passkey 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 @@ -47,15 +77,13 @@ import {blockchainService} from '@docknetwork/wallet-sdk-wasm/src/services/block * @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 @@ -67,15 +95,157 @@ import {blockchainService} from '@docknetwork/wallet-sdk-wasm/src/services/block * }); * * @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: { + * passkeyCredentialId: '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 : {}; + + 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 || hostname, + rpId: opts.rpId || hostname, + rpName: opts.rpName || 'Truvera Wallet', + storageKey: opts.storageKey || DEFAULT_PASSKEY_STORAGE_KEY, + passkeyCredentialId: opts.passkeyCredentialId || 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, passkeyCredentialId: string}>} Recovery mnemonic and base64url-encoded passkey 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'); + } + + // 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, + ); + + 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({ + passkeyCredentialId: credentialIdBase64url, + identifier: resolved.identifier, + }), + ); + + return {mnemonic, passkeyCredentialId: credentialIdBase64url}; +} + async function initialize({ edvUrl, edvAuthKey, @@ -83,35 +253,115 @@ 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 passkeyCredentialId = resolved.passkeyCredentialId; + let {identifier} = resolved; + const {rpId, storageKey} = resolved; + + // No explicit passkeyCredentialId — check localStorage for stored enrollment + if (!passkeyCredentialId) { + if (!isPasskeyEnrolled(storageKey)) { + // First time: enroll automatically + const result = await enrollPasskey({ + edvUrl, + edvAuthKey, + identifier, + rpName: resolved.rpName, + rpId, + storageKey, + }); + passkeyMnemonic = result.mnemonic; + } + + let stored; + try { + const storedValue = localStorage.getItem(storageKey); + if (storedValue) { + stored = JSON.parse(storedValue); + } + } catch (error) { + console.error( + 'Error accessing localStorage for passkey enrollment data:', + error, + ); + } + + if (!stored || !stored.passkeyCredentialId) { + throw new Error( + `Initialization failed: No valid passkey enrollment data found for key "${storageKey}". Re-enroll or provide passkey.passkeyCredentialId explicitly.`, + ); + } + + passkeyCredentialId = stored.passkeyCredentialId; + + // Use the identifier from enrollment to ensure PRF salt consistency + if ( + !resolved.identifier || + resolved.identifier === resolvePasskeyOptions(true).identifier + ) { + identifier = stored.identifier || identifier; + } + } + + let prfOptions; + if (passkeyCredentialId) { + try { + prfOptions = { + credentialId: base64urlToCredentialId(passkeyCredentialId), + rpId, + }; + } catch { + throw new Error('Initialization failed: invalid passkeyCredentialId'); + } + } else { + prfOptions = {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', ); } @@ -180,7 +430,7 @@ async function initialize({ throw new Error(`Failed to initialize wallet providers: ${error.message}`); } - return { + const result = { wallet, messageProvider, cloudWallet, @@ -388,53 +638,86 @@ 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 DockWalletSDK + * @namespace TruveraWebWallet + * + * @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, @@ -444,10 +727,21 @@ export { generateCloudWalletMasterKey, recoverCloudWalletMasterKey, createVerificationController, + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, }; export default { initialize, + + enrollPasskey, + isPasskeyEnrolled, createDataStore, createWallet, createCredentialProvider, @@ -457,4 +751,12 @@ export default { generateCloudWalletMasterKey, recoverCloudWalletMasterKey, createVerificationController, + checkPasskeySupport, + registerPasskey, + getPasskeyPRFKey, + credentialIdToBase64url, + base64urlToCredentialId, + enrollUserWithPasskey, + authenticateWithPasskey, + initializeCloudWalletWithPasskey, }; diff --git a/packages/web/src/index.test.js b/packages/web/src/index.test.js index ff087396..9310d9f5 100644 --- a/packages/web/src/index.test.js +++ b/packages/web/src/index.test.js @@ -24,6 +24,31 @@ 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: 'unknown'}), + 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', () => ({ @@ -171,7 +196,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 () => { @@ -200,5 +227,167 @@ 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.passkeyCredentialId).toBe('ChQe'); + expect(stored.identifier).toBeDefined(); + }); + + it('should authenticate with stored passkey on subsequent visits', async () => { + localStorage.setItem( + 'truvera-wallet-passkey', + JSON.stringify({ + passkeyCredentialId: '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 stored identifier for PRF salt consistency on return visits', async () => { + localStorage.setItem( + 'truvera-wallet-passkey', + JSON.stringify({ + passkeyCredentialId: '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: {passkeyCredentialId: null}, + }), + ).rejects.toThrow('No valid passkey enrollment data found'); + }); + + 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 passkeyCredentialId is provided', async () => { + const result = await WalletSDK.initialize({ + ...passkeyConfig, + passkey: { + passkeyCredentialId: '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.js b/packages/web/src/passkey.js new file mode 100644 index 00000000..0918dcdc --- /dev/null +++ b/packages/web/src/passkey.js @@ -0,0 +1,215 @@ +/** + * @module passkey + * @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 + */ + +/** + * 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) { + 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: 'unknown'}; +} + +/** + * 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) { + // 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, ''); +} + +/** + * 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 (typeof window === 'undefined' || !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: { + // 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, + }, + 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 ( + typeof window === 'undefined' || + typeof navigator === 'undefined' || + !window.PublicKeyCredential || + !navigator.credentials + ) { + throw new Error( + 'WebAuthn APIs are unavailable in this environment. ' + + 'Passkey operations require a browser with PublicKeyCredential and navigator.credentials support.', + ); + } + + 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), + }; +} diff --git a/packages/web/src/passkey.test.js b/packages/web/src/passkey.test.js new file mode 100644 index 00000000..5d93e40a --- /dev/null +++ b/packages/web/src/passkey.test.js @@ -0,0 +1,353 @@ +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 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: 'unknown'}); + }); + }); + + 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.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', + ); + }); + + 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 APIs are unavailable in this environment', + ); + }); + + 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'); + }); + }); +}); diff --git a/scripts/slack-reporter.js b/scripts/slack-reporter.js index 1c49d6b0..93fa1073 100644 --- a/scripts/slack-reporter.js +++ b/scripts/slack-reporter.js @@ -8,8 +8,16 @@ class SlackReporter { let totalTests = 0; let blocks = []; // Slack Blocks array + // Deduplicate test results by fullName to avoid counting retries as failures. + // With jest.retryTimes(), each retry attempt appears as a separate entry. + // We keep only the last result for each test (the final outcome). results.testResults.forEach(testResult => { + const finalResults = new Map(); testResult.testResults.forEach(result => { + finalResults.set(result.fullName, result); + }); + + finalResults.forEach(result => { totalTests++; const symbol = result.status === 'passed' ? ':large_green_circle:' : ':x:';