diff --git a/bin/qryptchat-agent.js b/bin/qryptchat-agent.js index 2b5c52e7..a9f23e72 100755 --- a/bin/qryptchat-agent.js +++ b/bin/qryptchat-agent.js @@ -1,471 +1,328 @@ #!/usr/bin/env node /** - * @fileoverview QryptChat headless agent CLI - * A non-interactive CLI for bots/agents and terminal power users. No TUI. + * Backwards-compatible headless agent CLI. * - * Commands: - * register --invite [--username ] [--display ] - * whoami - * conversations - * send - * listen [--json] - * - * Reuses MESSAGE_TYPES + the WS auth handshake from bin/qryptchat-cli.js and - * the ML-KEM keypair generation from src/lib/crypto/ml-kem.js. - * - * Env: - * QRYPTCHAT_API_URL (default http://localhost:5173) - * QRYPTCHAT_WS_URL (default ws://localhost:8080) - * NEXT_PUBLIC_SUPABASE_URL (required for `register`) - * NEXT_PUBLIC_SUPABASE_ANON_KEY (required for `register`) + * The former WebSocket service was retired when qrypt.chat moved to REST + + * server-sent events. Keep the old command name and local identity file, but + * use the same authenticated, encrypted API as `qc` so existing agents keep + * working without a dead `/ws` connection. */ -import WebSocket from 'ws'; -import { randomUUID } from 'crypto'; -import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; +import { randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { QcClient } from '../src/cli/api.js'; +import { ephemeralKeypair } from '../src/cli/crypto.js'; +import { joinAsAgent } from '../src/cli/agent.js'; +import { unlockSession } from '../src/cli/config.js'; -// --- Configuration --- - -const API_URL = process.env.QRYPTCHAT_API_URL || 'http://localhost:5173'; -const WS_URL = process.env.QRYPTCHAT_WS_URL || 'ws://localhost:8080'; +const API_URL = (process.env.QRYPTCHAT_API_URL || process.env.QC_URL || 'https://qrypt.chat').replace(/\/+$/, ''); const CONFIG_DIR = join(homedir(), '.qryptchat'); const AGENT_FILE = join(CONFIG_DIR, 'agent.json'); -// --- Message protocol (mirrors src/lib/websocket/utils/protocol.js) --- - -const MESSAGE_TYPES = { - AUTH: 'auth', - AUTH_SUCCESS: 'auth_success', - AUTH_ERROR: 'auth_error', - LOAD_CONVERSATIONS: 'load_conversations', - CONVERSATIONS_LOADED: 'conversations_loaded', - JOIN_CONVERSATION: 'join_conversation', - CONVERSATION_JOINED: 'conversation_joined', - LEAVE_CONVERSATION: 'leave_conversation', - SEND_MESSAGE: 'send_message', - MESSAGE_SENT: 'message_sent', - MESSAGE_RECEIVED: 'message_received', - LOAD_MESSAGES: 'load_messages', - MESSAGES_LOADED: 'messages_loaded', - TYPING_START: 'typing_start', - TYPING_STOP: 'typing_stop', - TYPING_UPDATE: 'typing_update', - USER_ONLINE: 'user_online', - USER_OFFLINE: 'user_offline', - PING: 'ping', - PONG: 'pong', - ERROR: 'error' -}; - -function createMessage(type, payload = {}) { - return JSON.stringify({ - type, - payload, - requestId: randomUUID(), - timestamp: new Date().toISOString() - }); -} - -// --- Identity persistence (~/.qryptchat/agent.json, mode 0600) --- - function saveAgent(agent) { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); - } + if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }); writeFileSync(AGENT_FILE, JSON.stringify(agent, null, 2), { mode: 0o600 }); } function loadAgent() { try { - if (existsSync(AGENT_FILE)) { - return JSON.parse(readFileSync(AGENT_FILE, 'utf-8')); - } + return existsSync(AGENT_FILE) ? JSON.parse(readFileSync(AGENT_FILE, 'utf8')) : null; } catch { - // ignore corrupt agent file - } - return null; -} - -function requireAgent() { - const agent = loadAgent(); - const token = agent?.session?.access_token; - if (!agent || !token) { - fail('Not registered. Run `qryptchat-agent register --invite ` first.'); + return null; } - return agent; } -// --- Output helpers --- - -function printJSON(obj) { - process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); -} - -function printNDJSON(obj) { - process.stdout.write(JSON.stringify(obj) + '\n'); -} - -function fail(msg, code = 1) { - process.stderr.write(`${msg}\n`); - process.exit(code); -} - -// --- Arg parsing --- - -/** - * Split argv into positional args + flags. - * Flags: --key value, or --bool (no value when next token is another flag/absent). - */ -function parseArgs(argv, booleanFlags = []) { +function parseArgs(argv) { const positional = []; const flags = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg.startsWith('--')) { - const key = arg.slice(2); - if (booleanFlags.includes(key)) { - flags[key] = true; - } else { - const next = argv[i + 1]; - if (next === undefined || next.startsWith('--')) { - flags[key] = true; - } else { - flags[key] = next; - i++; - } - } - } else { + if (!arg.startsWith('--')) { positional.push(arg); + continue; + } + const key = arg.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) flags[key] = true; + else { + flags[key] = next; + i++; } } return { positional, flags }; } -// --- WebSocket connect + auth --- - -/** - * Connect to the WS server, authenticate with the saved access_token, and - * resolve once AUTH_SUCCESS is received. Inbound messages are delivered to - * onMessage. PING is auto-answered with PONG. - * @param {string} accessToken - * @param {(msg: object, ws: WebSocket) => void} onMessage - * @returns {Promise} - */ -function connectAndAuth(accessToken, onMessage) { - return new Promise((resolve, reject) => { - const url = `${WS_URL}?token=${encodeURIComponent(accessToken)}`; - const ws = new WebSocket(url); - let authed = false; - - const timeout = setTimeout(() => { - if (!authed) { - ws.close(); - reject(new Error('WebSocket connection/auth timeout')); - } - }, 10000); +function usernameFor(name) { + const stem = String(name || 'agent') + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 20) || 'agent'; + return `${stem.length >= 3 ? stem : `${stem}_agent`}_${randomBytes(3).toString('hex')}`.slice(0, 30); +} - ws.on('open', () => { - ws.send(createMessage(MESSAGE_TYPES.AUTH, { token: accessToken })); - }); +function inviteToken(input) { + const text = String(input || '').trim(); + if (!text) throw new Error('Provide the agent invite URL or token.'); + if (!/^https?:\/\//i.test(text)) return text; + const url = new URL(text); + return url.hash.replace(/^#/, '') || url.searchParams.get('token') || ''; +} - ws.on('message', (data) => { - let msg; - try { - msg = JSON.parse(data.toString()); - } catch { - return; // ignore malformed - } +function toQcSession(agent) { + const keys1024 = agent.keys?.keys1024 || { + publicKey: agent.publicKey, + privateKey: agent.privateKey, + }; + const user = agent.user || { + id: agent.userId, + username: agent.username, + display_name: agent.displayName || agent.username, + }; + if (!agent.session?.access_token || !user.id || !keys1024.publicKey || !keys1024.privateKey) { + throw new Error('Agent identity is incomplete. Run `qryptchat-agent join ` to make a current agent identity.'); + } + return { + base: API_URL, + ...agent.session, + user, + keys: { ...(agent.keys || {}), keys1024 }, + }; +} - // Always answer PING with PONG. - if (msg.type === MESSAGE_TYPES.PING) { - ws.send(JSON.stringify({ - type: MESSAGE_TYPES.PONG, - payload: {}, - requestId: msg.requestId, - timestamp: new Date().toISOString() - })); - return; - } +async function createAgentClient() { + const env = { ...process.env, QC_URL: API_URL }; + const unlocked = await unlockSession(env); + if (unlocked?.session) { + return new QcClient(unlocked.session, { env, save: unlocked.save }); + } - if (msg.type === MESSAGE_TYPES.AUTH_SUCCESS) { - authed = true; - clearTimeout(timeout); - resolve(ws); - return; - } - if (msg.type === MESSAGE_TYPES.AUTH_ERROR) { - clearTimeout(timeout); - reject(new Error(msg.payload?.error || 'Authentication failed')); - return; - } + const agent = agentOrFail(); + const session = toQcSession(agent); + const client = new QcClient(session, { + env, + save: (next) => { + agent.session = { + access_token: next.access_token, + refresh_token: next.refresh_token, + expires_at: next.expires_at, + }; + agent.user = next.user; + agent.userId = next.user?.id || agent.userId; + agent.username = next.user?.username || agent.username; + saveAgent(agent); + }, + }); - if (onMessage) onMessage(msg, ws); - }); + // Older agent registrations use Supabase refresh tokens instead of qc's + // PKCE token. Keep those existing identities alive while they migrate. + const refreshQcSession = client.refresh.bind(client); + client.refresh = async () => { + try { + return await refreshQcSession(); + } catch (qcError) { + const supabaseUrl = agent.supabase?.url || process.env.NEXT_PUBLIC_SUPABASE_URL; + const anonKey = agent.supabase?.anonKey || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !anonKey || !client.session.refresh_token) throw qcError; + const response = await fetch(`${supabaseUrl.replace(/\/+$/, '')}/auth/v1/token?grant_type=refresh_token`, { + method: 'POST', + headers: { apikey: anonKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: client.session.refresh_token }), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.access_token) throw qcError; + client.session = { + ...client.session, + access_token: body.access_token, + refresh_token: body.refresh_token || client.session.refresh_token, + expires_at: body.expires_at || Math.floor(Date.now() / 1000) + (body.expires_in || 3600), + }; + client.save(client.session); + }; + }; + return client; +} - ws.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); +function agentOrFail() { + const agent = loadAgent(); + if (!agent) throw new Error('Not registered. Run `qryptchat-agent join ` first.'); + return agent; +} - ws.on('close', () => { - if (!authed) { - clearTimeout(timeout); - reject(new Error('WebSocket closed before authentication')); - } - }); - }); +function printJSON(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); } -// --- Commands --- +function fail(message, code = 1) { + process.stderr.write(`${message}\n`); + process.exitCode = code; +} async function cmdRegister(argv) { const { flags } = parseArgs(argv); - const inviteToken = flags.invite; - if (!inviteToken) { - fail('register requires --invite '); - } - + const token = flags.invite; + if (!token) throw new Error('register requires --invite . For agent identities use `join `.'); const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - if (!supabaseUrl || !supabaseAnonKey) { - fail('NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are required for register.'); - } + const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; + if (!supabaseUrl || !anonKey) throw new Error('NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY are required for legacy register.'); - // 1) Anonymous Supabase auth. const { createClient } = await import('@supabase/supabase-js'); - const supabase = createClient(supabaseUrl, supabaseAnonKey, { - auth: { persistSession: false, autoRefreshToken: false } - }); - - const { data: authData, error: authError } = await supabase.auth.signInAnonymously(); - if (authError) { - fail(`signInAnonymously failed: ${authError.message}`); - } - const session = authData?.session; - if (!session?.access_token) { - fail('signInAnonymously returned no session/access_token.'); - } + const supabase = createClient(supabaseUrl, anonKey, { auth: { persistSession: false, autoRefreshToken: false } }); + const { data, error } = await supabase.auth.signInAnonymously(); + if (error || !data?.session?.access_token) throw new Error(`Anonymous sign-in failed: ${error?.message || 'no session returned'}`); - // 2) Generate an ML-KEM-1024 keypair locally (reusing the app's module). - const { MLKEMKeyExchange, MLKEMUtils } = await import('../src/lib/crypto/ml-kem.js'); - const mlkem = new MLKEMKeyExchange('ML_KEM_1024'); - const { publicKey, privateKey } = await mlkem.generateKeyPair(); - const publicKeyB64 = MLKEMUtils.toBase64(publicKey); - const privateKeyB64 = MLKEMUtils.toBase64(privateKey); - - // 3) POST to /api/auth/register-anon with the anon Bearer token. - const res = await fetch(`${API_URL}/api/auth/register-anon`, { + const keys1024 = await ephemeralKeypair(); + const response = await fetch(`${API_URL}/api/auth/register-anon`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session.access_token}` - }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${data.session.access_token}` }, body: JSON.stringify({ - inviteToken, - username: flags.username, - displayName: flags.display, - publicKey: publicKeyB64 - }) + inviteToken: token, + username: flags.username || usernameFor(flags.display || 'agent'), + displayName: flags.display || flags.name, + publicKey: keys1024.publicKey, + }), }); - - let body; - try { - body = await res.json(); - } catch { - fail(`register-anon returned non-JSON (HTTP ${res.status}).`); - } - - if (!res.ok || !body?.success) { - fail(`Registration failed (HTTP ${res.status}): ${body?.error || 'unknown error'}`); - } - - const user = body.user; - - // 4) Persist identity (incl. private key) to ~/.qryptchat/agent.json (0600). - saveAgent({ + const body = await response.json().catch(() => ({})); + if (!response.ok || !body.user) throw new Error(body.error || `Registration failed (HTTP ${response.status})`); + const agent = { session: { - access_token: session.access_token, - refresh_token: session.refresh_token, - expires_at: session.expires_at + access_token: data.session.access_token, + refresh_token: data.session.refresh_token, + expires_at: data.session.expires_at, }, - privateKey: privateKeyB64, - publicKey: publicKeyB64, - userId: user?.id, - username: user?.username - }); - - printJSON(user); + keys: { keys1024 }, + publicKey: keys1024.publicKey, + privateKey: keys1024.privateKey, + user: body.user, + userId: body.user.id, + username: body.user.username, + supabase: { url: supabaseUrl, anonKey }, + }; + saveAgent(agent); + printJSON(body.user); } -function cmdWhoami() { - const agent = loadAgent(); - if (!agent) { - fail('Not registered. Run `qryptchat-agent register --invite ` first.'); - } - // NEVER print the private key. - printJSON({ - userId: agent.userId, - username: agent.username, - publicKey: agent.publicKey, - hasSession: Boolean(agent.session?.access_token) +async function cmdJoin(argv) { + const { positional, flags } = parseArgs(argv); + const input = flags.invite || positional[0]; + const token = inviteToken(input); + const inviteUrl = /^https?:\/\//i.test(String(input || '')) ? input : `${API_URL}/agents/join#${encodeURIComponent(token)}`; + const result = await joinAsAgent(inviteUrl, { + name: flags.name, + username: flags.username || usernameFor(flags.name), + env: process.env, }); + printJSON({ user: result.session.user, operator: result.operator, conversation_id: result.conversationId, savedIn: 'qc secure session vault' }); } -async function cmdConversations() { - const agent = requireAgent(); - const ws = await connectAndAuth(agent.session.access_token, (msg) => { - if (msg.type === MESSAGE_TYPES.CONVERSATIONS_LOADED) { - printJSON(msg.payload?.conversations || []); - ws.close(); - process.exit(0); - } - if (msg.type === MESSAGE_TYPES.ERROR) { - fail(`Server error: ${msg.payload?.error || 'unknown'}`); - } - }); - ws.send(createMessage(MESSAGE_TYPES.LOAD_CONVERSATIONS, {})); +async function cmdWhoami() { + const client = await createAgentClient(); + printJSON({ userId: client.me?.id, username: client.me?.username, display_name: client.me?.display_name, hasSession: Boolean(client.session?.access_token) }); +} - setTimeout(() => fail('Timed out waiting for conversations.'), 15000); +async function cmdConversations() { + const client = await createAgentClient(); + printJSON(await client.conversations()); } async function cmdSend(argv) { const { positional } = parseArgs(argv); const conversationId = positional[0]; const text = positional.slice(1).join(' '); - if (!conversationId || !text) { - fail('send requires '); - } - - const agent = requireAgent(); - const ws = await connectAndAuth(agent.session.access_token, (msg) => { - if (msg.type === MESSAGE_TYPES.MESSAGE_SENT) { - printJSON(msg.payload || {}); - ws.close(); - process.exit(0); - } - if (msg.type === MESSAGE_TYPES.ERROR) { - fail(`Server error: ${msg.payload?.error || 'unknown'}`); - } - }); - - ws.send(createMessage(MESSAGE_TYPES.SEND_MESSAGE, { - conversationId, - content: text, - messageType: 'text' - })); - - setTimeout(() => fail('Timed out waiting for message_sent.'), 15000); + if (!conversationId || !text) throw new Error('send requires '); + const client = await createAgentClient(); + const result = await client.send(conversationId, text); + printJSON({ success: true, conversationId, messageId: result.message?.id, skipped: result.skipped || 0 }); } async function cmdListen(argv) { - const { flags } = parseArgs(argv, ['json']); + const { flags } = parseArgs(argv); const jsonMode = Boolean(flags.json); - - const agent = requireAgent(); - - const emit = (obj) => (jsonMode ? printNDJSON(obj) : printJSON(obj)); - - const ws = await connectAndAuth(agent.session.access_token, (msg) => { - // Stream all inbound events (PING/PONG handled in connectAndAuth). - emit(msg); - }); - - if (!jsonMode) { - process.stderr.write('Listening for events. Press Ctrl-C to exit.\n'); + const client = await createAgentClient(); + const chats = await client.conversations(); + const titles = new Map(chats.map((chat) => [chat.id, chat.title])); + for (const chat of chats) await client.messages(chat.id, { limit: 1 }).catch(() => {}); + const seen = new Set(); + const abort = new AbortController(); + const emit = (event) => process.stdout.write(`${JSON.stringify(event)}\n`); + const shutdown = () => abort.abort(); + process.once('SIGINT', shutdown); + process.once('SIGTERM', shutdown); + if (!jsonMode) process.stderr.write('Listening to qrypt.chat over SSE. Press Ctrl-C to exit.\n'); + try { + await client.events(async ({ type, data }) => { + if (type !== 'NEW_MESSAGE' || !data?.message?.conversation_id) { + if (jsonMode && type !== 'CONNECTED') emit({ type, payload: data }); + return; + } + const conversationId = data.message.conversation_id; + const { messages } = await client.messages(conversationId, { limit: 100 }); + const message = messages.find((item) => item.id === data.message.id); + if (!message || seen.has(message.id)) return; + seen.add(message.id); + const event = { + type: 'message_received', + payload: { + id: message.id, + conversation_id: conversationId, + conversation_title: titles.get(conversationId) || conversationId, + sender: message.sender, + sender_id: message.senderId, + mine: message.mine, + content: message.text, + created_at: message.at, + }, + }; + if (jsonMode) emit(event); + else process.stdout.write(`[${event.payload.conversation_title}] ${event.payload.sender}: ${event.payload.content}\n`); + }, { signal: abort.signal, onStatus: (status) => { if (!jsonMode) process.stderr.write(`qryptchat-agent: ${status}\n`); } }); + } finally { + process.removeListener('SIGINT', shutdown); + process.removeListener('SIGTERM', shutdown); } - - const shutdown = () => { - try { ws.close(); } catch { /* noop */ } - process.exit(0); - }; - process.on('SIGINT', shutdown); - process.on('SIGTERM', shutdown); - - ws.on('close', () => { - if (!jsonMode) process.stderr.write('Connection closed.\n'); - process.exit(0); - }); } -// --- Usage --- - -const USAGE = `qryptchat-agent — headless agent CLI for qrypt.chat - -USAGE - qryptchat-agent [options] +const USAGE = `qryptchat-agent — headless qrypt.chat agent CLI (REST + SSE) COMMANDS - register --invite [--username ] [--display ] - signInAnonymously (Supabase) → generate ML-KEM-1024 keypair → - POST /api/auth/register-anon → persist identity to - ~/.qryptchat/agent.json (mode 0600). Prints the new user as JSON. - + join [--name NAME] [--username NAME] + register --invite [--username NAME] [--display NAME] (legacy) whoami - Print the saved identity as JSON (never the private key). - conversations - Connect WS, auth, load conversations, print the list as JSON, exit. - send - Connect WS, auth, send a message, print the result JSON, exit. - listen [--json] - Connect WS, auth, stream inbound events. With --json, emit one - compact JSON object per line (NDJSON) to stdout — pipeable for agents. - Handles PING→PONG. Exits cleanly on SIGINT. - - help, --help, -h - Show this usage. + help ENVIRONMENT - QRYPTCHAT_API_URL API base URL (default http://localhost:5173) - QRYPTCHAT_WS_URL WS server URL (default ws://localhost:8080) - NEXT_PUBLIC_SUPABASE_URL Supabase project URL (required for register) - NEXT_PUBLIC_SUPABASE_ANON_KEY Supabase anon key (required for register) - -EXAMPLES - qryptchat-agent register --invite qci1.xxx.yyy --username mybot - qryptchat-agent conversations - qryptchat-agent send 1234-uuid "hello from a bot" - qryptchat-agent listen --json | jq -c 'select(.type=="message_received")' + QRYPTCHAT_API_URL API base URL (default https://qrypt.chat) + QC_URL Alias for QRYPTCHAT_API_URL + NEXT_PUBLIC_SUPABASE_URL / NEXT_PUBLIC_SUPABASE_ANON_KEY are only needed by legacy register. `; -// --- Main --- - async function main() { - const [, , command, ...rest] = process.argv; - - if (!command || command === 'help' || command === '--help' || command === '-h') { - process.stdout.write(USAGE); - process.exit(command ? 0 : 1); - } - + const [, , command, ...argv] = process.argv; try { switch (command) { - case 'register': - await cmdRegister(rest); - break; - case 'whoami': - cmdWhoami(); - break; - case 'conversations': - await cmdConversations(); - break; - case 'send': - await cmdSend(rest); - break; - case 'listen': - await cmdListen(rest); + case 'help': + case '--help': + case '-h': + case undefined: + process.stdout.write(USAGE); break; - default: - fail(`Unknown command: ${command}\nRun \`qryptchat-agent --help\` for usage.`); + case 'register': await cmdRegister(argv); break; + case 'join': await cmdJoin(argv); break; + case 'whoami': await cmdWhoami(); break; + case 'conversations': await cmdConversations(); break; + case 'send': await cmdSend(argv); break; + case 'listen': await cmdListen(argv); break; + default: throw new Error(`Unknown command: ${command}\n\n${USAGE}`); } - } catch (err) { - fail(`Error: ${err?.message || err}`); + } catch (error) { + fail(error?.message || String(error)); } } -main(); +await main(); diff --git a/package.json b/package.json index ddaaa053..41779e62 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "qryptchat-web", "private": true, - "version": "0.21.0", + "version": "0.21.1", "type": "module", "bin": { "qc": "./bin/qc.js", diff --git a/packages/qryptchat/package.json b/packages/qryptchat/package.json index ae28307c..11783094 100644 --- a/packages/qryptchat/package.json +++ b/packages/qryptchat/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/qryptchat", - "version": "0.14.1", + "version": "0.14.2", "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/public/sw.js b/public/sw.js index 547be3ab..4ca063c0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -3,10 +3,8 @@ * Basic offline functionality without ES module imports */ -// Bumped to v1 -> v2 so clients running the old cache-first worker drop the -// stale app shell they precached on install. v3: page loads skip the HTTP -// cache's stale copies (see the fetch handler). -const CACHE_NAME = 'qryptchat-v3'; +// v4 drops stale chat code that could encrypt a sender copy to an old key. +const CACHE_NAME = 'qryptchat-v4'; const OFFLINE_SHELL = '/'; const STATIC_CACHE_URLS = [ OFFLINE_SHELL, @@ -186,4 +184,4 @@ self.addEventListener('message', (event) => { } }); -console.log('Service Worker loaded'); \ No newline at end of file +console.log('Service Worker loaded'); diff --git a/src/app/api/chat/conversations/[id]/mark-read/route.js b/src/app/api/chat/conversations/[id]/mark-read/route.js index 5019386a..ffb371b4 100644 --- a/src/app/api/chat/conversations/[id]/mark-read/route.js +++ b/src/app/api/chat/conversations/[id]/mark-read/route.js @@ -37,23 +37,52 @@ export async function POST(request, { params } = {}) { return NextResponse.json({ error: 'User not found' }, { status: 404 }); } - // Mark all unread messages in this conversation as read - const { error: updateError } = await supabase - .from('deliveries') - .update({ read_ts: new Date().toISOString() }) - .eq('recipient_user_id', internalUser.id) - .is('read_ts', null) - .is('deleted_ts', null) - .in('message_id', - supabase - .from('messages') - .select('id') - .eq('conversation_id', normalizedConversationId) - ); + const { data: participant, error: participantError } = await supabase + .from('conversation_participants') + .select('id') + .eq('conversation_id', normalizedConversationId) + .eq('user_id', internalUser.id) + .maybeSingle(); + + if (participantError || !participant) { + return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 }); + } + + // PostgREST `.in()` accepts an array, not a nested query builder. Page the + // conversation's IDs and update deliveries in bounded batches. + const batchSize = 500; + let offset = 0; + while (true) { + const { data: messageRows, error: messagesError } = await supabase + .from('messages') + .select('id') + .eq('conversation_id', normalizedConversationId) + .order('id') + .range(offset, offset + batchSize - 1); + + if (messagesError) { + console.error('Error loading conversation messages to mark read:', messagesError); + return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 }); + } + + const messageIds = (messageRows || []).map(({ id }) => id).filter(Boolean); + if (!messageIds.length) break; + + const { error: updateError } = await supabase + .from('deliveries') + .update({ read_ts: new Date().toISOString() }) + .eq('recipient_user_id', internalUser.id) + .is('read_ts', null) + .is('deleted_ts', null) + .in('message_id', messageIds); + + if (updateError) { + console.error('Error marking messages as read:', updateError); + return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 }); + } - if (updateError) { - console.error('Error marking messages as read:', updateError); - return NextResponse.json({ error: 'Failed to mark messages as read' }, { status: 500 }); + if (messageIds.length < batchSize) break; + offset += batchSize; } return NextResponse.json({ diff --git a/src/app/api/chat/conversations/[id]/mark-read/route.test.js b/src/app/api/chat/conversations/[id]/mark-read/route.test.js index 1bbae526..060f14e0 100644 --- a/src/app/api/chat/conversations/[id]/mark-read/route.test.js +++ b/src/app/api/chat/conversations/[id]/mark-read/route.test.js @@ -38,12 +38,23 @@ function createUsersQuery() { function createMessagesQuery() { const query = { select: vi.fn(() => query), - eq: mocks.messagesEq + eq: mocks.messagesEq, + order: vi.fn(() => query), + range: vi.fn().mockResolvedValue({ data: [{ id: 'message-id' }], error: null }) }; mocks.messagesEq.mockReturnValue(query); return query; } +function createParticipantsQuery() { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'participant-id' }, error: null }) + }; + return query; +} + function createDeliveriesQuery() { const query = { update: vi.fn(() => query), @@ -68,6 +79,7 @@ describe('POST /api/chat/conversations/[id]/mark-read', () => { mocks.from.mockImplementation((table) => { if (table === 'users') return createUsersQuery(); + if (table === 'conversation_participants') return createParticipantsQuery(); if (table === 'messages') return createMessagesQuery(); if (table === 'deliveries') return createDeliveriesQuery(); throw new Error(`Unexpected table: ${table}`); @@ -82,7 +94,7 @@ describe('POST /api/chat/conversations/[id]/mark-read', () => { expect(response.status).toBe(200); expect(mocks.messagesEq).toHaveBeenCalledWith('conversation_id', 'conversation-1'); - expect(mocks.deliveriesIn).toHaveBeenCalledWith('message_id', expect.any(Object)); + expect(mocks.deliveriesIn).toHaveBeenCalledWith('message_id', ['message-id']); }); it('rejects requests without a conversation id', async () => { diff --git a/src/app/api/chat/conversations/[id]/participants/route.js b/src/app/api/chat/conversations/[id]/participants/route.js index dd064bb6..b49609a8 100644 --- a/src/app/api/chat/conversations/[id]/participants/route.js +++ b/src/app/api/chat/conversations/[id]/participants/route.js @@ -185,6 +185,7 @@ export async function GET(request, { params } = {}) { return NextResponse.json({ success: true, participants: transformedParticipants, + current_user_id: user.id, conversation_id: conversationId }); diff --git a/src/app/api/messages/send/route.js b/src/app/api/messages/send/route.js index 6db3978e..32551c1d 100644 --- a/src/app/api/messages/send/route.js +++ b/src/app/api/messages/send/route.js @@ -193,7 +193,12 @@ export const POST = withAuth(async ({ request, locals }) => { return NextResponse.json({ success: true, - message: newMessage + // The messages row intentionally has no ciphertext. Return the sender's + // own recipient copy so the client can decrypt and render its acknowledgement. + message: { + ...newMessage, + encrypted_content: encryptedContents[userId] || null + } }); } catch (error) { console.error('Send message error:', error); diff --git a/src/cli/api.js b/src/cli/api.js index 1b3f0058..6a3467be 100644 --- a/src/cli/api.js +++ b/src/cli/api.js @@ -257,14 +257,24 @@ export class QcClient { const userIds = participants.map((p) => p.user_id); if (!userIds.length) throw new QcError('This conversation has no participants'); const { public_keys: keys = {} } = await this.request('/api/crypto/public-keys', { method: 'POST', body: { user_ids: userIds } }); + const ownId = this.me?.id; + const ownPublicKey = this.session.keys?.keys1024?.publicKey; + if (!ownId || !userIds.includes(ownId) || !ownPublicKey) { + throw new QcError('Could not identify this device’s own key in the conversation. Sign in again and retry.'); + } const encryptedContents = {}; const missing = []; for (const id of userIds) { - if (!keys[id]) { + const publicKey = id === ownId ? ownPublicKey : keys[id]; + if (!publicKey) { missing.push(id); continue; } - encryptedContents[id] = await this.ring.encrypt(text, keys[id]); + const encrypted = await this.ring.encrypt(text, publicKey); + if (id === ownId && (await this.ring.decrypt(encrypted)) !== text) { + throw new QcError('This device could not decrypt its own encrypted message. No message was sent.'); + } + encryptedContents[id] = encrypted; } if (!Object.keys(encryptedContents).length) throw new QcError('No participant has a public key yet'); const { message } = await this.request('/api/messages/send', { diff --git a/src/lib/crypto/multi-recipient-encryption.js b/src/lib/crypto/multi-recipient-encryption.js index 60acb899..28c2dbad 100644 --- a/src/lib/crypto/multi-recipient-encryption.js +++ b/src/lib/crypto/multi-recipient-encryption.js @@ -46,6 +46,7 @@ export class MultiRecipientEncryptionService { if (!this.isInitialized) { await this.initialize(); } + await postQuantumEncryption.refreshUserKeysFromStorage(); // Reset error collection for this encryption attempt this.encryptionErrors = []; @@ -73,6 +74,18 @@ export class MultiRecipientEncryptionService { throw new Error('No participant keys found for conversation'); } + // The published key can belong to a different device or a previous key + // backup. Encrypt this sender's copy to the keypair this device holds. + const ownUserId = participantKeys.currentUserId; + if (!ownUserId || !participantKeys.has(ownUserId)) { + throw new Error('Could not identify your own recipient key. Refresh the chat and try again.'); + } + const ownPublicKey = await publicKeyService.getMyPublicKey(); + if (!ownPublicKey) { + throw new Error('Your local encryption key is unavailable. Restore your keys in Settings.'); + } + participantKeys.set(ownUserId, ownPublicKey); + console.log(`🔐 [MULTI] Found ${participantKeys.size} participants to encrypt for`); // Encrypt the message for each participant @@ -110,12 +123,19 @@ export class MultiRecipientEncryptionService { messageContent, publicKey ); + if (userId === ownUserId) { + const decrypted = await postQuantumEncryption.decryptFromSender(encryptedContent, ''); + if (decrypted !== messageContent) { + throw new Error('Your message copy failed its local decrypt check. No message was sent.'); + } + } encryptedContents[userId] = encryptedContent; this.successfulEncryptionCount++; console.log(`🔐 [MULTI] ✅ Encrypted for participant: ${userId} using ML-KEM`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); + if (userId === ownUserId) throw new Error(errorMessage); this.failedEncryptionCount++; // Collect detailed error information @@ -442,4 +462,4 @@ export class MultiRecipientEncryptionService { } // Create and export singleton instance -export const multiRecipientEncryption = new MultiRecipientEncryptionService(); \ No newline at end of file +export const multiRecipientEncryption = new MultiRecipientEncryptionService(); diff --git a/src/lib/crypto/post-quantum-encryption.js b/src/lib/crypto/post-quantum-encryption.js index 534dbbf7..9cb3be21 100644 --- a/src/lib/crypto/post-quantum-encryption.js +++ b/src/lib/crypto/post-quantum-encryption.js @@ -62,6 +62,37 @@ export class PostQuantumEncryptionService { console.log(`🔐 Post-quantum encryption service initialized (${this.kemName} with ${this.kemName768} backward compatibility)`); } + /** + * Refresh in-memory keys from IndexedDB before encrypting or decrypting. + * Another open tab may have restored or rotated this device's keys while + * this singleton remained alive. + */ + async refreshUserKeysFromStorage() { + if (typeof window === 'undefined') return false; + + try { + const [keyData1024, keyData768] = await Promise.all([ + indexedDBManager.get(this.storageKey), + indexedDBManager.get(this.storageKey768) + ]); + let refreshed = false; + + if (keyData1024?.publicKey && keyData1024?.privateKey && keyData1024.algorithm === this.kemName) { + this.userKeys = { publicKey: keyData1024.publicKey, privateKey: keyData1024.privateKey }; + refreshed = true; + } + if (keyData768?.publicKey && keyData768?.privateKey && keyData768.algorithm === this.kemName768) { + this.userKeys768 = { publicKey: keyData768.publicKey, privateKey: keyData768.privateKey }; + refreshed = true; + } + + return refreshed; + } catch (error) { + console.warn('🔐 Could not refresh encryption keys from local storage:', error); + return false; + } + } + /** * Generate or load user's post-quantum key pair * @returns {Promise<{publicKey: string, privateKey: string}>} @@ -690,4 +721,4 @@ export class PostQuantumEncryptionService { } // Create and export singleton instance -export const postQuantumEncryption = new PostQuantumEncryptionService(); \ No newline at end of file +export const postQuantumEncryption = new PostQuantumEncryptionService(); diff --git a/src/lib/crypto/public-key-service.js b/src/lib/crypto/public-key-service.js index 9a13ef94..153e8d8a 100644 --- a/src/lib/crypto/public-key-service.js +++ b/src/lib/crypto/public-key-service.js @@ -211,7 +211,9 @@ export class PublicKeyService { const userIds = participants.map(/** @param {any} p */ (p) => p.user_id); console.log(`🔑 Getting public keys for ${userIds.length} participants in conversation ${conversationId}`); - return await this.getMultipleUserPublicKeys(userIds); + const participantKeys = await this.getMultipleUserPublicKeys(userIds); + participantKeys.currentUserId = data.current_user_id || null; + return participantKeys; } catch (error) { console.error(`🔑 ❌ Failed to get participant keys for conversation ${conversationId}:`, error); @@ -338,4 +340,4 @@ export class PublicKeyService { } // Create and export singleton instance -export const publicKeyService = new PublicKeyService(); \ No newline at end of file +export const publicKeyService = new PublicKeyService(); diff --git a/src/lib/stores/chat.js b/src/lib/stores/chat.js index 5bdb189a..8ae05099 100644 --- a/src/lib/stores/chat.js +++ b/src/lib/stores/chat.js @@ -26,6 +26,30 @@ export const useChatStore = create((set, get) => { let reconnectTimeout = null; let connectionToken = null; + function getLocalUserId() { + if (typeof window === 'undefined') return null; + try { + return JSON.parse(localStorage.getItem('qrypt_user') || 'null')?.id || null; + } catch { + return null; + } + } + + function normalizeEncryptedContent(value) { + if (value && typeof value === 'object' && value.type === 'Buffer' && Array.isArray(value.data)) { + value = new TextDecoder().decode(new Uint8Array(value.data)); + } + if (typeof value !== 'string') return value; + const text = value.trim(); + if (text.startsWith('{')) return text; + try { + const decoded = atob(text.replace(/-/g, '+').replace(/_/g, '/')); + return decoded.trimStart().startsWith('{') ? decoded : value; + } catch { + return value; + } + } + async function apiPost(endpoint, data = {}) { const response = await fetch(endpoint, { method: 'POST', @@ -111,6 +135,10 @@ export const useChatStore = create((set, get) => { const { activeConversation } = get(); if (messagePayload.shouldReloadMessages) { + // The send acknowledgement is already in local state. Don't let a + // self-broadcast race that acknowledgement and replace it with an + // incomplete database row. + if (message.sender_id === getLocalUserId()) return; if (activeConversation === message.conversation_id) { await get().loadMessages(message.conversation_id); } else { @@ -121,7 +149,8 @@ export const useChatStore = create((set, get) => { if (message.encrypted_content) { try { - message.content = await postQuantumEncryption.decryptFromSender(message.encrypted_content, ''); + await postQuantumEncryption.refreshUserKeysFromStorage(); + message.content = await postQuantumEncryption.decryptFromSender(normalizeEncryptedContent(message.encrypted_content), ''); } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (msg.includes('Algorithm mismatch') || msg.includes('invalid encapsulation key')) { @@ -220,10 +249,11 @@ export const useChatStore = create((set, get) => { const response = await apiPost('/api/messages/load', { conversationId }); if (response.success) { const msgs = response.messages || []; + await postQuantumEncryption.refreshUserKeysFromStorage(); for (const message of msgs) { if (message.encrypted_content) { try { - message.content = await postQuantumEncryption.decryptFromSender(message.encrypted_content, ''); + message.content = await postQuantumEncryption.decryptFromSender(normalizeEncryptedContent(message.encrypted_content), ''); } catch (error) { const errMsg = error instanceof Error ? error.message : String(error); if (errMsg.includes('Algorithm mismatch') || errMsg.includes('invalid encapsulation key') || errMsg.includes('invalid ciphertext')) { @@ -236,7 +266,19 @@ export const useChatStore = create((set, get) => { message.content = '[No content]'; } } - set({ activeConversation: conversationId, messages: msgs, loading: false, error: null, typingUsers: [] }); + // Read the optimistic rows at commit time so a send that completed + // while this fetch was in flight cannot be erased by this response. + const loadedIds = new Set(msgs.map((message) => message.id)); + const localMessages = get().activeConversation === conversationId + ? get().messages.filter((message) => message.localOnly && !loadedIds.has(message.id)) + : []; + set({ + activeConversation: conversationId, + messages: [...msgs, ...localMessages].sort((a, b) => new Date(a.created_at) - new Date(b.created_at)), + loading: false, + error: null, + typingUsers: [] + }); } } catch { set({ loading: false, error: 'Failed to load messages' }); @@ -251,16 +293,26 @@ export const useChatStore = create((set, get) => { if (replyToId) payload.replyToId = replyToId; const response = await apiPost('/api/messages/send', payload); if (response.success) { - const sentMessage = response.message; + const sentMessage = { ...response.message, localOnly: true }; if (sentMessage.encrypted_content) { try { - sentMessage.content = await postQuantumEncryption.decryptFromSender(sentMessage.encrypted_content, ''); + sentMessage.content = await postQuantumEncryption.decryptFromSender(normalizeEncryptedContent(sentMessage.encrypted_content), ''); } catch { sentMessage.content = content; } } else { sentMessage.content = content; } + + set((state) => { + if (state.activeConversation !== conversationId) { + return { activeConversation: conversationId, messages: [sentMessage] }; + } + if (!state.messages.some((message) => message.id === sentMessage.id)) { + return { messages: [...state.messages, sentMessage] }; + } + return { activeConversation: conversationId }; + }); return { success: true, data: sentMessage }; } } catch (error) { diff --git a/tests/stores/chat.test.js b/tests/stores/chat.test.js index 38f7a6db..5e9c2f6c 100644 --- a/tests/stores/chat.test.js +++ b/tests/stores/chat.test.js @@ -6,7 +6,10 @@ vi.mock('../../src/lib/crypto/multi-recipient-encryption.js', () => ({ })); vi.mock('../../src/lib/crypto/post-quantum-encryption.js', () => ({ - postQuantumEncryption: { decryptFromSender: vi.fn().mockResolvedValue('Decrypted message') }, + postQuantumEncryption: { + decryptFromSender: vi.fn().mockResolvedValue('Decrypted message'), + refreshUserKeysFromStorage: vi.fn().mockResolvedValue(false), + }, })); vi.mock('../../src/lib/crypto/public-key-service.js', () => ({