Skip to content

Commit 504d6bc

Browse files
ralyodioclaude
andauthored
qc keeps keys and tokens sealed at rest (qc 0.3.0) (#280)
session.json was plaintext (mode 0600 only): both private ML-KEM keys and the refresh token readable by anything that could read the file. It is now v2: only the server and who you are stay open (qc whoami); tokens and keypairs are sealed with ChaCha20-Poly1305 under a 32-byte key from QC_PASSPHRASE (scrypt), the OS keychain (macOS Keychain or secret-tool), or a passphrase asked once per run. Refreshed tokens are re-sealed under the same key. A v1 file is sealed on first use. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 83c02e7 commit 504d6bc

8 files changed

Lines changed: 293 additions & 19 deletions

File tree

‎packages/qryptchat/README.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,12 @@ claude mcp add qryptchat -- qc mcp
7373

7474
Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back.
7575

76-
The session and keys are kept in `~/.config/qc/session.json` (or `$QC_HOME`), mode 0600. Sessions refresh themselves; `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.
76+
Your keys and tokens are **never stored in plaintext**. `~/.config/qc/session.json` (or `$QC_HOME`) keeps only your username and the server URL in the clear. Everything else is sealed with ChaCha20-Poly1305 under a key from:
77+
78+
- your OS keychain (macOS Keychain, or libsecret's `secret-tool` on a Linux desktop), so you're never asked; or
79+
- a passphrase, stretched with scrypt. qc asks for it once per run; scripts and `qc mcp` read it from `QC_PASSPHRASE`.
80+
81+
Sessions refresh themselves and are re-sealed each time. `qc logout` deletes them. Point at another server with `--url` or `QC_URL`.
7782

7883
## Licence
7984

‎packages/qryptchat/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@profullstack/qryptchat",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"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.",
55
"type": "module",
66
"bin": {

‎src/cli/api.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* stream for live updates. Sessions refresh themselves (rotating refresh
55
* tokens, POST /api/cli/token) and every refresh is written back to disk.
66
*/
7-
import { baseUrl, saveSession } from './config.js';
7+
import { baseUrl } from './config.js';
88
import { keyring } from './crypto.js';
99
import { foldMessages, reactionEnvelope, REACTION_TYPE } from '../lib/chat/reactions.js';
1010

@@ -24,7 +24,8 @@ export class QcClient {
2424
constructor(session, options = {}) {
2525
this.session = session;
2626
this.base = baseUrl(session, options.env);
27-
this.save = options.save ?? ((s) => saveSession(s, options.env));
27+
// Re-seals the session (see config.unlockSession); a client without one keeps refreshes in memory only.
28+
this.save = options.save ?? (() => {});
2829
this.fetch = options.fetch ?? globalThis.fetch;
2930
this.ring = keyring(session.keys);
3031
this.refreshing = null;

‎src/cli/commands.js‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* scripts and agents and prints plain text (or JSON with --json).
44
*/
55
import { QcClient } from './api.js';
6-
import { clearSession, loadSession, sessionPath } from './config.js';
6+
import { clearSession, loadSession, sessionPath, unlockSession } from './config.js';
77
import { login } from './login.js';
88

99
export const HELP = `qc: qrypt.chat in your terminal (end-to-end encrypted, ML-KEM-1024)
@@ -21,7 +21,8 @@ Usage:
2121
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
2222
2323
<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
24-
Keys and session live in ${'$'}QC_HOME or ~/.config/qc (mode 0600).`;
24+
Keys and tokens are sealed (ChaCha20-Poly1305) in ${'$'}QC_HOME or ~/.config/qc; the key is in your
25+
OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
2526

2627
export function parseArgs(argv) {
2728
const args = { _: [], flags: {} };
@@ -64,12 +65,13 @@ export function findChat(chats, query) {
6465

6566
async function client(flags, { interactive = false } = {}) {
6667
if (flags.url) process.env.QC_URL = flags.url;
67-
let session = loadSession();
68-
if (!session) {
68+
// The session on disk is sealed: unlocking may ask for the passphrase (or read QC_PASSPHRASE / the keychain).
69+
let opened = await unlockSession();
70+
if (!opened) {
6971
if (!interactive) throw new Error('Not signed in. Run qc login.');
70-
session = await login({ oob: !!flags.oob });
72+
opened = await login({ oob: !!flags.oob });
7173
}
72-
return new QcClient(session);
74+
return new QcClient(opened.session, { save: opened.save });
7375
}
7476

7577
const stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' });
@@ -98,8 +100,8 @@ export async function main(argv, { version = '0.0.0' } = {}) {
98100
return runTui(c, { initialChat: rest[0] });
99101
}
100102
case 'login': {
101-
const session = await login({ oob: !!flags.oob });
102-
return out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys saved to ${sessionPath()}.`);
103+
const { session } = await login({ oob: !!flags.oob });
104+
return out(`Signed in as @${session.user?.username ?? 'unknown'} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
103105
}
104106
case 'logout':
105107
clearSession();

‎src/cli/config.js‎

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
77
import { homedir } from 'node:os';
88
import { join } from 'node:path';
9+
import { dataKey, seal, unseal } from './vault.js';
910

1011
export const DEFAULT_URL = 'https://qrypt.chat';
1112

@@ -21,16 +22,18 @@ export function baseUrl(session, env = process.env) {
2122
return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, '');
2223
}
2324

25+
/** The session file as stored: v2 (sealed) or a v1 plaintext one from qc 0.1/0.2. */
2426
export function loadSession(env = process.env) {
2527
try {
26-
const session = JSON.parse(readFileSync(sessionPath(env), 'utf8'));
27-
return session && session.access_token && session.keys ? session : null;
28+
const file = JSON.parse(readFileSync(sessionPath(env), 'utf8'));
29+
if (file?.v === 2 && file.sealed && file.user) return file;
30+
return file && file.access_token && file.keys ? file : null;
2831
} catch {
2932
return null;
3033
}
3134
}
3235

33-
export function saveSession(session, env = process.env) {
36+
function writeFile(obj, env) {
3437
const dir = configDir(env);
3538
mkdirSync(dir, { recursive: true, mode: 0o700 });
3639
try {
@@ -40,10 +43,36 @@ export function saveSession(session, env = process.env) {
4043
}
4144
const file = sessionPath(env);
4245
const tmp = `${file}.${process.pid}.tmp`;
43-
writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 });
46+
writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 0o600 });
4447
renameSync(tmp, file);
4548
}
4649

50+
const accountOf = (session) => String(session.user?.id || session.user?.username || 'default');
51+
52+
/**
53+
* Open the stored session: { session, save } where save() re-seals under the
54+
* same key (token refresh never asks again). A v1 plaintext file is sealed on
55+
* the spot, so no private key stays readable on disk past first use.
56+
*/
57+
export async function unlockSession(env = process.env, opts = {}) {
58+
const file = loadSession(env);
59+
if (!file) return null;
60+
const existing = file.v === 2 ? file.sealed : undefined;
61+
const { key, source } = await dataKey(accountOf(file), { existing, env, ...opts });
62+
const session = file.v === 2 ? unseal(file, key) : file;
63+
const save = (s) => writeFile(seal(s, key, source), env);
64+
if (file.v !== 2) save(session);
65+
return { session, save };
66+
}
67+
68+
/** Seal and store a brand-new session (after qc login); returns save() for it. */
69+
export async function storeSession(session, env = process.env, opts = {}) {
70+
const { key, source } = await dataKey(accountOf(session), { env, ...opts });
71+
const save = (s) => writeFile(seal(s, key, source), env);
72+
save(session);
73+
return save;
74+
}
75+
4776
export function clearSession(env = process.env) {
4877
const file = sessionPath(env);
4978
if (existsSync(file)) rmSync(file);

‎src/cli/login.js‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { createServer } from 'node:http';
1313
import { hostname } from 'node:os';
1414
import { spawn } from 'node:child_process';
1515
import { createInterface } from 'node:readline/promises';
16-
import { baseUrl, saveSession } from './config.js';
16+
import { baseUrl, storeSession } from './config.js';
1717
import { ephemeralKeypair, openKeyBlob } from './crypto.js';
1818
import { matchCode } from '../lib/auth/cli-match.js';
1919

@@ -204,6 +204,7 @@ export async function login(options = {}) {
204204
keys,
205205
created_at: new Date().toISOString(),
206206
};
207-
saveSession(session, env);
208-
return session;
207+
// Sealed before anything touches disk: keys and tokens are never plaintext at rest.
208+
const save = await storeSession(session, env, options.vault);
209+
return { session, save };
209210
}

‎src/cli/vault.js‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**
2+
* qc's session at rest: sealed, never plaintext.
3+
*
4+
* session.json (v2) keeps only what `qc whoami` needs in the clear (the server
5+
* and who you are). The tokens and BOTH private keypairs live in `sealed`,
6+
* ChaCha20-Poly1305 under a 32-byte data key that comes from, in order:
7+
*
8+
* QC_PASSPHRASE scrypt(passphrase, salt): for scripts and `qc mcp`
9+
* the OS keychain a random key in macOS Keychain or libsecret
10+
* (secret-tool), so a desktop never asks
11+
* a passphrase prompt scrypt again, asked once per run on a terminal
12+
*
13+
* A v1 (plaintext) session from qc 0.1/0.2 is sealed the first time it is used.
14+
*/
15+
import { randomBytes, scryptSync } from 'node:crypto';
16+
import { spawnSync } from 'node:child_process';
17+
import { createInterface } from 'node:readline';
18+
import { chacha20poly1305 } from '@noble/ciphers/chacha.js';
19+
20+
export const SERVICE = 'qrypt-qc';
21+
const SCRYPT = { N: 1 << 16, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
22+
const SECRET_FIELDS = ['access_token', 'refresh_token', 'expires_at', 'expires_in', 'token_type', 'keys'];
23+
24+
const b64 = (u8) => Buffer.from(u8).toString('base64');
25+
const unb64 = (s) => Uint8Array.from(Buffer.from(s, 'base64'));
26+
27+
export function deriveKey(passphrase, salt) {
28+
return new Uint8Array(scryptSync(String(passphrase).normalize('NFKC'), Buffer.from(salt), 32, SCRYPT));
29+
}
30+
31+
/** Seal the secret half of a session; returns the v2 file object. */
32+
export function seal(session, key, source) {
33+
const secret = {};
34+
const open = {};
35+
for (const [k, v] of Object.entries(session)) (SECRET_FIELDS.includes(k) ? secret : open)[k] = v;
36+
// Plain Uint8Arrays: noble refuses a Buffer from another realm (jsdom, some bundlers).
37+
const nonce = Uint8Array.from(randomBytes(12));
38+
const ct = chacha20poly1305(Uint8Array.from(key), nonce).encrypt(Uint8Array.from(Buffer.from(JSON.stringify(secret), 'utf8')));
39+
return { v: 2, ...open, sealed: { alg: 'chacha20-poly1305', source: source.kind, ...(source.salt ? { salt: source.salt } : {}), nonce: b64(nonce), ct: b64(ct) } };
40+
}
41+
42+
/** Open a v2 file object with its data key; throws on a wrong key. */
43+
export function unseal(file, key) {
44+
const { nonce, ct } = file.sealed;
45+
let plain;
46+
try {
47+
plain = chacha20poly1305(Uint8Array.from(key), unb64(nonce)).decrypt(unb64(ct));
48+
} catch {
49+
throw new Error('Wrong passphrase, or the session was tampered with.');
50+
}
51+
const { v, sealed, ...open } = file;
52+
return { ...open, ...JSON.parse(Buffer.from(plain).toString('utf8')) };
53+
}
54+
55+
// ---- OS keychain ----------------------------------------------------------
56+
57+
function run(cmd, args, input) {
58+
try {
59+
const r = spawnSync(cmd, args, { input, encoding: 'utf8', timeout: 10_000 });
60+
return r.status === 0 ? { ok: true, out: (r.stdout || '').trim() } : { ok: false };
61+
} catch {
62+
return { ok: false };
63+
}
64+
}
65+
66+
/** The platform keychain, or null when there is none usable here. */
67+
export function keychain({ platform = process.platform, env = process.env } = {}) {
68+
if (env.QC_NO_KEYCHAIN) return null;
69+
if (platform === 'darwin') {
70+
return {
71+
get: (account) => {
72+
const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
73+
return r.ok && r.out ? r.out : null;
74+
},
75+
set: (account, secret) => run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w', secret]).ok,
76+
};
77+
}
78+
if (platform === 'linux' && (env.DBUS_SESSION_BUS_ADDRESS || env.DISPLAY || env.WAYLAND_DISPLAY) && run('secret-tool', ['--version']).ok !== false) {
79+
const probe = spawnSync('which', ['secret-tool'], { encoding: 'utf8' });
80+
if (probe.status !== 0) return null;
81+
return {
82+
get: (account) => {
83+
const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
84+
return r.ok && r.out ? r.out : null;
85+
},
86+
set: (account, secret) => run('secret-tool', ['store', '--label', 'qc (qrypt.chat) session key', 'service', SERVICE, 'account', account], secret).ok,
87+
};
88+
}
89+
return null;
90+
}
91+
92+
// ---- passphrase prompt ----------------------------------------------------
93+
94+
/** Ask on the terminal without echoing. */
95+
export function askHidden(question) {
96+
return new Promise((resolve, reject) => {
97+
if (!process.stdin.isTTY) {
98+
reject(new Error('qc needs a passphrase to unlock your keys: set QC_PASSPHRASE, or run it on a terminal.'));
99+
return;
100+
}
101+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
102+
rl._writeToOutput = (s) => {
103+
if (s.includes(question)) process.stderr.write(s);
104+
};
105+
rl.question(question, (answer) => {
106+
rl.close();
107+
process.stderr.write('\n');
108+
resolve(answer);
109+
});
110+
});
111+
}
112+
113+
/**
114+
* The data key for an account. `existing` is the sealed block of a session
115+
* already on disk (decides the source); without it a new key is set up.
116+
*/
117+
export async function dataKey(account, { existing, env = process.env, ask = askHidden, chain = keychain({ env }) } = {}) {
118+
if (existing) {
119+
if (existing.source === 'keychain') {
120+
const k = chain?.get(account);
121+
if (!k) throw new Error('Your session key is not in the keychain any more. Run qc login again.');
122+
return { key: unb64(k), source: { kind: 'keychain' } };
123+
}
124+
const pass = env.QC_PASSPHRASE ?? (await ask(' Passphrase for your qc keys: '));
125+
return { key: deriveKey(pass, existing.salt), source: { kind: 'passphrase', salt: existing.salt } };
126+
}
127+
if (env.QC_PASSPHRASE) {
128+
const salt = b64(randomBytes(16));
129+
return { key: deriveKey(env.QC_PASSPHRASE, salt), source: { kind: 'passphrase', salt } };
130+
}
131+
if (chain) {
132+
const raw = randomBytes(32);
133+
if (chain.set(account, b64(raw))) return { key: new Uint8Array(raw), source: { kind: 'keychain' } };
134+
}
135+
process.stderr.write('\n qc keeps your keys encrypted. Choose a passphrase (you will need it each time qc starts).\n');
136+
for (;;) {
137+
const a = await ask(' New passphrase: ');
138+
if (a.length < 8) {
139+
process.stderr.write(' At least 8 characters, please.\n');
140+
continue;
141+
}
142+
const b = await ask(' Again: ');
143+
if (a !== b) {
144+
process.stderr.write(' Those did not match.\n');
145+
continue;
146+
}
147+
const salt = b64(randomBytes(16));
148+
return { key: deriveKey(a, salt), source: { kind: 'passphrase', salt } };
149+
}
150+
}

0 commit comments

Comments
 (0)