keebox 0.0.0-stage → 0.9.1

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
@@ -1,3 +1,317 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="assets/logo.svg" alt="KeeBox logo" width="120">
3
+ </p>
2
4
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
5
+ <h1 align="center">KeeBox</h1>
6
+
7
+ <p align="center">
8
+ <b>KeeBox</b> is an offline password manager that lives in your terminal.<br>
9
+ Your passwords. Your keys. Your files.
10
+ </p>
11
+
12
+ <p align="center">
13
+ <a href="https://www.npmjs.com/package/keebox"><img src="https://img.shields.io/npm/v/keebox?color=f5a524&label=npm" alt="npm version"></a>
14
+ <a href="https://www.npmjs.com/package/keebox"><img src="https://img.shields.io/npm/dm/keebox?color=f5a524" alt="npm downloads"></a>
15
+ <a href="https://opensource.org/license/mit"><img src="https://img.shields.io/badge/license-MIT-f5a524" alt="MIT license"></a>
16
+ <img src="https://img.shields.io/badge/node-%3E%3D18-f5a524?logo=nodedotjs&logoColor=white" alt="node >= 18">
17
+ <img src="https://img.shields.io/badge/dependencies-0-7ccf8a" alt="zero dependencies">
18
+ </p>
19
+
20
+ <p align="center">
21
+ <a href="https://keebox.eissa.top">Website</a> ·
22
+ <a href="#get-started">Get started</a> ·
23
+ <a href="#commands-using-the-pass-alias">Commands</a> ·
24
+ <a href="#encryption">Security</a>
25
+ </p>
26
+
27
+ ---
28
+
29
+ Most password managers ask you to trust them with everything: your passwords sit on their servers, behind their account system, their app updates and their subscription. When one of them is breached, changes its pricing or shuts down, your vault goes with it.
30
+
31
+ **KeeBox** takes the opposite approach. It's a password manager with no server and no account. Your vault is two small JSON files that you own: keep them on your laptop, or carry them on a USB stick and run KeeBox on any machine you plug into.
32
+
33
+ ##### Why switch to **KeeBox**
34
+
35
+ - **Nothing in the cloud.** KeeBox never touches the network: no sync server to breach, no telemetry, no account to phish. Your encrypted vault only exists where you put it.
36
+ - **Strong, standard encryption.** Every password, login and note is encrypted with AES-256-GCM. The key is locked behind your master password with scrypt, which makes guessing slow and expensive. Tampered or damaged entries are detected and refused, never silently decrypted into garbage.
37
+ - **A recovery phrase, like a crypto wallet.** Lost your credentials file or forgot your master password? A 12-word recovery phrase gets you back in. Without the phrase or the password, nobody can open the vault, not even the author of KeeBox.
38
+ - **Zero dependencies.** No `npm install` and no third-party packages, so there's no supply chain to compromise. The whole tool is about 1,300 lines of plain Node.js that you can read and audit in an afternoon.
39
+ - **Portable.** Copy the folder plus a Node.js binary to a USB drive and it runs on Linux, macOS, Windows and Android (Termux), no installation needed.
40
+ - **Fast from the terminal.** Generate strong passwords, find accounts by any field, and copy a password to the clipboard. It's wiped automatically after 20 seconds.
41
+ - **Yours to keep.** Export everything to CSV whenever you want, import accounts from CSV, and check your vault for damage. Open source and MIT licensed.
42
+
43
+ > **What you trade:** KeeBox doesn't autofill in your browser or sync between devices for you. You're in charge of your files and backups, and that's the point.
44
+
45
+ ##### Get started
46
+
47
+ Requires Node.js 18+.
48
+
49
+ ```
50
+ git clone https://github.com/eissapk/keybox.git keebox
51
+ cd keebox # no npm install needed, there are no dependencies
52
+ node index.js -h
53
+ ```
54
+
55
+ Clipboard: macOS and Windows/WSL work as is, Linux needs `xclip` (X11) or `wl-clipboard` (Wayland). On Android (Termux) passwords are printed instead of copied.
56
+
57
+ ##### Structure
58
+
59
+ ```
60
+ ├── assets
61
+ │ └── logo.svg
62
+ ├── credentials.json # created on first run (master password lock), not in git
63
+ ├── db.json # created on first run (accounts + recovery lock), not in git
64
+ ├── index.js # CLI entry: commands + master password prompt
65
+ ├── node # optional, for a portable copy (see below), not in git
66
+ │ ├── linux/node
67
+ │ ├── mac/node
68
+ │ └── win/node.exe
69
+ ├── options # one file per command
70
+ │ ├── add.js
71
+ │ ├── check.js
72
+ │ ├── delete.js
73
+ │ ├── export.js
74
+ │ ├── get.js
75
+ │ ├── import.js
76
+ │ ├── index.js
77
+ │ ├── recovery.js # 'recovery' + 'recover'
78
+ │ └── update.js
79
+ ├── package.json
80
+ ├── README.md
81
+ ├── test # npm test
82
+ └── utils
83
+ ├── cli.js # argument parser
84
+ ├── colors.js # KeeBox palette (truecolor / 256 / 16 colors)
85
+ ├── config.js # create / change the master password
86
+ ├── csv.js
87
+ ├── fields.js
88
+ ├── index.js # encrypt/decrypt, load/save db, clipboard, helpers
89
+ ├── log.js # message styles (✓ / [+] / [-] / [!] / ✗)
90
+ ├── mnemonic.js # 12-word recovery phrase (BIP39)
91
+ ├── prompt.js
92
+ ├── table.js
93
+ ├── vault.js # vault key locking (master password / recovery phrase)
94
+ └── wordlist.js # BIP39 English wordlist
95
+ ```
96
+
97
+ ##### Make it portable (USB flash)
98
+
99
+ Copy the project with a Node.js binary per OS, so it runs on machines without Node installed:
100
+
101
+ ```
102
+ cp -r /path/to/project /path/to/usb-flash
103
+ cp "$(command -v node)" /path/to/usb-flash/node/linux/node # repeat for mac/node and win/node.exe
104
+ ```
105
+
106
+ Run it from the flash drive:
107
+
108
+ ```
109
+ /path/to/usb-flash/node/linux/node /path/to/usb-flash/index.js -h
110
+ ```
111
+
112
+ Or add an alias in your `.bashrc` / `.zshrc`, and use it anywhere:
113
+
114
+ ```
115
+ alias pass='node /path/to/usb-flash/index.js'
116
+ pass --help
117
+ pass --version
118
+ ```
119
+
120
+ ##### Commands (using the `pass` alias)
121
+
122
+ ```
123
+ pass get # list all accounts
124
+ pass get --id 2 # show account with id 2
125
+ pass get --name google # show accounts whose name contains "google"
126
+ pass get --id 2 --cp # copy its password to the clipboard (auto-cleared after 20s)
127
+ pass get --id 2 --show # show its login/notes
128
+ pass get --id 2 --edit # update it (Enter = keep a value, '-' = clear it)
129
+ pass get --id 2 --rm # delete it
130
+ pass add --name google --username foo@gmail.com --url www.google.com # add an account (asks for its password)
131
+ pass add --name github --username foo --url github.com --login foo@mail.com --notes "2FA via phone" # with login/notes (encrypted)
132
+ pass make --size 20 # generate a random strong password of 20 chars
133
+ pass export # export all accounts, decrypted, to keebox-export-<date>.csv
134
+ pass export --name google --out google.csv # export matching accounts to google.csv
135
+ pass import --file accounts.csv # add accounts from a csv (same columns as export; id is ignored, duplicates skipped)
136
+ pass check # damage check: lists accounts that are broken or can't be decrypted (read-only, exit code 1 if any)
137
+ pass recovery # create (or replace) the 12-word recovery phrase
138
+ pass recover # lost credentials.json / forgot the master password: unlock with the recovery phrase, set a new master password
139
+ ```
140
+
141
+ Exported CSV files are **not encrypted**, delete them when you're done.
142
+
143
+ ##### Create / change master password
144
+
145
+ On a new (empty) vault, the first `pass add` / `pass get` asks you to create one. To create it yourself, or change it later (asks the current one first; `db.json` stays as is, and the recovery phrase keeps working):
146
+
147
+ ```
148
+ node utils/config.js
149
+ ```
150
+
151
+ ##### Encryption
152
+
153
+ Each password, login and notes is encrypted (AES-256-GCM) with one random 256-bit **vault key** (login/notes show as `***` in the table, read them with `--show`). The vault key is never stored as is, only locked, in two independent places:
154
+
155
+ | Where | Locked with | Opened by |
156
+ |---|---|---|
157
+ | `credentials.json` → `{ salt, n, key }` | scrypt(master password, salt) + AES-256-GCM | the master password |
158
+ | `db.json` → `recovery: { salt, n, key }` | scrypt(12-word phrase, salt) + AES-256-GCM | the recovery phrase (`pass recover`) |
159
+
160
+ What gets you into the accounts:
161
+
162
+ | You have | Access? |
163
+ |---|---|
164
+ | `db.json` + `credentials.json` + master password | yes (normal use) |
165
+ | `db.json` + recovery phrase | yes: `pass recover` sets a new master password and writes a new `credentials.json` |
166
+ | `db.json` + master password, but no `credentials.json` | **no**: the master password only opens `credentials.json` (restore `credentials.json.bak` or a backup) |
167
+ | `db.json` alone | **no**, for anyone: it would mean guessing a 128-bit phrase or a 256-bit key |
168
+
169
+ So keep `db.json` and `credentials.json` together and backed up, choose a strong master password (a copied `credentials.json` + `db.json` is only as safe as it), and keep the recovery phrase offline.
170
+
171
+ ##### Recovery phrase (emergency key)
172
+
173
+ `pass recovery` makes a 12-word phrase (BIP39 English words, like a crypto wallet seed: 128 random bits + checksum) and locks the same vault key with it into `db.json`. The phrase itself is never saved, it's shown once: write it down, then type it back to confirm.
174
+
175
+ If `credentials.json` is deleted or you forget the master password, `pass recover` asks for the 12 words (any case/spacing; typos and swapped words are caught by the checksum), unlocks the vault key and lets you set a new master password. Only `credentials.json` is written, `db.json` isn't changed, and the same phrase keeps working afterwards. Running `pass recovery` again replaces the phrase (the old one stops working, except against an older `db.json.bak`).
176
+
177
+ Anyone with the phrase + `db.json` can read every account, so guard it like the master password.
178
+
179
+ ##### Files
180
+
181
+ `db.json` (`recovery` is `null` until you run `pass recovery`):
182
+
183
+ ```json
184
+ {
185
+ "recovery": { "salt": "...", "n": 32768, "key": "iv.tag.data" },
186
+ "accounts": [
187
+ {
188
+ "id": 1,
189
+ "name": "github",
190
+ "url": "github.com",
191
+ "username": "foo",
192
+ "login": "iv.tag.data",
193
+ "notes": "iv.tag.data",
194
+ "password": "iv.tag.data",
195
+ "created": "2026-01-01T00:00:00.000Z",
196
+ "updated": "2026-01-01T00:00:00.000Z"
197
+ }
198
+ ]
199
+ }
200
+ ```
201
+
202
+ `credentials.json` (`{}` until a master password is created):
203
+
204
+ ```json
205
+ { "salt": "...", "n": 32768, "key": "iv.tag.data" }
206
+ ```
207
+
208
+ Both are created automatically if missing, and every save keeps the previous version as `db.json.bak` / `credentials.json.bak`. If `credentials.json` is missing but `db.json` has data, KeeBox won't create a new master password (that would make a new vault key and lock out the existing accounts); it asks you to run `pass recover` or restore `credentials.json.bak` instead.
209
+
210
+ ##### Decryption flow
211
+
212
+ ```
213
+ pass get --id 11 --cp
214
+ │
215
+ ▼
216
+ ┌───────────────────────────────┐
217
+ │ Master password exists? │ (credentials.json)
218
+ └───────────────┬───────────────┘
219
+ no │ │ yes
220
+ ▼ ▼
221
+ ┌──────────────────────────┐ ┌──────────────────────────┐
222
+ │ db.json has data? │ │ Ask MASTER PASSWORD │
223
+ │ yes: stop, "run 'recover'│ │ (trim spaces) │
224
+ │ or restore .bak" │ └────────────┬─────────────┘
225
+ │ no: choose + re-type, │ │
226
+ │ new random vault key, │ │
227
+ │ save it, continue ─────┐ │ ▼
228
+ └────────────────────────┼─┘
229
+ │ ╔═══════════════════════════════╗
230
+ │ ║ PART 1: MASTER PASSWORD ║
231
+ │ ║ credentials.json {salt,n,key} ║
232
+ │ ╚═══════════════╤═══════════════╝
233
+ │ ▼
234
+ │ ┌──────────────────────────────┐
235
+ │ │ scrypt(password, salt, n) │
236
+ │ │ → temporary key │
237
+ │ └──────────────┬───────────────┘
238
+ │ ▼
239
+ │ ┌──────────────────────────────┐
240
+ │ │ AES-GCM decrypt "key" │
241
+ │ │ (iv.tag.data) │
242
+ │ └───────┬──────────────┬───────┘
243
+ │ tag OK │ │ tag fails
244
+ │ │ ▼
245
+ │ │ ┌──────────────────────┐
246
+ │ │ │ "Wrong password" │──► stop
247
+ │ │ └──────────────────────┘
248
+ │ ▼
249
+ │ ┌──────────────────────────────┐
250
+ └─────►│ VAULT KEY (in memory only) │
251
+ └──────────────┬───────────────┘
252
+ ▼
253
+ ╔═══════════════════════════════╗
254
+ ║ PART 2: ACCOUNT PASSWORD ║
255
+ ╚═══════════════╤═══════════════╝
256
+ ▼
257
+ ┌──────────────────────────────┐
258
+ │ Filter db.json accounts │
259
+ │ exactly 1 match? else stop │
260
+ └──────────────┬───────────────┘
261
+ ▼
262
+ ┌──────────────────────────────┐
263
+ │ split "iv.tag.data" │
264
+ │ AES-GCM decrypt with │
265
+ │ VAULT KEY + iv + tag │
266
+ └───────┬──────────────┬───────┘
267
+ tag OK │ │ fails
268
+ ▼ ▼
269
+ ┌───────────────────────┐ ┌───────────────────┐
270
+ │ plain password │ │ "empty or damaged"│
271
+ │ Termux: print it │ └───────────────────┘
272
+ │ else: clipboard, │
273
+ │ auto-clear in 20s │
274
+ └───────────────────────┘
275
+ ```
276
+
277
+ ##### Recovery flow
278
+
279
+ ```
280
+ pass recover
281
+ │
282
+ ▼
283
+ ┌───────────────────────────────┐
284
+ │ db.json has a recovery lock? │──► no: stop
285
+ └───────────────┬───────────────┘
286
+ ▼
287
+ ┌───────────────────────────────┐
288
+ │ Ask RECOVERY PHRASE (12 words)│
289
+ │ 12 known words + checksum OK? │──► no: "Invalid recovery phrase"
290
+ └───────────────┬───────────────┘
291
+ ▼
292
+ ┌───────────────────────────────┐
293
+ │ scrypt(phrase, salt, n) │
294
+ │ AES-GCM decrypt recovery.key │──► tag fails: "Wrong recovery phrase"
295
+ └───────────────┬───────────────┘
296
+ ▼
297
+ ┌───────────────────────────────┐
298
+ │ VAULT KEY (in memory only) │
299
+ │ choose new MASTER PASSWORD │
300
+ │ → writes credentials.json │
301
+ │ (db.json unchanged) │
302
+ └───────────────────────────────┘
303
+ ```
304
+
305
+ | Step | File | Uses |
306
+ |---|---|---|
307
+ | Ask for or create the master password | `index.js` → `askMaster`, `utils/config.js` → `createMaster` | master password |
308
+ | Master password → vault key | `utils/vault.js` → `unlock` | `credentials.json` `salt`, `n`, `key` |
309
+ | Create the recovery phrase (`pass recovery`) | `options/recovery.js` → `createRecovery`, `utils/vault.js` → `makeRecovery`, `utils/mnemonic.js` | `db.json` `recovery` |
310
+ | Recovery phrase → vault key (`pass recover`) | `options/recovery.js` → `recoverVault`, `utils/vault.js` → `recover` | `db.json` `recovery` |
311
+ | Vault key → account password | `utils/index.js` → `decrypt` (called from `options/get.js` → `copyEntry`) | `iv`, `tag`, `data` |
312
+
313
+ ##### Tests
314
+
315
+ ```
316
+ npm test
317
+ ```
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 4 28 10 16 16 4 10z" fill="#f9c879"/><path d="M4 10 16 16v13L4 23z" fill="#ea9a35"/><path d="M16 16 28 10v13l-12 6z" fill="#b9661b"/><path d="M8.4 12.2 20.4 6.2 23.6 7.8 11.6 13.8z" fill="#fff4de"/><path d="M8.4 12.2 11.6 13.8v13l-3.2-1.6z" fill="#f8deb0"/><g transform="matrix(1 -0.5 0 1 0 0)"><rect x="18.6" y="29.4" width="7.2" height="4.6" rx=".7" fill="#fff4de"/><g fill="#7a3f0c"><circle cx="20.1" cy="31.7" r=".6"/><circle cx="21.5" cy="31.7" r=".6"/><circle cx="22.9" cy="31.7" r=".6"/><circle cx="24.3" cy="31.7" r=".6"/></g></g><path d="M4 10 16 4l12 6" fill="none" stroke="#fff" stroke-opacity=".45" stroke-width=".6" stroke-linejoin="round"/><path d="M4 10 16 16 28 10M16 16v13" fill="none" stroke="#7a3f0c" stroke-opacity=".25" stroke-width=".5" stroke-linejoin="round"/></svg>
package/index.js ADDED
@@ -0,0 +1,108 @@
1
+ const { mkPass, askPassword, loadData, copyOrPrint, run, FIELDS, SECRET_FIELDS, log, hasMaster, unlock, createMaster, parseArgs } = require("./utils");
2
+ const crypto = require("crypto");
3
+ const pkgJSON = require("./package.json");
4
+ const { getEntry, addEntry, exportEntries, importEntries, checkDb, createRecovery, recoverVault } = require("./options");
5
+
6
+ run(main);
7
+
8
+ async function main() {
9
+ // connect to db
10
+ const data = loadData();
11
+ if (!data) return;
12
+ const { db, cred } = data;
13
+
14
+ const parsed = parseArgs(process.argv.slice(2), {
15
+ version: pkgJSON.version,
16
+ description: "KeeBox CLI Tool (" + pkgJSON.version + ")",
17
+ options: [
18
+ ["--id <id>", "Include account id - use it with 'get/add' commands"],
19
+ ["--name <name>", "Include account name - use it with 'get/add' commands"],
20
+ ["--url <url>", "Include account url - use it with 'get/add' commands"],
21
+ ["--username <username>", "Include account username - use it with 'get/add' commands"],
22
+ ["--login <login>", "Include account login (encrypted) - use it with 'add' command"],
23
+ ["--notes <notes>", "Include account notes (encrypted) - use it with 'add' command"],
24
+ ["--cp", "Copy account password to clipboard - use it with 'get' command"],
25
+ ["--show", "Show account login/notes - use it with 'get' command"],
26
+ ["--edit", "Update account in db - use it with 'get' command"],
27
+ ["--rm", "Delete account in db - use it with 'get' command"],
28
+ ["--size <size>", "Password length - use it with 'make' command"],
29
+ ["--out <file>", "CSV file to write (default: keebox-export-<date>.csv) - use it with 'export' command"],
30
+ ["--file <file>", "CSV file to read (columns: name,password + optional url,username,login,notes,created,updated) - use it with 'import' command"],
31
+ ],
32
+ commands: { add: "Add account", get: "display account(s)", make: "Generate a password", export: "Export account(s) as decrypted CSV (filter with --id/--name/...)", import: "Import account(s) from a CSV file (e.g. one made by 'export')", check: "Damage check: find accounts that are broken or can't be decrypted (changes nothing)", recovery: "Create (or replace) the 12-word recovery phrase", recover: "Lost credentials.json / forgot the master password: unlock with the recovery phrase and set a new master password" },
33
+ });
34
+ if (parsed) await { add, get, make, export: exportCsv, import: importCsv, check, recovery, recover }[parsed.command](parsed.opts, db, cred);
35
+ }
36
+
37
+ // ask master password -> vault key, or null
38
+ async function askMaster(cred, db) {
39
+ if (!hasMaster(cred)) {
40
+ // a new vault key would make the existing accounts unreadable
41
+ if (db.accounts.length || db.recovery) {
42
+ log.error("credentials.json is missing or has no master password, but db.json has data.");
43
+ log.warnErr("Run 'recover' with your recovery phrase, or restore credentials.json.bak");
44
+ return null;
45
+ }
46
+ log.warn("No MASTER PASSWORD yet, let's create one.");
47
+ const key = await createMaster(cred, crypto.randomBytes(32));
48
+ if (key) log.hint("Tip: run 'recovery' to create a 12-word recovery phrase, in case credentials.json is lost");
49
+ return key;
50
+ }
51
+ const key = unlock((await askPassword("MASTER PASSWORD:")).trim(), cred);
52
+ if (!key) log.error("Wrong password!");
53
+ return key;
54
+ }
55
+
56
+ async function add(opts, db, cred) {
57
+ for (const f of ["name", "url", "username"]) if (!opts[f]) return log.error("Missing --" + f + " argument!");
58
+ const entry = {};
59
+ FIELDS.forEach((f) => (entry[f] = opts[f] || ""));
60
+
61
+ const key = await askMaster(cred, db);
62
+ if (key) await addEntry(db, entry, key);
63
+ }
64
+
65
+ async function get(opts, db, cred) {
66
+ for (const f of SECRET_FIELDS) if (opts[f]) return log.error("Can't search by --" + f + ", it's encrypted (read it with --show)");
67
+ const query = {};
68
+ ["id", ...FIELDS].forEach((f) => opts[f] && (query[f] = opts[f]));
69
+
70
+ const key = await askMaster(cred, db);
71
+ if (key) await getEntry(db, query, opts, key);
72
+ }
73
+
74
+ async function make(opts) {
75
+ if (!opts.size) return log.error("Missing --size argument!");
76
+ const length = Number(opts.size);
77
+ if (!Number.isInteger(length) || length < 4 || length > 1024) return log.error("--size must be a number between 4 and 1024");
78
+ await copyOrPrint(mkPass(length), "", true);
79
+ }
80
+
81
+ async function exportCsv(opts, db, cred) {
82
+ for (const f of SECRET_FIELDS) if (opts[f]) return log.error("Can't filter by --" + f + ", it's encrypted");
83
+ const query = {};
84
+ ["id", ...FIELDS].forEach((f) => opts[f] && (query[f] = opts[f]));
85
+ const file = opts.out || "keebox-export-" + new Date().toISOString().slice(0, 10) + ".csv";
86
+ await exportEntries(db, query, file, () => askMaster(cred, db));
87
+ }
88
+
89
+ async function importCsv(opts, db, cred) {
90
+ if (!opts.file) return log.error("Missing --file argument!");
91
+ await importEntries(db, opts.file, () => askMaster(cred, db));
92
+ }
93
+
94
+ async function check(opts, db, cred) {
95
+ await checkDb(db, cred, async () => {
96
+ const key = unlock((await askPassword("MASTER PASSWORD:")).trim(), cred);
97
+ if (!key) log.error("Wrong password!");
98
+ return key;
99
+ });
100
+ }
101
+
102
+ async function recovery(opts, db, cred) {
103
+ await createRecovery(db, () => askMaster(cred, db));
104
+ }
105
+
106
+ async function recover(opts, db, cred) {
107
+ await recoverVault(db, cred);
108
+ }
package/options/add.js ADDED
@@ -0,0 +1,16 @@
1
+ const { askPassword, encrypt, decryptField, saveOrReport, nextEntryId, dbPath, SECRET_FIELDS, log } = require("../utils");
2
+
3
+ async function addEntry(db, entry, key) {
4
+ const entryExists = db.accounts.some((obj) => ["name", "url", "username"].every((f) => entry[f] == obj[f]) && entry.login == decryptField(obj, "login", key));
5
+ if (entryExists) return log.warn("Entry exists!");
6
+
7
+ SECRET_FIELDS.forEach((f) => (entry[f] = entry[f] && encrypt(entry[f], key)));
8
+ entry.password = encrypt(await askPassword("Add password:"), key);
9
+ entry.id = nextEntryId(db.accounts);
10
+ entry.created = new Date().toISOString();
11
+ entry.updated = entry.created;
12
+ db.accounts.push(entry); // push new entry
13
+ saveOrReport(dbPath, db, "Added entry #" + entry.id, "Error while adding entry!", "added");
14
+ }
15
+
16
+ module.exports = addEntry;
@@ -0,0 +1,123 @@
1
+ const fs = require("fs");
2
+ const { decrypt, isEncrypted, hasMaster, entryLabel, parseDb, dbPath, log, FIELDS, SECRET_FIELDS } = require("../utils");
3
+
4
+ const ENCRYPTED = ["password", ...SECRET_FIELDS];
5
+
6
+ // one entry -> list of problems ([] = healthy). Without `key` only the structure is checked (no decryption).
7
+ function checkEntry(entry, key) {
8
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return ["not an account object"];
9
+ const problems = [];
10
+ if (!Number.isInteger(entry.id) || entry.id < 1) problems.push("bad id (" + JSON.stringify(entry.id) + ")");
11
+ if (typeof entry.name !== "string" || !entry.name.trim()) problems.push("name is empty");
12
+ for (const f of FIELDS) if (entry[f] != null && typeof entry[f] !== "string") problems.push(f + " isn't text");
13
+ if (!entry.password) problems.push("password is empty");
14
+
15
+ for (const f of ENCRYPTED) {
16
+ if (!entry[f] || typeof entry[f] !== "string") continue;
17
+ if (!isEncrypted(entry[f])) problems.push(f + " isn't encrypted (unknown format)");
18
+ else if (key && !canDecrypt(entry[f], key)) problems.push(f + " can't be decrypted (damaged)");
19
+ }
20
+ for (const f of ["created", "updated"]) if (entry[f] && isNaN(Date.parse(entry[f]))) problems.push(f + " isn't a valid date");
21
+ return problems;
22
+ }
23
+
24
+ function canDecrypt(value, key) {
25
+ try {
26
+ decrypt(value, key);
27
+ return true;
28
+ } catch (err) {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ // whole db -> [{ entry, problems }] for the damaged entries only (also catches duplicate ids)
34
+ function checkEntries(db, key) {
35
+ const seen = new Map(); // id -> count
36
+ db.forEach((e) => e && e.id != null && seen.set(e.id, (seen.get(e.id) || 0) + 1));
37
+ return db
38
+ .map((entry) => {
39
+ const problems = checkEntry(entry, key);
40
+ if (entry && seen.get(entry.id) > 1) problems.push("duplicate id (" + seen.get(entry.id) + " entries use #" + entry.id + ")");
41
+ return { entry, problems };
42
+ })
43
+ .filter((r) => r.problems.length);
44
+ }
45
+
46
+ // a locked vault key { salt, n, key } (credentials.json or db.json "recovery") -> list of problems, prefixed with `label`
47
+ function checkLock(lock, label) {
48
+ const problems = [];
49
+ if (typeof lock.salt !== "string" || !/^[0-9a-f]{32}$/.test(lock.salt)) problems.push(label + ": bad salt");
50
+ if (!Number.isInteger(lock.n) || lock.n < 2 || (lock.n & (lock.n - 1)) !== 0) problems.push(label + ": bad n (must be a power of 2)");
51
+ if (!isEncrypted(lock.key)) problems.push(label + ": key isn't encrypted (unknown format)");
52
+ return problems;
53
+ }
54
+
55
+ // credentials.json -> list of problems. `accounts` tells whether a missing master password is a problem.
56
+ function checkCredentials(cred, accounts) {
57
+ if (!hasMaster(cred)) {
58
+ const hasSecrets = accounts.some((e) => e && ENCRYPTED.some((f) => isEncrypted(e[f])));
59
+ return hasSecrets ? ["credentials.json has no master password, but db.json has encrypted entries (run 'recover' with your recovery phrase, or restore credentials.json.bak)"] : [];
60
+ }
61
+ return checkLock(cred, "credentials.json");
62
+ }
63
+
64
+ // db.json "recovery" -> list of problems (none when there's no recovery phrase)
65
+ function checkRecovery(recovery) {
66
+ if (recovery == null) return [];
67
+ if (typeof recovery !== "object" || Array.isArray(recovery)) return ["db.json: recovery isn't an object"];
68
+ return checkLock(recovery, "db.json recovery");
69
+ }
70
+
71
+ // accounts of db.json.bak, or null
72
+ function loadBackup(file = dbPath + ".bak") {
73
+ try {
74
+ return parseDb(JSON.parse(fs.readFileSync(file, "utf8"))).accounts;
75
+ } catch (err) {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ // id of a damaged entry -> true when db.json.bak has a healthy copy of it
81
+ function healthyInBackup(entry, backup, key) {
82
+ if (!backup || !entry || entry.id == null) return false;
83
+ const copies = backup.filter((e) => e && e.id === entry.id);
84
+ return copies.length === 1 && checkEntry(copies[0], key).length === 0;
85
+ }
86
+
87
+ // read-only: print every damaged entry + credentials/recovery problem. askKey() -> vault key, or null (wrong password)
88
+ async function checkDb(db, cred, askKey) {
89
+ const { accounts } = db;
90
+ const credProblems = checkCredentials(cred, accounts);
91
+ const recoveryProblems = checkRecovery(db.recovery);
92
+ [...credProblems, ...recoveryProblems].forEach((p) => log.problem(p));
93
+ if (db.recovery == null && accounts.length) log.hint("No recovery phrase yet, create one with 'recovery'");
94
+
95
+ let key = null;
96
+ if (hasMaster(cred) && !credProblems.length) {
97
+ key = await askKey();
98
+ if (!key) return void (process.exitCode = 1);
99
+ } else {
100
+ log.warn("Can't unlock the vault, checking structure only (no decryption)");
101
+ }
102
+
103
+ const damaged = checkEntries(accounts, key);
104
+ const backup = damaged.length ? loadBackup() : null;
105
+ for (const { entry, problems } of damaged) {
106
+ const label = entry && typeof entry === "object" ? entryLabel(entry) : JSON.stringify(entry);
107
+ log.problem(label + ": " + problems.join(", "));
108
+ if (key && healthyInBackup(entry, backup, key)) log.hint(" a healthy copy is in db.json.bak");
109
+ }
110
+
111
+ const total = accounts.length + " entr" + (accounts.length === 1 ? "y" : "ies") + " checked";
112
+ if (!damaged.length && !credProblems.length && !recoveryProblems.length) return log.ok(total + ", no damage found" + (key ? "" : " (structure only)"));
113
+ process.exitCode = 1;
114
+ const notes = (credProblems.length ? ", credentials.json damaged" : "") + (recoveryProblems.length ? ", recovery damaged (make a new one with 'recovery')" : "");
115
+ log.warn(total + ", " + damaged.length + " damaged" + notes);
116
+ log.hint("Fix with 'get --id <id> --edit' / '--rm', or restore from db.json.bak / credentials.json.bak");
117
+ }
118
+
119
+ module.exports = checkDb;
120
+ module.exports.checkEntry = checkEntry;
121
+ module.exports.checkEntries = checkEntries;
122
+ module.exports.checkCredentials = checkCredentials;
123
+ module.exports.checkRecovery = checkRecovery;
@@ -0,0 +1,10 @@
1
+ const { confirm, saveOrReport, entryLabel, dbPath, colors } = require("../utils");
2
+
3
+ async function deleteEntry(entry, db) {
4
+ const label = entryLabel(entry);
5
+ if (!(await confirm("Entry " + label + " will be " + colors.red("deleted") + ", Are you sure"))) return;
6
+ db.accounts.splice(db.accounts.findIndex((obj) => obj.id == entry.id), 1);
7
+ saveOrReport(dbPath, db, "Deleted entry " + label, "Error while deleting entry!", "removed");
8
+ }
9
+
10
+ module.exports = deleteEntry;
@@ -0,0 +1,54 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { confirm, decryptField, filterEntries, colors, log, FIELDS, SECRET_FIELDS } = require("../utils");
4
+ const { toCsv } = require("../utils/csv");
5
+
6
+ const COLUMNS = ["id", ...FIELDS, "password", "created", "updated"];
7
+
8
+ // entries -> csv text with every secret decrypted, or null if any field couldn't be decrypted (error already printed)
9
+ function entriesToCsv(entries, key) {
10
+ const rows = [COLUMNS];
11
+ for (const entry of entries) {
12
+ const row = [];
13
+ for (const col of COLUMNS) {
14
+ const value = col === "password" || SECRET_FIELDS.includes(col) ? decryptField(entry, col, key) : entry[col];
15
+ if (value === null) return null;
16
+ row.push(value);
17
+ }
18
+ rows.push(row);
19
+ }
20
+ return toCsv(rows);
21
+ }
22
+
23
+ // ask before overwriting `file` -> true when it's ok to write
24
+ async function canWrite(file) {
25
+ if (!fs.existsSync(file)) return true;
26
+ return confirm(file + " exists, " + colors.amber("overwrite") + " it");
27
+ }
28
+
29
+ // write the decrypted entries (all, or the ones matching `query`) to `file`, readable by the owner only
30
+ async function exportEntries(db, query, file, askKey) {
31
+ const entries = Object.keys(query).length ? filterEntries(db.accounts, query) : db.accounts;
32
+ if (!entries.length) return log.warn("No entries to export");
33
+
34
+ file = path.resolve(file);
35
+ if (!(await canWrite(file))) return;
36
+
37
+ const key = await askKey();
38
+ if (!key) return;
39
+ const csv = entriesToCsv(entries, key);
40
+ if (csv === null) return log.error("Export aborted, nothing written");
41
+
42
+ try {
43
+ fs.writeFileSync(file, csv, { mode: 0o600 });
44
+ fs.chmodSync(file, 0o600); // mode only applies to new files
45
+ } catch (err) {
46
+ process.exitCode = 1;
47
+ return log.error("Error while exporting!", err);
48
+ }
49
+ log.added("Exported " + entries.length + " entr" + (entries.length === 1 ? "y" : "ies") + " to " + file);
50
+ log.warn("This file is NOT encrypted, delete it when you're done with it.");
51
+ }
52
+
53
+ module.exports = exportEntries;
54
+ module.exports.entriesToCsv = entriesToCsv;
package/options/get.js ADDED
@@ -0,0 +1,16 @@
1
+ const { copyEntry, showEntry, drawTable, filterEntries, log } = require("../utils");
2
+
3
+ async function getEntry(db, query, opts, key) {
4
+ const { deleteEntry, updateEntry } = require("./index"); // here, not at the top: options/index.js loads this file, so its exports are only ready at call time
5
+ if (!Object.keys(query).length) return drawTable(db.accounts); // show all table
6
+
7
+ const filteredEntries = filterEntries(db.accounts, query);
8
+ drawTable(filteredEntries); // show matched items
9
+
10
+ const action = opts.edit ? updateEntry : opts.rm ? deleteEntry : opts.cp ? copyEntry : opts.show ? showEntry : null;
11
+ if (!action || !filteredEntries.length) return;
12
+ if (filteredEntries.length > 1) return log.warn("Multiple entries matched, narrow it down (e.g. --id <id>)");
13
+ await action(filteredEntries[0], db, key);
14
+ }
15
+
16
+ module.exports = getEntry;