@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 +6 -1
- package/dist/chunk-vfahhydf.js +190 -0
- package/dist/{chunk-c8qkdept.js → chunk-wxqsayqd.js} +39 -13
- package/dist/commands.js +2 -1
- package/dist/mcp.js +2 -1
- package/dist/tui.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-fb6zrz6m.js +0 -40
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
|
-
|
|
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
|
-
|
|
5
|
+
unlockSession,
|
|
6
|
+
storeSession,
|
|
6
7
|
clearSession
|
|
7
|
-
} from "./chunk-
|
|
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 ?? ((
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
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
|
|
1356
|
-
if (!
|
|
1381
|
+
let opened = await unlockSession();
|
|
1382
|
+
if (!opened) {
|
|
1357
1383
|
if (!interactive)
|
|
1358
1384
|
throw new Error("Not signed in. Run qc login.");
|
|
1359
|
-
|
|
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
|
|
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
package/dist/mcp.js
CHANGED
package/dist/tui.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "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": {
|
package/dist/chunk-fb6zrz6m.js
DELETED
|
@@ -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 };
|