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 +316 -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
package/README.md
CHANGED
|
@@ -1,3 +1,317 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.svg" alt="KeeBox logo" width="120">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
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
|
+
```
|
package/assets/logo.svg
ADDED
|
@@ -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;
|
package/options/check.js
ADDED
|
@@ -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;
|