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.16.0",
"version": "0.16.1",
"type": "module",
"bin": {
"qc": "./bin/qc.js",
Expand Down
1 change: 1 addition & 0 deletions packages/qryptchat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```

`<chat>` is a chat id or any unique part of its name. Add `--json` for machine output.
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.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": {
Expand Down
12 changes: 12 additions & 0 deletions src/cli/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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/<you> shows): emoji, pronouns, website, bio. */
async profile() {
const { user } = await this.request(`/api/users/by-username/${encodeURIComponent(this.me?.username ?? '')}`);
Expand Down
25 changes: 25 additions & 0 deletions src/cli/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.'));
Expand Down
2 changes: 1 addition & 1 deletion src/cli/tui.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
71 changes: 64 additions & 7 deletions src/lib/components/EncryptionWarning.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand All @@ -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
Expand All @@ -36,15 +62,44 @@ 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 {
setGenerating(false);
}
}

if (!checked || hasKeys) return null;
if (!checked || (hasKeys && !mismatch)) return null;

if (hasKeys && mismatch) {
return (
<div className="encryption-warning" role="alert">
<div className="warning-icon">⚠️</div>
<div className="warning-content">
<strong>This device has different keys from your account</strong>
<p>
Messages sent to you are encrypted to your account&apos;s key, which this browser doesn&apos;t hold, so they
won&apos;t open here. Restore your keys (backup PIN or key file) to read everything.
</p>
{error && <p className="warning-error">{error}</p>}
<div className="warning-actions">
<button type="button" className="warn-btn warn-btn-primary" onClick={() => router.push('/settings')} disabled={generating}>
Restore my keys
</button>
<button type="button" className="warn-btn warn-btn-secondary" onClick={handleUseThisDevice} disabled={generating}>
{generating ? 'Publishing…' : "Use this device's keys"}
</button>
</div>
</div>
<style>{warningCss}</style>
</div>
);
}

return (
<div className="encryption-warning">
Expand Down Expand Up @@ -79,7 +134,12 @@ export default function EncryptionWarning() {
loses access to all past messages.
</p>
</div>
<style>{`
<style>{warningCss}</style>
</div>
);
}

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; }
Expand All @@ -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; }
`}</style>
</div>
);
}
`;
41 changes: 31 additions & 10 deletions src/lib/crypto/key-sync-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>}
*/
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
Expand All @@ -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.
Expand All @@ -157,31 +178,31 @@ 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();
const dbKey = data?.public_key ?? null;

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';
}
}

Expand Down
21 changes: 19 additions & 2 deletions src/lib/crypto/key-sync-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions tests/cli/qc-resilience.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
});
});
Loading
Loading