Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "qryptchat-web",
"private": true,
"version": "0.5.3",
"version": "0.5.4",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
2 changes: 1 addition & 1 deletion packages/qryptchat/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
5 changes: 3 additions & 2 deletions src/app/api/cli/authorize/route.js
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand All @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions src/app/cli/authorize/page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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(() => ({}));
Expand Down
18 changes: 18 additions & 0 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {} });
Expand Down
6 changes: 5 additions & 1 deletion src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
12 changes: 10 additions & 2 deletions src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -267,6 +269,8 @@ export async function runTui(client, { initialChat } = {}) {
}
}

let keysChecked = false;

async function loadConversations() {
try {
const before = state.activeId;
Expand All @@ -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}`);
Expand Down
23 changes: 23 additions & 0 deletions src/lib/auth/cli-auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions tests/cli/cli-auth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
19 changes: 19 additions & 0 deletions tests/cli/qc-resilience.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading