Repository navigation
fix(keys): seal key backups under a passphrase + recovery key, not a PIN (0.18.0) - #347
Merged
Merged
Conversation
…PIN (0.18.0) Server key backups were PBKDF2(4-12 digit PIN) ciphertext. No server key opened them, but anyone with the DB (leak, or root on dev2) could try every 4-6 digit PIN offline in seconds to minutes; 5 of 8 prod rows were also only 100k iterations, and user_backup_pins held a scrypt hash of the same PIN as a second offline oracle. The PIN lockout did not even hold online: own-row RLS policies (plus full anon/authenticated grants after the dev2 move) let any session read its ciphertext straight from PostgREST. - seal v2 (src/lib/crypto/key-backup-seal.js): random AES-256-GCM data key wrapped under PBKDF2-SHA256 600k of a >=12 char passphrase and under a random 160-bit recovery key shown once; AAD binds auth user id + field. All in the browser; the server sees neither secret. - PUT /api/auth/key-backup accepts only seal-v2 envelopes, sets seal_version=2 and deletes the PIN hash. Replacing a verifiable PIN backup still needs that PIN once. - POST returns a v2 blob to its owner; legacy PIN rows stay behind the PIN gate until LEGACY_KEY_MIGRATION=off (410 after the cutoff). - POST /api/auth/backup-pin is retired (410). - Sign-in/sign-up/settings: passphrase + one-time recovery key; signing in with local keys and a PIN backup prompts a re-seal; restoring a PIN backup re-seals right after. - Migration: key_backups.seal_version, key_backups/user_backup_pins/ encrypted_key_backups service-role only. scripts/sql/key-backups-unmigrated.sql counts what is left. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ThreatCrush Security Scan17 finding(s) MEDIUM: 13 | LOW: 4
Snippets are redacted; ThreatCrush never prints matched credential material. |
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 the unlock-on-use rule after the 2026-10-27 dev2 compromise: no user secret may be openable with what sits on our servers.
What was wrong
key_backups.encrypted_keys(8 rows)user_backup_pins(3 scrypt + 5 discarded)/api/auth/key-backupkey_backupsauthenticated, plus full anon/authenticated grants (dev2 move)cli_auth_codes.key_blobencrypted_key_backupsThe fix
src/lib/crypto/key-backup-seal.js, browser only): a random AES-256-GCM data key encrypts the keys. That data key is wrapped twice: under HKDF(PBKDF2-SHA256 600k of a passphrase of 12+ characters, not digits only) and under HKDF(a random 160-bit recovery key shown once). AAD binds the auth user id and the field name. The server stores only the envelope and never sees the passphrase or the recovery key.PUTaccepts only seal-v2 envelopes (SEAL_REQUIREDotherwise), setsseal_version=2and deletes the PIN hash. Replacing a PIN backup the server can verify still needs that PIN once, so a stolen session can't swap it.POSTreturns a v2 blob to its owner. Legacy PIN rows stay behind the PIN gate untilLEGACY_KEY_MIGRATION=off; after that switch it returns 410LEGACY_BACKUP_DISABLED.POST /api/auth/backup-pinis retired (410).Migration
supabase/migrations/20261028120000_key_backups_seal_v2.sqladdsseal_version(existing rows = 1) and makeskey_backups,user_backup_pinsandencrypted_key_backupsservice-role only. It was dry-run against prod in a rolled-back transaction: 0 policies left, andauthenticatedlost SELECT. Deploy the app first, then apply the migration (the app uses the service role everywhere). ThenNOTIFY pgrst, which is in the file.Rows still under a PIN:
scripts/sql/key-backups-unmigrated.sql(counts only). Each upgrades the next time its owner signs in on a device with keys. Nothing is deleted. The cutoff (LEGACY_KEY_MIGRATION=off, then deleting the leftover v1 rows) is a dated decision.Tests
vitest run: 136 files, 902 passed, 1 skipped.next buildpasses.Not applicable here: there is no server-side use of user keys, so no unlock session or TTL store is needed.
🤖 Generated with Claude Code