Passkeys
Passkeys Passwordless WebAuthn (FIDO2) authentication using the PRF extension to maintain zero-knowledge encryption. PRF is mandatory -- without it, passkey ...
Passkeys
Passwordless WebAuthn (FIDO2) authentication using the PRF extension to maintain zero-knowledge encryption. PRF is mandatory – without it, passkey login would leak key material.
Why This Exists
- Passkeys are faster and more secure than passwords, but a naive implementation leaks the
credential_id(public), which could be used to derive wrapping keys if the server is breached - The PRF extension produces a deterministic signature from the passkey’s private key, giving us secret key material equivalent to a password
- A global salt (
SHA256(rp_id)[:32]) enables true passwordless login: no email lookup needed before authentication
How It Works
sequenceDiagram
participant C as Client
participant B as Browser / Authenticator
participant S as Server
Note over C,S: Registration
C->>S: POST /auth/passkey/registration/initiate
S-->>C: CreationOptions + PRF extension
C->>B: navigator.credentials.create()
B-->>C: credential + PRF signature, or PRF enabled without output
C->>B: navigator.credentials.get() for new credential if PRF output missing
B-->>C: PRF signature
C->>C: HKDF(PRF_sig, user_salt) → wrapping key
C->>C: Wrap master key with wrapping key
C->>S: POST /auth/passkey/registration/complete
Note over C,S: Login
C->>S: POST /auth/passkey/assertion/initiate
S-->>C: Challenge + global salt SHA256(rp_id)[:32]
C->>B: navigator.credentials.get() + PRF
B-->>C: assertion + PRF signature
C->>S: POST /auth/passkey/assertion/verify
S-->>C: encrypted_master_key, user_email_salt
C->>C: HKDF(PRF_sig, salt) → unwrap master key
C->>C: Decrypt email → derive lookup_hash
C->>S: POST /auth/login (completes session)
PRF-Based Key Wrapping
- Client uses global salt
prf_eval_first = SHA256(rp_id)[:32]for the PRF extension - Authenticator signs the salt with its private key, producing a deterministic PRF signature unique per passkey
- Wrapping key derived via
HKDF(PRF_signature, user_email_salt, "masterkey_wrapping")– seederiveWrappingKeyFromPRF()in cryptoService.ts - Master key encrypted with wrapping key and stored on server as
encrypted_master_key - On login, same global salt produces the same PRF signature, recovering the wrapping key deterministically
The global salt approach solves the chicken-and-egg problem: the server can send prf_eval_first without knowing user identity.
Registration Flow
POST /auth/passkey/registration/initiategenerates WebAuthnPublicKeyCredentialCreationOptionswith PRF extension- Browser creates credential; frontend checks PRF support and output. If creation enabled PRF but did not return output, the frontend immediately requests a scoped assertion for the new credential to derive PRF output. If PRF remains unavailable: registration is blocked and the user is offered password+2FA.
POST /auth/passkey/registration/completeverifies attestation viapy_webauthn, stores passkey inuser_passkeystable- Client wraps master key with PRF-derived key, uploads wrapped key
See SecureAccountTopContent.svelte for PRF validation during signup, PasskeyRegistrationBottomContent.svelte for registration UI.
Login Flow
POST /auth/passkey/assertion/initiategenerates challenge with PRF extension using global salt- User authenticates via biometric/PIN
POST /auth/passkey/assertion/verifyverifies signature viapy_webauthn, identifies user bycredential_id->user_id(viauser_passkeys), starts cache warming- Server returns
encrypted_email_with_master_key,encrypted_master_key,user_email_salt - Client derives wrapping key from PRF, unwraps master key, decrypts email
- Client derives
lookup_hash = SHA256(PRF_signature + user_email_salt)and completes auth viaPOST /auth/login - Frontend waits for cache warming (WebSocket sync status) before loading main interface
See Login.svelte and auth_passkey.py.
Email Retrieval for Passwordless Login
The user does not enter their email during passkey login, but the server needs it for notifications:
- During signup: email encrypted with master key -> stored as
encrypted_email_with_master_key - During login: server returns this field; client decrypts with master key (derived from PRF)
- Client derives
email_encryption_key = SHA256(email + user_email_salt)and sends it to server for notification decryption
Data Structures
user_passkeys Table
| Column | Type | Purpose |
|---|---|---|
hashed_user_id |
string (indexed) | Privacy-preserving lookup |
user_id |
string | Direct reverse lookup |
credential_id |
string (unique) | Base64 WebAuthn credential ID |
public_key_cose |
string | Primary format for py_webauthn verification |
public_key_jwk |
json | Backward compatibility |
aaguid |
string | Authenticator identifier |
sign_count |
integer | Cloned authenticator detection |
encrypted_device_name |
string | User-friendly name (encrypted) |
registered_at, last_used_at |
timestamp | Audit |
Schema: user_passkeys.yml. The prf_eval_first is no longer stored per user – the global salt approach makes it unnecessary.
Users Table Addition
encrypted_email_with_master_key– email encrypted with master key for passwordless login retrieval
Security Considerations
- PRF mandatory: non-PRF passkey registration is never allowed. Detected via
navigator.credentials.create()with PRF extension and, when needed, an immediate scopednavigator.credentials.get()fallback for the new credential. - Sign count validation: if
sign_countdoes not increase, the authenticator may be cloned; flagged as suspicious - Challenge freshness: new challenge per registration/assertion, expires after 5 minutes. See challenge caching in auth_passkey.py
- Cache warming: starts immediately after passkey verification (async), ensuring instant sync when authentication completes
Device Support
PRF support depends on the whole browser + OS + authenticator chain. Chrome on Linux supports passkeys through Google Password Manager, while Linux users may also rely on PRF-capable USB security keys because Linux does not have a universal native platform passkey provider. Non-PRF authenticators and browsers remain unsupported for passkey-based encryption.
Edge Cases
- Browser lacks WebAuthn: error message shown, forced to password-only
- User loses passkey: recovery key is primary recovery method; email-based account reset as last resort (see Account Recovery)
- Passkey login fails: fall back to password login if password exists
- Cloned authenticator: flag account, require 2FA, send security alert
Related Docs
- Signup & Login – full authentication flow context
- Encryption Architecture – master key wrapping details
- Account Recovery – recovery when passkey is lost
- Security Architecture – overall security model