Skip to content

Commit 11ced5a

Browse files
ralyodioclaude
andcommitted
fix(crypto): re-sync public key on rotation + explicit key recovery prompt
Two related cross-device key problems: 1. needsKeySync() only checked whether *any* public key existed in the DB for the user, not whether it matched the local key. After switching browsers / rotating keys, the DB kept the OLD public key, so the sync was skipped and everyone kept encrypting to a dead key the new device can't read. Now it compares the DB key to the local key and re-syncs on mismatch (the server RPC already upserts via ON CONFLICT DO UPDATE). 2. EncryptionWarning now gives an explicit choice when a device has no keys: "Upload existing keys" (recommended — routes to Settings import/restore, preserves history) vs "Generate new keys" (with a confirm that past messages become unreadable). Previously it silently pointed at Settings, which let users end up on fresh keys without understanding the cost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d7b9797 commit 11ced5a

2 files changed

Lines changed: 75 additions & 11 deletions

File tree

‎src/lib/components/EncryptionWarning.jsx‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,21 @@
11
'use client';
22

33
import { useState, useEffect } from 'react';
4+
import { useRouter } from 'next/navigation';
45
import { keyManager } from '@/lib/crypto/key-manager.js';
56

67
export default function EncryptionWarning() {
8+
const router = useRouter();
79
const [hasKeys, setHasKeys] = useState(true);
810
const [checked, setChecked] = useState(false);
11+
const [generating, setGenerating] = useState(false);
12+
const [error, setError] = useState('');
913

1014
useEffect(() => {
1115
async function check() {
1216
try {
13-
const has = await keyManager.hasUserKeys();
14-
setHasKeys(has);
17+
await keyManager.initialize();
18+
setHasKeys(await keyManager.hasUserKeys());
1519
} catch {
1620
setHasKeys(false);
1721
}
@@ -20,20 +24,72 @@ export default function EncryptionWarning() {
2024
check();
2125
}, []);
2226

27+
async function handleGenerate() {
28+
// New keys give you a fresh identity but make every message that was
29+
// encrypted to your previous key permanently unreadable. Restoring/uploading
30+
// existing keys is almost always what the user actually wants.
31+
const ok = window.confirm(
32+
'Generate brand-new keys?\n\nYou will NOT be able to read any past messages or files that were encrypted to your previous keys. If you have a backup or an exported key file, use "Upload existing keys" instead.'
33+
);
34+
if (!ok) return;
35+
setGenerating(true);
36+
setError('');
37+
try {
38+
await keyManager.generateUserKeys();
39+
setHasKeys(true);
40+
} catch (err) {
41+
setError(err?.message || 'Failed to generate keys');
42+
} finally {
43+
setGenerating(false);
44+
}
45+
}
46+
2347
if (!checked || hasKeys) return null;
2448

2549
return (
2650
<div className="encryption-warning">
2751
<div className="warning-icon">⚠️</div>
2852
<div className="warning-content">
29-
<strong>Encryption keys not found</strong>
30-
<p>Your messages cannot be encrypted. Please go to <a href="/settings">Settings</a> to generate or restore your encryption keys.</p>
53+
<strong>No encryption keys on this device</strong>
54+
<p>
55+
This browser doesn&apos;t have your encryption keys, so messages can&apos;t be decrypted or sent
56+
securely. Upload your existing keys to keep your history, or generate new ones to start fresh.
57+
</p>
58+
{error && <p className="warning-error">{error}</p>}
59+
<div className="warning-actions">
60+
<button
61+
type="button"
62+
className="warn-btn warn-btn-primary"
63+
onClick={() => router.push('/settings')}
64+
disabled={generating}
65+
>
66+
Upload existing keys
67+
</button>
68+
<button
69+
type="button"
70+
className="warn-btn warn-btn-secondary"
71+
onClick={handleGenerate}
72+
disabled={generating}
73+
>
74+
{generating ? 'Generating…' : 'Generate new keys'}
75+
</button>
76+
</div>
77+
<p className="warning-hint">
78+
Switched browsers or devices? Choose <strong>Upload existing keys</strong> — generating new keys
79+
loses access to all past messages.
80+
</p>
3181
</div>
3282
<style>{`
33-
.encryption-warning { display: flex; align-items: center; gap: 1rem; padding: .75rem 1rem; background: rgba(245,158,11,.1); border-bottom: 1px solid #f59e0b; color: #92400e; }
34-
.warning-icon { font-size: 1.25rem; flex-shrink: 0; }
83+
.encryption-warning { display: flex; align-items: flex-start; gap: 1rem; padding: .75rem 1rem; background: rgba(245,158,11,.1); border-bottom: 1px solid #f59e0b; color: #92400e; }
84+
.warning-icon { font-size: 1.25rem; flex-shrink: 0; line-height: 1.4; }
3585
.warning-content p { font-size: .875rem; margin-top: .25rem; }
36-
.warning-content a { color: #92400e; font-weight: 600; }
86+
.warning-error { color: #991b1b; font-weight: 600; }
87+
.warning-actions { display: flex; gap: .5rem; margin-top: .625rem; flex-wrap: wrap; }
88+
.warn-btn { padding: .375rem .75rem; font-size: .875rem; border-radius: .375rem; cursor: pointer; border: 1px solid transparent; font-weight: 600; }
89+
.warn-btn:disabled { opacity: .6; cursor: default; }
90+
.warn-btn-primary { background: #92400e; color: #fff; }
91+
.warn-btn-secondary { background: transparent; color: #92400e; border-color: #92400e; }
92+
.warning-hint { font-size: .75rem; margin-top: .5rem; opacity: .85; }
3793
`}</style>
3894
</div>
3995
);

‎src/lib/crypto/key-sync-service.js‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,14 +148,22 @@ export class KeySyncService {
148148
return false;
149149
}
150150

151-
// Check if our key exists in the database
152-
const hasKeyInDb = data.public_keys && data.public_keys[currentUserId];
153-
154-
if (!hasKeyInDb) {
151+
// Check if our key exists in the database AND matches the local key.
152+
// Presence alone isn't enough: after switching browsers / rotating keys
153+
// the DB still holds the OLD public key, so a presence-only check would
154+
// skip the sync and leave everyone encrypting to a dead key.
155+
const dbKey = data.public_keys && data.public_keys[currentUserId];
156+
157+
if (!dbKey) {
155158
console.log('🔑 Public key not found in database, sync needed');
156159
return true;
157160
}
158161

162+
if (dbKey !== publicKey) {
163+
console.log('🔑 Database public key differs from local key (rotated/new device), sync needed');
164+
return true;
165+
}
166+
159167
console.log('🔑 Public key already synced to database');
160168
return false;
161169

0 commit comments

Comments
 (0)