Tim Cappalli co-edits the WebAuthn specification. On February 27 he published a post titled "Please, please, please stop using passkeys for encrypting user data". The argument fits in one sentence of his: "When you overload a credential used for authentication by also using it for encryption, the 'blast radius' for losing that credential becomes immeasurably larger." The scenario is a person deleting a passkey from their credential manager, with no warning, and later finding the encrypted backup it protected is unreachable. His summary of the outcome: "Goodbye, memories."
We shipped shell.online 0.13.0 today, and one of the things in it is passkey unlock for the session vault, built on the same PRF extension he is writing about. So this post is about why we did it anyway, and precisely what a deleted passkey can and cannot take with it.
What the vault is, and why it is now optional
Every shell.online session is end-to-end encrypted under its own password, and the relay carries ciphertext. Before 0.12 that password lived in whichever browser first opened the session, so clearing that browser could lose the session for good. The vault moves the password's home to the account. Each session password is sealed to one P-256 keypair per person using ephemeral ECDH and AES-256-GCM, and the service stores the private half only in encrypted form. The header comment in vault-crypto.ts is the whole design in a paragraph. It is the same rule our tunnels on Pilot Protocol follow: the infrastructure in the middle carries ciphertext and holds no key that opens it.
0.13.0 makes the vault optional. The CLI always prints the password. The session card now has a Vault line that reads one of four ways: saved to your account vault, not saved because there is no account vault, not linked (run shell login), or not saved because of the warning printed above it. Those four states are constants in session_link.go. Generated passwords also got longer in this release: ten characters, each one an independent six-bit symbol, so 60 bits of entropy where the old eight-character password carried 48.
Three wraps around one key
The part that answers Cappalli is the key hierarchy. The vault's private key is encrypted under a random 32-byte vault key. That vault key is then wrapped separately, in up to three ways:
- A 20-byte recovery key, shown once at setup as eight groups of Crockford base32, run through HKDF-SHA256.
- A vault password, through PBKDF2-SHA256 at 600,000 iterations with a 16-byte salt that is scoped to the account ID.
- Up to 16 passkeys. Each one's PRF output goes through HKDF-SHA256 with the account ID and the credential ID in the info string, and the result wraps the same vault key.
Every wrap opens the same 32 bytes. Delete a passkey and you have removed one wrapping. The password still opens the vault. The recovery key still opens the vault. Nothing sealed to the account changes, because the account keypair never depended on the passkey. That is the structural reason a lost authenticator is an inconvenience here rather than a data loss.
Two ordering rules in the code guarantee a passkey can never be the only way in. The function that adds a passkey wrap refuses to run without a vault password already set, and the error string says so: "Set a vault password before adding a passkey." And a vault cannot be created at all until you have typed the last group of the recovery key back, which VaultGate.tsx enforces before anything is sent to the service. By the time a passkey is enrolled, two other wraps already exist.
What happens at enrollment
vault-passkey.ts is 116 lines. Registration asks for a resident key with user verification required, attestation set to none, and a fresh 32-byte random PRF salt evaluated at creation time. If the authenticator returns no PRF result, enrollment stops with "This passkey cannot unlock encrypted data. Try another device or use your vault password." Nothing is written. The PRF secret is zeroed in a finally block after it has wrapped the key, on both the enrollment and unlock paths.
That refusal matters because PRF support is still uneven. Corbado's support matrix, updated September 9, has 1Password, Proton Pass and Keeper returning PRF consistently, Dashlane and NordPass returning nothing, and Bitwarden going from 100 percent on Linux with Firefox to 0 percent on iOS with Safari. We would rather learn which one you use at enrollment than by failing at unlock.
What the service can and cannot do to the unlock list
The server never sees the vault key, the vault password, or a PRF output. What it holds is a keyring blob: the wraps, their salts, labels and credential IDs. The server-side vault.ts checks the shape of that blob before storing it. Keyring under 16 KB. Password iterations exactly 600,000. Passkey salts exactly 32 bytes. At most 16 passkeys. Labels at most 80 characters. Every wrap exactly a nonce, 32 bytes of key and a GCM tag. None of that is cryptographic verification, since the service has no key to verify with. It stops the store filling with junk shaped like a vault.
Changing the unlock list is the one operation someone holding a stolen session token would want, so the PATCH handler requires a sign-in within the last ten minutes, and the Postgres update is a compare-and-swap on the vault version:
UPDATE account_keys SET recovery_wrap = $3, updated_at = $4
WHERE uid = $1 AND version = $2
Only the wrap column moves. The encrypted private key and the public key stay where they are, so adding or removing a passkey can never rotate the key that colleagues and the CLI seal passwords to.
The part we have not done
Cappalli asked credential managers to warn before deleting a passkey that carries PRF, and he asked relying parties to publish how they use passkeys beyond authentication. Our README and docs say the vault "unlocks with its password or a supported passkey; the recovery key is the break-glass fallback". We do not publish the W3C well-known passkey endpoints document, and at v0.13.0 there is no reference to it, or to prfUsageDetails, anywhere in the repository. Until credential managers act on that signal it is cosmetic, but it is the missing piece and we would rather list it than have someone find it.
His central point stands. A passkey is an excellent authentication credential and a dangerous encryption root. The way to use PRF without inheriting the blast radius is to make sure the passkey is never the root: one wrap among several, enrollable only after the others exist, removable without touching the key underneath. That is what 0.13.0 does, and the code above is where to check it.
Top comments (0)