@profullstack/qryptchat 0.2.0 → 0.4.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
 
@@ -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 };
@@ -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";
@@ -897,6 +898,28 @@ function foldMessages(messages, myUserId) {
897
898
  });
898
899
  }
899
900
 
901
+ // src/lib/chat/calls.js
902
+ var CALL_TYPE = "call";
903
+ var CALL_JOINABLE_MS = 6 * 60 * 60 * 1000;
904
+ function parseCall(content) {
905
+ try {
906
+ const d = JSON.parse(content);
907
+ if (d?.type !== CALL_TYPE || typeof d.room !== "string" || typeof d.key !== "string")
908
+ return null;
909
+ const key = Uint8Array.from(atob(d.key), (c) => c.charCodeAt(0));
910
+ if (key.length < 32)
911
+ return null;
912
+ return { room: d.room, key, video: !!d.video, at: typeof d.at === "string" ? d.at : null };
913
+ } catch {
914
+ return null;
915
+ }
916
+ }
917
+ function callSummary(message, myUserId) {
918
+ const call = parseCall(message.content);
919
+ const who = message.sender_id === myUserId ? "You" : message.sender?.display_name || message.sender?.username || "Someone";
920
+ return `\uD83D\uDCDE ${who} started ${call?.video ? "a video" : "a voice"} call`;
921
+ }
922
+
900
923
  // src/cli/api.js
901
924
  class QcError extends Error {
902
925
  constructor(message, status) {
@@ -910,7 +933,7 @@ class QcClient {
910
933
  constructor(session, options = {}) {
911
934
  this.session = session;
912
935
  this.base = baseUrl(session, options.env);
913
- this.save = options.save ?? ((s) => saveSession(s, options.env));
936
+ this.save = options.save ?? (() => {});
914
937
  this.fetch = options.fetch ?? globalThis.fetch;
915
938
  this.ring = keyring(session.keys);
916
939
  this.refreshing = null;
@@ -972,7 +995,9 @@ class QcClient {
972
995
  const raw = [];
973
996
  for (const m of messages) {
974
997
  let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
975
- if (m.message_type === "file" || m.has_attachments) {
998
+ if (m.message_type === CALL_TYPE) {
999
+ content = `${callSummary({ ...m, content }, this.me?.id)} · join on qrypt.chat`;
1000
+ } else if (m.message_type === "file" || m.has_attachments) {
976
1001
  content = `\uD83D\uDCCE ${content && content !== "[File attachment]" ? `${content} ` : ""}(attachment: open qrypt.chat to download)`;
977
1002
  }
978
1003
  raw.push({ ...m, content });
@@ -1285,8 +1310,8 @@ async function login(options = {}) {
1285
1310
  keys,
1286
1311
  created_at: new Date().toISOString()
1287
1312
  };
1288
- saveSession(session, env);
1289
- return session;
1313
+ const save = await storeSession(session, env, options.vault);
1314
+ return { session, save };
1290
1315
  }
1291
1316
 
1292
1317
  // src/cli/commands.js
@@ -1305,7 +1330,8 @@ Usage:
1305
1330
  qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
1306
1331
 
1307
1332
  <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).`;
1333
+ Keys and tokens are sealed (ChaCha20-Poly1305) in ${"$"}QC_HOME or ~/.config/qc; the key is in your
1334
+ OS keychain, or comes from a passphrase (QC_PASSPHRASE for scripts and qc mcp).`;
1309
1335
  function parseArgs2(argv) {
1310
1336
  const args = { _: [], flags: {} };
1311
1337
  for (let i = 0;i < argv.length; i++) {
@@ -1352,13 +1378,13 @@ function findChat2(chats, query) {
1352
1378
  async function client(flags, { interactive = false } = {}) {
1353
1379
  if (flags.url)
1354
1380
  process.env.QC_URL = flags.url;
1355
- let session = loadSession();
1356
- if (!session) {
1381
+ let opened = await unlockSession();
1382
+ if (!opened) {
1357
1383
  if (!interactive)
1358
1384
  throw new Error("Not signed in. Run qc login.");
1359
- session = await login({ oob: !!flags.oob });
1385
+ opened = await login({ oob: !!flags.oob });
1360
1386
  }
1361
- return new QcClient(session);
1387
+ return new QcClient(opened.session, { save: opened.save });
1362
1388
  }
1363
1389
  var stamp = (iso) => new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
1364
1390
  async function readStdin() {
@@ -1388,8 +1414,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1388
1414
  return runTui(c, { initialChat: rest[0] });
1389
1415
  }
1390
1416
  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()}.`);
1417
+ const { session } = await login({ oob: !!flags.oob });
1418
+ return out(`Signed in as @${session.user?.username ?? "unknown"} on ${session.base}. Keys sealed (ChaCha20-Poly1305) in ${sessionPath()}.`);
1393
1419
  }
1394
1420
  case "logout":
1395
1421
  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-wxqsayqd.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-wxqsayqd.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.4.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 };