Skip to content

fix(keys): seal key backups under a passphrase + recovery key, not a PIN (0.18.0) - #347

Merged
sh1pt-actions-fleet[bot] merged 1 commit into
masterfrom
sealed-keys/key-backup-floor
Oct 28, 2026
Merged

sh1pt-actions-fleet[bot] merged 1 commit into
masterfrom
sealed-keys/key-backup-floor

Conversation

@sh1pt-actions-fleet

Copy link
Copy Markdown
Contributor

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

Where What Openable from the server?
key_backups.encrypted_keys (8 rows) ML-KEM private keys, ChaCha20-Poly1305 under PBKDF2(4-12 digit PIN); 5 rows at 100k iterations, 3 at 600k No server key, but a DB dump lets anyone try all 10^4..10^6 PINs offline: seconds to minutes on a GPU
user_backup_pins (3 scrypt + 5 discarded) scrypt(PIN) A second, cheaper offline oracle for the same PIN
/api/auth/key-backup the server received the PIN on every backup/restore root could log it
RLS on key_backups own-row SELECT/INSERT/UPDATE/DELETE for authenticated, plus full anon/authenticated grants (dev2 move) Any session read its ciphertext straight from PostgREST, so the PIN lockout never held
cli_auth_codes.key_blob sealed to the CLI's ephemeral ML-KEM key No: fine, unchanged
encrypted_key_backups dead table, 0 rows Locked down anyway

The fix

  • Seal v2 (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.
  • Trade-off chosen: raise the entropy floor rather than build an online oracle (OPAQUE or a rate-limited server key). Any server-held OPRF or oracle key is exactly what root on the box takes, and then a PIN falls offline again. A passphrase of 12+ characters at 600k iterations, or a 160-bit recovery key, survives a full DB and env dump. The cost is UX: a passphrase instead of a PIN, and losing both secrets means the backup is gone (the UI says so).
  • PUT accepts only seal-v2 envelopes (SEAL_REQUIRED otherwise), sets seal_version=2 and 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.
  • POST returns a v2 blob to its owner. Legacy PIN rows stay behind the PIN gate until LEGACY_KEY_MIGRATION=off; after that switch it returns 410 LEGACY_BACKUP_DISABLED.
  • POST /api/auth/backup-pin is retired (410).
  • UI on sign-up (phone, CoinPay, passkey, anon) and in Settings: passphrase, then the recovery key shown once. Signing in with local keys over a PIN backup prompts a re-seal, and restoring a PIN backup re-seals right after.

Migration

supabase/migrations/20261028120000_key_backups_seal_v2.sql adds seal_version (existing rows = 1) and makes key_backups, user_backup_pins and encrypted_key_backups service-role only. It was dry-run against prod in a rolled-back transaction: 0 policies left, and authenticated lost SELECT. Deploy the app first, then apply the migration (the app uses the service role everywhere). Then NOTIFY 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

  • New: seal round-trip for passphrase and recovery key, wrong secret, wrong user (AAD), tampering, PIN and weak-passphrase refusal, KDF floor, envelope shape. Client: nothing secret is in the PUT body and restore sends no secret. Route: legacy format refused, v2 stored and the PIN retired, PIN needed to replace a v1 backup, the cutoff switch, and a check that no route or seal code reads a secret from the env.
  • vitest run: 136 files, 902 passed, 1 skipped. next build passes.

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

…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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

17 finding(s)

MEDIUM: 13 | LOW: 4

Severity Rule Location
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:38
MEDIUM js-unescaped-html-sink src/app/blog/[slug]/page.jsx:66
MEDIUM js-unescaped-html-sink src/app/faq/page.jsx:57
MEDIUM js-unescaped-html-sink src/app/layout.jsx:142
MEDIUM js-unescaped-html-sink src/app/layout.jsx:146
MEDIUM js-unescaped-html-sink src/app/page.jsx:50
MEDIUM js-open-redirect src/app/phone/page.jsx:70
MEDIUM redos-nested-quantifier src/lib/auth/dns-name.js:88
MEDIUM redos-nested-quantifier src/lib/chat/voice-mode.js:116
MEDIUM redos-nested-quantifier src/lib/chat/voice-mode.js:121
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:85
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:130
MEDIUM js-unescaped-html-sink src/lib/components/PitFooter.jsx:15
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/private-key-import-export.test.js:252
LOW secret-generic-credential tests/private-key-import-export.test.js:264

Snippets are redacted; ThreatCrush never prints matched credential material.

@sh1pt-actions-fleet
sh1pt-actions-fleet Bot merged commit 5521c20 into master Oct 28, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant