All notable changes to this project will be documented in this file.
This project follows Semantic Versioning.
PRF is now the only registration mode. Key-in-Handle (KiH) registration has been removed.
- KiH key material was interceptable in JavaScript. In KiH mode the 32-byte AES key was stored in the passkey
user.idand returned to the page asuserHandleon every login. Any script running in the page's origin (via XSS, a malicious browser extension, or a compromised dependency) could read that key material during a login/registration and decrypt the user's publickind:31777(v=3) blob offline. This is not exploitable from relay data alone — a genuine build never publishes key material — and requires code execution on the victim's page. PRF mode keeps the key inside the authenticator, eliminating this interception vector (note: the decrypted nsec is still returned to the app, so a compromised page can always read the key once decrypted — see SECURITY.md). - All WebAuthn calls route through native references captured at import time (
natives.ts), and key buffers are zeroed via prototype-pollution-resistant helpers (builtins.ts).
- KiH registration removed.
kihStrategy.register()throwsKihRegistrationDisabledError.generateUserId()was removed. The PRF strategy (prfStrategy) is the default forsetup,setupKeytr,addBackupGateway,discover, andloginWithKeytr. - PRF-only, no fallback. Authenticators without PRF (password-manager extensions, Firefox Android, some older security keys) now fail registration with
PrfNotSupportedError. There is no password/local fallback. KEYTR_VERSIONis now1(PRF). Legacy KiH isKEYTR_KIH_VERSION(3). BareencryptNsec/decryptNsec/buildAadcalls that omitversionnow default to1; passversion: KEYTR_KIH_VERSIONfor legacy blobs.RegisterOptions.pubkey(hex, stored asuser.id) is required again for the low-levelregisterPasskey. The high-level helpers derive and thread it for you.- Constants
KEY_SIZE/USER_ID_SIZE/MODE_BYTErenamed toKIH_KEY_SIZE/KIH_USER_ID_SIZE/KIH_MODE_BYTE.
migrateFromKih()— logs in with an existing KiH passkey, re-encrypts the nsec under a new PRF passkey, publishes the newv=1event, then publishes a NIP-09kind:5deletion (atag31777:<pubkey>:<d-tag>) to retire the old KiH event.buildKeytrDeletionEvent()— NIP-09 deletion builder forkind:31777events.DELETION_EVENT_KINDconstant.- Restored PRF pipeline:
prfStrategy,checkPrfSupport(),PrfNotSupportedError,prfRegistrationExtension/prfAuthenticationExtension/extractPrfOutput/isPrfEnabled, the two-ceremony Safari-safe discover, and the YubiKey create→get fallback with Signal-API orphan cleanup. detectMode(),DiscoverResult.mode,KeytrCredential.prfSupported,PRF_USER_ID_SIZE.
- KiH decryption (
kihStrategy,kihAuthenticatePasskey,v=3event support) — retained so existing users can log in and migrate; scheduled for removal in a future release. Migrate now withmigrateFromKih().
- Hardened KiH key material against JS-level interception — cache
navigator.credentials.create/getandUint8Array.prototypemethods at module load to resist monkey-patching (POC-1) and prototype pollution (POC-3).
- Removed PRF mode; added the pluggable
KeyStrategyinterface. KiH became the sole built-in strategy. (Superseded by 0.8.0, which restores PRF as the default and deprecates KiH.)
- Orphaned credential cleanup —
registerPasskey()now callssignalUnknownCredential()(WebAuthn Signal API) when PRF fails after credential creation. The browser removes the orphaned passkey from the picker so only the subsequent KiH credential remains. No-op on browsers without Signal API support.
- Single-credential registration —
setup()now pre-checks PRF support viagetClientCapabilities()before creating any credential. On platforms that definitively report no PRF support (e.g. GrapheneOS), registration skips straight to KiH mode, avoiding the creation of an orphaned PRF credential followed by a second KiH credential. Previously, the PRF-first-with-KiH-fallback pattern could create two passkeys on the device when PRF failed after credential creation.
- Comprehensive capability detection —
checkCapabilities()returns a fullWebAuthnCapabilitiesreport (PRF, conditional mediation, Related Origins, Signal API). UsesPublicKeyCredential.getClientCapabilities()(Chrome 132+) when available, falls back to feature detection. - Conditional UI (passkey autofill) —
mediation: 'conditional'option ondiscover()anddiscoverPasskey()for inline passkey suggestions instead of the modal picker. Requires<input autocomplete="webauthn">in the DOM. - WebAuthn Signal API —
signalUnknownCredential(),signalAllAcceptedCredentialIds(),signalCurrentUserDetails()for credential lifecycle management (Chrome 132+). All are no-ops on unsupported browsers. - Backup eligibility flags —
KeytrCredentialnow includesbackupEligible(BE) andbackupState(BS) flags parsed fromauthenticatorDataafter registration.parseBackupFlags()exported from./webauthn. - WebAuthn Level 3 hints —
hintsparameter onSetupOptions,RegisterOptions,KihRegisterOptions,AuthenticateOptions, andDiscoverOptionsfor authenticator routing ('security-key','client-device','hybrid'). - SSR safety —
ensureBrowser()guard throwsWebAuthnErrorin non-browser environments. All WebAuthn functions call this internally. WebAuthnCapabilitiestypecheckCapabilities()andensureBrowser()exports
checkPrfSupport()now usesgetClientCapabilities()for accurate PRF detection when available, falling back to optimistic reportingdiscover()anddiscoverPasskey()now acceptmediationandhintsoptions- Registration functions (
registerPasskey,registerKihPasskey) now parse backup flags and passhintsto WebAuthn ceremonies
- Key-in-Handle (KiH) mode — PRF-free passwordless passkey encryption. A random 256-bit encryption key is stored in the passkey's
user.idfield ([0x03 || key], 33 bytes). Works with all authenticators including password manager extensions (1Password, Bitwarden, Dashlane) that don't support PRF. Always 1 biometric prompt. - Unified
setup()API — tries PRF registration first, falls back to KiH ifPrfNotSupportedErroris thrown. Returnsmode: 'prf' | 'kih'. - Unified
discover()API — auto-detects mode fromuserHandlelength (32 bytes = PRF, 33 bytes with0x03prefix = KiH). KiH discovery completes in 1 prompt (no step-2 PRF assertion needed). registerKihPasskey()— KiH-specific registration (no PRF extension, single ceremony)unifiedDiscover()— low-level unified discoverable authenticationfetchKeytrEventByDTag()— relay query by#dtag for KiH mode (no pubkey needed)generateKihUserId(),detectMode(),extractKihKey()— KiH user.id helpersbuildAad()— now exported, accepts version parameterKEYTR_KIH_VERSION,KIH_KEY_SIZE,KIH_USER_ID_SIZE,KIH_MODE_BYTE,PRF_USER_ID_SIZEconstantsKeytrMode,UnifiedDiscoverResult,KihRegisterOptions,KihRegisterResult,SetupOptions,SetupResult,DiscoverLoginResulttypesaadVersionoption onEncryptOptionsandDecryptOptions— AAD version byte0x03for KiH prevents cross-mode decryptionversionoption onBuildEventOptions—v=3tag for KiH eventsmodefield onParsedKeytrEvent— detected fromvtag (1= PRF,3= KiH)
- Parallel relay operations —
publishKeytrEventandfetchKeytrEventsnow query all relays concurrently viaPromise.allSettled()instead of sequentially, reducing worst-case latency fromN × timeoutto1 × timeout - Upgraded to noble/scure v2 —
@noble/ciphers^2.1.0,@noble/hashes^2.0.0,@scure/base^2.0.0 buildAad()inencrypt.tsis now exported and parameterized by version (was private, hardcoded toKEYTR_VERSION)decrypt.tsimports sharedbuildAadfromencrypt.tsinstead of duplicating it
- Safari iOS 18+ discoverable login —
discoverPasskey()now uses a two-step flow: discovery without PRF, then a targeted assertion with PRF. Safari does not return PRF extension output during discoverable authentication (emptyallowCredentials). The second assertion targets the discovered credential ID, which the browser auto-approves without an additional biometric prompt.
- Discoverable passkey login —
discoverPasskey()anddiscoverAndLogin()for zero-prior-knowledge login. The browser shows available passkeys, the user picks one, and the nsec is recovered without any npub input or localStorage state. DiscoverOptionsandDiscoverResulttypes for the discoverable flow.- Configurable timeouts for WebAuthn ceremonies (
timeoutoption onRegisterOptionsandAuthenticateOptions) - Configurable timeouts for relay operations (
RelayOptionswithtimeoutparameter) - Integration tests for relay publish/fetch roundtrips
- Integration tests for WebAuthn credential lifecycle (mocked)
- BREAKING:
RegisterOptions.pubkeyis now a required field (hex-encoded 32-byte Nostr public key). The pubkey is stored as WebAuthnuser.idto enable discoverable authentication. setupKeytr()andaddBackupGateway()derive the pubkey automatically from the nsec — no change needed for callers of these high-level functions.- Registration uses
hexToBytes(pubkey)asuser.idinstead ofrandomBytes(32).
- New registrations work with discoverable login immediately.
- Old registrations (random
user.id) still work withloginWithKeytr(events)but cannot usediscoverAndLogin(). Users can re-register their passkey to upgrade.
- NIP-K1 implementation: passkey-encrypted nsec keys for Nostr
- AES-256-GCM encryption with HKDF-SHA256 key derivation from WebAuthn PRF output
registerPasskey()andauthenticatePasskey()for WebAuthn credential managementencryptNsec()/decryptNsec()with AAD binding to credential ID- Binary blob serialization (93 bytes: version + IV + HKDF salt + ciphertext)
- Kind:31777 event building and parsing (
buildKeytrEvent/parseKeytrEvent) - Relay publish/fetch with multi-relay support and deduplication
- Nostr key utilities (nsec/npub encoding, key generation, hex conversion)
- High-level
setupKeytr()andloginWithKeytr()convenience functions - Federated gateway model for cross-client passkey compatibility
- Password fallback implementation (disabled from public API — unsafe for relay publication)
- Browser demo application
- NIP-K1 specification document
- PRF output and derived keys are zeroed after use
- AAD prevents credential/ciphertext substitution attacks
- Password fallback disabled pending safe UX design