@profullstack/qryptchat 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,7 +73,12 @@ claude mcp add qryptchat -- qc mcp
73
73
 
74
74
  Over SSH, `qc login --oob` shows a code in the browser for you to paste instead of redirecting back.
75
75
 
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`.
77
82
 
78
83
  ## Licence
79
84
 
@@ -2,9 +2,10 @@ import {
2
2
  sessionPath,
3
3
  baseUrl,
4
4
  loadSession,
5
- saveSession,
5
+ unlockSession,
6
+ storeSession,
6
7
  clearSession
7
- } from "./chunk-fb6zrz6m.js";
8
+ } from "./chunk-vfahhydf.js";
8
9
 
9
10
  // src/cli/crypto.js
10
11
  import { MlKem1024 as MlKem10242 } from "mlkem";
@@ -910,7 +911,7 @@ class QcClient {
910
911
  constructor(session, options = {}) {
911
912
  this.session = session;
912
913
  this.base = baseUrl(session, options.env);
913
- this.save = options.save ?? ((s) => saveSession(s, options.env));
914
+ this.save = options.save ?? (() => {});
914
915
  this.fetch = options.fetch ?? globalThis.fetch;
915
916
  this.ring = keyring(session.keys);
916
917
  this.refreshing = null;
@@ -1285,8 +1286,8 @@ async function login(options = {}) {
1285
1286
  keys,
1286
1287
  created_at: new Date().toISOString()
1287
1288
  };
1288
- saveSession(session, env);
1289
- return session;
1289
+ const save = await storeSession(session, env, options.vault);
1290
+ return { session, save };
1290
1291
  }
1291
1292
 
1292
1293
  // src/cli/commands.js
@@ -1305,7 +1306,8 @@ Usage:
1305
1306
  qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
1306
1307
 
1307
1308
  <chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
1308
- Keys and session live in ${"$"}QC_HOME or ~/.config/qc (mode 0600).`;
1309
+ Keys and tokens are sealed (ChaCha20-Poly1305) in ${"$"}QC_HOME or ~/.config/qc; the key is in your
1310
+ OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
1309
1311
  function parseArgs2(argv) {
1310
1312
  const args = { _: [], flags: {} };
1311
1313
  for (let i = 0;i < argv.length; i++) {
@@ -1352,13 +1354,13 @@ function findChat2(chats, query) {
1352
1354
  async function client(flags, { interactive = false } = {}) {
1353
1355
  if (flags.url)
1354
1356
  process.env.QC_URL = flags.url;
1355
- let session = loadSession();
1356
- if (!session) {
1357
+ let opened = await unlockSession();
1358
+ if (!opened) {
1357
1359
  if (!interactive)
1358
1360
  throw new Error("Not signed in. Run qc login.");
1359
- session = await login({ oob: !!flags.oob });
1361
+ opened = await login({ oob: !!flags.oob });
1360
1362
  }
1361
- return new QcClient(session);
1363
+ return new QcClient(opened.session, { save: opened.save });
1362
1364
  }
1363
1365
  var stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
1364
1366
  async function readStdin() {
@@ -1388,8 +1390,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1388
1390
  return runTui(c, { initialChat: rest[0] });
1389
1391
  }
1390
1392
  case "login": {
1391
- const session = await login({ oob: !!flags.oob });
1392
- return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys saved to ${sessionPath()}.`);
1393
+ const { session } = await login({ oob: !!flags.oob });
1394
+ return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
1393
1395
  }
1394
1396
  case "logout":
1395
1397
  clearSession();
@@ -0,0 +1,190 @@
1
+ // src/cli/config.js
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ // src/cli/vault.js
7
+ import { randomBytes, scryptSync } from "node:crypto";
8
+ import { spawnSync } from "node:child_process";
9
+ import { createInterface } from "node:readline";
10
+ import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
11
+ var SERVICE = "qrypt-qc";
12
+ var SCRYPT = { N: 1 << 16, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
13
+ var SECRET_FIELDS = ["access_token", "refresh_token", "expires_at", "expires_in", "token_type", "keys"];
14
+ var b64 = (u8) => Buffer.from(u8).toString("base64");
15
+ var unb64 = (s) => Uint8Array.from(Buffer.from(s, "base64"));
16
+ function deriveKey(passphrase, salt) {
17
+ return new Uint8Array(scryptSync(String(passphrase).normalize("NFKC"), Buffer.from(salt), 32, SCRYPT));
18
+ }
19
+ function seal(session, key, source) {
20
+ const secret = {};
21
+ const open = {};
22
+ for (const [k, v] of Object.entries(session))
23
+ (SECRET_FIELDS.includes(k) ? secret : open)[k] = v;
24
+ const nonce = Uint8Array.from(randomBytes(12));
25
+ const ct = chacha20poly1305(Uint8Array.from(key), nonce).encrypt(Uint8Array.from(Buffer.from(JSON.stringify(secret), "utf8")));
26
+ return { v: 2, ...open, sealed: { alg: "chacha20-poly1305", source: source.kind, ...source.salt ? { salt: source.salt } : {}, nonce: b64(nonce), ct: b64(ct) } };
27
+ }
28
+ function unseal(file, key) {
29
+ const { nonce, ct } = file.sealed;
30
+ let plain;
31
+ try {
32
+ plain = chacha20poly1305(Uint8Array.from(key), unb64(nonce)).decrypt(unb64(ct));
33
+ } catch {
34
+ throw new Error("Wrong passphrase, or the session was tampered with.");
35
+ }
36
+ const { v, sealed, ...open } = file;
37
+ return { ...open, ...JSON.parse(Buffer.from(plain).toString("utf8")) };
38
+ }
39
+ function run(cmd, args, input) {
40
+ try {
41
+ const r = spawnSync(cmd, args, { input, encoding: "utf8", timeout: 1e4 });
42
+ return r.status === 0 ? { ok: true, out: (r.stdout || "").trim() } : { ok: false };
43
+ } catch {
44
+ return { ok: false };
45
+ }
46
+ }
47
+ function keychain({ platform = process.platform, env = process.env } = {}) {
48
+ if (env.QC_NO_KEYCHAIN)
49
+ return null;
50
+ if (platform === "darwin") {
51
+ return {
52
+ get: (account) => {
53
+ const r = run("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"]);
54
+ return r.ok && r.out ? r.out : null;
55
+ },
56
+ set: (account, secret) => run("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret]).ok
57
+ };
58
+ }
59
+ if (platform === "linux" && (env.DBUS_SESSION_BUS_ADDRESS || env.DISPLAY || env.WAYLAND_DISPLAY) && run("secret-tool", ["--version"]).ok !== false) {
60
+ const probe = spawnSync("which", ["secret-tool"], { encoding: "utf8" });
61
+ if (probe.status !== 0)
62
+ return null;
63
+ return {
64
+ get: (account) => {
65
+ const r = run("secret-tool", ["lookup", "service", SERVICE, "account", account]);
66
+ return r.ok && r.out ? r.out : null;
67
+ },
68
+ set: (account, secret) => run("secret-tool", ["store", "--label", "qc (qrypt.chat) session key", "service", SERVICE, "account", account], secret).ok
69
+ };
70
+ }
71
+ return null;
72
+ }
73
+ function askHidden(question) {
74
+ return new Promise((resolve, reject) => {
75
+ if (!process.stdin.isTTY) {
76
+ reject(new Error("qc needs a passphrase to unlock your keys: set QC_PASSPHRASE, or run it on a terminal."));
77
+ return;
78
+ }
79
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
80
+ rl._writeToOutput = (s) => {
81
+ if (s.includes(question))
82
+ process.stderr.write(s);
83
+ };
84
+ rl.question(question, (answer) => {
85
+ rl.close();
86
+ process.stderr.write(`
87
+ `);
88
+ resolve(answer);
89
+ });
90
+ });
91
+ }
92
+ async function dataKey(account, { existing, env = process.env, ask = askHidden, chain = keychain({ env }) } = {}) {
93
+ if (existing) {
94
+ if (existing.source === "keychain") {
95
+ const k = chain?.get(account);
96
+ if (!k)
97
+ throw new Error("Your session key is not in the keychain any more. Run qc login again.");
98
+ return { key: unb64(k), source: { kind: "keychain" } };
99
+ }
100
+ const pass = env.QC_PASSPHRASE ?? await ask(" Passphrase for your qc keys: ");
101
+ return { key: deriveKey(pass, existing.salt), source: { kind: "passphrase", salt: existing.salt } };
102
+ }
103
+ if (env.QC_PASSPHRASE) {
104
+ const salt = b64(randomBytes(16));
105
+ return { key: deriveKey(env.QC_PASSPHRASE, salt), source: { kind: "passphrase", salt } };
106
+ }
107
+ if (chain) {
108
+ const raw = randomBytes(32);
109
+ if (chain.set(account, b64(raw)))
110
+ return { key: new Uint8Array(raw), source: { kind: "keychain" } };
111
+ }
112
+ process.stderr.write(`
113
+ qc keeps your keys encrypted. Choose a passphrase (you will need it each time qc starts).
114
+ `);
115
+ for (;; ) {
116
+ const a = await ask(" New passphrase: ");
117
+ if (a.length < 8) {
118
+ process.stderr.write(` At least 8 characters, please.
119
+ `);
120
+ continue;
121
+ }
122
+ const b = await ask(" Again: ");
123
+ if (a !== b) {
124
+ process.stderr.write(` Those did not match.
125
+ `);
126
+ continue;
127
+ }
128
+ const salt = b64(randomBytes(16));
129
+ return { key: deriveKey(a, salt), source: { kind: "passphrase", salt } };
130
+ }
131
+ }
132
+
133
+ // src/cli/config.js
134
+ var DEFAULT_URL = "https://qrypt.chat";
135
+ function configDir(env = process.env) {
136
+ if (env.QC_HOME)
137
+ return env.QC_HOME;
138
+ return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
139
+ }
140
+ var sessionPath = (env = process.env) => join(configDir(env), "session.json");
141
+ function baseUrl(session, env = process.env) {
142
+ return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
143
+ }
144
+ function loadSession(env = process.env) {
145
+ try {
146
+ const file = JSON.parse(readFileSync(sessionPath(env), "utf8"));
147
+ if (file?.v === 2 && file.sealed && file.user)
148
+ return file;
149
+ return file && file.access_token && file.keys ? file : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+ function writeFile(obj, env) {
155
+ const dir = configDir(env);
156
+ mkdirSync(dir, { recursive: true, mode: 448 });
157
+ try {
158
+ chmodSync(dir, 448);
159
+ } catch {}
160
+ const file = sessionPath(env);
161
+ const tmp = `${file}.${process.pid}.tmp`;
162
+ writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: 384 });
163
+ renameSync(tmp, file);
164
+ }
165
+ var accountOf = (session) => String(session.user?.id || session.user?.username || "default");
166
+ async function unlockSession(env = process.env, opts = {}) {
167
+ const file = loadSession(env);
168
+ if (!file)
169
+ return null;
170
+ const existing = file.v === 2 ? file.sealed : undefined;
171
+ const { key, source } = await dataKey(accountOf(file), { existing, env, ...opts });
172
+ const session = file.v === 2 ? unseal(file, key) : file;
173
+ const save = (s) => writeFile(seal(s, key, source), env);
174
+ if (file.v !== 2)
175
+ save(session);
176
+ return { session, save };
177
+ }
178
+ async function storeSession(session, env = process.env, opts = {}) {
179
+ const { key, source } = await dataKey(accountOf(session), { env, ...opts });
180
+ const save = (s) => writeFile(seal(s, key, source), env);
181
+ save(session);
182
+ return save;
183
+ }
184
+ function clearSession(env = process.env) {
185
+ const file = sessionPath(env);
186
+ if (existsSync(file))
187
+ rmSync(file);
188
+ }
189
+
190
+ export { configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession };
package/dist/commands.js CHANGED
@@ -3,7 +3,8 @@ import {
3
3
  parseArgs2,
4
4
  findChat2,
5
5
  main2
6
- } from "./chunk-c8qkdept.js";
6
+ } from "./chunk-4r99spcc.js";
7
+ import"./chunk-vfahhydf.js";
7
8
  export {
8
9
  HELP2 as HELP,
9
10
  findChat2 as findChat,
package/dist/mcp.js CHANGED
@@ -1,6 +1,7 @@
1
+ import"./chunk-vfahhydf.js";
1
2
  import {
2
3
  findChat2
3
- } from "./chunk-c8qkdept.js";
4
+ } from "./chunk-4r99spcc.js";
4
5
 
5
6
  // src/cli/mcp.js
6
7
  import { createInterface } from "node:readline";
package/dist/tui.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  configDir
3
- } from "./chunk-fb6zrz6m.js";
3
+ } from "./chunk-vfahhydf.js";
4
4
 
5
5
  // src/cli/tui.js
6
6
  import { createApp, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,40 +0,0 @@
1
- // src/cli/config.js
2
- import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- var DEFAULT_URL = "https://qrypt.chat";
6
- function configDir(env = process.env) {
7
- if (env.QC_HOME)
8
- return env.QC_HOME;
9
- return join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "qc");
10
- }
11
- var sessionPath = (env = process.env) => join(configDir(env), "session.json");
12
- function baseUrl(session, env = process.env) {
13
- return (env.QC_URL || session?.base || DEFAULT_URL).replace(/\/+$/, "");
14
- }
15
- function loadSession(env = process.env) {
16
- try {
17
- const session = JSON.parse(readFileSync(sessionPath(env), "utf8"));
18
- return session && session.access_token && session.keys ? session : null;
19
- } catch {
20
- return null;
21
- }
22
- }
23
- function saveSession(session, env = process.env) {
24
- const dir = configDir(env);
25
- mkdirSync(dir, { recursive: true, mode: 448 });
26
- try {
27
- chmodSync(dir, 448);
28
- } catch {}
29
- const file = sessionPath(env);
30
- const tmp = `${file}.${process.pid}.tmp`;
31
- writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 384 });
32
- renameSync(tmp, file);
33
- }
34
- function clearSession(env = process.env) {
35
- const file = sessionPath(env);
36
- if (existsSync(file))
37
- rmSync(file);
38
- }
39
-
40
- export { configDir, sessionPath, baseUrl, loadSession, saveSession, clearSession };