feat(web): WebAuthn PRF-based key encryption for web client#1836
Merged
Conversation
Add passkey-keystore.js implementing opt-in encryption for secret keys at rest using WebAuthn PRF (Touch ID, Face ID, Windows Hello). Keys are wrapped with AES-256-GCM derived from the authenticator's PRF output via HKDF-SHA256. - MWEB envelope format (distinct from native MENC/ChaCha20-Poly1305) - Non-extractable CryptoKey held in closure, never exposed to JS - AAD binding prevents ciphertext-swapping attacks in IndexedDB - Separate MidenKeystore_* Dexie DB avoids schema conflicts - Migration fallback reads plaintext from main DB and re-encrypts - Feature detection via getClientCapabilities + UA heuristic fallback - Playwright tests with CDP virtual authenticator (8 test cases) Update api-types.d.ts with PasskeyEncryptionOptions, PasskeyKeystore interfaces, optional keystore.sign, and function declarations.
Add passkey resolution in MidenClient.create() before the keystore branch. When passkeyEncryption is set, dynamically imports the passkey module and creates encrypted keystore callbacks. - Dynamic import() for tree-shaking (module only loaded when needed) - console.warn when both passkeyEncryption and keystore are provided - Export isPasskeyPrfSupported and createPasskeyKeystore from index.js
Wire passkey encryption into the React SDK's MidenProvider. When config.passkeyEncryption is set and no SignerContext is active, the provider dynamically imports createPasskeyKeystore and creates an encrypted client via createClientWithExternalKeystore. - Add PasskeyEncryptionOptions and storeName to MidenConfig - Re-export isPasskeyPrfSupported and PasskeyEncryptionOptions - Passkey encryption ignored when external signer is active
Add Docusaurus page for passkey encryption covering usage for both web SDK and React SDK, security properties, browser support, migration, export/import limitations, cross-device behavior, and credential loss. Update React SDK README with passkey encryption section including basic usage, feature detection, advanced options, and important notes. Update React SDK CLAUDE.md with config example and quick reference.
…example - Add @miden-sdk/vite-plugin (from main) for COOP/COEP headers, worker format, and WASM resolve aliases - Fix React StrictMode double-mount bug in MidenProvider that prevented client initialization (reset isInitializedRef in cleanup) - Update wallet example to use local packages and vite-plugin - Enable passkeyEncryption in wallet example config - Add vite-plugin to .gitignore allowlist
…ck, input validation
… remove dead code
The passkey-keystore.js module was only bundled into index.js but tests import it as a standalone module from http://localhost:8080/passkey-keystore.js. Add it as a separate rollup entry so it outputs to dist/passkey-keystore.js.
… tests Bare module specifiers like 'dexie' cannot be resolved inside page.evaluate() in the browser context. Replace with native IndexedDB API calls for the 3 tests that read/write raw keystore records.
- Add console.warn when WebAuthn PRF is unsupported instead of silently falling through to standard keystore - Revert wallet example back to devnet config
Collaborator
|
I tried the wallet example locally and it runs correctly. However, I have some doubts on how the user relies on the passkey. IIUC, the passkey is used to encrypt the keys stored in IndexedDb, so if the user somehow loses the passkey, they lose complete access to the wallet. Would the wallet implementation do that? I don't think it would be correct, at least we should expose the seed phrase so users can eventually restore the keys. It might be worth mentioning in the wallet example as a warning. |
JereSalo
reviewed
Mar 4, 2026
Collaborator
|
I tried the wallet example against a local node as well and it worked fine :) |
…ation tests The migration fallback used `accountAuths` (plural) but the actual IndexedDB table created by idxdb-store is `accountAuth` (singular), causing migration to silently fail. Also adds three tests covering the migration path: successful migrate-and-delete, subsequent reads from encrypted keystore, and no-op when key is not found.
TomasArrachea
approved these changes
Mar 6, 2026
WiktorStarczewski
added a commit
that referenced
this pull request
Mar 9, 2026
* feat(web-client): add WebAuthn PRF-based key encryption module Add passkey-keystore.js implementing opt-in encryption for secret keys at rest using WebAuthn PRF (Touch ID, Face ID, Windows Hello). Keys are wrapped with AES-256-GCM derived from the authenticator's PRF output via HKDF-SHA256. - MWEB envelope format (distinct from native MENC/ChaCha20-Poly1305) - Non-extractable CryptoKey held in closure, never exposed to JS - AAD binding prevents ciphertext-swapping attacks in IndexedDB - Separate MidenKeystore_* Dexie DB avoids schema conflicts - Migration fallback reads plaintext from main DB and re-encrypts - Feature detection via getClientCapabilities + UA heuristic fallback - Playwright tests with CDP virtual authenticator (8 test cases) Update api-types.d.ts with PasskeyEncryptionOptions, PasskeyKeystore interfaces, optional keystore.sign, and function declarations. * feat(web-client): integrate passkeyEncryption into MidenClient.create() Add passkey resolution in MidenClient.create() before the keystore branch. When passkeyEncryption is set, dynamically imports the passkey module and creates encrypted keystore callbacks. - Dynamic import() for tree-shaking (module only loaded when needed) - console.warn when both passkeyEncryption and keystore are provided - Export isPasskeyPrfSupported and createPasskeyKeystore from index.js * feat(react-sdk): add passkeyEncryption support to MidenProvider Wire passkey encryption into the React SDK's MidenProvider. When config.passkeyEncryption is set and no SignerContext is active, the provider dynamically imports createPasskeyKeystore and creates an encrypted client via createClientWithExternalKeystore. - Add PasskeyEncryptionOptions and storeName to MidenConfig - Re-export isPasskeyPrfSupported and PasskeyEncryptionOptions - Passkey encryption ignored when external signer is active * docs: add passkey encryption documentation Add Docusaurus page for passkey encryption covering usage for both web SDK and React SDK, security properties, browser support, migration, export/import limitations, cross-device behavior, and credential loss. Update React SDK README with passkey encryption section including basic usage, feature detection, advanced options, and important notes. Update React SDK CLAUDE.md with config example and quick reference. * feat: add vite-plugin, fix StrictMode init, enable passkey in wallet example - Add @miden-sdk/vite-plugin (from main) for COOP/COEP headers, worker format, and WASM resolve aliases - Fix React StrictMode double-mount bug in MidenProvider that prevented client initialization (reset isInitializedRef in cleanup) - Update wallet example to use local packages and vite-plugin - Enable passkeyEncryption in wallet example config - Add vite-plugin to .gitignore allowlist * chore: update yarn lockfiles * chore: remove debug logs from MidenProvider * docs: add changelog entry for passkey encryption feature * fix: review fixes — migration round-trip verification, passkey fallback, input validation * fix: add passkey fallback to MidenClient.create() for unsupported browsers * fix: review round 2 — verify migration bytes, fix storeName mismatch, remove dead code * fix: pass storeName to fallback createClient path in MidenProvider * docs: fill changelog PR number (#1836) * fix(ci): run prettier, regenerate typedoc, remove version sync script * fix(ci): ignore vite-plugin in root eslint config * fix(web-client): add passkey-keystore as standalone rollup entry point The passkey-keystore.js module was only bundled into index.js but tests import it as a standalone module from http://localhost:8080/passkey-keystore.js. Add it as a separate rollup entry so it outputs to dist/passkey-keystore.js. * fix(web-client): use native IndexedDB API instead of Dexie in passkey tests Bare module specifiers like 'dexie' cannot be resolved inside page.evaluate() in the browser context. Replace with native IndexedDB API calls for the 3 tests that read/write raw keystore records. * fix(web-client): address PR review feedback - Add console.warn when WebAuthn PRF is unsupported instead of silently falling through to standard keystore - Revert wallet example back to devnet config * fix(rust-client): merge use statements to satisfy nightly fmt * docs(wallet-example): add key recovery warning for passkey encryption * fix(web-client): fix accountAuth table name in migration and add migration tests The migration fallback used `accountAuths` (plural) but the actual IndexedDB table created by idxdb-store is `accountAuth` (singular), causing migration to silently fail. Also adds three tests covering the migration path: successful migrate-and-delete, subsequent reads from encrypted keystore, and no-op when key is not found. * fix(web-client): format passkey-keystore test with prettier * docs(web-client): mention seed phrase recovery in credential loss section
WiktorStarczewski
added a commit
that referenced
this pull request
Mar 9, 2026
* feat(web-client): add WebAuthn PRF-based key encryption module Add passkey-keystore.js implementing opt-in encryption for secret keys at rest using WebAuthn PRF (Touch ID, Face ID, Windows Hello). Keys are wrapped with AES-256-GCM derived from the authenticator's PRF output via HKDF-SHA256. - MWEB envelope format (distinct from native MENC/ChaCha20-Poly1305) - Non-extractable CryptoKey held in closure, never exposed to JS - AAD binding prevents ciphertext-swapping attacks in IndexedDB - Separate MidenKeystore_* Dexie DB avoids schema conflicts - Migration fallback reads plaintext from main DB and re-encrypts - Feature detection via getClientCapabilities + UA heuristic fallback - Playwright tests with CDP virtual authenticator (8 test cases) Update api-types.d.ts with PasskeyEncryptionOptions, PasskeyKeystore interfaces, optional keystore.sign, and function declarations. * feat(web-client): integrate passkeyEncryption into MidenClient.create() Add passkey resolution in MidenClient.create() before the keystore branch. When passkeyEncryption is set, dynamically imports the passkey module and creates encrypted keystore callbacks. - Dynamic import() for tree-shaking (module only loaded when needed) - console.warn when both passkeyEncryption and keystore are provided - Export isPasskeyPrfSupported and createPasskeyKeystore from index.js * feat(react-sdk): add passkeyEncryption support to MidenProvider Wire passkey encryption into the React SDK's MidenProvider. When config.passkeyEncryption is set and no SignerContext is active, the provider dynamically imports createPasskeyKeystore and creates an encrypted client via createClientWithExternalKeystore. - Add PasskeyEncryptionOptions and storeName to MidenConfig - Re-export isPasskeyPrfSupported and PasskeyEncryptionOptions - Passkey encryption ignored when external signer is active * docs: add passkey encryption documentation Add Docusaurus page for passkey encryption covering usage for both web SDK and React SDK, security properties, browser support, migration, export/import limitations, cross-device behavior, and credential loss. Update React SDK README with passkey encryption section including basic usage, feature detection, advanced options, and important notes. Update React SDK CLAUDE.md with config example and quick reference. * feat: add vite-plugin, fix StrictMode init, enable passkey in wallet example - Add @miden-sdk/vite-plugin (from main) for COOP/COEP headers, worker format, and WASM resolve aliases - Fix React StrictMode double-mount bug in MidenProvider that prevented client initialization (reset isInitializedRef in cleanup) - Update wallet example to use local packages and vite-plugin - Enable passkeyEncryption in wallet example config - Add vite-plugin to .gitignore allowlist * chore: update yarn lockfiles * chore: remove debug logs from MidenProvider * docs: add changelog entry for passkey encryption feature * fix: review fixes — migration round-trip verification, passkey fallback, input validation * fix: add passkey fallback to MidenClient.create() for unsupported browsers * fix: review round 2 — verify migration bytes, fix storeName mismatch, remove dead code * fix: pass storeName to fallback createClient path in MidenProvider * docs: fill changelog PR number (#1836) * fix(ci): run prettier, regenerate typedoc, remove version sync script * fix(ci): ignore vite-plugin in root eslint config * fix(web-client): add passkey-keystore as standalone rollup entry point The passkey-keystore.js module was only bundled into index.js but tests import it as a standalone module from http://localhost:8080/passkey-keystore.js. Add it as a separate rollup entry so it outputs to dist/passkey-keystore.js. * fix(web-client): use native IndexedDB API instead of Dexie in passkey tests Bare module specifiers like 'dexie' cannot be resolved inside page.evaluate() in the browser context. Replace with native IndexedDB API calls for the 3 tests that read/write raw keystore records. * fix(web-client): address PR review feedback - Add console.warn when WebAuthn PRF is unsupported instead of silently falling through to standard keystore - Revert wallet example back to devnet config * fix(rust-client): merge use statements to satisfy nightly fmt * docs(wallet-example): add key recovery warning for passkey encryption * fix(web-client): fix accountAuth table name in migration and add migration tests The migration fallback used `accountAuths` (plural) but the actual IndexedDB table created by idxdb-store is `accountAuth` (singular), causing migration to silently fail. Also adds three tests covering the migration path: successful migrate-and-delete, subsequent reads from encrypted keystore, and no-op when key is not found. * fix(web-client): format passkey-keystore test with prettier * docs(web-client): mention seed phrase recovery in credential loss section
WiktorStarczewski
added a commit
that referenced
this pull request
Mar 9, 2026
* feat(web-client): add WebAuthn PRF-based key encryption module Add passkey-keystore.js implementing opt-in encryption for secret keys at rest using WebAuthn PRF (Touch ID, Face ID, Windows Hello). Keys are wrapped with AES-256-GCM derived from the authenticator's PRF output via HKDF-SHA256. - MWEB envelope format (distinct from native MENC/ChaCha20-Poly1305) - Non-extractable CryptoKey held in closure, never exposed to JS - AAD binding prevents ciphertext-swapping attacks in IndexedDB - Separate MidenKeystore_* Dexie DB avoids schema conflicts - Migration fallback reads plaintext from main DB and re-encrypts - Feature detection via getClientCapabilities + UA heuristic fallback - Playwright tests with CDP virtual authenticator (8 test cases) Update api-types.d.ts with PasskeyEncryptionOptions, PasskeyKeystore interfaces, optional keystore.sign, and function declarations. * feat(web-client): integrate passkeyEncryption into MidenClient.create() Add passkey resolution in MidenClient.create() before the keystore branch. When passkeyEncryption is set, dynamically imports the passkey module and creates encrypted keystore callbacks. - Dynamic import() for tree-shaking (module only loaded when needed) - console.warn when both passkeyEncryption and keystore are provided - Export isPasskeyPrfSupported and createPasskeyKeystore from index.js * feat(react-sdk): add passkeyEncryption support to MidenProvider Wire passkey encryption into the React SDK's MidenProvider. When config.passkeyEncryption is set and no SignerContext is active, the provider dynamically imports createPasskeyKeystore and creates an encrypted client via createClientWithExternalKeystore. - Add PasskeyEncryptionOptions and storeName to MidenConfig - Re-export isPasskeyPrfSupported and PasskeyEncryptionOptions - Passkey encryption ignored when external signer is active * docs: add passkey encryption documentation Add Docusaurus page for passkey encryption covering usage for both web SDK and React SDK, security properties, browser support, migration, export/import limitations, cross-device behavior, and credential loss. Update React SDK README with passkey encryption section including basic usage, feature detection, advanced options, and important notes. Update React SDK CLAUDE.md with config example and quick reference. * feat: add vite-plugin, fix StrictMode init, enable passkey in wallet example - Add @miden-sdk/vite-plugin (from main) for COOP/COEP headers, worker format, and WASM resolve aliases - Fix React StrictMode double-mount bug in MidenProvider that prevented client initialization (reset isInitializedRef in cleanup) - Update wallet example to use local packages and vite-plugin - Enable passkeyEncryption in wallet example config - Add vite-plugin to .gitignore allowlist * chore: update yarn lockfiles * chore: remove debug logs from MidenProvider * docs: add changelog entry for passkey encryption feature * fix: review fixes — migration round-trip verification, passkey fallback, input validation * fix: add passkey fallback to MidenClient.create() for unsupported browsers * fix: review round 2 — verify migration bytes, fix storeName mismatch, remove dead code * fix: pass storeName to fallback createClient path in MidenProvider * docs: fill changelog PR number (#1836) * fix(ci): run prettier, regenerate typedoc, remove version sync script * fix(ci): ignore vite-plugin in root eslint config * fix(web-client): add passkey-keystore as standalone rollup entry point The passkey-keystore.js module was only bundled into index.js but tests import it as a standalone module from http://localhost:8080/passkey-keystore.js. Add it as a separate rollup entry so it outputs to dist/passkey-keystore.js. * fix(web-client): use native IndexedDB API instead of Dexie in passkey tests Bare module specifiers like 'dexie' cannot be resolved inside page.evaluate() in the browser context. Replace with native IndexedDB API calls for the 3 tests that read/write raw keystore records. * fix(web-client): address PR review feedback - Add console.warn when WebAuthn PRF is unsupported instead of silently falling through to standard keystore - Revert wallet example back to devnet config * fix(rust-client): merge use statements to satisfy nightly fmt * docs(wallet-example): add key recovery warning for passkey encryption * fix(web-client): fix accountAuth table name in migration and add migration tests The migration fallback used `accountAuths` (plural) but the actual IndexedDB table created by idxdb-store is `accountAuth` (singular), causing migration to silently fail. Also adds three tests covering the migration path: successful migrate-and-delete, subsequent reads from encrypted keystore, and no-op when key is not found. * fix(web-client): format passkey-keystore test with prettier * docs(web-client): mention seed phrase recovery in credential loss section
WiktorStarczewski
added a commit
that referenced
this pull request
Apr 30, 2026
Resolves drift accumulated since this PR was originally opened against miden-client next (March 2026). The web-sdk split (#1992 / #2135) moved all crates/web-client/, packages/react-sdk/, and assorted JS/TS files out of miden-client and into the dedicated 0xMiden/web-sdk repo, so the merge takes the deletions from next for those paths. Conflict resolutions: - 14 modify/delete conflicts (web-client, react-sdk, typedoc, scripts) accept deletion. The web-side changes from #1836 that were on this branch live in web-sdk PR #27 (wiktor/migrate-1835-passkey-keystore) now and are no longer part of miden-client. - .github/workflows/lint.yml: drop the eslint + react-sdk-lint jobs HEAD added — their make targets (rust-client-ts-lint, react-sdk-lint) don't exist in current miden-client now that the TS/JS code moved out. - CHANGELOG.md: drop the [FEATURE][web] passkey entry (it's a web-sdk feature, not a miden-client one anymore). Keep next's web/rust entries unchanged. The [FEATURE][rust,cli] FilesystemKeyStore encryption entry that this PR adds is already at line 112 (above the conflict region) and stays. - Cargo.toml (rust-client): merge std-feature additions (argon2/chacha20poly1305/zeroize from HEAD + tempfile/tokio from next), and the [dependencies] block — keep next's workspace=true refactor for chrono/prost/serde_json/tokio/etc., and add HEAD's encryption deps (argon2, chacha20poly1305, zeroize) on top. - Cargo.lock: take next's, then re-resolved with the new Cargo.toml via cargo update --workspace. cargo check --workspace --all-features is clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of a solution to #30 alongside #1835
Note, the vite-plugin is already merged into main, and is cherry-picked here without changes to be used in the example (should not produce merge conflicts when main goes to next), so doesnt need to be reviewed.
Summary
Adds opt-in passkey-based encryption for secret keys at rest in the web client. When enabled, keys stored in IndexedDB are encrypted using AES-256-GCM with a wrapping key derived from the WebAuthn PRF extension (Touch ID, Face ID, Windows Hello).
Without this feature, secret keys are stored as plaintext in IndexedDB, accessible to any JavaScript running in the same origin (XSS payloads, compromised dependencies, browser extensions). Passkey encryption adds a hardware-backed layer of protection — decrypting keys requires a biometric prompt.
Zero Rust/WASM changes — the entire feature uses the existing external keystore callback system from JavaScript.
How it works
navigator.credentials.create()with PRF extension registers a passkey bound to the originnavigator.credentials.get()evaluates PRF, returning a deterministic 32-byte secret from the authenticator's secure enclave (requires biometric)CryptoKeyinsertKeycallback encrypts secret key → stores ciphertext in dedicatedMidenKeystore_*IndexedDBgetKeycallback reads ciphertext → decrypts with wrapping key → returns plaintext bytes to WASMsigncallback — Rust callsgetKey(triggers decryption), then signs locally in WASMTesting
The wallet example in
packages/react/examplesis configured to use the new functionality (needs a 0.14 local node).Encrypted format
Distinct from the native CLI's
MENCformat (Argon2id + ChaCha20-Poly1305). Public key commitment bytes are used as AES-GCM additional authenticated data (AAD), binding each ciphertext to its key and preventing ciphertext-swapping attacks.Security properties
CryptoKey— raw bytes never exposed to JSUsage
Web SDK
isPasskeyPrfSupported()is also exported for UI purposes (e.g., conditionally showing a "biometric encryption" toggle), but is not required —MidenClient.create()handles the check internally.React SDK
Both SDKs silently fall back to standard (unencrypted) keystore on browsers that don't support WebAuthn PRF (Firefox, older browsers).
Browser support
Additional changes
@miden-sdk/vite-plugin— Brought frommainbranch. Provides COOP/COEP headers, worker format config, and WASM resolve aliases for Vite-based dApps.MidenProviderwhere the double-mount cycle prevented client initialization (isInitializedRefwas not reset in the effect cleanup).passkeyEncryption: true.getKeyfinds no encrypted entry, it reads plaintext from the main DB, re-encrypts, verifies round-trip (byte comparison), then deletes the plaintext. Seamless upgrade path.Files changed
crates/web-client/js/passkey-keystore.jscrates/web-client/js/client.jsMidenClient.create()crates/web-client/js/types/api-types.d.tsPasskeyEncryptionOptions,ClientOptions.passkeyEncryption, optionalkeystore.signcrates/web-client/js/index.jsisPasskeyPrfSupported,createPasskeyKeystorecrates/web-client/test/passkey-keystore.test.tspackages/react-sdk/src/context/MidenProvider.tsxpackages/react-sdk/src/types/index.tsPasskeyEncryptionOptions, config typespackages/react-sdk/src/index.tsisPasskeyPrfSupportedpackages/vite-plugin/*docs/external/src/web-client/passkey-encryption.mdTest plan
isPasskeyPrfSupported()returnstruein Chrome/Safari/Edge,falsein FirefoxMidenClient.create({ passkeyEncryption: true })triggers biometric promptMidenKeystore_*DB has MWEB-prefixed entryMidenClient.create()without passkey — existing flow unchangedyarn testincrates/web-client/