keebox 0.0.0-stage → 0.9.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/utils/index.js ADDED
@@ -0,0 +1,295 @@
1
+ function isAndroid() {
2
+ return require("os").platform() === "android";
3
+ }
4
+
5
+ const colors = require("./colors");
6
+ const log = require("./log");
7
+ const { FIELDS, SECRET_FIELDS } = require("./fields");
8
+ const crypto = require("crypto");
9
+ const path = require("path");
10
+ const { once } = require("events");
11
+ const prompt = require("./prompt");
12
+ const drawTable = require("./table");
13
+ const parseArgs = require("./cli");
14
+
15
+ const dbPath = path.resolve(__dirname, "../db.json");
16
+ const credPath = path.resolve(__dirname, "../credentials.json");
17
+
18
+ // cryptographically secure, exact length, at least one char of each class (length >= 4)
19
+ function mkPass(length) {
20
+ const sets = ["abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", "0123456789", "!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"];
21
+ const all = sets.join("");
22
+ const pick = (str) => str[crypto.randomInt(str.length)];
23
+ const chars = sets.map(pick);
24
+ while (chars.length < length) chars.push(pick(all));
25
+ // Fisher-Yates shuffle
26
+ for (let i = chars.length - 1; i > 0; i--) {
27
+ const j = crypto.randomInt(i + 1);
28
+ [chars[i], chars[j]] = [chars[j], chars[i]];
29
+ }
30
+ return chars.join("");
31
+ }
32
+
33
+ // AES-256-GCM with the vault key -> "iv.tag.data" (no key stored)
34
+ function encrypt(text, key) {
35
+ const iv = crypto.randomBytes(12);
36
+ const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
37
+ const data = Buffer.concat([cipher.update(text, "utf8"), cipher.final()]);
38
+ return [iv.toString("hex"), cipher.getAuthTag().toString("hex"), data.toString("hex")].join(".");
39
+ }
40
+
41
+ function decrypt(str, key) {
42
+ if (!isEncrypted(str)) throw new Error("unknown encryption format");
43
+ const [iv, tag, data] = str.split(".");
44
+ const decipher = crypto.createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"), { authTagLength: 16 });
45
+ decipher.setAuthTag(Buffer.from(tag, "hex"));
46
+ return Buffer.concat([decipher.update(Buffer.from(data, "hex")), decipher.final()]).toString("utf8");
47
+ }
48
+
49
+ // made by encrypt() ("iv.tag.data")
50
+ function isEncrypted(value) {
51
+ return /^[0-9a-f]{24}\.[0-9a-f]{32}\.[0-9a-f]*$/.test(String(value));
52
+ }
53
+
54
+ // decrypt one secret field of an entry -> text ("" if empty), or null after printing the error
55
+ function decryptField(entry, field, key) {
56
+ if (!entry[field]) return "";
57
+ try {
58
+ return decrypt(entry[field], key);
59
+ } catch (err) {
60
+ log.error("Couldn't decrypt " + field + " of entry #" + entry.id + " (damaged)");
61
+ process.exitCode = 1;
62
+ return null;
63
+ }
64
+ }
65
+
66
+ // safe write: temp file + fsync + rename (atomic), keeps previous version as <file>.bak
67
+ function saveJson(file, data) {
68
+ const fs = require("fs");
69
+ const tmp = file + ".tmp";
70
+ const fd = fs.openSync(tmp, "w");
71
+ try {
72
+ fs.writeFileSync(fd, JSON.stringify(data, null, 2));
73
+ fs.fsyncSync(fd);
74
+ } finally {
75
+ fs.closeSync(fd);
76
+ }
77
+ if (fs.existsSync(file)) fs.copyFileSync(file, file + ".bak");
78
+ fs.renameSync(tmp, file);
79
+ }
80
+
81
+ // read json file, create it with `fallback` if missing
82
+ function loadJson(file, fallback) {
83
+ const fs = require("fs");
84
+ if (!fs.existsSync(file)) {
85
+ saveJson(file, fallback);
86
+ log.hint("Created " + file);
87
+ }
88
+ return JSON.parse(fs.readFileSync(file).toString());
89
+ }
90
+
91
+ // clipboard commands for this OS: [command, args, stdin encoding]
92
+ function clipboardCommands() {
93
+ const env = process.env;
94
+ if (process.platform === "darwin") return { copy: ["pbcopy", []], paste: ["pbpaste", []] };
95
+ if (process.platform === "win32" || env.WSL_DISTRO_NAME) {
96
+ const exe = process.platform === "win32" ? "" : ".exe"; // WSL calls the Windows binaries
97
+ return { copy: ["clip" + exe, [], "utf16le"], paste: ["powershell" + exe, ["-NoProfile", "-Command", "Get-Clipboard"]] };
98
+ }
99
+ if (env.WAYLAND_DISPLAY) return { copy: ["wl-copy", []], paste: ["wl-paste", ["--no-newline"]], clear: ["wl-copy", ["--clear"]] };
100
+ return { copy: ["xclip", ["-selection", "clipboard"]], paste: ["xclip", ["-selection", "clipboard", "-o"]] };
101
+ }
102
+
103
+ // runs in a detached node process (serialized with .toString()), so it must be self-contained
104
+ async function clearClipboardLater(cmds, ms) {
105
+ const cp = require("child_process");
106
+ let text = "";
107
+ process.stdin.setEncoding("utf8");
108
+ for await (const chunk of process.stdin) text += chunk;
109
+ await require("timers/promises").setTimeout(ms);
110
+ const opts = { windowsHide: true, timeout: 5000 };
111
+ const cur = cp.spawnSync(cmds.paste[0], cmds.paste[1], { ...opts, encoding: "utf8" });
112
+ if (cur.status !== 0 || ![text, text + "\n", text + "\r\n"].includes(cur.stdout)) return; // changed since, leave it
113
+ const clear = cmds.clear || cmds.copy;
114
+ cp.spawnSync(clear[0], clear[1], { ...opts, input: "", stdio: ["pipe", "ignore", "ignore"] });
115
+ }
116
+
117
+ // copy to clipboard, then clear it after `seconds` (only if it still holds the same text)
118
+ // -> seconds until it clears (0 = couldn't schedule clearing), throws if copying failed
119
+ async function copyToClipboard(text, seconds = 20) {
120
+ const { spawn } = require("child_process");
121
+ const cmds = clipboardCommands();
122
+ const [command, args, encoding] = cmds.copy;
123
+
124
+ const child = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
125
+ child.on("error", () => {}); // reported through once() below
126
+ child.stdin.on("error", () => {});
127
+ child.stdin.end(Buffer.from(String(text), encoding || "utf8"));
128
+ // xclip/wl-copy stay in the background to serve the clipboard, so wait for "exit" (not stdio "close")
129
+ const [code] = await once(child, "exit"); // rejects on "error", e.g. ENOENT: command not installed
130
+ if (code) throw new Error(command + " exited with code " + code);
131
+
132
+ try {
133
+ const script = "(" + clearClipboardLater.toString() + ")(" + JSON.stringify(cmds) + "," + seconds * 1000 + ");";
134
+ const clearer = spawn(process.execPath, ["-e", script], { detached: true, stdio: ["pipe", "ignore", "ignore"], windowsHide: true });
135
+ clearer.on("error", () => {});
136
+ clearer.unref();
137
+ clearer.stdin.end(String(text)); // password goes via stdin, not argv
138
+ await once(clearer.stdin, "finish");
139
+ return seconds;
140
+ } catch (err) {
141
+ return 0; // copied, but couldn't schedule clearing
142
+ }
143
+ }
144
+
145
+ // ---- shared app helpers ----
146
+
147
+ // db.json content -> the same { recovery, accounts } object, or throws when it has another shape
148
+ function parseDb(raw) {
149
+ const ok = raw && typeof raw === "object" && !Array.isArray(raw) && "recovery" in raw && (raw.recovery === null || typeof raw.recovery === "object") && Array.isArray(raw.accounts);
150
+ if (!ok) throw new Error('db.json must be { "recovery": null | { salt, n, key }, "accounts": [...] }');
151
+ return raw;
152
+ }
153
+
154
+ // load db.json + credentials.json (created if missing) -> { db, cred }, or null after printing the error
155
+ // db = { recovery, accounts }: saving `db` writes the whole db.json
156
+ function loadData() {
157
+ try {
158
+ const db = parseDb(loadJson(dbPath, { recovery: null, accounts: [] }));
159
+ const cred = loadJson(credPath, {});
160
+ if (!cred || typeof cred !== "object" || Array.isArray(cred)) throw new Error("credentials.json must be an object");
161
+ return { db, cred };
162
+ } catch (err) {
163
+ log.error("Couldn't connect to db! (backups: db.json.bak, credentials.json.bak)", "\n", err);
164
+ return null;
165
+ }
166
+ }
167
+
168
+ // save, then print `okMsg` with log[kind] ("added", "updated", "removed", "ok") -> true, or print `errMsg` + the error -> false
169
+ function saveOrReport(file, data, okMsg, errMsg, kind = "added") {
170
+ try {
171
+ saveJson(file, data);
172
+ } catch (err) {
173
+ log.error(errMsg, err);
174
+ process.exitCode = 1;
175
+ return false;
176
+ }
177
+ log[kind](okMsg);
178
+ return true;
179
+ }
180
+
181
+ // single hidden password prompt -> password
182
+ async function askPassword(message) {
183
+ return (await prompt([{ type: "password", name: "password", message }])).password;
184
+ }
185
+
186
+ // yes/no prompt -> true/false
187
+ async function confirm(message) {
188
+ return (await prompt([{ type: "confirm", name: "ok", message }])).ok;
189
+ }
190
+
191
+ // run an async entry point: unexpected errors are printed and exit with code 1
192
+ function run(main) {
193
+ main().catch((err) => {
194
+ log.error("Unexpected error:", err);
195
+ process.exitCode = 1;
196
+ });
197
+ }
198
+
199
+ // "#3 github (eissa)"
200
+ function entryLabel(entry) {
201
+ return "#" + entry.id + " " + entry.name + " (" + entry.username + ")";
202
+ }
203
+
204
+ // copy to the clipboard (auto-cleared), or print it on Android / when copying fails
205
+ // what: shown in "Copied <what> to clipboard" (omitted when empty), show: also print the text on success
206
+ // show: "Copied to clipboard (clears in 20s): <text>" (text in amber)
207
+ // else: "✓ Copied password of entry #3 to clipboard, clears in 20s"
208
+ async function copyOrPrint(text, what, show) {
209
+ if (isAndroid()) return console.log(colors.amber(text));
210
+ let seconds;
211
+ try {
212
+ seconds = await copyToClipboard(text);
213
+ } catch (err) {
214
+ log.warnErr("Couldn't copy to clipboard (linux needs xclip, or wl-clipboard on Wayland): " + err.message);
215
+ return console.log(colors.amber(text));
216
+ }
217
+ const copied = "Copied " + (what ? what + " " : "") + "to clipboard";
218
+ if (show) return console.log(colors.green(copied + (seconds ? " (clears in " + seconds + "s)" : "") + ":") + " " + colors.amber(text));
219
+ log.ok(copied + (seconds ? ", clears in " + seconds + "s" : ""));
220
+ }
221
+
222
+ // decrypt an entry's password and copy it (or print it, see copyOrPrint)
223
+ async function copyEntry(entry, db, key) {
224
+ let password;
225
+ try {
226
+ password = decrypt(entry.password, key);
227
+ } catch (err) {
228
+ log.error("Couldn't decrypt password of entry #" + entry.id + " (empty or damaged)");
229
+ process.exitCode = 1;
230
+ return;
231
+ }
232
+ await copyOrPrint(password, "password of entry #" + entry.id);
233
+ }
234
+
235
+ // print an entry's secret fields (not the password)
236
+ async function showEntry(entry, db, key) {
237
+ for (const f of SECRET_FIELDS) {
238
+ const value = decryptField(entry, f, key);
239
+ if (value === null) return;
240
+ log.field(f, value);
241
+ }
242
+ }
243
+
244
+ // next free entry id: highest id + 1 (1 for an empty db)
245
+ function nextEntryId(entries) {
246
+ return entries.reduce((max, obj) => Math.max(max, Number(obj.id) || 0), 0) + 1;
247
+ }
248
+
249
+ // id: exact match, other fields: case-insensitive "contains"
250
+ function filterEntries(entries, query) {
251
+ const keys = Object.keys(query);
252
+ return entries.filter((obj) =>
253
+ keys.every((key) => {
254
+ if (key === "id") return String(obj.id) === String(query.id).trim();
255
+ return String(obj[key] ?? "")
256
+ .toLowerCase()
257
+ .includes(String(query[key]).toLowerCase());
258
+ })
259
+ );
260
+ }
261
+
262
+ module.exports = {
263
+ dbPath,
264
+ credPath,
265
+ FIELDS,
266
+ parseDb,
267
+ SECRET_FIELDS,
268
+ loadData,
269
+ saveOrReport,
270
+ askPassword,
271
+ confirm,
272
+ run,
273
+ entryLabel,
274
+ copyOrPrint,
275
+ copyEntry,
276
+ showEntry,
277
+ decryptField,
278
+ nextEntryId,
279
+ filterEntries,
280
+ drawTable,
281
+ prompt,
282
+ mkPass,
283
+ decrypt,
284
+ encrypt,
285
+ isEncrypted,
286
+ colors,
287
+ log,
288
+ parseArgs,
289
+ ...require("./mnemonic"),
290
+ };
291
+
292
+ // vault.js and config.js import from this file at their top, so they load only after module.exports is filled in
293
+ // (earlier they'd get an empty object). One per line: config.js needs vault's functions here already.
294
+ Object.assign(module.exports, require("./vault"));
295
+ Object.assign(module.exports, require("./config"));
package/utils/log.js ADDED
@@ -0,0 +1,30 @@
1
+ // one look for every message the CLI prints (same as the website terminal):
2
+ // ✓ done green (log.ok)
3
+ // [+] Added ... green (log.added) [+] Updated ... amber (log.updated)
4
+ // [-] Deleted ... amber (log.removed) [!] damaged ... red (log.problem)
5
+ // ! careful ... amber (log.warn) ✗ failed ... red (log.error, stderr)
6
+ // dim hints gray (log.hint) plain text (log.info)
7
+ // console.log / console.error are looked up on every call, so tests can swap them.
8
+ const colors = require("./colors");
9
+
10
+ const out =
11
+ (prefix, color, stream = "log") =>
12
+ (msg, ...extra) =>
13
+ console[stream](color((prefix ? prefix + " " : "") + msg), ...extra);
14
+
15
+ const log = {
16
+ info: out("", colors.text),
17
+ hint: out("", colors.gray),
18
+ ok: out("✓", colors.green),
19
+ added: out("[+]", colors.green),
20
+ updated: out("[+]", colors.amber),
21
+ removed: out("[-]", colors.amber),
22
+ warn: out("!", colors.amber),
23
+ warnErr: out("!", colors.amber, "error"),
24
+ problem: out("[!]", colors.red, "error"),
25
+ error: out("✗", colors.red, "error"),
26
+ // "label: value" pair, e.g. the decrypted notes of an entry
27
+ field: (label, value) => console.log(colors.amber(label + ":") + " " + (value ? colors.text(value) : colors.dim("-"))),
28
+ };
29
+
30
+ module.exports = log;
@@ -0,0 +1,49 @@
1
+ // 12-word recovery phrase, BIP39 style: 128 random bits + 4-bit checksum (first bits of sha256), 11 bits per word
2
+ const crypto = require("crypto");
3
+ const WORDS = require("./wordlist");
4
+
5
+ const INDEX = new Map(WORDS.map((w, i) => [w, i]));
6
+ const WORD_COUNT = 12;
7
+ const ENTROPY_BYTES = 16; // 128 bits -> 4 checksum bits -> 132 bits = 12 words
8
+
9
+ const bits = (buf) => [...buf].map((b) => b.toString(2).padStart(8, "0")).join("");
10
+ const checksum = (entropy) => bits(crypto.createHash("sha256").update(entropy).digest()).slice(0, (entropy.length * 8) / 32);
11
+
12
+ // 16 bytes -> 12 words
13
+ function toWords(entropy) {
14
+ if (entropy.length !== ENTROPY_BYTES) throw new Error("entropy must be " + ENTROPY_BYTES + " bytes");
15
+ return (bits(entropy) + checksum(entropy)).match(/.{11}/g).map((b) => WORDS[parseInt(b, 2)]);
16
+ }
17
+
18
+ // a new random phrase -> "word1 word2 ... word12"
19
+ function newPhrase() {
20
+ return toWords(crypto.randomBytes(ENTROPY_BYTES)).join(" ");
21
+ }
22
+
23
+ // typed phrase -> words (lowercase, any spacing)
24
+ function splitPhrase(phrase) {
25
+ return String(phrase ?? "")
26
+ .trim()
27
+ .toLowerCase()
28
+ .split(/\s+/)
29
+ .filter(Boolean);
30
+ }
31
+
32
+ // typed phrase -> what's wrong with it, or null when it's a valid 12-word phrase (catches most typos / swapped words)
33
+ function checkPhrase(phrase) {
34
+ const words = splitPhrase(phrase);
35
+ if (words.length !== WORD_COUNT) return "needs " + WORD_COUNT + " words, got " + words.length;
36
+ const unknown = words.filter((w) => !INDEX.has(w));
37
+ if (unknown.length) return "unknown word(s): " + unknown.join(", ");
38
+ const all = words.map((w) => INDEX.get(w).toString(2).padStart(11, "0")).join("");
39
+ const entropy = Buffer.from(all.slice(0, ENTROPY_BYTES * 8).match(/.{8}/g).map((b) => parseInt(b, 2)));
40
+ if (all.slice(ENTROPY_BYTES * 8) !== checksum(entropy)) return "checksum doesn't match (a word is wrong or out of order)";
41
+ return null;
42
+ }
43
+
44
+ // typed phrase -> the exact text used as the scrypt password (so spacing/case don't matter)
45
+ function normalizePhrase(phrase) {
46
+ return splitPhrase(phrase).join(" ");
47
+ }
48
+
49
+ module.exports = { toWords, newPhrase, splitPhrase, checkPhrase, normalizePhrase };
@@ -0,0 +1,119 @@
1
+ // minimal inquirer replacement, public Node APIs only
2
+ // await prompt([{ type: "input" | "password" | "confirm", name, message, default }]) -> answers
3
+ // - interactive (stdin is a terminal): readline for input/confirm, raw-mode reader for passwords (masked with "•")
4
+ // - piped stdin: one shared line queue, so answers can be scripted: printf 'pw\ny\n' | node index.js get --id 1 --rm
5
+ // - Ctrl+C exits 130; Ctrl+D / end of input cancels with exit 1 (never auto-answers, e.g. "delete? (Y/n)")
6
+ const readline = require("readline");
7
+ const readlinePromises = require("readline/promises");
8
+
9
+ // prompts go to the terminal even when stdout is redirected (node index.js get > list.txt)
10
+ function promptOut() {
11
+ return process.stdout.isTTY || !process.stderr.isTTY ? process.stdout : process.stderr;
12
+ }
13
+
14
+ function abortPrompt(code) {
15
+ if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
16
+ promptOut().write("\n");
17
+ process.exit(code);
18
+ }
19
+
20
+ // interactive text line, with normal editing (arrows, Home/End, ...)
21
+ async function readTtyLine(label) {
22
+ const rl = readlinePromises.createInterface({ input: process.stdin, output: promptOut(), historySize: 0 });
23
+ let answered = false;
24
+ rl.on("SIGINT", () => abortPrompt(130));
25
+ rl.on("close", () => answered || abortPrompt(1)); // Ctrl+D
26
+ const answer = await rl.question(label);
27
+ answered = true;
28
+ rl.close();
29
+ return answer;
30
+ }
31
+
32
+ // interactive password: raw mode, each char shown as a dim "•" (never the text itself); Backspace and Ctrl+U work, pasting works
33
+ const MASK_ERASE = "\b \b";
34
+ function mask(n) {
35
+ const { colors } = require("./index");
36
+ return colors.dim("•".repeat(n));
37
+ }
38
+ function readTtyPassword(label) {
39
+ return new Promise((resolve) => {
40
+ const { stdin } = process;
41
+ const out = promptOut();
42
+ out.write(label);
43
+ readline.emitKeypressEvents(stdin);
44
+ stdin.setRawMode(true);
45
+ stdin.resume();
46
+ let value = "";
47
+ const onKey = (str, key = {}) => {
48
+ if (key.ctrl && key.name === "c") return abortPrompt(130);
49
+ if (key.ctrl && key.name === "d") return value ? undefined : abortPrompt(1);
50
+ if (key.name === "return" || key.name === "enter") {
51
+ stdin.removeListener("keypress", onKey);
52
+ stdin.setRawMode(false);
53
+ stdin.pause();
54
+ out.write("\n");
55
+ return resolve(value);
56
+ }
57
+ const chars = Array.from(value);
58
+ if (key.name === "backspace") {
59
+ if (chars.length) out.write(MASK_ERASE);
60
+ value = chars.slice(0, -1).join(""); // whole char, not half an emoji
61
+ } else if (key.ctrl && key.name === "u") {
62
+ out.write(MASK_ERASE.repeat(chars.length));
63
+ value = "";
64
+ } else if (str && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(str)) {
65
+ value += str; // skip arrows, tab, etc.
66
+ out.write(mask(Array.from(str).length));
67
+ }
68
+ };
69
+ stdin.on("keypress", onKey);
70
+ });
71
+ }
72
+
73
+ // piped stdin: keep one reader so lines that arrive early are queued, not lost
74
+ let piped;
75
+ function readPipedLine(label) {
76
+ if (!piped) {
77
+ piped = { lines: [], waiting: null, closed: false };
78
+ piped.rl = readline.createInterface({ input: process.stdin, terminal: false, crlfDelay: Infinity }); // \r\n = one line (Windows)
79
+ piped.rl.on("line", (line) => (piped.waiting ? piped.waiting(line) : piped.lines.push(line)));
80
+ piped.rl.on("close", () => ((piped.closed = true), piped.waiting && abortPrompt(1)));
81
+ }
82
+ promptOut().write(label);
83
+ return new Promise((resolve) => {
84
+ const done = (line) => {
85
+ piped.waiting = null;
86
+ piped.rl.pause(); // lets the process exit when no more questions come
87
+ promptOut().write("\n"); // piped answers aren't echoed
88
+ resolve(line);
89
+ };
90
+ if (piped.lines.length) return done(piped.lines.shift());
91
+ if (piped.closed) return abortPrompt(1);
92
+ piped.waiting = done;
93
+ piped.rl.resume();
94
+ });
95
+ }
96
+
97
+ async function prompt(questions) {
98
+ const { colors: c } = require("./index"); // here, not at the top: index.js loads this file, so its exports are only ready at call time
99
+ const tty = Boolean(process.stdin.isTTY);
100
+ const read = (label, type) => (!tty ? readPipedLine(label) : type === "password" ? readTtyPassword(label) : readTtyLine(label));
101
+ const answers = {};
102
+ for (const q of questions) {
103
+ // website look: "? MASTER PASSWORD: ••••••" with a gray "?", off-white question and dim hints
104
+ const hint = q.type === "confirm" ? c.dim(q.default === false ? " (y/N)" : " (Y/n)") : "";
105
+ const label = c.gray("? ") + c.text(String(q.message).trim()) + hint + " ";
106
+ // Git Bash/mintty without winpty: no terminal, so the terminal itself echoes what you type
107
+ if (q.type === "password" && !tty && process.env.MSYSTEM && !process.stdout.isTTY)
108
+ process.stderr.write(c.yellow("warning: input will be visible, run with: winpty node ...\n"));
109
+ let answer = await read(label, q.type);
110
+ if (q.type === "confirm") {
111
+ while (!/^(y(es)?|no?|)$/i.test(answer.trim())) answer = await read(label, q.type); // re-ask on junk
112
+ answer = answer.trim() === "" ? q.default !== false : /^y/i.test(answer.trim());
113
+ } else if (answer === "" && q.default !== undefined) answer = q.default;
114
+ answers[q.name] = answer;
115
+ }
116
+ return answers;
117
+ }
118
+
119
+ module.exports = prompt;
package/utils/table.js ADDED
@@ -0,0 +1,69 @@
1
+ // entries table: drawTable(entries) prints a box table (no passwords), fitted to the terminal width
2
+ const textWidth = (str) => [...str].length;
3
+ const cleanCell = (val) =>
4
+ String(val ?? "")
5
+ .replace(/\s+/g, " ")
6
+ .trim();
7
+ const truncate = (str, max) => (textWidth(str) <= max ? str : [...str].slice(0, Math.max(max - 1, 0)).join("") + "…");
8
+ const pad = (str, w, right) => (right ? " ".repeat(w - textWidth(str)) + str : str + " ".repeat(w - textWidth(str)));
9
+
10
+ // "15-11-2026" (day-month-year) in local time, "" if missing/invalid (entries added before timestamps existed)
11
+ const formatDate = (iso) => {
12
+ const d = new Date(iso);
13
+ if (!iso || isNaN(d)) return "";
14
+ return String(d.getDate()).padStart(2, "0") + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + d.getFullYear();
15
+ };
16
+ const DATE_COLUMNS = ["created", "updated"];
17
+
18
+ // shrink the widest columns until the table fits the terminal (columns in `fixed` never shrink)
19
+ function fitWidths(widths, maxTotal, fixed) {
20
+ const total = () => widths.reduce((a, b) => a + b, 0) + widths.length * 3 + 1; // "│ " + " " per cell + last "│"
21
+ while (total() > maxTotal) {
22
+ let widest = -1;
23
+ for (let i = 0; i < widths.length; i++) if (!fixed.includes(i) && (widest === -1 || widths[i] > widths[widest])) widest = i;
24
+ if (widest === -1 || widths[widest] <= 4) break; // can't shrink any further, let the terminal wrap
25
+ widths[widest]--;
26
+ }
27
+ return widths;
28
+ }
29
+
30
+ // print entries as a box table (no passwords)
31
+ function drawTable(entries) {
32
+ const { FIELDS, SECRET_FIELDS, colors } = require("./index"); // here, not at the top: index.js loads this file, so its exports are only ready at call time
33
+ const COLUMNS = ["id", ...FIELDS, ...DATE_COLUMNS];
34
+ if (!entries.length) return console.log(colors.gray("0 entries"));
35
+
36
+ const cell = (obj, col) => {
37
+ if (SECRET_FIELDS.includes(col) && obj[col]) return "***";
38
+ if (col === "updated" && formatDate(obj.updated) === formatDate(obj.created)) return ""; // same day as created -> "-"
39
+ return DATE_COLUMNS.includes(col) ? formatDate(obj[col]) : cleanCell(obj[col]);
40
+ };
41
+ const rows = entries.map((obj) => COLUMNS.map((col) => cell(obj, col)));
42
+ const widths = COLUMNS.map((col, i) => Math.max(col.length, ...rows.map((r) => textWidth(r[i]) || 1)));
43
+ const fixed = COLUMNS.map((col, i) => (col === "id" || DATE_COLUMNS.includes(col) ? i : -1)).filter((i) => i !== -1);
44
+ fitWidths(widths, process.stdout.columns || Infinity, fixed);
45
+
46
+ // website look: dim rounded frame, amber bold headers, amber ids, off-white values, dim "-" for empty cells
47
+ const line = (l, m, r) => colors.dim(l + widths.map((w) => "─".repeat(w + 2)).join(m) + r);
48
+ const bar = colors.dim("│");
49
+ const row = (cells) => bar + cells.map((c) => " " + c + " ").join(bar) + bar;
50
+
51
+ const out = [line("╭", "┬", "╮"), row(COLUMNS.map((col, i) => colors.bold.amber(pad(truncate(col.toUpperCase(), widths[i]), widths[i])))), line("├", "┼", "┤")];
52
+ rows.forEach((r) => {
53
+ out.push(
54
+ row(
55
+ r.map((val, i) => {
56
+ if (!val) return colors.dim(pad("-", widths[i]));
57
+ const text = pad(truncate(val, widths[i]), widths[i], i === 0);
58
+ if (i === 0) return colors.amber(text);
59
+ return val === "***" ? colors.gray(text) : colors.text(text);
60
+ })
61
+ )
62
+ );
63
+ });
64
+ out.push(line("╰", "┴", "╯"));
65
+ out.push(colors.gray(" " + entries.length + (entries.length === 1 ? " entry" : " entries")));
66
+ console.log(out.join("\n"));
67
+ }
68
+
69
+ module.exports = drawTable;
package/utils/vault.js ADDED
@@ -0,0 +1,50 @@
1
+ // Vault key handling:
2
+ // - one random 256-bit "vault key" encrypts every entry password (see utils.encrypt)
3
+ // - credentials.json holds that vault key, encrypted with a key derived
4
+ // from the master password (scrypt + random salt): { salt, n, key }
5
+ // - db.json "recovery" holds the same vault key, locked the same way with a 12-word
6
+ // recovery phrase instead of the master password, so losing credentials.json
7
+ // (or forgetting the master password) isn't fatal
8
+ const crypto = require("crypto");
9
+ const { encrypt, decrypt } = require("./index");
10
+ const { newPhrase, normalizePhrase } = require("./mnemonic");
11
+
12
+ const SCRYPT_N = 32768; // ~100ms on a laptop, slows down brute force
13
+
14
+ function deriveKey(password, saltHex, n) {
15
+ return crypto.scryptSync(password, Buffer.from(saltHex, "hex"), 32, { N: n, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
16
+ }
17
+
18
+ // master password + vault key -> credentials object { salt, n, key }
19
+ function lockVault(password, vaultKey) {
20
+ const salt = crypto.randomBytes(16).toString("hex");
21
+ return { salt, n: SCRYPT_N, key: encrypt(vaultKey.toString("hex"), deriveKey(password, salt, SCRYPT_N)) };
22
+ }
23
+
24
+ function hasMaster(cred) {
25
+ return Boolean(cred.key);
26
+ }
27
+
28
+ // master password -> vault key (or null if wrong / no master yet)
29
+ function unlock(password, cred) {
30
+ if (!hasMaster(cred)) return null;
31
+ try {
32
+ return Buffer.from(decrypt(cred.key, deriveKey(password, cred.salt, cred.n)), "hex");
33
+ } catch (err) {
34
+ return null; // wrong password
35
+ }
36
+ }
37
+
38
+ // vault key -> { phrase, recovery }: a new 12-word phrase + the vault key locked with it (db.json "recovery")
39
+ function makeRecovery(vaultKey) {
40
+ const phrase = newPhrase();
41
+ return { phrase, recovery: lockVault(phrase, vaultKey) };
42
+ }
43
+
44
+ // recovery phrase + db.json "recovery" -> vault key, or null (wrong phrase / no recovery). Spacing and case don't matter.
45
+ function recover(phrase, recovery) {
46
+ if (!recovery) return null;
47
+ return unlock(normalizePhrase(phrase), recovery);
48
+ }
49
+
50
+ module.exports = { lockVault, hasMaster, unlock, makeRecovery, recover };