Skip to content

Commit 3d4bbdc

Browse files
ralyodioclaude
andauthored
fix(crypto): sign-in never replaces a published key; qc keys publish (0.16.1, qc 0.12.0) (#342)
Anthony (qc TUI): "[Encrypted message - decryption failed]". Cause: on 2026-10-27 02:07 UTC a browser signed in as chovy with empty key storage. postQuantumEncryption.initialize() generated a fresh keypair, and keySyncService.autoSyncOnLogin saw "local key differs from database" and published it over the account key (behaviour added in 11ced5a for rotation). Every message sent after that was encrypted to a key only that one browser holds, so qc and every other device failed. No reset happened (the key backup from 10-06 is untouched, /api/keys/reset never called). qc 0.11.0 changed no crypto code. - needsKeySync syncs only when the account has NO published key. A different key is reported (publishedKeyStatus 'differs', 'qrypt:key-mismatch' event), never replaced. - EncryptionWarning: "This device has different keys from your account" with Restore my keys (first) or a confirmed "Use this device's keys". "Generate new keys" now publishes explicitly (it relied on the sign-in sync). - qc keys [publish]: check whether this terminal holds the account key, and put it back (confirm or --yes). The TUI mismatch notice points at it. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 943bf19 commit 3d4bbdc

11 files changed

Lines changed: 223 additions & 22 deletions

File tree

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "qryptchat-web",
33
"private": true,
4-
"version": "0.16.0",
4+
"version": "0.16.1",
55
"type": "module",
66
"bin": {
77
"qc": "./bin/qc.js",

‎packages/qryptchat/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ qc mentions ops @al # who @al completes to: @alice, @alina
5555
echo "deploy done" | qc send ops -
5656
qc listen --json # new messages as NDJSON, until you stop it
5757
qc whoami
58+
qc keys # are this terminal's keys your account's? (qc keys publish puts them back)
5859
```
5960

6061
`<chat>` is a chat id or any unique part of its name. Add `--json` for machine output.

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.11.0",
3+
"version": "0.12.0",
44
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
55
"type": "module",
66
"bin": {

‎src/cli/api.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ export class QcClient {
111111
}
112112
}
113113

114+
/**
115+
* Make this terminal's public key the account's again: everyone encrypts to
116+
* the published key, so only do this when these keys read your history
117+
* (another device published a key qc does not hold).
118+
*/
119+
async publishKey() {
120+
const mine = this.session.keys?.keys1024?.publicKey;
121+
if (!mine) throw new QcError('This terminal holds no keys. Run qc login.');
122+
await this.request('/api/crypto/public-keys', { method: 'PUT', body: { public_key: mine, key_type: 'ML-KEM-1024' } });
123+
return this.keyCheck();
124+
}
125+
114126
/** Your public profile (what qrypt.chat/u/<you> shows): emoji, pronouns, website, bio. */
115127
async profile() {
116128
const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`);

‎src/cli/commands.js‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Usage:
3232
qc logout forget this terminal's session and keys
3333
qc update [--check] update qc to the latest release (--check only says whether there is one)
3434
qc whoami who this terminal is signed in as
35+
qc keys [publish] whether this terminal's keys are your account's; publish makes them the
36+
account's again (when another device published keys qc does not hold)
3537
qc profile [--emoji 🔭] [--pronouns she/her] [--website URL] [--bio TEXT]
3638
show or set your public profile (an empty value clears it);
3739
--openprofile prints it as OpenProfile.md
@@ -205,6 +207,29 @@ export async function main(argv, { version = '0.0.0' } = {}) {
205207
}
206208
return;
207209
}
210+
case 'keys': {
211+
const c = await client(flags);
212+
const status = await c.keyCheck();
213+
if (rest[0] !== 'publish') {
214+
if (flags.json) return json({ keys: status });
215+
if (status === 'ok') return out(ok("This terminal's keys are your account's keys."));
216+
if (status === 'mismatch') return out(warn("Your account publishes a different key, so messages sent to you since then won't decrypt here. If older messages read fine in qc read, run qc keys publish."));
217+
return out(info("Could not compare keys (no key on file, or the check failed)."));
218+
}
219+
if (status === 'ok') return out(ok('Already published: nothing to do.'));
220+
// Everyone encrypts to the published key: replacing it is a deliberate act.
221+
if (!flags.yes) {
222+
if (!process.stdin.isTTY) throw new Error('qc keys publish replaces the key everyone encrypts to. Add --yes to confirm.');
223+
const { createInterface } = await import('node:readline/promises');
224+
const rl = createInterface({ input: process.stdin, output: process.stdout });
225+
const answer = await rl.question("Publish this terminal's key? Devices holding the other key will stop decrypting new messages until they restore your keys. [y/N] ");
226+
rl.close();
227+
if (!/^y(es)?$/i.test(answer.trim())) return out(info('Left the published key alone.'));
228+
}
229+
const after = await c.publishKey();
230+
if (flags.json) return json({ published: true, keys: after });
231+
return out(ok("Published. New messages are encrypted to this terminal's key again; other devices on the other key should restore from Settings > Keys."));
232+
}
208233
case 'logout':
209234
clearSession();
210235
return out(ok('Signed out. This terminal no longer holds your keys.'));

‎src/cli/tui.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -309,7 +309,7 @@ function saveRecent(list) {
309309
}
310310

311311
/** Run the full-screen client until Ctrl+C. */
312-
const KEY_MISMATCH = "Keys don't match your account, so messages can't decrypt. Run qc login and approve from a browser that can read your chats.";
312+
const KEY_MISMATCH = "Keys don't match your account, so new messages can't decrypt. If your history reads here, run qc keys publish; else qc login from a browser that reads your chats.";
313313

314314
export async function runTui(client, { initialChat } = {}) {
315315
const state = initialState(client.me);

‎src/lib/components/EncryptionWarning.jsx‎

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
import { useState, useEffect } from 'react';
44
import { useRouter } from 'next/navigation';
55
import { keyManager } from '@/lib/crypto/key-manager.js';
6+
import { keySyncService } from '@/lib/crypto/key-sync-service.js';
67

78
export default function EncryptionWarning() {
89
const router = useRouter();
910
const [hasKeys, setHasKeys] = useState(true);
11+
// This device's keys are not the account's: messages sent to you won't open here.
12+
const [mismatch, setMismatch] = useState(false);
1013
const [checked, setChecked] = useState(false);
1114
const [generating, setGenerating] = useState(false);
1215
const [error, setError] = useState('');
@@ -15,15 +18,38 @@ export default function EncryptionWarning() {
1518
async function check() {
1619
try {
1720
await keyManager.initialize();
18-
setHasKeys(await keyManager.hasUserKeys());
21+
const has = await keyManager.hasUserKeys();
22+
setHasKeys(has);
23+
if (has) setMismatch((await keySyncService.publishedKeyStatus()) === 'differs');
1924
} catch {
2025
setHasKeys(false);
2126
}
2227
setChecked(true);
2328
}
2429
check();
30+
const onMismatch = () => setMismatch(true);
31+
window.addEventListener('qrypt:key-mismatch', onMismatch);
32+
return () => window.removeEventListener('qrypt:key-mismatch', onMismatch);
2533
}, []);
2634

35+
async function handleUseThisDevice() {
36+
const ok = window.confirm(
37+
"Make this device's keys your account's keys?\n\nEveryone will encrypt new messages to this device's key. Your other devices (and qc) will stop reading new messages until they restore these keys, and this device still won't read your past messages. Restoring your keys here is almost always the better choice."
38+
);
39+
if (!ok) return;
40+
setGenerating(true);
41+
setError('');
42+
try {
43+
const result = await keySyncService.forceSyncPublicKey();
44+
if (!result.success) throw new Error(result.error || 'Could not publish this device\'s key');
45+
setMismatch(false);
46+
} catch (err) {
47+
setError(err?.message || 'Could not publish this device\'s key');
48+
} finally {
49+
setGenerating(false);
50+
}
51+
}
52+
2753
async function handleGenerate() {
2854
// New keys give you a fresh identity but make every message that was
2955
// encrypted to your previous key permanently unreadable. Restoring/uploading
@@ -36,15 +62,44 @@ export default function EncryptionWarning() {
3662
setError('');
3763
try {
3864
await keyManager.generateUserKeys();
65+
// Chosen on purpose, so publish it: sign-in never replaces a published key by itself.
66+
const result = await keySyncService.forceSyncPublicKey();
67+
if (!result.success) throw new Error(result.error || 'Keys made, but publishing them failed');
3968
setHasKeys(true);
69+
setMismatch(false);
4070
} catch (err) {
4171
setError(err?.message || 'Failed to generate keys');
4272
} finally {
4373
setGenerating(false);
4474
}
4575
}
4676

47-
if (!checked || hasKeys) return null;
77+
if (!checked || (hasKeys && !mismatch)) return null;
78+
79+
if (hasKeys && mismatch) {
80+
return (
81+
<div className="encryption-warning" role="alert">
82+
<div className="warning-icon">⚠️</div>
83+
<div className="warning-content">
84+
<strong>This device has different keys from your account</strong>
85+
<p>
86+
Messages sent to you are encrypted to your account&apos;s key, which this browser doesn&apos;t hold, so they
87+
won&apos;t open here. Restore your keys (backup PIN or key file) to read everything.
88+
</p>
89+
{error && <p className="warning-error">{error}</p>}
90+
<div className="warning-actions">
91+
<button type="button" className="warn-btn warn-btn-primary" onClick={() => router.push('/settings')} disabled={generating}>
92+
Restore my keys
93+
</button>
94+
<button type="button" className="warn-btn warn-btn-secondary" onClick={handleUseThisDevice} disabled={generating}>
95+
{generating ? 'Publishing…' : "Use this device's keys"}
96+
</button>
97+
</div>
98+
</div>
99+
<style>{warningCss}</style>
100+
</div>
101+
);
102+
}
48103

49104
return (
50105
<div className="encryption-warning">
@@ -79,7 +134,12 @@ export default function EncryptionWarning() {
79134
loses access to all past messages.
80135
</p>
81136
</div>
82-
<style>{`
137+
<style>{warningCss}</style>
138+
</div>
139+
);
140+
}
141+
142+
const warningCss = `
83143
.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; }
84144
.warning-icon { font-size: 1.25rem; flex-shrink: 0; line-height: 1.4; }
85145
.warning-content p { font-size: .875rem; margin-top: .25rem; }
@@ -90,7 +150,4 @@ export default function EncryptionWarning() {
90150
.warn-btn-primary { background: #92400e; color: #fff; }
91151
.warn-btn-secondary { background: transparent; color: #92400e; border-color: #92400e; }
92152
.warning-hint { font-size: .75rem; margin-top: .5rem; opacity: .85; }
93-
`}</style>
94-
</div>
95-
);
96-
}
153+
`;

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

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,32 @@ export class KeySyncService {
110110
}
111111

112112
/**
113-
* Check if user needs key sync (has keys locally but not in database)
113+
* Check if user needs key sync (has keys locally but not in database).
114+
*
115+
* Only an account with NO published key is synced. A published key that
116+
* differs from this device's is never replaced here: a browser with empty
117+
* storage generates a fresh keypair on its own (initialize()), and publishing
118+
* that on sign-in (2026-10-27) silently moved the account to a key no other
119+
* device or qc held, so every new message failed to decrypt everywhere else.
120+
* A mismatch is reported instead (publishedKeyStatus / 'qrypt:key-mismatch')
121+
* and the person chooses: restore their keys here, or publish this device's.
114122
* @returns {Promise<boolean>}
115123
*/
116124
async needsKeySync() {
117-
if (typeof window === 'undefined') return false;
125+
const status = await this.publishedKeyStatus();
126+
if (status === 'differs' && typeof window !== 'undefined') {
127+
window.dispatchEvent(new CustomEvent('qrypt:key-mismatch'));
128+
}
129+
return status === 'missing';
130+
}
131+
132+
/**
133+
* How this device's public key compares with the account's published one:
134+
* 'match', 'missing' (none published), 'differs', 'no-local-key' or 'unknown'.
135+
* @returns {Promise<'match'|'missing'|'differs'|'no-local-key'|'unknown'>}
136+
*/
137+
async publishedKeyStatus() {
138+
if (typeof window === 'undefined') return 'unknown';
118139

119140
try {
120141
// Check if we have local keys
@@ -125,14 +146,14 @@ export class KeySyncService {
125146
const publicKey = await postQuantumEncryption.getPublicKey();
126147
if (!publicKey) {
127148
console.log('🔑 No local public key found, sync not needed');
128-
return false;
149+
return 'no-local-key';
129150
}
130151

131152
const currentUserId = this.getCurrentUserId();
132153

133154
if (!currentUserId) {
134155
console.log('🔑 No current user ID, cannot determine sync status');
135-
return false;
156+
return 'unknown';
136157
}
137158

138159
// Ask for our own key specifically rather than scanning the all-keys list.
@@ -157,31 +178,31 @@ export class KeySyncService {
157178
// Fail closed: re-uploading is the destructive direction, so an unreadable
158179
// answer must not be treated as "the database has nothing".
159180
console.log('🔑 Cannot check database keys, leaving the published key alone');
160-
return false;
181+
return 'unknown';
161182
}
162183

163184
const data = await response.json();
164185
const dbKey = data?.public_key ?? null;
165186

166187
if (!dbKey) {
167188
console.log('🔑 Public key not found in database, sync needed');
168-
return true;
189+
return 'missing';
169190
}
170191

171192
if (dbKey !== publicKey) {
172-
console.log('🔑 Database public key differs from local key (rotated/new device), sync needed');
173-
return true;
193+
console.log('🔑 This device holds different keys from the account; leaving the published key alone');
194+
return 'differs';
174195
}
175196

176197
console.log('🔑 Public key already synced to database');
177-
return false;
198+
return 'match';
178199

179200
} catch (error) {
180201
console.error('🔑 Error checking key sync status:', error);
181202
// Publishing is the side with consequences -- it replaces the key everyone
182203
// encrypts to. Not knowing the answer is a reason to leave it alone, not to
183204
// overwrite it.
184-
return false;
205+
return 'unknown';
185206
}
186207
}
187208

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

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,26 @@ describe('keySyncService.needsKeySync', () => {
6464
await expect(keySyncService.needsKeySync()).resolves.toBe(true);
6565
});
6666

67-
it('syncs when the stored key belongs to a different keypair', async () => {
67+
// 2026-10-27: a browser with empty storage made itself a fresh keypair and the
68+
// sign-in sync published it over the account's key, so qc and every other
69+
// device stopped decrypting new messages. A different key is reported, never replaced.
70+
it('never replaces a published key that differs from this device; reports the mismatch', async () => {
6871
stubFetch({ body: { public_key: 'some-other-key-bbbb' } });
69-
await expect(keySyncService.needsKeySync()).resolves.toBe(true);
72+
const seen = vi.fn();
73+
window.addEventListener('qrypt:key-mismatch', seen);
74+
await expect(keySyncService.needsKeySync()).resolves.toBe(false);
75+
await expect(keySyncService.publishedKeyStatus()).resolves.toBe('differs');
76+
window.removeEventListener('qrypt:key-mismatch', seen);
77+
expect(seen).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('publishedKeyStatus tells match, missing and unknown apart', async () => {
81+
stubFetch({ body: { public_key: LOCAL_KEY } });
82+
await expect(keySyncService.publishedKeyStatus()).resolves.toBe('match');
83+
stubFetch({ body: { public_key: null } });
84+
await expect(keySyncService.publishedKeyStatus()).resolves.toBe('missing');
85+
stubFetch({ ok: false, body: {} });
86+
await expect(keySyncService.publishedKeyStatus()).resolves.toBe('unknown');
7087
});
7188

7289
// Publishing replaces the key everyone encrypts to, so an unknown answer must not

‎tests/cli/qc-resilience.test.js‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,20 @@ describe('keyCheck', () => {
7575
const client = new QcClient(session(), { fetch: withServerKey(undefined) });
7676
expect(await client.keyCheck()).toBe('unknown');
7777
});
78+
it('publishKey puts this terminal\'s key back as the account key', async () => {
79+
let published = 'someone-else';
80+
const fetch = vi.fn(async (url, init) => {
81+
if (init?.method === 'PUT') {
82+
published = JSON.parse(init.body).public_key;
83+
return json({ success: true });
84+
}
85+
return json({ public_keys: { a: published } });
86+
});
87+
const client = new QcClient(session(), { fetch });
88+
expect(await client.keyCheck()).toBe('mismatch');
89+
expect(await client.publishKey()).toBe('ok');
90+
const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
91+
expect(new URL(put[0]).pathname).toBe('/api/crypto/public-keys');
92+
expect(JSON.parse(put[1].body)).toEqual({ public_key: keys.keys1024.publicKey, key_type: 'ML-KEM-1024' });
93+
});
7894
});

0 commit comments

Comments
 (0)