diff --git a/crates/web-client/js/client.js b/crates/web-client/js/client.js index e0cae112..44a96e80 100644 --- a/crates/web-client/js/client.js +++ b/crates/web-client/js/client.js @@ -64,6 +64,36 @@ export class MidenClient { const seed = options?.seed ? await hashSeed(options.seed) : undefined; + // Resolve passkey encryption → keystore callbacks (before the keystore branch) + if (options?.passkeyEncryption && options?.keystore) { + console.warn( + "Both passkeyEncryption and keystore provided; keystore takes precedence." + ); + } + if (options?.passkeyEncryption && !options?.keystore) { + const { createPasskeyKeystore, isPasskeyPrfSupported } = + await import("./passkey-keystore.js"); + if (await isPasskeyPrfSupported()) { + const passkeyOpts = + typeof options.passkeyEncryption === "object" + ? options.passkeyEncryption + : {}; + const storeName = options?.storeName || "default"; + const result = await createPasskeyKeystore(storeName, passkeyOpts); + options = { + ...options, + storeName, + keystore: { getKey: result.getKey, insertKey: result.insertKey }, + }; + } + // Unsupported browser — fall through to standard keystore + else { + console.warn( + "passkeyEncryption was requested but WebAuthn PRF is not supported in this browser. Falling back to standard keystore." + ); + } + } + const rpcUrl = resolveRpcUrl(options?.rpcUrl); const noteTransportUrl = resolveNoteTransportUrl(options?.noteTransportUrl); diff --git a/crates/web-client/js/index.js b/crates/web-client/js/index.js index 87743331..9cd9cc84 100644 --- a/crates/web-client/js/index.js +++ b/crates/web-client/js/index.js @@ -60,6 +60,10 @@ export { MidenClient }; export { CompilerResource }; export { createP2IDNote, createP2IDENote, buildSwapTag }; export { StorageView, StorageResult, wordToBigInt }; +export { + isPasskeyPrfSupported, + createPasskeyKeystore, +} from "./passkey-keystore.js"; // Internal exports — used by integration tests that need direct access to the low-level WebClient proxy. export { diff --git a/crates/web-client/js/passkey-keystore.js b/crates/web-client/js/passkey-keystore.js new file mode 100644 index 00000000..8fcc9299 --- /dev/null +++ b/crates/web-client/js/passkey-keystore.js @@ -0,0 +1,610 @@ +/** + * WebAuthn PRF-based key encryption for Miden web client. + * + * Provides opt-in passkey encryption for secret keys at rest using the WebAuthn + * PRF extension (Touch ID, Face ID, Windows Hello). Keys are wrapped with + * AES-256-GCM using a wrapping key derived from the authenticator's PRF output. + * + * Browser support: Chrome 116+, Safari 18+, Edge 116+. Firefox does NOT support PRF. + * + * @module passkey-keystore + */ + +import Dexie from "dexie"; + +// ════════════════════════════════════════════════════════════════ +// Constants +// ════════════════════════════════════════════════════════════════ + +/** Magic bytes for web-encrypted payloads. Distinct from native "MENC" (ChaCha20-Poly1305). */ +const ENCRYPTED_MAGIC = new Uint8Array([0x4d, 0x57, 0x45, 0x42]); // "MWEB" + +/** Format version byte. Bump on breaking format changes. */ +const ENCRYPTED_VERSION = 0x01; + +/** Fixed HKDF salt — must never change (needed to reproduce wrapping key across sessions). */ +const HKDF_SALT = new TextEncoder().encode("miden-client-passkey-v1"); + +/** Fixed HKDF info — domain separation for the wrapping key derivation. */ +const HKDF_INFO = new TextEncoder().encode("aes-256-gcm-wrapping-key"); + +/** Fixed salt for WebAuthn PRF evaluation. */ +const PRF_EVAL_SALT = new TextEncoder().encode("miden-passkey-prf-salt-v1"); + +/** AES-GCM IV length in bytes. */ +const IV_LEN = 12; + +/** Header length: magic (4) + version (1) + IV (12) = 17 bytes. */ +const HEADER_LEN = 4 + 1 + IV_LEN; + +/** localStorage key prefix for credential IDs. */ +const CREDENTIAL_STORAGE_PREFIX = "miden_passkey_credential_"; + +// ════════════════════════════════════════════════════════════════ +// Feature detection +// ════════════════════════════════════════════════════════════════ + +/** + * Returns `true` if the current browser supports WebAuthn with the PRF extension. + * + * Checks for: + * 1. `PublicKeyCredential` API availability + * 2. Platform authenticator availability + * 3. PRF extension support (via `getClientCapabilities` or fallback heuristic) + * + * @returns {Promise} + */ +export async function isPasskeyPrfSupported() { + try { + if ( + typeof window === "undefined" || + !window.PublicKeyCredential || + !navigator.credentials + ) { + return false; + } + + // Check platform authenticator + if ( + typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === + "function" + ) { + const available = + await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + if (!available) return false; + } + + // Prefer getClientCapabilities if available (Chrome 132+) + if (typeof PublicKeyCredential.getClientCapabilities === "function") { + const capabilities = await PublicKeyCredential.getClientCapabilities(); + return capabilities?.["extension:prf"] === true; + } + + // Fallback: check user agent for known-supporting browsers + const ua = navigator.userAgent; + // Chrome/Edge 116+ support PRF + const chromeMatch = ua.match(/Chrom(?:e|ium)\/(\d+)/); + if (chromeMatch && parseInt(chromeMatch[1], 10) >= 116) return true; + // Safari 18+ supports PRF + const safariMatch = ua.match(/Version\/(\d+).*Safari/); + if (safariMatch && parseInt(safariMatch[1], 10) >= 18) return true; + + return false; + } catch { + return false; + } +} + +// ════════════════════════════════════════════════════════════════ +// WebAuthn operations +// ════════════════════════════════════════════════════════════════ + +/** + * Registers a new passkey with PRF extension support. + * + * @param {object} options + * @param {string} [options.rpId] - Relying party ID. Defaults to current hostname. + * @param {string} [options.rpName] - Relying party display name. Defaults to "Miden Client". + * @param {string} [options.userName] - User display name. Defaults to "Miden Wallet User". + * @returns {Promise<{ credentialId: string, prfOutput: ArrayBuffer }>} + */ +async function registerPasskey(options = {}) { + const rpId = options.rpId || window.location.hostname; + const rpName = options.rpName || "Miden Client"; + const userName = options.userName || "Miden Wallet User"; + + // Generate a random user ID (not secret, just needs to be unique) + const userId = crypto.getRandomValues(new Uint8Array(32)); + + const credential = await navigator.credentials.create({ + publicKey: { + rp: { id: rpId, name: rpName }, + user: { + id: userId, + name: userName, + displayName: userName, + }, + challenge: crypto.getRandomValues(new Uint8Array(32)), + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + { alg: -257, type: "public-key" }, // RS256 + ], + authenticatorSelection: { + authenticatorAttachment: "platform", + residentKey: "required", + userVerification: "required", + }, + extensions: { + prf: { + eval: { first: PRF_EVAL_SALT }, + }, + }, + }, + }); + + const prfResults = credential.getClientExtensionResults()?.prf; + if (!prfResults?.results?.first) { + throw new Error( + "WebAuthn PRF extension not supported by this authenticator. " + + "Use isPasskeyPrfSupported() to check before enabling passkey encryption." + ); + } + + const credentialId = bufferToBase64Url(credential.rawId); + return { + credentialId, + prfOutput: prfResults.results.first, + }; +} + +/** + * Authenticates with an existing passkey and evaluates the PRF extension. + * + * @param {string} credentialId - Base64url-encoded credential ID. + * @param {string} [rpId] - Relying party ID. Defaults to current hostname. + * @returns {Promise} The PRF output (32 bytes). + */ +async function authenticateWithPrf(credentialId, rpId) { + rpId = rpId || window.location.hostname; + const credentialIdBuffer = base64UrlToBuffer(credentialId); + + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rpId, + allowCredentials: [ + { + id: credentialIdBuffer, + type: "public-key", + transports: ["internal"], + }, + ], + userVerification: "required", + extensions: { + prf: { + eval: { first: PRF_EVAL_SALT }, + }, + }, + }, + }); + + const prfResults = assertion.getClientExtensionResults()?.prf; + if (!prfResults?.results?.first) { + throw new Error( + "PRF evaluation failed. The authenticator did not return a PRF result." + ); + } + + return prfResults.results.first; +} + +// ════════════════════════════════════════════════════════════════ +// Key derivation & encryption +// ════════════════════════════════════════════════════════════════ + +/** + * Derives a non-extractable AES-256-GCM wrapping key from PRF output via HKDF-SHA256. + * + * @param {ArrayBuffer} prfOutput - Raw PRF output from the authenticator. + * @returns {Promise} Non-extractable AES-256-GCM key. + */ +async function deriveWrappingKey(prfOutput) { + if (prfOutput.byteLength < 32) { + throw new Error( + `PRF output too short: expected at least 32 bytes, got ${prfOutput.byteLength}. ` + + "The authenticator may not support sufficient entropy for key derivation." + ); + } + + const keyMaterial = await crypto.subtle.importKey( + "raw", + prfOutput, + "HKDF", + false, + ["deriveKey"] + ); + + return crypto.subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: HKDF_SALT, + info: HKDF_INFO, + }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, // non-extractable + ["encrypt", "decrypt"] + ); +} + +/** + * Encrypts a secret key using AES-256-GCM with the MWEB envelope format. + * + * Format: [4B: "MWEB"] [1B: version=0x01] [12B: IV] [NB: ciphertext + 16B auth tag] + * + * The pub key commitment bytes are used as AES-GCM additional authenticated data (AAD), + * binding each ciphertext to its corresponding public key. This prevents ciphertext-swapping + * attacks where an attacker rearranges entries in IndexedDB. + * + * @param {CryptoKey} wrappingKey - AES-256-GCM wrapping key. + * @param {Uint8Array} plaintext - Secret key bytes to encrypt. + * @param {Uint8Array} pubKeyCommitment - Public key commitment bytes (used as AAD). + * @returns {Promise} MWEB-envelope ciphertext. + */ +async function encryptSecretKey(wrappingKey, plaintext, pubKeyCommitment) { + const iv = crypto.getRandomValues(new Uint8Array(IV_LEN)); + + const ciphertext = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + additionalData: pubKeyCommitment, + }, + wrappingKey, + plaintext + ); + + // Assemble: magic + version + iv + ciphertext + const output = new Uint8Array(HEADER_LEN + ciphertext.byteLength); + output.set(ENCRYPTED_MAGIC, 0); + output[4] = ENCRYPTED_VERSION; + output.set(iv, 5); + output.set(new Uint8Array(ciphertext), HEADER_LEN); + + return output; +} + +/** + * Decrypts an MWEB-envelope ciphertext using AES-256-GCM. + * + * @param {CryptoKey} wrappingKey - AES-256-GCM wrapping key. + * @param {Uint8Array} envelope - MWEB-envelope ciphertext. + * @param {Uint8Array} pubKeyCommitment - Public key commitment bytes (used as AAD). + * @returns {Promise} Decrypted secret key bytes. + */ +async function decryptSecretKey(wrappingKey, envelope, pubKeyCommitment) { + if (envelope.length < HEADER_LEN + 16) { + throw new Error("Encrypted payload too short"); + } + + // Validate magic + if ( + envelope[0] !== ENCRYPTED_MAGIC[0] || + envelope[1] !== ENCRYPTED_MAGIC[1] || + envelope[2] !== ENCRYPTED_MAGIC[2] || + envelope[3] !== ENCRYPTED_MAGIC[3] + ) { + throw new Error("Invalid encrypted payload: bad magic bytes"); + } + + // Validate version + const version = envelope[4]; + if (version !== ENCRYPTED_VERSION) { + throw new Error( + `Unsupported encryption format version: ${version}. ` + + `This client supports version ${ENCRYPTED_VERSION}. ` + + "You may need to update the SDK." + ); + } + + const iv = envelope.slice(5, 5 + IV_LEN); + const ciphertext = envelope.slice(HEADER_LEN); + + const plaintext = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + additionalData: pubKeyCommitment, + }, + wrappingKey, + ciphertext + ); + + return new Uint8Array(plaintext); +} + +// ════════════════════════════════════════════════════════════════ +// Credential persistence +// ════════════════════════════════════════════════════════════════ + +function getStoredCredentialId(storeName) { + return localStorage.getItem(CREDENTIAL_STORAGE_PREFIX + storeName); +} + +function storeCredentialId(storeName, credentialId) { + localStorage.setItem(CREDENTIAL_STORAGE_PREFIX + storeName, credentialId); +} + +// ════════════════════════════════════════════════════════════════ +// Encrypted key storage (separate Dexie database) +// ════════════════════════════════════════════════════════════════ + +/** + * Opens (or creates) the encrypted keystore database. + * + * Uses a separate Dexie database `MidenKeystore_${storeName}` to avoid + * schema conflicts with the main `MidenClientDB` database. + * + * @param {string} storeName + * @returns {Dexie} + */ +function openKeystoreDb(storeName) { + const db = new Dexie(`MidenKeystore_${storeName}`); + db.version(1).stores({ + keys: "pubKeyHex", + }); + return db; +} + +/** + * Opens the main client Dexie database for migration fallback reads. + * + * This reads directly from the idxdb-store's `accountAuth` table, which + * stores plaintext secret keys keyed by `pubKeyCommitmentHex`. + * + * The DB name must match what the WASM/Rust side uses. When storeName is + * explicitly provided by the user, it is used as-is. We cannot determine + * the auto-generated name (`MidenClientDB_{network_id}`) from JS because + * that requires the WASM client to have been initialized first. + * + * @param {string} storeName - The store name as passed to MidenClient.create(). + * @returns {Dexie | null} The Dexie DB, or null if storeName is unknown. + */ +function openMainDbForMigration(storeName) { + if (!storeName) return null; + const db = new Dexie(storeName); + // Declare just the table we need — Dexie allows partial schema declarations + // for read-only access to existing databases. + db.version(1).stores({ + accountAuth: "pubKeyCommitmentHex", + }); + return db; +} + +// ════════════════════════════════════════════════════════════════ +// Base64url utilities +// ════════════════════════════════════════════════════════════════ + +function bufferToBase64Url(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function base64UrlToBuffer(base64url) { + const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; +} + +function bytesToHex(bytes) { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +function hexToBytes(hex) { + if (hex.length % 2 !== 0) { + throw new Error("Hex string must have even length"); + } + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + const byte = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + if (Number.isNaN(byte)) { + throw new Error(`Invalid hex character at position ${i * 2}`); + } + bytes[i] = byte; + } + return bytes; +} + +// ════════════════════════════════════════════════════════════════ +// Factory +// ════════════════════════════════════════════════════════════════ + +/** + * Creates a passkey-encrypted keystore backed by WebAuthn PRF. + * + * If no `credentialId` is provided and none exists in localStorage for the given + * store name, a new passkey is registered (triggering a biometric prompt for + * registration). If a credential ID is found (either provided or in localStorage), + * the existing passkey is used (triggering a biometric prompt for authentication). + * + * The returned `getKey` and `insertKey` callbacks are compatible with the + * `ClientOptions.keystore` interface and transparently encrypt/decrypt keys + * using the PRF-derived wrapping key. + * + * **Migration**: When `getKey` finds no encrypted entry in the keystore DB but + * `storeName` is explicitly provided, it attempts to read the plaintext key from + * the main client database's `accountAuth` table. If found, the key is + * transparently re-encrypted and migrated to the keystore DB. Migration is only + * available when `storeName` is explicitly provided (since the auto-generated DB + * name is not known to JS before WASM initialization). + * + * @param {string} storeName - Store isolation key (from ClientOptions.storeName). + * @param {object} [options] + * @param {string} [options.credentialId] - Existing credential ID (base64url). + * @param {string} [options.rpId] - WebAuthn relying party ID. + * @param {string} [options.rpName] - Relying party display name. + * @param {string} [options.userName] - User display name for the passkey. + * @returns {Promise<{ getKey: Function, insertKey: Function, credentialId: string }>} + */ +export async function createPasskeyKeystore(storeName, options = {}) { + if (!storeName || typeof storeName !== "string") { + throw new Error("storeName is required and must be a non-empty string"); + } + + // Check browser support + const supported = await isPasskeyPrfSupported(); + if (!supported) { + throw new Error( + "WebAuthn PRF extension is not supported in this browser. " + + "Use isPasskeyPrfSupported() to check before enabling passkeyEncryption. " + + "Supported browsers: Chrome 116+, Safari 18+, Edge 116+." + ); + } + + // Resolve credential ID: provided > localStorage > register new + let credentialId = options.credentialId || getStoredCredentialId(storeName); + let prfOutput; + + if (credentialId) { + // Authenticate with existing passkey + prfOutput = await authenticateWithPrf(credentialId, options.rpId); + } else { + // Register new passkey + const result = await registerPasskey({ + rpId: options.rpId, + rpName: options.rpName, + userName: options.userName, + }); + credentialId = result.credentialId; + prfOutput = result.prfOutput; + } + + // Persist credential ID for future sessions + storeCredentialId(storeName, credentialId); + + // Derive wrapping key (non-extractable, held in closure) + const wrappingKey = await deriveWrappingKey(prfOutput); + + // Open encrypted keystore database + const keystoreDb = openKeystoreDb(storeName); + + // Open main DB for migration (only when storeName is explicitly provided) + const mainDb = openMainDbForMigration(storeName); + + /** + * getKey callback — decrypts the secret key for a given pub key commitment. + * + * The `pubKey` parameter contains the pub key commitment bytes (RPO256 hash + * of the public key), NOT the raw public key. This is a 32-byte Uint8Array. + * + * @param {Uint8Array} pubKey - Public key commitment bytes (32 bytes). + * @returns {Promise} + */ + async function getKey(pubKey) { + const pubKeyHex = bytesToHex(pubKey); + + // Try encrypted keystore first + const record = await keystoreDb.keys.get(pubKeyHex); + if (record) { + const ciphertext = hexToBytes(record.ciphertextHex); + return await decryptSecretKey(wrappingKey, ciphertext, pubKey); + } + + // Migration fallback: try main DB for plaintext keys + if (mainDb) { + try { + const authRecord = await mainDb + .table("accountAuth") + .where("pubKeyCommitmentHex") + .equals(pubKeyHex) + .first(); + + if (authRecord?.secretKeyHex) { + const plaintext = hexToBytes(authRecord.secretKeyHex); + + // Re-encrypt and migrate to keystore DB + const encrypted = await encryptSecretKey( + wrappingKey, + plaintext, + pubKey + ); + await keystoreDb.keys.put({ + pubKeyHex, + ciphertextHex: bytesToHex(encrypted), + }); + + // Verify round-trip before deleting plaintext — if decryption + // fails or bytes don't match, the plaintext entry is preserved. + const verifyRecord = await keystoreDb.keys.get(pubKeyHex); + const verifyCt = hexToBytes(verifyRecord.ciphertextHex); + const decrypted = await decryptSecretKey( + wrappingKey, + verifyCt, + pubKey + ); + if ( + decrypted.length !== plaintext.length || + !decrypted.every((b, i) => b === plaintext[i]) + ) { + throw new Error("Migration round-trip verification failed"); + } + + // Remove plaintext from old DB after successful verification + try { + await mainDb + .table("accountAuth") + .where("pubKeyCommitmentHex") + .equals(pubKeyHex) + .delete(); + } catch { + // Best-effort cleanup — old DB schema may differ + } + + return plaintext; + } + } catch (e) { + // Main DB may not exist, have different schema, or be at a higher + // Dexie version — migration is best-effort, not required. + console.debug("Passkey migration: could not read from main DB", e); + } + } + + return undefined; + } + + /** + * insertKey callback — encrypts and stores the secret key. + * + * The `pubKey` parameter contains the pub key commitment bytes (RPO256 hash + * of the public key), NOT the raw public key. This is a 32-byte Uint8Array. + * + * @param {Uint8Array} pubKey - Public key commitment bytes (32 bytes). + * @param {Uint8Array} secretKey - Secret key bytes to encrypt. + * @returns {Promise} + */ + async function insertKey(pubKey, secretKey) { + const pubKeyHex = bytesToHex(pubKey); + const encrypted = await encryptSecretKey(wrappingKey, secretKey, pubKey); + + // Upsert: put() overwrites existing entries. This is intentional — + // the WASM side may re-insert during migration or key rotation. + await keystoreDb.keys.put({ + pubKeyHex, + ciphertextHex: bytesToHex(encrypted), + }); + } + + return { getKey, insertKey, credentialId }; +} diff --git a/crates/web-client/js/types/api-types.d.ts b/crates/web-client/js/types/api-types.d.ts index b9384e54..5e4a041c 100644 --- a/crates/web-client/js/types/api-types.d.ts +++ b/crates/web-client/js/types/api-types.d.ts @@ -152,6 +152,21 @@ export declare const AccountType: { /** Union of valid AccountType numeric values. */ export type AccountTypeValue = 0 | 1 | 2 | 3; +// ════════════════════════════════════════════════════════════════ +// Passkey encryption options +// ════════════════════════════════════════════════════════════════ + +export interface PasskeyEncryptionOptions { + /** Existing credential ID (base64url). Omit to register a new passkey. */ + credentialId?: string; + /** WebAuthn relying party ID. Defaults to current hostname. */ + rpId?: string; + /** Relying party display name. Defaults to "Miden Client". */ + rpName?: string; + /** User display name for the passkey. Defaults to "Miden Wallet User". */ + userName?: string; +} + // ════════════════════════════════════════════════════════════════ // Client options // ════════════════════════════════════════════════════════════════ @@ -193,8 +208,24 @@ export interface ClientOptions { keystore?: { getKey: GetKeyCallback; insertKey: InsertKeyCallback; - sign: SignCallback; + /** + * Optional signing callback. When omitted, the Rust/WASM side calls `getKey` + * to retrieve the secret key and signs locally. Only provide this if you need + * signing to happen outside of WASM (e.g., in a remote HSM). + */ + sign?: SignCallback; }; + /** + * Opt-in passkey encryption for keys at rest. Pass `true` for defaults + * or a `PasskeyEncryptionOptions` object to reuse an existing credential. + * + * When `true`, checks localStorage for an existing credential and reuses it + * if found; otherwise registers a new passkey (triggering a biometric prompt). + * + * Requires Chrome 116+, Safari 18+, or Edge 116+. Firefox does NOT support PRF. + * Mutually exclusive with `keystore` — if both are provided, `keystore` takes precedence. + */ + passkeyEncryption?: boolean | PasskeyEncryptionOptions; } // ════════════════════════════════════════════════════════════════ @@ -988,3 +1019,37 @@ export declare function importStore( /** Returns the initialized WASM module. Throws if WASM is unavailable. */ export declare function getWasmOrThrow(): Promise; + +// ════════════════════════════════════════════════════════════════ +// Passkey utilities (tree-shakeable) +// ════════════════════════════════════════════════════════════════ + +/** + * Returns `true` if the current browser supports WebAuthn with the PRF extension, + * which is required for passkey-based key encryption. + * + * Supported browsers: Chrome 116+, Safari 18+, Edge 116+. Firefox does NOT support PRF. + */ +export declare function isPasskeyPrfSupported(): Promise; + +/** Result of `createPasskeyKeystore()`. */ +export interface PasskeyKeystore { + /** Decrypts and returns the secret key for a given pub key commitment. */ + getKey: GetKeyCallback; + /** Encrypts and stores a secret key for a given pub key commitment. */ + insertKey: InsertKeyCallback; + /** The credential ID (base64url) of the passkey used for this keystore. */ + credentialId: string; +} + +/** + * Creates a passkey-encrypted keystore backed by WebAuthn PRF. + * + * Registers a new passkey or authenticates with an existing one (biometric prompt), + * then returns `getKey`/`insertKey` callbacks that transparently encrypt/decrypt + * secret keys using the PRF-derived wrapping key. + */ +export declare function createPasskeyKeystore( + storeName: string, + options?: PasskeyEncryptionOptions +): Promise; diff --git a/crates/web-client/rollup.config.js b/crates/web-client/rollup.config.js index f38f06b6..68e1bbcb 100644 --- a/crates/web-client/rollup.config.js +++ b/crates/web-client/rollup.config.js @@ -164,6 +164,16 @@ export default [ }, ], }, + // Build the passkey-keystore as a standalone module (used by tests and direct imports) + { + input: "./js/passkey-keystore.js", + output: { + dir: `dist`, + format: "es", + sourcemap: true, + }, + plugins: [resolve(), commonjs()], + }, // Classic worker build. // // Safari/WKWebView is extremely slow with module workers ({type: "module"}), diff --git a/crates/web-client/test/passkey-keystore.test.ts b/crates/web-client/test/passkey-keystore.test.ts new file mode 100644 index 00000000..6bc2fcd4 --- /dev/null +++ b/crates/web-client/test/passkey-keystore.test.ts @@ -0,0 +1,652 @@ +import { + test as base, + expect, + BrowserContext, + CDPSession, +} from "@playwright/test"; +import { RUN_ID, getRpcUrl } from "./playwright.global.setup"; + +/** + * Passkey keystore tests using Chrome DevTools Protocol virtual authenticator. + * + * These tests use CDP to create a virtual authenticator with PRF extension support, + * allowing us to test the full passkey encryption flow without real hardware. + * + * Only runs on Chromium (CDP is not available on WebKit/Firefox). + */ + +let cdpSession: CDPSession; +let authenticatorId: string; + +function generateStoreName(suffix: string): string { + return `test_passkey_${RUN_ID}_${suffix}`; +} + +// Custom test fixture that sets up a virtual authenticator with PRF support +const test = base.extend<{ forEachTest: void }>({ + forEachTest: [ + async ({ page, browserName }, use) => { + // Virtual authenticators with PRF require CDP (Chromium only) + if (browserName !== "chromium") { + test.skip(); + return; + } + + cdpSession = await page.context().newCDPSession(page); + + // Enable the virtual authenticator environment + await cdpSession.send("WebAuthn.enable", { + enableUI: false, + }); + + // Add a virtual authenticator with PRF extension support + const result = await cdpSession.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + hasPrf: true, + }, + }); + authenticatorId = result.authenticatorId; + + await page.goto("http://localhost:8080"); + + // Import the SDK + await page.evaluate(async () => { + const sdkExports = await import("./index.js"); + for (const [key, value] of Object.entries(sdkExports)) { + (window as any)[key] = value; + } + }); + + await use(); + + // Cleanup + await cdpSession.send("WebAuthn.removeVirtualAuthenticator", { + authenticatorId, + }); + await cdpSession.send("WebAuthn.disable"); + }, + { auto: true }, + ], +}); + +test.describe("passkey keystore", () => { + test("isPasskeyPrfSupported returns true with virtual authenticator", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const supported = await page.evaluate(async () => { + return await (window as any).isPasskeyPrfSupported(); + }); + + expect(supported).toBe(true); + }); + + test("createPasskeyKeystore registers a new passkey and returns callbacks", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("register"); + + const result = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + return { + hasGetKey: typeof keystore.getKey === "function", + hasInsertKey: typeof keystore.insertKey === "function", + hasCredentialId: typeof keystore.credentialId === "string", + credentialIdLength: keystore.credentialId.length, + }; + }, storeName); + + expect(result.hasGetKey).toBe(true); + expect(result.hasInsertKey).toBe(true); + expect(result.hasCredentialId).toBe(true); + expect(result.credentialIdLength).toBeGreaterThan(0); + }); + + test("credential ID is persisted to localStorage and reused", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("persist"); + + const { credentialId1, credentialId2 } = await page.evaluate( + async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + + // First call — registers a new passkey + const ks1 = await createPasskeyKeystore(storeName); + const credentialId1 = ks1.credentialId; + + // Second call — should reuse the same credential from localStorage + const ks2 = await createPasskeyKeystore(storeName); + const credentialId2 = ks2.credentialId; + + return { credentialId1, credentialId2 }; + }, + storeName + ); + + expect(credentialId1).toBe(credentialId2); + }); + + test("insertKey encrypts and getKey decrypts round-trip", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("roundtrip"); + + const result = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + // Create fake pub key commitment (32 bytes) and secret key + const pubKey = new Uint8Array(32); + crypto.getRandomValues(pubKey); + const secretKey = new Uint8Array(64); + crypto.getRandomValues(secretKey); + + // Insert (encrypts) + await keystore.insertKey(pubKey, secretKey); + + // Get (decrypts) + const retrieved = await keystore.getKey(pubKey); + + return { + original: Array.from(secretKey), + retrieved: retrieved ? Array.from(retrieved) : null, + }; + }, storeName); + + expect(result.retrieved).toEqual(result.original); + }); + + test("getKey returns undefined for unknown key", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("unknown"); + + const result = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const unknownPubKey = new Uint8Array(32); + crypto.getRandomValues(unknownPubKey); + return await keystore.getKey(unknownPubKey); + }, storeName); + + expect(result).toBeUndefined(); + }); + + test("encrypted data in IndexedDB starts with MWEB magic", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("magic"); + + const magicBytes = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const pubKey = new Uint8Array(32); + crypto.getRandomValues(pubKey); + const secretKey = new Uint8Array(64); + crypto.getRandomValues(secretKey); + + await keystore.insertKey(pubKey, secretKey); + + // Read raw data from IndexedDB to verify MWEB magic + const pubKeyHex = Array.from(pubKey) + .map((b: number) => b.toString(16).padStart(2, "0")) + .join(""); + const record = await new Promise((resolve, reject) => { + const req = indexedDB.open(`MidenKeystore_${storeName}`); + req.onsuccess = () => { + const tx = req.result.transaction("keys", "readonly"); + const getReq = tx.objectStore("keys").get(pubKeyHex); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); + const ciphertextHex = record?.ciphertextHex as string; + + // First 4 bytes (8 hex chars) should be "MWEB" = 4d574542 + return ciphertextHex?.substring(0, 8); + }, storeName); + + expect(magicBytes).toBe("4d574542"); + }); + + test("different pub keys produce different ciphertexts", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("unique-ct"); + + const result = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const secretKey = new Uint8Array(64); + crypto.getRandomValues(secretKey); + + const pubKey1 = new Uint8Array(32); + crypto.getRandomValues(pubKey1); + const pubKey2 = new Uint8Array(32); + crypto.getRandomValues(pubKey2); + + await keystore.insertKey(pubKey1, secretKey); + await keystore.insertKey(pubKey2, secretKey); + + // Read raw ciphertexts from IndexedDB + const hex1 = Array.from(pubKey1) + .map((b: number) => b.toString(16).padStart(2, "0")) + .join(""); + const hex2 = Array.from(pubKey2) + .map((b: number) => b.toString(16).padStart(2, "0")) + .join(""); + + const readRecord = (key: string) => + new Promise((resolve, reject) => { + const req = indexedDB.open(`MidenKeystore_${storeName}`); + req.onsuccess = () => { + const tx = req.result.transaction("keys", "readonly"); + const getReq = tx.objectStore("keys").get(key); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); + + const record1 = await readRecord(hex1); + const record2 = await readRecord(hex2); + + return { + ct1: record1?.ciphertextHex, + ct2: record2?.ciphertextHex, + }; + }, storeName); + + // Same plaintext with different AAD (pub key commitment) → different ciphertext + // Also, random IV ensures uniqueness + expect(result.ct1).not.toBe(result.ct2); + }); + + test("ciphertext bound to pub key commitment (AAD prevents swapping)", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("aad"); + + const decryptFailed = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const pubKey1 = new Uint8Array(32); + crypto.getRandomValues(pubKey1); + const pubKey2 = new Uint8Array(32); + crypto.getRandomValues(pubKey2); + const secretKey = new Uint8Array(64); + crypto.getRandomValues(secretKey); + + await keystore.insertKey(pubKey1, secretKey); + + // Manually swap the ciphertext to pubKey2's entry in IndexedDB + const hex1 = Array.from(pubKey1) + .map((b: number) => b.toString(16).padStart(2, "0")) + .join(""); + const hex2 = Array.from(pubKey2) + .map((b: number) => b.toString(16).padStart(2, "0")) + .join(""); + + const dbName = `MidenKeystore_${storeName}`; + const record = await new Promise((resolve, reject) => { + const req = indexedDB.open(dbName); + req.onsuccess = () => { + const tx = req.result.transaction("keys", "readonly"); + const getReq = tx.objectStore("keys").get(hex1); + getReq.onsuccess = () => resolve(getReq.result); + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); + // Put the same ciphertext under pubKey2 + await new Promise((resolve, reject) => { + const req = indexedDB.open(dbName); + req.onsuccess = () => { + const tx = req.result.transaction("keys", "readwrite"); + const putReq = tx.objectStore("keys").put({ + pubKeyHex: hex2, + ciphertextHex: record?.ciphertextHex, + }); + putReq.onsuccess = () => resolve(); + putReq.onerror = () => reject(putReq.error); + }; + req.onerror = () => reject(req.error); + }); + + // Attempting to decrypt with pubKey2 should fail (AAD mismatch) + try { + await keystore.getKey(pubKey2); + return false; // Should not reach here + } catch { + return true; // AES-GCM auth tag verification failed + } + }, storeName); + + expect(decryptFailed).toBe(true); + }); + + test("works with MidenClient.create via passkeyEncryption option", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("client-api"); + const rpcUrl = getRpcUrl(); + + const result = await page.evaluate( + async ({ storeName, rpcUrl }) => { + const MidenClient = (window as any).MidenClient; + const client = await MidenClient.create({ + rpcUrl, + storeName, + passkeyEncryption: true, + }); + return { + clientCreated: client != null, + hasAccounts: typeof client.accounts === "object", + hasTransactions: typeof client.transactions === "object", + }; + }, + { storeName, rpcUrl } + ); + + expect(result.clientCreated).toBe(true); + expect(result.hasAccounts).toBe(true); + expect(result.hasTransactions).toBe(true); + }); + + test("migration: reads plaintext key from main DB, encrypts, and removes plaintext", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("migrate"); + + const result = await page.evaluate(async (storeName) => { + // 1. Seed the main DB with a plaintext key using native IndexedDB + const pubKeyHex = "aa".repeat(32); // 32-byte fake pub key commitment as hex + const secretKeyHex = "bb".repeat(64); // 64-byte fake secret key as hex + + await new Promise((resolve, reject) => { + const req = indexedDB.open(storeName, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("accountAuth")) { + db.createObjectStore("accountAuth", { + keyPath: "pubKeyCommitmentHex", + }); + } + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction("accountAuth", "readwrite"); + tx.objectStore("accountAuth").put({ + pubKeyCommitmentHex: pubKeyHex, + secretKeyHex, + }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + req.onerror = () => reject(req.error); + }); + + // 2. Create passkey keystore — migration should kick in on getKey + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + // 3. Convert hex pubKey to bytes for getKey call + const pubKeyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + pubKeyBytes[i] = parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16); + } + + const retrieved = await keystore.getKey(pubKeyBytes); + + // 4. Verify the returned key matches the original plaintext + const expectedBytes = new Uint8Array(64); + for (let i = 0; i < 64; i++) { + expectedBytes[i] = parseInt(secretKeyHex.slice(i * 2, i * 2 + 2), 16); + } + + // 5. Check plaintext was removed from main DB + const plaintextGone = await new Promise((resolve, reject) => { + const req = indexedDB.open(storeName); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction("accountAuth", "readonly"); + const getReq = tx.objectStore("accountAuth").get(pubKeyHex); + getReq.onsuccess = () => { + db.close(); + resolve(getReq.result == null); + }; + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); + + // 6. Check encrypted entry exists in keystore DB + const encryptedExists = await new Promise((resolve, reject) => { + const req = indexedDB.open(`MidenKeystore_${storeName}`); + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction("keys", "readonly"); + const getReq = tx.objectStore("keys").get(pubKeyHex); + getReq.onsuccess = () => { + db.close(); + resolve(getReq.result != null); + }; + getReq.onerror = () => reject(getReq.error); + }; + req.onerror = () => reject(req.error); + }); + + return { + retrieved: retrieved ? Array.from(retrieved) : null, + expected: Array.from(expectedBytes), + plaintextGone, + encryptedExists, + }; + }, storeName); + + expect(result.retrieved).toEqual(result.expected); + expect(result.plaintextGone).toBe(true); + expect(result.encryptedExists).toBe(true); + }); + + test("migration: subsequent getKey reads from encrypted keystore, not main DB", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("migrate-cached"); + + const result = await page.evaluate(async (storeName) => { + // Seed main DB with plaintext key + const pubKeyHex = "cc".repeat(32); + const secretKeyHex = "dd".repeat(64); + + await new Promise((resolve, reject) => { + const req = indexedDB.open(storeName, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("accountAuth")) { + db.createObjectStore("accountAuth", { + keyPath: "pubKeyCommitmentHex", + }); + } + }; + req.onsuccess = () => { + const db = req.result; + const tx = db.transaction("accountAuth", "readwrite"); + tx.objectStore("accountAuth").put({ + pubKeyCommitmentHex: pubKeyHex, + secretKeyHex, + }); + tx.oncomplete = () => { + db.close(); + resolve(); + }; + tx.onerror = () => reject(tx.error); + }; + req.onerror = () => reject(req.error); + }); + + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const pubKeyBytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + pubKeyBytes[i] = parseInt(pubKeyHex.slice(i * 2, i * 2 + 2), 16); + } + + // First call triggers migration + const first = await keystore.getKey(pubKeyBytes); + + // Second call should read from encrypted keystore (plaintext is gone) + const second = await keystore.getKey(pubKeyBytes); + + return { + first: first ? Array.from(first) : null, + second: second ? Array.from(second) : null, + match: + first != null && + second != null && + Array.from(first).every((b, i) => b === Array.from(second!)[i]), + }; + }, storeName); + + expect(result.first).not.toBeNull(); + expect(result.second).toEqual(result.first); + expect(result.match).toBe(true); + }); + + test("migration: skipped when main DB has no matching key", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("migrate-miss"); + + const result = await page.evaluate(async (storeName) => { + // Create main DB with accountAuth table but no matching entry + await new Promise((resolve, reject) => { + const req = indexedDB.open(storeName, 1); + req.onupgradeneeded = () => { + const db = req.result; + if (!db.objectStoreNames.contains("accountAuth")) { + db.createObjectStore("accountAuth", { + keyPath: "pubKeyCommitmentHex", + }); + } + }; + req.onsuccess = () => { + req.result.close(); + resolve(); + }; + req.onerror = () => reject(req.error); + }); + + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + const keystore = await createPasskeyKeystore(storeName); + + const pubKeyBytes = new Uint8Array(32); + crypto.getRandomValues(pubKeyBytes); + + const retrieved = await keystore.getKey(pubKeyBytes); + return { retrieved }; + }, storeName); + + expect(result.retrieved).toBeUndefined(); + }); + + test("explicit credentialId option reuses existing passkey", async ({ + page, + browserName, + }) => { + test.skip(browserName !== "chromium", "CDP required"); + + const storeName = generateStoreName("explicit-cred"); + + const result = await page.evaluate(async (storeName) => { + const { createPasskeyKeystore } = await import("./passkey-keystore.js"); + + // Register + const ks1 = await createPasskeyKeystore(storeName); + const credentialId = ks1.credentialId; + + // Clear localStorage to prove the explicit option works + localStorage.removeItem(`miden_passkey_credential_${storeName}`); + + // Re-create with explicit credentialId + const differentStore = storeName + "_reuse"; + const ks2 = await createPasskeyKeystore(differentStore, { + credentialId, + }); + + // Insert with ks1, retrieve with ks2 (same PRF → same wrapping key) + const pubKey = new Uint8Array(32); + crypto.getRandomValues(pubKey); + const secretKey = new Uint8Array(48); + crypto.getRandomValues(secretKey); + + await ks1.insertKey(pubKey, secretKey); + + // ks2 has the same wrapping key but different DB + // So we need to use the same DB by using the same storeName + const ks3 = await createPasskeyKeystore(storeName, { credentialId }); + const retrieved = await ks3.getKey(pubKey); + + return { + credentialId1: credentialId, + credentialId2: ks2.credentialId, + original: Array.from(secretKey), + retrieved: retrieved ? Array.from(retrieved) : null, + }; + }, storeName); + + expect(result.credentialId1).toBe(result.credentialId2); + expect(result.retrieved).toEqual(result.original); + }); +}); diff --git a/crates/web-client/vitest.config.js b/crates/web-client/vitest.config.js index 55c1d293..d7e0702c 100644 --- a/crates/web-client/vitest.config.js +++ b/crates/web-client/vitest.config.js @@ -28,6 +28,11 @@ export default defineConfig({ "js/index.js", "js/client.js", "js/storageView.js", + // WebAuthn-dependent: passkey-keystore depends on browser-only + // navigator.credentials APIs (WebAuthn PRF) that aren't available + // in the node test environment. Covered by Playwright integration + // tests (test/passkey-keystore.test.ts). + "js/passkey-keystore.js", // Tests not yet ported on next — main has them, but the source has // drifted from the napi-binding sync (PR #13) enough that the tests // need review before they apply. Tracked for a follow-up PR. Once diff --git a/knip.jsonc b/knip.jsonc index d2246b22..0afe6cf2 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -21,11 +21,6 @@ "vite", ], "ignoreDependencies": [ - // `dexie` is bundled into the web-client test page via rollup at - // test runtime — `page.evaluate(...)` blocks load the rolled-up - // bundle from http://localhost:8080, which transitively imports - // dexie. Knip's static scan can't see across the build boundary. - "dexie", // `publint` and `@arethetypeswrong/cli` are invoked by the // `check:publint` / `check:attw` scripts in root package.json via // `pnpm exec publint` / `pnpm exec attw`. Knip flags them as unused @@ -104,8 +99,17 @@ // `./crates/miden_client_web` is the wasm-bindgen module emitted // into dist/ by the rollup rust plugin; the .d.ts files in // js/types/ reference it but it doesn't exist until after build. - // Knip can't follow either of these dynamic targets. - "ignoreUnresolved": ["./index.js", "./crates/miden_client_web"], + // `./passkey-keystore.js` is the rolled-up passkey module emitted + // into dist/ by a dedicated rollup config in rollup.config.js; the + // dynamic import inside test/passkey-keystore.test.ts:100's + // page.evaluate(...) resolves it from http://localhost:8080/, not + // from the test file's path. + // Knip can't follow any of these dynamic targets. + "ignoreUnresolved": [ + "./index.js", + "./crates/miden_client_web", + "./passkey-keystore.js", + ], }, "crates/idxdb-store/src": { // Each `ts/*.ts` is independently tsc-compiled into `js/` and