Skip to content

fix(crypto): stop republishing the public key on every login - #256

Merged
ralyodio merged 1 commit into
masterfrom
fix/key-sync-overwrite
Aug 16, 2026
Merged

fix(crypto): stop republishing the public key on every login#256
ralyodio merged 1 commit into
masterfrom
fix/key-sync-overwrite

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Root cause of the ChaCha20-Poly1305 decryption failed: invalid tag errors.

What was happening

needsKeySync() never found the stored key, so autoSyncOnLogin() re-uploaded the browser's local public key on every single login. Two bugs stacked:

  1. /api/crypto/public-keys/all returns a bare array of {user_id, public_key}, but the check read data.public_keys[currentUserId] off it. data.public_keys is undefined on an array, so the lookup never resolved.
  2. Even with the right shape it would still miss: user_public_keys.user_id holds the auth user id, while getCurrentUserId() returns the internal users.id. Same identity-domain drift as IA-040 — the thing that made five RLS policies silently dead.

The published key is what everyone else encrypts to. Re-uploading it from a browser whose keypair had been regenerated silently replaced the good key, and every message sent afterwards was encrypted to a key the recipient could no longer decrypt. That is exactly the reported symptom, including the detail that the odd message does decrypt — those predate the swap.

Confirmed against production: chovy's user_public_keys row was last written 2026-08-16 03:09:51, during the session in the bug report, by this auto-sync.

The fix

Ask for our own key by internal id and let the server resolve the identity domain — one id space, no list to mis-index. Both failure paths now fail closed: publishing is the direction with consequences, so an unreadable or errored check leaves the stored key alone rather than assuming the database has nothing.

As a side benefit this stops every client pulling the entire membership list on login just to find one row.

What this does not do

It stops the ongoing damage; it does not recover history already encrypted to a lost keypair. That needs the key-backup restore path — EncryptedKeyBackup.restoreFromBackup() and MasterKeyDerivation.deriveFromCredentials() are both written and both have zero callers, so backups are being written and can never be restored. chovy has a backup row from 2026-08-10. Worth a follow-up.

Verification

  • 5 new regression tests pin each branch, including that a matching stored key triggers no upload.
  • 505 tests pass across 85 files; pnpm build clean.

🤖 Generated with Claude Code

needsKeySync() never found the stored key, so autoSyncOnLogin() re-uploaded the
browser's local public key on every single login.

Two bugs stacked:

- /api/crypto/public-keys/all returns a bare array of {user_id, public_key}, but
  the check read `data.public_keys[currentUserId]` off it. `data.public_keys` is
  undefined on an array, so the lookup never resolved.
- Even with the right shape it would still have missed: user_public_keys.user_id
  holds the auth user id, while getCurrentUserId() returns the internal users.id.
  Same identity domain drift as IA-040, which is what made five RLS policies dead.

The published key is what everyone else encrypts to. Re-uploading it from a
browser whose keypair had been regenerated silently replaced the good key, and
every message sent afterwards was encrypted to a key the recipient could no
longer decrypt -- surfacing as "ChaCha20-Poly1305 decryption failed: invalid tag"
on load, with the odd message decrypting fine because it predated the swap.

Now the service asks for its own key by internal id and lets the server resolve
the identity domain, so there is one id space and no list to mis-index. Both
failure paths also fail closed: publishing is the direction with consequences, so
an unreadable or errored check leaves the stored key alone instead of assuming
the database has nothing.

This stops the ongoing damage. It does not recover history already encrypted to a
lost keypair -- that needs the key-backup restore path, which is written but has
no callers.

505 tests pass; build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

91 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 10 | LOW: 76

Severity Rule Location
HIGH secret-generic-credential scripts/supabase-email.sh:21
HIGH secret-generic-credential scripts/supabase-twilio.sh:32
HIGH secret-generic-credential scripts/supabase-twilio.sh:47
HIGH js-host-header-trust src/lib/websocket/middleware/auth.js:31
HIGH js-cors-origin-reflected src/lib/websocket/server.js:121
MEDIUM manifest-install-lifecycle-script package.json:33
MEDIUM js-shell-exec-interpolation scripts/convert-routes.mjs:9
MEDIUM redos-nested-quantifier src/app/api/profile/update/route.js:73
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:138
MEDIUM js-unescaped-html-sink src/app/layout.jsx:142
MEDIUM js-unescaped-html-sink src/app/page.jsx:47
MEDIUM js-unescaped-html-sink src/lib/components/chat/MessageItem.jsx:60
LOW secret-generic-credential src/app/api/auth/invite-anon/route.test.js:24
LOW secret-generic-credential src/app/api/auth/register-anon/route.test.js:32
LOW secret-jwt src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/auth/upload-avatar/route.test.js:27
LOW secret-generic-credential src/app/api/profile/update/route.test.js:63
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:47
LOW secret-generic-credential src/lib/websocket/middleware/auth.test.js:64
LOW secret-generic-credential tests/auth-key-generation.test.js:109
LOW secret-generic-credential tests/auth-key-generation.test.js:110
LOW secret-generic-credential tests/auth-key-generation.test.js:157
LOW secret-generic-credential tests/auth-key-generation.test.js:158
LOW secret-generic-credential tests/auth-key-generation.test.js:207
LOW secret-generic-credential tests/auth-key-generation.test.js:208
LOW secret-generic-credential tests/auth-key-generation.test.js:250
LOW secret-generic-credential tests/auth-key-generation.test.js:251
LOW secret-generic-credential tests/auth-key-generation.test.js:293
LOW secret-generic-credential tests/auth-key-generation.test.js:294
LOW secret-generic-credential tests/auth-key-generation.test.js:340
LOW secret-generic-credential tests/auth-key-generation.test.js:341
LOW secret-generic-credential tests/auth-key-generation.test.js:381
LOW secret-generic-credential tests/auth-key-generation.test.js:382
LOW secret-generic-credential tests/chat-archive.test.js:22
LOW secret-jwt tests/debug-sms.js:10
LOW secret-generic-credential tests/gpg-private-key-export.test.js:63
LOW secret-generic-credential tests/gpg-private-key-export.test.js:64
LOW secret-generic-credential tests/nuclear-delete.test.js:19
LOW secret-generic-credential tests/nuclear-delete.test.js:89
LOW secret-generic-credential tests/nuclear-delete.test.js:97
LOW secret-generic-credential tests/password-security-fix-verification.test.js:51
LOW secret-generic-credential tests/password-security-fix-verification.test.js:75
LOW secret-generic-credential tests/password-security-fix-verification.test.js:76
LOW secret-generic-credential tests/password-security-fix-verification.test.js:102
LOW secret-generic-credential tests/password-security-fix-verification.test.js:156
LOW secret-generic-credential tests/password-security-fix-verification.test.js:174
LOW secret-generic-credential tests/phone-auth-fix.test.js:49

…and 41 more. Full results in the Security tab.

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit 84006cc into master Aug 16, 2026
10 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