@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 +6 -1
- package/dist/{chunk-c8qkdept.js → chunk-4r99spcc.js} +14 -12
- package/dist/chunk-vfahhydf.js +190 -0
- 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
|
|
|
@@ -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";
|
|
@@ -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 ?? ((
|
|
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
|
-
|
|
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
|
|
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
|
|
1356
|
-
if (!
|
|
1357
|
+
let opened = await unlockSession();
|
|
1358
|
+
if (!opened) {
|
|
1357
1359
|
if (!interactive)
|
|
1358
1360
|
throw new Error("Not signed in. Run qc login.");
|
|
1359
|
-
|
|
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
|
|
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
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.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": {
|
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 };
|