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/README.md +308 -2
- package/assets/logo.svg +1 -0
- package/index.js +108 -0
- package/options/add.js +16 -0
- package/options/check.js +123 -0
- package/options/delete.js +10 -0
- package/options/export.js +54 -0
- package/options/get.js +16 -0
- package/options/import.js +90 -0
- package/options/index.js +20 -0
- package/options/recovery.js +63 -0
- package/options/update.js +31 -0
- package/package.json +18 -4
- package/test/check.test.js +66 -0
- package/test/crypto.test.js +200 -0
- package/test/export.test.js +56 -0
- package/test/import.test.js +43 -0
- package/test/recovery.test.js +129 -0
- package/utils/cli.js +130 -0
- package/utils/colors.js +71 -0
- package/utils/config.js +40 -0
- package/utils/csv.js +59 -0
- package/utils/fields.js +6 -0
- package/utils/index.js +295 -0
- package/utils/log.js +30 -0
- package/utils/mnemonic.js +49 -0
- package/utils/prompt.js +119 -0
- package/utils/table.js +69 -0
- package/utils/vault.js +50 -0
- package/utils/wordlist.js +134 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// run: npm test (or: node --test)
|
|
2
|
+
const { test, describe } = require("node:test");
|
|
3
|
+
const assert = require("node:assert/strict");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
const { encrypt, decrypt, lockVault, unlock, makeRecovery, recover, parseDb, toWords, newPhrase, checkPhrase, normalizePhrase } = require("../utils");
|
|
6
|
+
const { checkRecovery } = require("../options/check");
|
|
7
|
+
const { formatPhrase } = require("../options/recovery");
|
|
8
|
+
const WORDS = require("../utils/wordlist");
|
|
9
|
+
|
|
10
|
+
describe("wordlist", () => {
|
|
11
|
+
test("is the BIP39 English list", () => {
|
|
12
|
+
assert.equal(WORDS.length, 2048);
|
|
13
|
+
assert.equal(new Set(WORDS).size, 2048);
|
|
14
|
+
const hash = crypto.createHash("sha256").update(WORDS.join("\n") + "\n").digest("hex");
|
|
15
|
+
assert.equal(hash, "2f5eed53a4727b4bf8880d8f3f199efc90e58503646d9ff8eff3a2ed3b24dbda");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("mnemonic", () => {
|
|
20
|
+
test("matches the official BIP39 test vectors", () => {
|
|
21
|
+
const vectors = {
|
|
22
|
+
"00000000000000000000000000000000": "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
|
23
|
+
"7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f7f": "legal winner thank year wave sausage worth useful legal winner thank yellow",
|
|
24
|
+
"80808080808080808080808080808080": "letter advice cage absurd amount doctor acoustic avoid letter advice cage above",
|
|
25
|
+
ffffffffffffffffffffffffffffffff: "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
|
26
|
+
};
|
|
27
|
+
for (const [hex, phrase] of Object.entries(vectors)) {
|
|
28
|
+
assert.equal(toWords(Buffer.from(hex, "hex")).join(" "), phrase);
|
|
29
|
+
assert.equal(checkPhrase(phrase), null);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("new phrases are 12 valid, random words", () => {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (let i = 0; i < 200; i++) {
|
|
36
|
+
const p = newPhrase();
|
|
37
|
+
assert.equal(p.split(" ").length, 12);
|
|
38
|
+
assert.equal(checkPhrase(p), null);
|
|
39
|
+
seen.add(p);
|
|
40
|
+
}
|
|
41
|
+
assert.equal(seen.size, 200);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("accepts any case / spacing", () => {
|
|
45
|
+
const p = newPhrase();
|
|
46
|
+
const messy = " " + p.toUpperCase().split(" ").join(" \n\t ") + " ";
|
|
47
|
+
assert.equal(checkPhrase(messy), null);
|
|
48
|
+
assert.equal(normalizePhrase(messy), p);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("reports wrong word count, unknown words, bad checksum", () => {
|
|
52
|
+
const words = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".split(" ");
|
|
53
|
+
assert.match(checkPhrase(words.slice(1).join(" ")), /needs 12 words, got 11/);
|
|
54
|
+
assert.match(checkPhrase(""), /got 0/);
|
|
55
|
+
assert.match(checkPhrase([...words.slice(0, 11), "abuot"].join(" ")), /unknown word\(s\): abuot/);
|
|
56
|
+
assert.match(checkPhrase([...words.slice(0, 11), "abandon"].join(" ")), /checksum/); // valid word, wrong one
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("swapping two words is almost always caught", () => {
|
|
60
|
+
let caught = 0;
|
|
61
|
+
for (let i = 0; i < 200; i++) {
|
|
62
|
+
const w = newPhrase().split(" ");
|
|
63
|
+
if (w[0] === w[1]) continue;
|
|
64
|
+
[w[0], w[1]] = [w[1], w[0]];
|
|
65
|
+
if (checkPhrase(w.join(" "))) caught++;
|
|
66
|
+
}
|
|
67
|
+
assert.ok(caught > 150, "caught " + caught + "/200"); // 4-bit checksum: ~15/16
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("recovery (vault key locked with the phrase)", () => {
|
|
72
|
+
// scrypt is slow on purpose, so share one recovery
|
|
73
|
+
const vaultKey = crypto.randomBytes(32);
|
|
74
|
+
const { phrase, recovery } = makeRecovery(vaultKey);
|
|
75
|
+
const entry = encrypt("site password", vaultKey);
|
|
76
|
+
|
|
77
|
+
test("recovery -> { salt, n, key } without the vault key in it", () => {
|
|
78
|
+
assert.deepEqual(Object.keys(recovery).sort(), ["key", "n", "salt"]);
|
|
79
|
+
assert.ok(!JSON.stringify(recovery).includes(vaultKey.toString("hex")));
|
|
80
|
+
assert.deepEqual(checkRecovery(recovery), []);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("the phrase opens the vault key, even typed messily", () => {
|
|
84
|
+
assert.ok(recover(phrase, recovery).equals(vaultKey));
|
|
85
|
+
assert.ok(recover(" " + phrase.toUpperCase().replace(/ /g, " ") + "\n", recovery).equals(vaultKey));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("wrong phrase / no recovery / tampered recovery -> null", () => {
|
|
89
|
+
assert.equal(recover(newPhrase(), recovery), null);
|
|
90
|
+
assert.equal(recover(phrase, null), null);
|
|
91
|
+
assert.equal(recover(phrase, { ...recovery, key: recovery.key.replace(/.$/, (c) => (c === "0" ? "1" : "0")) }), null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("lost credentials.json: phrase -> vault key -> new master password, old entries still decrypt", () => {
|
|
95
|
+
const key = recover(phrase, recovery);
|
|
96
|
+
const cred = lockVault("brand new master", key); // what 'recover' saves as the new credentials.json
|
|
97
|
+
assert.equal(decrypt(entry, unlock("brand new master", cred)), "site password");
|
|
98
|
+
assert.ok(recover(phrase, recovery).equals(unlock("brand new master", cred))); // phrase keeps working
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a new phrase replaces the old one", () => {
|
|
102
|
+
const next = makeRecovery(vaultKey);
|
|
103
|
+
assert.notEqual(next.phrase, phrase);
|
|
104
|
+
assert.equal(recover(phrase, next.recovery), null);
|
|
105
|
+
assert.ok(recover(next.phrase, next.recovery).equals(vaultKey));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("checkRecovery finds a damaged recovery", () => {
|
|
109
|
+
assert.deepEqual(checkRecovery(null), []);
|
|
110
|
+
assert.deepEqual(checkRecovery([]), ["db.json: recovery isn't an object"]);
|
|
111
|
+
assert.deepEqual(checkRecovery({ salt: "x", n: 3, key: "x" }), ["db.json recovery: bad salt", "db.json recovery: bad n (must be a power of 2)", "db.json recovery: key isn't encrypted (unknown format)"]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("formatPhrase numbers all 12 words", () => {
|
|
115
|
+
const plain = formatPhrase(phrase).replace(/\x1b\[[0-9;]*m/g, "");
|
|
116
|
+
phrase.split(" ").forEach((w, i) => assert.ok(plain.includes(i + 1 + ". " + w), w));
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("parseDb", () => {
|
|
121
|
+
test("accepts { recovery, accounts }", () => {
|
|
122
|
+
for (const db of [{ recovery: { salt: "s", n: 2, key: "k" }, accounts: [{ id: 1 }] }, { recovery: null, accounts: [] }]) assert.equal(parseDb(db), db);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects anything else, including a plain array of accounts", () => {
|
|
126
|
+
const bad = [[], [{ id: 1, name: "a" }], null, {}, { accounts: [] }, { recovery: null }, { recovery: null, accounts: {} }, { recovery: "x", accounts: [] }, "x", 1];
|
|
127
|
+
for (const b of bad) assert.throws(() => parseDb(b), /db.json must be/, JSON.stringify(b));
|
|
128
|
+
});
|
|
129
|
+
});
|
package/utils/cli.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// minimal commander replacement
|
|
2
|
+
// supports: --opt <value>, --opt=value, boolean --flag, commands, options before or after the command,
|
|
3
|
+
// -h/--help, help [command], -V/--version, "--" (end of options), help wrapped to the terminal width.
|
|
4
|
+
// errors go to stderr with exit code 1. Uses process.exitCode (not process.exit) so piped output is never cut off.
|
|
5
|
+
const colors = require("./colors");
|
|
6
|
+
|
|
7
|
+
// spec: { version, description, options: [["--id <id>", "desc"], ["--cp", "desc"]], commands: { add: "desc" } }
|
|
8
|
+
// returns { command, opts, args }, or null when it already handled help/version/an error
|
|
9
|
+
function parseArgs(argv, spec) {
|
|
10
|
+
const name = "keebox";
|
|
11
|
+
const builtins = [
|
|
12
|
+
["-V, --version", "output the version number"],
|
|
13
|
+
["-h, --help", "display help for command"],
|
|
14
|
+
];
|
|
15
|
+
const defs = spec.options.concat(builtins).map(([flags, desc]) => {
|
|
16
|
+
const m = /^(?:-(\w), )?--([\w-]+)(?: <[\w-]+>)?$/.exec(flags);
|
|
17
|
+
if (!m) throw new Error("bad option spec: " + flags);
|
|
18
|
+
return { flags, desc, short: m[1], long: m[2], key: m[2].replace(/-(\w)/g, (_, c) => c.toUpperCase()), takesValue: flags.endsWith(">") };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// "✗ error: unknown option '--nme'" in red, then the "(Did you mean --name?)" hint in gray
|
|
22
|
+
const fail = (msg) => {
|
|
23
|
+
const [first, ...rest] = msg.split("\n");
|
|
24
|
+
process.stderr.write(colors.red("✗ error: " + first) + "\n" + rest.map((l) => colors.gray(l.trim()) + "\n").join(""));
|
|
25
|
+
process.exitCode = 1;
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const opts = {};
|
|
30
|
+
const args = [];
|
|
31
|
+
for (let i = 0; i < argv.length; i++) {
|
|
32
|
+
const arg = argv[i];
|
|
33
|
+
if (arg === "--") {
|
|
34
|
+
args.push(...argv.slice(i + 1));
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (arg.length < 2 || arg[0] !== "-") {
|
|
38
|
+
args.push(arg);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const eq = arg.startsWith("--") ? arg.indexOf("=") : -1;
|
|
42
|
+
const flag = eq === -1 ? arg : arg.slice(0, eq);
|
|
43
|
+
const def = defs.find((d) => flag === "--" + d.long || (d.short && flag === "-" + d.short));
|
|
44
|
+
if (!def) return fail(`unknown option '${arg}'` + suggest(flag, defs.map((d) => "--" + d.long)));
|
|
45
|
+
if (!def.takesValue) {
|
|
46
|
+
if (eq !== -1) return fail(`option '${flag}' does not take a value`);
|
|
47
|
+
opts[def.key] = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const value = eq !== -1 ? arg.slice(eq + 1) : argv[++i];
|
|
51
|
+
if (value === undefined) return fail(`option '${def.flags}' argument missing`);
|
|
52
|
+
opts[def.key] = value; // repeated option: last one wins
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const isCommand = (c) => Object.prototype.hasOwnProperty.call(spec.commands, c); // not "constructor", "toString", ...
|
|
56
|
+
const command = args[0];
|
|
57
|
+
if (opts.version) return void process.stdout.write(spec.version + "\n");
|
|
58
|
+
if (opts.help) return void process.stdout.write(help(isCommand(command) ? command : null));
|
|
59
|
+
if (command === "help") {
|
|
60
|
+
if (args[1] && !isCommand(args[1])) return fail(`unknown command '${args[1]}'`);
|
|
61
|
+
return void process.stdout.write(help(args[1]));
|
|
62
|
+
}
|
|
63
|
+
if (!command) {
|
|
64
|
+
process.stderr.write(help());
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (!isCommand(command)) return fail(`unknown command '${command}'` + suggest(command, Object.keys(spec.commands)) + "\nTry running --help");
|
|
69
|
+
delete opts.help;
|
|
70
|
+
delete opts.version;
|
|
71
|
+
return { command, opts, args: args.slice(1) };
|
|
72
|
+
|
|
73
|
+
// website look: amber section titles and "$ keebox ..." usage line, off-white terms, gray descriptions
|
|
74
|
+
function help(cmd) {
|
|
75
|
+
const heading = (t) => colors.bold.amber(t);
|
|
76
|
+
const usage = (args) => colors.amber("$ ") + colors.text(name + " " + args);
|
|
77
|
+
if (cmd) return `${heading("Usage")}\n ${usage(cmd + " [options]")}\n\n${colors.gray(spec.commands[cmd])}\n\n${heading("Options")}\n${table([builtins[1]])}\n`;
|
|
78
|
+
const commands = Object.entries(spec.commands).concat([["help [command]", "display help for command"]]);
|
|
79
|
+
const options = spec.options.concat(builtins);
|
|
80
|
+
return `${heading("Usage")}\n ${usage("[options] [command]")}\n\n${colors.gray(spec.description)}\n\n${heading("Options")}\n${table(options, commands)}\n\n${heading("Commands")}\n${table(commands, options)}\n`;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// two-column list, descriptions wrapped to the terminal width (columns line up across both lists)
|
|
85
|
+
function table(rows, otherRows = []) {
|
|
86
|
+
const pad = Math.max(...rows.concat(otherRows).map(([term]) => term.length));
|
|
87
|
+
const width = Math.max((process.stdout.columns || 80) - pad - 4, 40);
|
|
88
|
+
// "<value>" / "[command]" placeholders dim, the rest of the term off-white
|
|
89
|
+
const styleTerm = (term) => term.replace(/([<\[][^>\]]+[>\]])|([^<\[]+)/g, (_, arg, text) => (arg ? colors.dim(arg) : colors.text(text)));
|
|
90
|
+
return rows.map(([term, desc]) => " " + styleTerm(term) + " ".repeat(pad - term.length) + " " + wrap(desc, width).map((l) => colors.gray(l)).join("\n" + " ".repeat(pad + 4))).join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function wrap(text, width) {
|
|
94
|
+
const lines = [""];
|
|
95
|
+
for (const word of text.split(/\s+/)) {
|
|
96
|
+
const last = lines[lines.length - 1];
|
|
97
|
+
if (last && last.length + 1 + word.length > width) lines.push(word);
|
|
98
|
+
else lines[lines.length - 1] = last ? last + " " + word : word;
|
|
99
|
+
}
|
|
100
|
+
return lines;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// "(Did you mean --name?)" / "(Did you mean one of ...?)": closest matches within 2 edits (a swap counts as 1)
|
|
104
|
+
function suggest(input, candidates) {
|
|
105
|
+
const strip = (s) => s.replace(/^--/, "");
|
|
106
|
+
const dist = (a, b) => {
|
|
107
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i]);
|
|
108
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
109
|
+
for (let i = 1; i <= a.length; i++)
|
|
110
|
+
for (let j = 1; j <= b.length; j++) {
|
|
111
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
112
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
113
|
+
}
|
|
114
|
+
return d[a.length][b.length];
|
|
115
|
+
};
|
|
116
|
+
let best = 3;
|
|
117
|
+
let similar = [];
|
|
118
|
+
for (const c of candidates) {
|
|
119
|
+
const [a, b] = [strip(input), strip(c)];
|
|
120
|
+
const distance = dist(a, b);
|
|
121
|
+
const length = Math.max(a.length, b.length);
|
|
122
|
+
if (b.length <= 1 || (length - distance) / length <= 0.4 || distance > best) continue;
|
|
123
|
+
if (distance < best) (best = distance), (similar = []);
|
|
124
|
+
similar.push(c);
|
|
125
|
+
}
|
|
126
|
+
similar.sort();
|
|
127
|
+
return similar.length > 1 ? `\n(Did you mean one of ${similar.join(", ")}?)` : similar.length ? `\n(Did you mean ${similar[0]}?)` : "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = parseArgs;
|
package/utils/colors.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// tiny ANSI color helper (replaces the "colors" package): colors.red("x"), colors.bold.amber("x")
|
|
2
|
+
// KeeBox palette (same as the website): warm amber accent, soft green / red, warm grays on a dark terminal.
|
|
3
|
+
// - truecolor when the terminal says so (COLORTERM=truecolor|24bit, or a known truecolor terminal),
|
|
4
|
+
// else the closest xterm-256 color, else the basic 16 ANSI colors
|
|
5
|
+
// - disabled when output isn't a terminal or NO_COLOR is set, FORCE_COLOR turns it on (FORCE_COLOR=0 turns it off)
|
|
6
|
+
const env = process.env;
|
|
7
|
+
|
|
8
|
+
function colorLevel() {
|
|
9
|
+
if ("NO_COLOR" in env || env.FORCE_COLOR === "0" || env.FORCE_COLOR === "false") return 0;
|
|
10
|
+
if (!("FORCE_COLOR" in env) && !process.stdout.isTTY) return 0; // "get > list.txt" stays plain text
|
|
11
|
+
if (env.TERM === "dumb" && !("FORCE_COLOR" in env)) return 0;
|
|
12
|
+
if (env.FORCE_COLOR === "3" || /^(truecolor|24bit)$/i.test(env.COLORTERM || "")) return 3;
|
|
13
|
+
if (["iTerm.app", "WezTerm", "vscode", "ghostty"].includes(env.TERM_PROGRAM) || env.WT_SESSION || /kitty|alacritty|direct/.test(env.TERM || "")) return 3;
|
|
14
|
+
if (process.platform === "win32") return 3; // Windows 10+ console supports 24-bit
|
|
15
|
+
if (env.FORCE_COLOR === "2" || /256/.test(env.TERM || "")) return 2;
|
|
16
|
+
return 1;
|
|
17
|
+
}
|
|
18
|
+
const level = colorLevel();
|
|
19
|
+
|
|
20
|
+
// [hex, basic ANSI fallback]
|
|
21
|
+
const PALETTE = {
|
|
22
|
+
amber: ["#f5a524", 33], // accent: headers, ids, the "$" prompt
|
|
23
|
+
green: ["#7ccf8a", 32], // success
|
|
24
|
+
red: ["#f07a6e", 31], // errors
|
|
25
|
+
blue: ["#7aa8f0", 34], // info
|
|
26
|
+
text: ["#ece6da", 37], // regular text, a warm off-white
|
|
27
|
+
gray: ["#8a847a", 90], // hints, secondary text
|
|
28
|
+
dim: ["#55514b", 90], // borders, empty cells, comments
|
|
29
|
+
};
|
|
30
|
+
const ALIASES = { yellow: "amber", cyan: "amber", white: "text", muted: "gray" }; // older names keep working
|
|
31
|
+
|
|
32
|
+
const hexToRgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
33
|
+
// closest xterm-256 color: nearest of the 6x6x6 cube (16-231) and the grayscale ramp (232-255)
|
|
34
|
+
const CUBE = [0, 95, 135, 175, 215, 255];
|
|
35
|
+
function rgbTo256(rgb) {
|
|
36
|
+
const dist = (c) => c.reduce((sum, v, i) => sum + (v - rgb[i]) ** 2, 0);
|
|
37
|
+
const near = (v) => CUBE.reduce((best, c, i) => (Math.abs(c - v) < Math.abs(CUBE[best] - v) ? i : best), 0);
|
|
38
|
+
const [r, g, b] = rgb.map(near);
|
|
39
|
+
const cube = { code: 16 + 36 * r + 6 * g + b, d: dist([CUBE[r], CUBE[g], CUBE[b]]) };
|
|
40
|
+
const step = Math.max(0, Math.min(23, Math.round((rgb.reduce((a, v) => a + v, 0) / 3 - 8) / 10)));
|
|
41
|
+
const gray = { code: 232 + step, d: dist(Array(3).fill(8 + step * 10)) };
|
|
42
|
+
return gray.d < cube.d ? gray.code : cube.code;
|
|
43
|
+
}
|
|
44
|
+
function fg(hex, basic) {
|
|
45
|
+
if (level === 3) return [`38;2;${hexToRgb(hex).join(";")}`, 39];
|
|
46
|
+
if (level === 2) return [`38;5;${rgbTo256(hexToRgb(hex))}`, 39];
|
|
47
|
+
return [basic, 39];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const STYLES = { bold: [1, 22], italic: [3, 23], underline: [4, 24] };
|
|
51
|
+
for (const name in PALETTE) STYLES[name] = fg(...PALETTE[name]);
|
|
52
|
+
for (const name in ALIASES) STYLES[name] = STYLES[ALIASES[name]];
|
|
53
|
+
|
|
54
|
+
function style(codes) {
|
|
55
|
+
// re-open the outer style after a nested reset, so colors.green("a " + colors.bold("b") + " c") stays green
|
|
56
|
+
const fn = (str) =>
|
|
57
|
+
level
|
|
58
|
+
? codes.reduceRight((s, [open, close]) => `\x1b[${open}m${s.split(`\x1b[${close}m`).join(`\x1b[${close}m\x1b[${open}m`)}\x1b[${close}m`, String(str))
|
|
59
|
+
: String(str);
|
|
60
|
+
for (const name in STYLES) Object.defineProperty(fn, name, { get: () => style([...codes, STYLES[name]]) });
|
|
61
|
+
return fn;
|
|
62
|
+
}
|
|
63
|
+
const colors = style([]);
|
|
64
|
+
|
|
65
|
+
colors.enabled = level > 0;
|
|
66
|
+
colors.level = level;
|
|
67
|
+
// text without ANSI codes / its width on screen
|
|
68
|
+
colors.strip = (str) => String(str).replace(/\x1b\[[0-9;]*m/g, "");
|
|
69
|
+
colors.width = (str) => [...colors.strip(str)].length;
|
|
70
|
+
|
|
71
|
+
module.exports = colors;
|
package/utils/config.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const { askPassword, loadData, saveOrReport, run, credPath, log, lockVault, hasMaster, unlock } = require("./index");
|
|
2
|
+
const crypto = require("crypto");
|
|
3
|
+
|
|
4
|
+
// usage: node utils/config.js -> create the master password, or change it
|
|
5
|
+
if (require.main === module) run(main);
|
|
6
|
+
module.exports = { createMaster };
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const data = loadData();
|
|
10
|
+
if (!data) return;
|
|
11
|
+
const { db, cred } = data;
|
|
12
|
+
if (!hasMaster(cred)) {
|
|
13
|
+
// a new vault key would make the existing accounts unreadable
|
|
14
|
+
if (db.accounts.length || db.recovery) return log.error("credentials.json is missing, but db.json has data. Run 'recover' with your recovery phrase, or restore credentials.json.bak");
|
|
15
|
+
return createMaster(cred, crypto.randomBytes(32)); // first time: new vault key
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// changing it requires the current one; the vault key stays the same, so db.json is untouched
|
|
19
|
+
const key = unlock((await askPassword("Current MASTER PASSWORD: ")).trim(), cred);
|
|
20
|
+
if (!key) return log.error("Wrong password!");
|
|
21
|
+
await createMaster(cred, key, "New MASTER PASSWORD: ");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ask for a master password and lock `vaultKey` with it -> vaultKey, or null if nothing was saved
|
|
25
|
+
async function createMaster(cred, vaultKey, message = "Choose a MASTER PASSWORD: ") {
|
|
26
|
+
const password = (await askPassword(message)).trim();
|
|
27
|
+
if (!password) return fail("MASTER PASSWORD can't be empty");
|
|
28
|
+
const retyped = (await askPassword("Re-type: ")).trim();
|
|
29
|
+
if (password != retyped) return fail("MASTER PASSWORD not matched");
|
|
30
|
+
const [okMsg, kind] = hasMaster(cred) ? ["Changed MASTER PASSWORD", "updated"] : ["Added MASTER PASSWORD", "added"];
|
|
31
|
+
const next = lockVault(password, vaultKey);
|
|
32
|
+
if (!saveOrReport(credPath, next, okMsg, "Error while saving credentials!", kind)) return null;
|
|
33
|
+
Object.assign(cred, next);
|
|
34
|
+
return vaultKey;
|
|
35
|
+
|
|
36
|
+
function fail(msg) {
|
|
37
|
+
log.error(msg);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/utils/csv.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// RFC 4180 CSV: fields with a comma, quote or line break are quoted ("" for a quote), rows end with CRLF.
|
|
2
|
+
// Values are written as-is (no spreadsheet formula escaping), so passwords like "=abc" or "-x" survive a re-import.
|
|
3
|
+
function csvField(value) {
|
|
4
|
+
const s = value == null ? "" : String(value);
|
|
5
|
+
return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// rows: array of arrays -> csv text
|
|
9
|
+
function toCsv(rows) {
|
|
10
|
+
return rows.map((row) => row.map(csvField).join(",")).join("\r\n") + "\r\n";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// csv text -> array of rows (arrays of strings). Accepts CRLF or LF, a leading BOM, quoted fields with
|
|
14
|
+
// "" and line breaks inside; skips blank lines. Throws on an unclosed quote or text right after a closing quote.
|
|
15
|
+
function parseCsv(text) {
|
|
16
|
+
text = String(text).replace(/^/, "");
|
|
17
|
+
const rows = [];
|
|
18
|
+
let row = [];
|
|
19
|
+
let field = "";
|
|
20
|
+
let line = 1;
|
|
21
|
+
let i = 0;
|
|
22
|
+
const endField = () => (row.push(field), (field = ""));
|
|
23
|
+
const endRow = () => {
|
|
24
|
+
endField();
|
|
25
|
+
if (row.length > 1 || row[0] !== "") rows.push(row); // skip blank lines
|
|
26
|
+
row = [];
|
|
27
|
+
};
|
|
28
|
+
while (i < text.length) {
|
|
29
|
+
const ch = text[i];
|
|
30
|
+
if (ch === '"' && field === "") {
|
|
31
|
+
const start = line;
|
|
32
|
+
i++;
|
|
33
|
+
for (;;) {
|
|
34
|
+
if (i >= text.length) throw new Error("unclosed quote (line " + start + ")");
|
|
35
|
+
if (text[i] === '"') {
|
|
36
|
+
if (text[i + 1] === '"') (field += '"'), (i += 2);
|
|
37
|
+
else break;
|
|
38
|
+
} else {
|
|
39
|
+
if (text[i] === "\n") line++;
|
|
40
|
+
field += text[i++];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
i++; // closing quote
|
|
44
|
+
if (i < text.length && !/[,\r\n]/.test(text[i])) throw new Error("unexpected text after closing quote (line " + line + ")");
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === ",") endField();
|
|
48
|
+
else if (ch === "\r" || ch === "\n") {
|
|
49
|
+
if (ch === "\r" && text[i + 1] === "\n") i++;
|
|
50
|
+
endRow();
|
|
51
|
+
line++;
|
|
52
|
+
} else field += ch;
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
if (field !== "" || row.length) endRow();
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { toCsv, parseCsv };
|
package/utils/fields.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// entry fields (besides id/password/created/updated), also the table columns after id
|
|
2
|
+
const FIELDS = ["name", "url", "username", "login", "notes"];
|
|
3
|
+
// fields encrypted like the password: "***" in the table, read with --show
|
|
4
|
+
const SECRET_FIELDS = ["login", "notes"];
|
|
5
|
+
|
|
6
|
+
module.exports = { FIELDS, SECRET_FIELDS };
|