From e2024e31ab60a8fe649b446ba05630ac7ffcb713 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 6 Oct 2026 10:08:04 +0000 Subject: [PATCH] qc login: never hand a terminal keys that cannot read the account (0.5.4, qc 0.4.3) /cli/authorize called postQuantumEncryption.initialize(), which silently generates a fresh keypair in a browser that has none. qc then held keys no message was encrypted to, including its own sends, so everything showed '[Encrypted message - decryption failed]'. - The page only loads stored keys; with none it says to restore first. - /api/cli/authorize compares the handed-over public key with the account's key in user_public_keys and refuses a mismatch (409 key_mismatch). - qc checks its key against the account after login and in the TUI, and says so plainly instead of failing every message. Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/qryptchat/package.json | 2 +- src/app/api/cli/authorize/route.js | 5 +++-- src/app/cli/authorize/page.jsx | 15 +++++++++++++-- src/cli/api.js | 18 ++++++++++++++++++ src/cli/commands.js | 6 +++++- src/cli/tui.js | 12 ++++++++++-- src/lib/auth/cli-auth.js | 23 +++++++++++++++++++++++ tests/cli/cli-auth.test.js | 21 +++++++++++++++++++++ tests/cli/qc-resilience.test.js | 19 +++++++++++++++++++ 10 files changed, 114 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index 745e3b03..1b2a5c38 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.5.3", + "version": "0.5.4", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index 3540a80a..b8af5429 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.4.2", + "version": "0.4.3", "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/app/api/cli/authorize/route.js b/src/app/api/cli/authorize/route.js index 82bd6ab0..3afaea9b 100644 --- a/src/app/api/cli/authorize/route.js +++ b/src/app/api/cli/authorize/route.js @@ -1,10 +1,10 @@ import { NextResponse } from 'next/server'; import { withAuth } from '@/lib/api/middleware/auth.js'; -import { CliAuthError, issueCode, serviceClient } from '@/lib/auth/cli-auth.js'; +import { assertAccountKey, CliAuthError, issueCode, serviceClient } from '@/lib/auth/cli-auth.js'; /** * POST /api/cli/authorize: the signed-in web app approves a `qc login`. - * Body: { code_challenge, code_challenge_method: "S256", redirect_uri, client_name, key_blob } + * Body: { code_challenge, code_challenge_method: "S256", redirect_uri, client_name, key_blob, public_key } * Returns { code }. See src/lib/auth/cli-auth.js for the whole flow. */ export const POST = withAuth(async ({ request, locals }) => { @@ -18,6 +18,7 @@ export const POST = withAuth(async ({ request, locals }) => { return NextResponse.json({ error: 'invalid_request', error_description: 'Only S256 is supported' }, { status: 400 }); } try { + await assertAccountKey(serviceClient(), locals.user.id, body.public_key); const code = await issueCode(serviceClient(), { authUserId: locals.user.id, codeChallenge: body.code_challenge, diff --git a/src/app/cli/authorize/page.jsx b/src/app/cli/authorize/page.jsx index 453f97b3..8e9b5a84 100644 --- a/src/app/cli/authorize/page.jsx +++ b/src/app/cli/authorize/page.jsx @@ -41,8 +41,18 @@ function Authorize() { setStatus('working'); setError(''); try { - await postQuantumEncryption.initialize(); - const keys = await postQuantumEncryption.exportUserKeys(); + // Load, never generate: initialize() would quietly make a fresh keypair + // for a browser without keys, and qc would get keys that read nothing. + await Promise.all([postQuantumEncryption.loadUserKeys(), postQuantumEncryption.loadUserKeys768()]); + const k1024 = postQuantumEncryption.userKeys; + const k768 = postQuantumEncryption.userKeys768; + if (!k1024?.publicKey || !k1024?.privateKey) { + throw new Error('This browser has no encryption keys. Restore them first (Settings > Keys, backup PIN), then run qc login again.'); + } + const keys = { + keys1024: { ...k1024, algorithm: postQuantumEncryption.kemName }, + ...(k768?.publicKey && k768?.privateKey ? { keys768: { ...k768, algorithm: postQuantumEncryption.kemName768 } } : {}), + }; const keyBlob = await postQuantumEncryption.encryptForRecipient(JSON.stringify({ v: 1, ...keys }), kem); const session = JSON.parse(localStorage.getItem('qrypt_session') || '{}'); const res = await fetch('/api/cli/authorize', { @@ -57,6 +67,7 @@ function Authorize() { redirect_uri: redirectUri, client_name: client, key_blob: keyBlob, + public_key: k1024.publicKey, }), }); const body = await res.json().catch(() => ({})); diff --git a/src/cli/api.js b/src/cli/api.js index df9ae9bc..e45c471f 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -93,6 +93,24 @@ export class QcClient { return data; } + /** + * Whether this terminal's keys are the account's current keys: 'ok', + * 'mismatch' (everything sent to you is encrypted to a key qc does not hold, + * so nothing will decrypt) or 'unknown' (no key on file, or the check failed). + */ + async keyCheck() { + try { + const me = this.me?.id; + const mine = this.session.keys?.keys1024?.publicKey; + if (!me || !mine) return 'unknown'; + const { public_keys: keys = {} } = await this.request('/api/crypto/public-keys', { method: 'POST', body: { user_ids: [me] } }); + if (!keys[me]) return 'unknown'; + return keys[me] === mine ? 'ok' : 'mismatch'; + } catch { + return 'unknown'; + } + } + /** Conversations, newest activity first, each with a display title. */ async conversations() { const { conversations = [] } = await this.request('/api/conversations/load', { method: 'POST', body: {} }); diff --git a/src/cli/commands.js b/src/cli/commands.js index 4173dec4..2a0ff923 100644 --- a/src/cli/commands.js +++ b/src/cli/commands.js @@ -101,7 +101,11 @@ export async function main(argv, { version = '0.0.0' } = {}) { } case 'login': { const { session } = await login({ oob: !!flags.oob }); - return out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`); + out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`); + if ((await new QcClient(session).keyCheck()) === 'mismatch') { + out("Warning: the browser handed over keys that are not your account's current keys, so messages will not decrypt here. Restore your keys in that browser (Settings > Keys) or approve from the device you chat on, then run qc login again."); + } + return; } case 'logout': clearSession(); diff --git a/src/cli/tui.js b/src/cli/tui.js index 8e49b881..9b0ba1fe 100644 --- a/src/cli/tui.js +++ b/src/cli/tui.js @@ -237,16 +237,18 @@ 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."; + export async function runTui(client, { initialChat } = {}) { const state = initialState(client.me); state.recent = loadRecent(); const abort = new AbortController(); const app = await createApp({ quitKeys: ['ctrl+c'], focusNavigation: false }); const redraw = () => app.invalidate(); - const note = (msg) => { + const note = (msg, { sticky = false } = {}) => { state.notice = msg; redraw(); - if (msg) setTimeout(() => { + if (msg && !sticky) setTimeout(() => { if (state.notice === msg) { state.notice = ''; redraw(); @@ -267,6 +269,8 @@ export async function runTui(client, { initialChat } = {}) { } } + let keysChecked = false; + async function loadConversations() { try { const before = state.activeId; @@ -281,6 +285,10 @@ export async function runTui(client, { initialChat } = {}) { // The events stream joins every chat's live room itself, so unread // counts arrive without loading each chat (which tripped the rate limit). redraw(); + if (!keysChecked) { + keysChecked = true; + if ((await client.keyCheck()) === 'mismatch') note(KEY_MISMATCH, { sticky: true }); + } } catch (err) { state.loading = false; note(err.status === 401 ? 'Session ended: run qc login' : `Could not load chats: ${err.message}`); diff --git a/src/lib/auth/cli-auth.js b/src/lib/auth/cli-auth.js index 1ec8486a..ae9a7988 100644 --- a/src/lib/auth/cli-auth.js +++ b/src/lib/auth/cli-auth.js @@ -117,6 +117,29 @@ export async function mintSession(service, authUserId) { return sessionPayload(data.session); } +/** + * Refuse to hand a terminal keys that cannot read the account's messages. + * Everyone encrypts to the public key in user_public_keys; a browser that lost + * its keys (or made fresh ones) would otherwise give qc a keypair that decrypts + * nothing. With no key on file yet there is nothing to compare against. + */ +export async function assertAccountKey(service, authUserId, publicKey) { + if (typeof publicKey !== 'string' || !publicKey) return; + const { data } = await service + .from('user_public_keys') + .select('public_key') + .eq('user_id', authUserId) + .eq('key_type', 'ML-KEM-1024') + .maybeSingle(); + if (data?.public_key && data.public_key !== publicKey) { + throw new CliAuthError( + 'key_mismatch', + "This browser's encryption keys are not your account's current keys, so a terminal signed in with them could not read your messages. Restore your keys (Settings > Keys, backup PIN) or approve from the device you normally chat on.", + 409 + ); + } +} + /** Store a one-time code for an approved request; returns the code itself. */ export async function issueCode(service, { authUserId, codeChallenge, redirectUri, clientName, keyBlob }) { if (!validChallenge(codeChallenge)) throw new CliAuthError('invalid_request', 'code_challenge must be an S256 challenge'); diff --git a/tests/cli/cli-auth.test.js b/tests/cli/cli-auth.test.js index 071fb239..ad1e065d 100644 --- a/tests/cli/cli-auth.test.js +++ b/tests/cli/cli-auth.test.js @@ -147,3 +147,24 @@ describe('refresh', () => { await expect(refresh('r1')).rejects.toMatchObject({ code: 'invalid_grant' }); }); }); + +describe('assertAccountKey', () => { + const service = (publicKey) => ({ + from: () => { + const q = { select: () => q, eq: () => q, maybeSingle: async () => ({ data: publicKey ? { public_key: publicKey } : null, error: null }) }; + return q; + } + }); + + it('refuses keys that are not the account key, so qc never gets keys that read nothing', async () => { + const { assertAccountKey } = await import('../../src/lib/auth/cli-auth.js'); + await expect(assertAccountKey(service('server-key'), 'u', 'fresh-browser-key')).rejects.toMatchObject({ code: 'key_mismatch', status: 409 }); + }); + + it('passes the account key, an account with no key yet, and an old page that sends none', async () => { + const { assertAccountKey } = await import('../../src/lib/auth/cli-auth.js'); + await expect(assertAccountKey(service('server-key'), 'u', 'server-key')).resolves.toBeUndefined(); + await expect(assertAccountKey(service(null), 'u', 'any')).resolves.toBeUndefined(); + await expect(assertAccountKey(service('server-key'), 'u', undefined)).resolves.toBeUndefined(); + }); +}); diff --git a/tests/cli/qc-resilience.test.js b/tests/cli/qc-resilience.test.js index 42bc84ee..e00fcf77 100644 --- a/tests/cli/qc-resilience.test.js +++ b/tests/cli/qc-resilience.test.js @@ -57,3 +57,22 @@ describe('qc under rate limits and token rotation', () => { expect(refreshes).toBe(1); }); }); + +describe('keyCheck', () => { + const withServerKey = (publicKey) => vi.fn(async () => json({ public_keys: { a: publicKey } })); + + it('says ok when the terminal holds the account key', async () => { + const client = new QcClient(session(), { fetch: withServerKey(keys.keys1024.publicKey) }); + expect(await client.keyCheck()).toBe('ok'); + }); + + it('says mismatch when messages are encrypted to a key qc does not hold', async () => { + const client = new QcClient(session(), { fetch: withServerKey('someone-else') }); + expect(await client.keyCheck()).toBe('mismatch'); + }); + + it('says unknown when there is nothing to compare', async () => { + const client = new QcClient(session(), { fetch: withServerKey(undefined) }); + expect(await client.keyCheck()).toBe('unknown'); + }); +});