diff --git a/package.json b/package.json index b5c45b4a..3c82ad96 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.16.0", + "version": "0.16.1", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/README.md b/packages/qryptchat/README.md index 426da35a..8eb271bb 100644 --- a/packages/qryptchat/README.md +++ b/packages/qryptchat/README.md @@ -55,6 +55,7 @@ qc mentions ops @al # who @al completes to: @alice, @alina echo "deploy done" | qc send ops - qc listen --json # new messages as NDJSON, until you stop it qc whoami +qc keys # are this terminal's keys your account's? (qc keys publish puts them back) ``` `` is a chat id or any unique part of its name. Add `--json` for machine output. diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 329253db..98324bc0 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.11.0", + "version": "0.12.0", "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.", "type": "module", "bin": { diff --git a/src/cli/api.js b/src/cli/api.js index 294a1ce4..bf2ca9c6 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -111,6 +111,18 @@ export class QcClient { } } + /** + * Make this terminal's public key the account's again: everyone encrypts to + * the published key, so only do this when these keys read your history + * (another device published a key qc does not hold). + */ + async publishKey() { + const mine = this.session.keys?.keys1024?.publicKey; + if (!mine) throw new QcError('This terminal holds no keys. Run qc login.'); + await this.request('/api/crypto/public-keys', { method: 'PUT', body: { public_key: mine, key_type: 'ML-KEM-1024' } }); + return this.keyCheck(); + } + /** Your public profile (what qrypt.chat/u/ shows): emoji, pronouns, website, bio. */ async profile() { const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`); diff --git a/src/cli/commands.js b/src/cli/commands.js index 4d732a9c..816bd1da 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -32,6 +32,8 @@ Usage: qc logout forget this terminal's session and keys qc update [--check] update qc to the latest release (--check only says whether there is one) qc whoami who this terminal is signed in as + qc keys [publish] whether this terminal's keys are your account's; publish makes them the + account's again (when another device published keys qc does not hold) qc profile [--emoji πŸ”­] [--pronouns she/her] [--website URL] [--bio TEXT] show or set your public profile (an empty value clears it); --openprofile prints it as OpenProfile.md @@ -205,6 +207,29 @@ export async function main(argv, { version = '0.0.0' } = {}) { } return; } + case 'keys': { + const c = await client(flags); + const status = await c.keyCheck(); + if (rest[0] !== 'publish') { + if (flags.json) return json({ keys: status }); + if (status === 'ok') return out(ok("This terminal's keys are your account's keys.")); + 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.")); + return out(info("Could not compare keys (no key on file, or the check failed).")); + } + if (status === 'ok') return out(ok('Already published: nothing to do.')); + // Everyone encrypts to the published key: replacing it is a deliberate act. + if (!flags.yes) { + if (!process.stdin.isTTY) throw new Error('qc keys publish replaces the key everyone encrypts to. Add --yes to confirm.'); + const { createInterface } = await import('node:readline/promises'); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + 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] "); + rl.close(); + if (!/^y(es)?$/i.test(answer.trim())) return out(info('Left the published key alone.')); + } + const after = await c.publishKey(); + if (flags.json) return json({ published: true, keys: after }); + 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.")); + } case 'logout': clearSession(); return out(ok('Signed out. This terminal no longer holds your keys.')); diff --git a/src/cli/tui.js b/src/cli/tui.js index 4d3ffbbd..37d16da1 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -309,7 +309,7 @@ function saveRecent(list) { } /** Run the full-screen client until Ctrl+C. */ -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."; +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."; export async function runTui(client, { initialChat } = {}) { const state = initialState(client.me); diff --git a/src/lib/components/EncryptionWarning.jsx b/src/lib/components/EncryptionWarning.jsx index 14274545..b1c6be16 100644 --- a/src/lib/components/EncryptionWarning.jsx +++ b/src/lib/components/EncryptionWarning.jsx @@ -3,10 +3,13 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { keyManager } from '@/lib/crypto/key-manager.js'; +import { keySyncService } from '@/lib/crypto/key-sync-service.js'; export default function EncryptionWarning() { const router = useRouter(); const [hasKeys, setHasKeys] = useState(true); + // This device's keys are not the account's: messages sent to you won't open here. + const [mismatch, setMismatch] = useState(false); const [checked, setChecked] = useState(false); const [generating, setGenerating] = useState(false); const [error, setError] = useState(''); @@ -15,15 +18,38 @@ export default function EncryptionWarning() { async function check() { try { await keyManager.initialize(); - setHasKeys(await keyManager.hasUserKeys()); + const has = await keyManager.hasUserKeys(); + setHasKeys(has); + if (has) setMismatch((await keySyncService.publishedKeyStatus()) === 'differs'); } catch { setHasKeys(false); } setChecked(true); } check(); + const onMismatch = () => setMismatch(true); + window.addEventListener('qrypt:key-mismatch', onMismatch); + return () => window.removeEventListener('qrypt:key-mismatch', onMismatch); }, []); + async function handleUseThisDevice() { + const ok = window.confirm( + "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." + ); + if (!ok) return; + setGenerating(true); + setError(''); + try { + const result = await keySyncService.forceSyncPublicKey(); + if (!result.success) throw new Error(result.error || 'Could not publish this device\'s key'); + setMismatch(false); + } catch (err) { + setError(err?.message || 'Could not publish this device\'s key'); + } finally { + setGenerating(false); + } + } + async function handleGenerate() { // New keys give you a fresh identity but make every message that was // encrypted to your previous key permanently unreadable. Restoring/uploading @@ -36,7 +62,11 @@ export default function EncryptionWarning() { setError(''); try { await keyManager.generateUserKeys(); + // Chosen on purpose, so publish it: sign-in never replaces a published key by itself. + const result = await keySyncService.forceSyncPublicKey(); + if (!result.success) throw new Error(result.error || 'Keys made, but publishing them failed'); setHasKeys(true); + setMismatch(false); } catch (err) { setError(err?.message || 'Failed to generate keys'); } finally { @@ -44,7 +74,32 @@ export default function EncryptionWarning() { } } - if (!checked || hasKeys) return null; + if (!checked || (hasKeys && !mismatch)) return null; + + if (hasKeys && mismatch) { + return ( +
+
⚠️
+
+ This device has different keys from your account +

+ Messages sent to you are encrypted to your account's key, which this browser doesn't hold, so they + won't open here. Restore your keys (backup PIN or key file) to read everything. +

+ {error &&

{error}

} +
+ + +
+
+ +
+ ); + } return (
@@ -79,7 +134,12 @@ export default function EncryptionWarning() { loses access to all past messages.

- + + ); +} + +const warningCss = ` .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; } .warning-icon { font-size: 1.25rem; flex-shrink: 0; line-height: 1.4; } .warning-content p { font-size: .875rem; margin-top: .25rem; } @@ -90,7 +150,4 @@ export default function EncryptionWarning() { .warn-btn-primary { background: #92400e; color: #fff; } .warn-btn-secondary { background: transparent; color: #92400e; border-color: #92400e; } .warning-hint { font-size: .75rem; margin-top: .5rem; opacity: .85; } - `} - - ); -} + `; diff --git a/src/lib/crypto/key-sync-service.js b/src/lib/crypto/key-sync-service.js index 2fa1b44f..6647d174 100644 --- a/src/lib/crypto/key-sync-service.js +++ b/src/lib/crypto/key-sync-service.js @@ -110,11 +110,32 @@ export class KeySyncService { } /** - * Check if user needs key sync (has keys locally but not in database) + * Check if user needs key sync (has keys locally but not in database). + * + * Only an account with NO published key is synced. A published key that + * differs from this device's is never replaced here: a browser with empty + * storage generates a fresh keypair on its own (initialize()), and publishing + * that on sign-in (2026-10-27) silently moved the account to a key no other + * device or qc held, so every new message failed to decrypt everywhere else. + * A mismatch is reported instead (publishedKeyStatus / 'qrypt:key-mismatch') + * and the person chooses: restore their keys here, or publish this device's. * @returns {Promise} */ async needsKeySync() { - if (typeof window === 'undefined') return false; + const status = await this.publishedKeyStatus(); + if (status === 'differs' && typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('qrypt:key-mismatch')); + } + return status === 'missing'; + } + + /** + * How this device's public key compares with the account's published one: + * 'match', 'missing' (none published), 'differs', 'no-local-key' or 'unknown'. + * @returns {Promise<'match'|'missing'|'differs'|'no-local-key'|'unknown'>} + */ + async publishedKeyStatus() { + if (typeof window === 'undefined') return 'unknown'; try { // Check if we have local keys @@ -125,14 +146,14 @@ export class KeySyncService { const publicKey = await postQuantumEncryption.getPublicKey(); if (!publicKey) { console.log('πŸ”‘ No local public key found, sync not needed'); - return false; + return 'no-local-key'; } const currentUserId = this.getCurrentUserId(); if (!currentUserId) { console.log('πŸ”‘ No current user ID, cannot determine sync status'); - return false; + return 'unknown'; } // Ask for our own key specifically rather than scanning the all-keys list. @@ -157,7 +178,7 @@ export class KeySyncService { // Fail closed: re-uploading is the destructive direction, so an unreadable // answer must not be treated as "the database has nothing". console.log('πŸ”‘ Cannot check database keys, leaving the published key alone'); - return false; + return 'unknown'; } const data = await response.json(); @@ -165,23 +186,23 @@ export class KeySyncService { if (!dbKey) { console.log('πŸ”‘ Public key not found in database, sync needed'); - return true; + return 'missing'; } if (dbKey !== publicKey) { - console.log('πŸ”‘ Database public key differs from local key (rotated/new device), sync needed'); - return true; + console.log('πŸ”‘ This device holds different keys from the account; leaving the published key alone'); + return 'differs'; } console.log('πŸ”‘ Public key already synced to database'); - return false; + return 'match'; } catch (error) { console.error('πŸ”‘ Error checking key sync status:', error); // Publishing is the side with consequences -- it replaces the key everyone // encrypts to. Not knowing the answer is a reason to leave it alone, not to // overwrite it. - return false; + return 'unknown'; } } diff --git a/src/lib/crypto/key-sync-service.test.js b/src/lib/crypto/key-sync-service.test.js index 12b2efbe..54ba40a1 100644 --- a/src/lib/crypto/key-sync-service.test.js +++ b/src/lib/crypto/key-sync-service.test.js @@ -64,9 +64,26 @@ describe('keySyncService.needsKeySync', () => { await expect(keySyncService.needsKeySync()).resolves.toBe(true); }); - it('syncs when the stored key belongs to a different keypair', async () => { + // 2026-10-27: a browser with empty storage made itself a fresh keypair and the + // sign-in sync published it over the account's key, so qc and every other + // device stopped decrypting new messages. A different key is reported, never replaced. + it('never replaces a published key that differs from this device; reports the mismatch', async () => { stubFetch({ body: { public_key: 'some-other-key-bbbb' } }); - await expect(keySyncService.needsKeySync()).resolves.toBe(true); + const seen = vi.fn(); + window.addEventListener('qrypt:key-mismatch', seen); + await expect(keySyncService.needsKeySync()).resolves.toBe(false); + await expect(keySyncService.publishedKeyStatus()).resolves.toBe('differs'); + window.removeEventListener('qrypt:key-mismatch', seen); + expect(seen).toHaveBeenCalledTimes(1); + }); + + it('publishedKeyStatus tells match, missing and unknown apart', async () => { + stubFetch({ body: { public_key: LOCAL_KEY } }); + await expect(keySyncService.publishedKeyStatus()).resolves.toBe('match'); + stubFetch({ body: { public_key: null } }); + await expect(keySyncService.publishedKeyStatus()).resolves.toBe('missing'); + stubFetch({ ok: false, body: {} }); + await expect(keySyncService.publishedKeyStatus()).resolves.toBe('unknown'); }); // Publishing replaces the key everyone encrypts to, so an unknown answer must not diff --git a/tests/cli/qc-resilience.test.js b/tests/cli/qc-resilience.test.js index e00fcf77..82e9b998 100644 --- a/tests/cli/qc-resilience.test.js +++ b/tests/cli/qc-resilience.test.js @@ -75,4 +75,20 @@ describe('keyCheck', () => { const client = new QcClient(session(), { fetch: withServerKey(undefined) }); expect(await client.keyCheck()).toBe('unknown'); }); + it('publishKey puts this terminal\'s key back as the account key', async () => { + let published = 'someone-else'; + const fetch = vi.fn(async (url, init) => { + if (init?.method === 'PUT') { + published = JSON.parse(init.body).public_key; + return json({ success: true }); + } + return json({ public_keys: { a: published } }); + }); + const client = new QcClient(session(), { fetch }); + expect(await client.keyCheck()).toBe('mismatch'); + expect(await client.publishKey()).toBe('ok'); + const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT'); + expect(new URL(put[0]).pathname).toBe('/api/crypto/public-keys'); + expect(JSON.parse(put[1].body)).toEqual({ public_key: keys.keys1024.publicKey, key_type: 'ML-KEM-1024' }); + }); }); diff --git a/tests/components/EncryptionWarning.test.jsx b/tests/components/EncryptionWarning.test.jsx new file mode 100644 index 00000000..22cdb989 --- /dev/null +++ b/tests/components/EncryptionWarning.test.jsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +const m = vi.hoisted(() => ({ hasKeys: true, status: 'match', sync: null, push: null })); +vi.mock('next/navigation', () => ({ useRouter: () => ({ push: m.push }) })); +vi.mock('@/lib/crypto/key-manager.js', () => ({ + keyManager: { initialize: async () => {}, hasUserKeys: async () => m.hasKeys, generateUserKeys: async () => {} }, +})); +vi.mock('@/lib/crypto/key-sync-service.js', () => ({ + keySyncService: { publishedKeyStatus: async () => m.status, forceSyncPublicKey: (...a) => m.sync(...a) }, +})); + +import EncryptionWarning from '../../src/lib/components/EncryptionWarning.jsx'; + +beforeEach(() => { + m.hasKeys = true; + m.status = 'match'; + m.sync = vi.fn(async () => ({ success: true })); + m.push = vi.fn(); + vi.spyOn(window, 'confirm').mockReturnValue(true); +}); + +describe('EncryptionWarning', () => { + it('stays hidden when this device holds the account keys', async () => { + const { container } = render(); + await waitFor(() => expect(container.innerHTML).toBe('')); + }); + + it('says so when this device holds different keys, and restoring is the first choice', async () => { + m.status = 'differs'; + render(); + expect(await screen.findByText('This device has different keys from your account')).toBeInTheDocument(); + fireEvent.click(screen.getByText('Restore my keys')); + expect(m.push).toHaveBeenCalledWith('/settings'); + expect(m.sync).not.toHaveBeenCalled(); + }); + + it("publishes this device's key only after a confirm", async () => { + m.status = 'differs'; + render(); + fireEvent.click(await screen.findByText("Use this device's keys")); + await waitFor(() => expect(m.sync).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByText('This device has different keys from your account')).toBeNull()); + }); + + it('generating new keys publishes them explicitly', async () => { + m.hasKeys = false; + render(); + fireEvent.click(await screen.findByText('Generate new keys')); + await waitFor(() => expect(m.sync).toHaveBeenCalledTimes(1)); + }); +});