@papervault/cli 0.1.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/LICENSE +21 -0
- package/README.md +212 -0
- package/package.json +59 -0
- package/src/audit.js +52 -0
- package/src/browser.js +24 -0
- package/src/cli.js +42 -0
- package/src/commands/backup.js +217 -0
- package/src/commands/init.js +508 -0
- package/src/commands/sources.js +25 -0
- package/src/commands/verify.js +71 -0
- package/src/config.js +69 -0
- package/src/index.js +10 -0
- package/src/kit-runner.js +72 -0
- package/src/server.js +128 -0
- package/src/sources/azure.js +151 -0
- package/src/sources/file.js +58 -0
- package/src/sources/index.js +40 -0
- package/src/sources/stdin.js +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 PaperVault.xyz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# PaperVault CLI — Store secrets on paper using threshold encryption
|
|
2
|
+
|
|
3
|
+
**PaperVault 📄🔐** is a free open source tool for creating offline paper-based data vaults for your foundational secrets, such as passwords, 2FA recovery codes, digital asset keys, hard drive encryption keys, and other critical data.
|
|
4
|
+
|
|
5
|
+
This is the **command-line version**. Same crypto, same paper output, same [papervault.xyz/unlock](https://papervault.xyz/unlock) recovery path — but driven from your terminal so you can wire it into shell pipelines, pull straight from secret stores like Azure Key Vault, or import a `.env` file in one command. The browser app lives at [papervault.xyz](https://papervault.xyz).
|
|
6
|
+
|
|
7
|
+
## 🔐 Overview
|
|
8
|
+
|
|
9
|
+
PaperVault encrypts your secrets and splits the decryption key into shards that can be printed on paper or saved to digital media. Keys are split using [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing). Choose how many keys to create and how many are needed to unlock — for example, 5 keys with any 3 required (3-of-5).
|
|
10
|
+
|
|
11
|
+
The CLI produces the same v2 vault format as the browser app, so kits made here unlock identically at [papervault.xyz/unlock](https://papervault.xyz/unlock).
|
|
12
|
+
|
|
13
|
+
## 🚀 Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Install once
|
|
17
|
+
npm install -g @papervault/cli
|
|
18
|
+
|
|
19
|
+
# Walk through the wizard
|
|
20
|
+
papervault init
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run the wizard without installing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx @papervault/init
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The wizard asks where your secrets live. Three common paths:
|
|
30
|
+
|
|
31
|
+
| Where they are | What to pick | What happens |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| In your head / on a sticky note | **"I'll add them now"** | Walks you through entering each secret; nothing saved to disk. |
|
|
34
|
+
| In a `.env` file | **"Import from .env file"** | Multiselect which entries to back up; nothing saved to disk. |
|
|
35
|
+
| In Azure Key Vault | **"Azure Key Vault"** | Uses your `az login` cache; writes a `papervault.config.json` so future runs are one command. |
|
|
36
|
+
|
|
37
|
+
After that, your browser opens with a print-ready kit — print the vault page and each key page, distribute, done.
|
|
38
|
+
|
|
39
|
+
Requires Node.js ≥ 24.
|
|
40
|
+
|
|
41
|
+
## 🔑 Key Features
|
|
42
|
+
|
|
43
|
+
- **Works offline** — No internet required after install; safe to run on an air-gapped machine.
|
|
44
|
+
- **Client-side only** — All crypto runs locally. No data ever leaves your device.
|
|
45
|
+
- **Printable** — Vault and keys come out as standalone HTML pages with embedded QR codes; print or save as PDF.
|
|
46
|
+
- **Flexible thresholds** — Any M-of-N combination (up to 20 keys).
|
|
47
|
+
- **Pluggable sources** — Manual entry, `.env` files, Azure Key Vault today; AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault, 1Password on the roadmap.
|
|
48
|
+
- **Social recovery & digital inheritance** — Keys can be distributed for recovery in emergencies.
|
|
49
|
+
- **Round-trip compatible** — Kits unlock at [papervault.xyz/unlock](https://papervault.xyz/unlock), same as web-app vaults.
|
|
50
|
+
|
|
51
|
+
## 📄 Vault vs key shares (social recovery)
|
|
52
|
+
|
|
53
|
+
The encrypted vault page and the key share pages are **separate documents** — keyholders need both the threshold of keys *and* the vault page to recover. See [Vault vs key shares](https://github.com/boazeb/papervault#-vault-vs-key-shares-social-recovery) in the main repo for the full social-recovery model.
|
|
54
|
+
|
|
55
|
+
## 📖 How It Works
|
|
56
|
+
|
|
57
|
+
1. **Pick a source** — manual entry, `.env`, JSON file, or a cloud secret store. Secrets only ever live in memory.
|
|
58
|
+
2. **Configure shares** — choose number of keys (N) and recovery threshold (M).
|
|
59
|
+
3. **Encrypt + split** — your secrets are encrypted with a fresh AES-256-GCM key; that key is split via Shamir's algorithm.
|
|
60
|
+
4. **Print & distribute** — the CLI opens your browser to an ephemeral, in-memory page with a single "Print kit" button. The OS print dialog handles the rest (use "Save as PDF" from the dialog if you'd rather save than print).
|
|
61
|
+
5. **Recovery** — go to [papervault.xyz/unlock](https://papervault.xyz/unlock) on any device, scan the vault QR, then scan any M key QRs. Your secrets reappear.
|
|
62
|
+
|
|
63
|
+
## 🔧 Commands
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
papervault init # Interactive wizard (most users start here)
|
|
67
|
+
papervault backup [options] # Generate a kit (reads papervault.config.json if present)
|
|
68
|
+
papervault sources list # Show available secret backends
|
|
69
|
+
papervault verify <kit-dir> # Round-trip check on a saved kit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run any command with `--help` for the full flag reference.
|
|
73
|
+
|
|
74
|
+
### Source URIs
|
|
75
|
+
|
|
76
|
+
| Scheme | Status | Notes |
|
|
77
|
+
|---|---|---|
|
|
78
|
+
| `file://<path>` | ✓ ready | JSON file you maintain — see schema below |
|
|
79
|
+
| `stdin` (`-`) | ✓ ready | Pipe JSON in for scripts |
|
|
80
|
+
| `azure-kv://<vault-name>` | ✓ ready | Uses `az login` cache |
|
|
81
|
+
| `aws-sm://` | roadmap | AWS Secrets Manager |
|
|
82
|
+
| `gcp-sm://` | roadmap | GCP Secret Manager |
|
|
83
|
+
| `vault://` | roadmap | HashiCorp Vault |
|
|
84
|
+
| `1password://` | roadmap | 1Password CLI |
|
|
85
|
+
|
|
86
|
+
### JSON source schema
|
|
87
|
+
|
|
88
|
+
For `file://` and stdin sources:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"vaultName": "Optional default label",
|
|
93
|
+
"freeText": "Optional freeform notes",
|
|
94
|
+
"secrets": [
|
|
95
|
+
{"kind": "password", "service": "GitHub", "username": "alice", "password": "...", "url": "https://github.com"},
|
|
96
|
+
{"kind": "wallet", "name": "BTC cold", "seed": "...", "address": "..."},
|
|
97
|
+
{"kind": "apikey", "service": "Stripe live", "key": "sk_live_...", "secret": "..."},
|
|
98
|
+
{"kind": "note", "title": "Lawyer contact", "content": "..."},
|
|
99
|
+
{"kind": "custom", "label": "anything", "value": "..."}
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Field names follow the web app's structured-entry format so kits unlock identically there.
|
|
105
|
+
|
|
106
|
+
### Config file
|
|
107
|
+
|
|
108
|
+
`papervault init` writes `papervault.config.json` in the current directory for recurring sources:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"version": "1",
|
|
113
|
+
"source": "azure-kv://my-vault",
|
|
114
|
+
"threshold": 2,
|
|
115
|
+
"shares": 3,
|
|
116
|
+
"vaultName": "Production DR Kit",
|
|
117
|
+
"custodianNames": ["alice", "bob", "carol"],
|
|
118
|
+
"savePath": "/Users/me/papervault-backups"
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`papervault backup` picks this up automatically. CLI flags override config values. Pass `--no-config` to ignore it. **The config never holds secret values** — only how/where to fetch them at backup time.
|
|
123
|
+
|
|
124
|
+
## 🔍 Audit with AI
|
|
125
|
+
|
|
126
|
+
This is open source software. Run a quick AI-assisted audit via the [Audit with AI](https://github.com/boazeb/papervault#-audit-with-ai) links in the main repo (ChatGPT / Claude / Gemini / Grok / Perplexity, each one-click).
|
|
127
|
+
|
|
128
|
+
## 🛡️ Security Model
|
|
129
|
+
|
|
130
|
+
### Cryptographic Foundation
|
|
131
|
+
|
|
132
|
+
- **Algorithm**: Shamir's Secret Sharing over GF(2^8) via [shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing).
|
|
133
|
+
- **Encryption**: AES-256-GCM (authenticated) via the Web Crypto API (`crypto.subtle`). No JavaScript reimplementation of the cipher.
|
|
134
|
+
- **Key Generation**: Cryptographically secure random number generation via `crypto.getRandomValues()`. Never `Math.random()`.
|
|
135
|
+
- **QR Codes**: Level-M error correction (~15% damage recovery) for reliable scanning from paper.
|
|
136
|
+
- **Vault format**: v2 — byte-identical to vaults produced by the web app, so kits unlock at [papervault.xyz/unlock](https://papervault.xyz/unlock).
|
|
137
|
+
|
|
138
|
+
### Security Best Practices
|
|
139
|
+
|
|
140
|
+
1. **Air-Gapped Usage**: Run the CLI on an offline computer for maximum security.
|
|
141
|
+
2. **Source Code Review**: Audit the code before using with critical secrets (see the AI audit links above).
|
|
142
|
+
3. **Physical Security**: Store paper keys and the vault page in separate, secure locations.
|
|
143
|
+
4. **Test Recovery**: Always test your recovery process at papervault.xyz/unlock before relying on it.
|
|
144
|
+
5. **Durable Storage**: For maximum durability, consider archive-grade paper in tamper-evident envelopes.
|
|
145
|
+
6. **Audit Log**: The CLI writes one line per invocation to `~/.papervault/audit.log`. Secret values are **never** logged; secret names are hashed to a 16-char fingerprint by default.
|
|
146
|
+
|
|
147
|
+
### Threat Model
|
|
148
|
+
|
|
149
|
+
PaperVault does NOT protect against:
|
|
150
|
+
|
|
151
|
+
- ❌ Physical compromise of threshold number of keys + vault
|
|
152
|
+
- ❌ Shoulder surfing during secret entry
|
|
153
|
+
- ❌ Malicious modifications to the source code
|
|
154
|
+
- ❌ Compromise of the cloud secret store you're pulling from
|
|
155
|
+
- ❌ Social engineering
|
|
156
|
+
|
|
157
|
+
## 🔧 Technical Details
|
|
158
|
+
|
|
159
|
+
### Architecture
|
|
160
|
+
|
|
161
|
+
- **Crypto core**: [`@papervault/core`](https://www.npmjs.com/package/@papervault/core) — AES-GCM, Shamir, HTML page generation
|
|
162
|
+
- **CLI shell**: this package — interactive wizard, source adapters, ephemeral print server
|
|
163
|
+
- **MCP server**: [`@papervault/mcp`](https://www.npmjs.com/package/@papervault/mcp) — lets AI agents trigger backups as a safety step
|
|
164
|
+
- **Pure JS**, no native deps. Uses Node 24's built-in WebCrypto.
|
|
165
|
+
|
|
166
|
+
### Print server
|
|
167
|
+
|
|
168
|
+
By default, `papervault backup` opens an ephemeral localhost HTTP server bound to `127.0.0.1` on a random port. The server holds the kit in memory only, serves a single-page UI with a "Print kit" button, and shuts down (dropping references) when you click "Done". Use `--save <dir>` to also write the HTML files to disk.
|
|
169
|
+
|
|
170
|
+
### Limits
|
|
171
|
+
|
|
172
|
+
- **Maximum Keys**: 20 (cryptographic library constraint)
|
|
173
|
+
- **Storage Limit**: 300 characters of user content per vault (QR code optimization — same as the web app)
|
|
174
|
+
|
|
175
|
+
## 🔗 Related Packages
|
|
176
|
+
|
|
177
|
+
- [`@papervault/core`](https://www.npmjs.com/package/@papervault/core) — Crypto + Shamir + page generation library
|
|
178
|
+
- [`@papervault/mcp`](https://www.npmjs.com/package/@papervault/mcp) — MCP server for AI agents
|
|
179
|
+
- [`@papervault/init`](https://www.npmjs.com/package/@papervault/init) — `npx` setup wizard
|
|
180
|
+
- [PaperVault web app](https://papervault.xyz) — same crypto, browser version
|
|
181
|
+
- [Main repo](https://github.com/boazeb/papervault) — issues, docs, SECURITY.md
|
|
182
|
+
|
|
183
|
+
## 🤝 Contributing
|
|
184
|
+
|
|
185
|
+
Contributions are welcome! See the main repo at [github.com/boazeb/papervault](https://github.com/boazeb/papervault).
|
|
186
|
+
|
|
187
|
+
## 📄 License
|
|
188
|
+
|
|
189
|
+
MIT — see [LICENSE](LICENSE).
|
|
190
|
+
|
|
191
|
+
## 🙏 Acknowledgments
|
|
192
|
+
|
|
193
|
+
- [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_Secret_Sharing) algorithm by Adi Shamir
|
|
194
|
+
- [shamir-secret-sharing](https://github.com/privy-io/shamir-secret-sharing) — Shamir over GF(2^8)
|
|
195
|
+
- [bip39](https://github.com/bitcoinjs/bip39) — two-word key aliases
|
|
196
|
+
- [qrcode](https://github.com/soldair/node-qrcode) — QR generation
|
|
197
|
+
- [@clack/prompts](https://github.com/natemoo-re/clack) — interactive wizard UX
|
|
198
|
+
|
|
199
|
+
## 📞 Support
|
|
200
|
+
|
|
201
|
+
- **Issues**: [GitHub Issues](https://github.com/boazeb/papervault/issues)
|
|
202
|
+
|
|
203
|
+
## ⚠️ Disclaimer
|
|
204
|
+
|
|
205
|
+
This software is provided "as is" without warranty. Users are responsible for:
|
|
206
|
+
|
|
207
|
+
- Verifying the security of their implementation
|
|
208
|
+
- Testing recovery procedures before relying on them
|
|
209
|
+
- Maintaining physical security of printed keys
|
|
210
|
+
- Understanding the cryptographic principles involved
|
|
211
|
+
|
|
212
|
+
**Always test with non-critical data first!**
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@papervault/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line PaperVault. Encrypt secrets, split with Shamir Secret Sharing, generate printable disaster-recovery kits from .env files, JSON, Azure Key Vault, and more.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"papervault": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./sources": "./src/sources/index.js",
|
|
12
|
+
"./audit": "./src/audit.js",
|
|
13
|
+
"./commands/init.js": "./src/commands/init.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=24.x"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://papervault.xyz",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/boazeb/papervault.git",
|
|
27
|
+
"directory": "papervault-cli"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/boazeb/papervault/issues"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"papervault",
|
|
34
|
+
"cli",
|
|
35
|
+
"command-line",
|
|
36
|
+
"cryptography",
|
|
37
|
+
"shamir-secret-sharing",
|
|
38
|
+
"aes-256-gcm",
|
|
39
|
+
"secret-sharing",
|
|
40
|
+
"paper-wallet",
|
|
41
|
+
"cold-storage",
|
|
42
|
+
"disaster-recovery",
|
|
43
|
+
"key-vault",
|
|
44
|
+
"azure-key-vault",
|
|
45
|
+
"secrets-management",
|
|
46
|
+
"open-source"
|
|
47
|
+
],
|
|
48
|
+
"author": "PaperVault.xyz",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"publishConfig": {
|
|
51
|
+
"access": "public"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@azure/identity": "^4.4.1",
|
|
55
|
+
"@azure/keyvault-secrets": "^4.9.0",
|
|
56
|
+
"@clack/prompts": "^0.7.0",
|
|
57
|
+
"@papervault/core": "^0.1.0"
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/audit.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Audit logging. Records what was done, NEVER what was in it.
|
|
2
|
+
// Default location: ~/.papervault/audit.log
|
|
3
|
+
// Format: one JSON object per line (jsonl) — easy to grep + parse.
|
|
4
|
+
|
|
5
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
function auditPath() {
|
|
11
|
+
return process.env.PAPERVAULT_AUDIT_LOG || join(homedir(), '.papervault', 'audit.log');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} entry
|
|
16
|
+
* @param {string} entry.action e.g. 'backup', 'verify', 'sources.list'
|
|
17
|
+
* @param {string} [entry.sourceUri]
|
|
18
|
+
* @param {number} [entry.secretCount]
|
|
19
|
+
* @param {number} [entry.threshold]
|
|
20
|
+
* @param {number} [entry.shares]
|
|
21
|
+
* @param {string} [entry.vaultId]
|
|
22
|
+
* @param {string} [entry.outcome] 'success' | 'failed' | 'aborted'
|
|
23
|
+
* @param {string} [entry.error]
|
|
24
|
+
* @param {string[]} [entry.secretNames] Hashed before write unless PAPERVAULT_LOG_NAMES=1
|
|
25
|
+
*/
|
|
26
|
+
export async function audit(entry) {
|
|
27
|
+
const path = auditPath();
|
|
28
|
+
try {
|
|
29
|
+
await mkdir(dirname(path), { recursive: true });
|
|
30
|
+
} catch { /* dir might exist; OK */ }
|
|
31
|
+
|
|
32
|
+
const logNamesPlaintext = process.env.PAPERVAULT_LOG_NAMES === '1';
|
|
33
|
+
const safe = { ...entry };
|
|
34
|
+
if (Array.isArray(entry.secretNames)) {
|
|
35
|
+
if (logNamesPlaintext) {
|
|
36
|
+
safe.secretNames = entry.secretNames;
|
|
37
|
+
} else {
|
|
38
|
+
// Deterministic fingerprint of the sorted name list. Lets you tell
|
|
39
|
+
// two runs covered the same set without revealing the names.
|
|
40
|
+
const sorted = [...entry.secretNames].sort().join('\n');
|
|
41
|
+
safe.namesFingerprint = createHash('sha256').update(sorted).digest('hex').slice(0, 16);
|
|
42
|
+
delete safe.secretNames;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
safe.ts = new Date().toISOString();
|
|
46
|
+
try {
|
|
47
|
+
await appendFile(path, JSON.stringify(safe) + '\n', 'utf8');
|
|
48
|
+
} catch (err) {
|
|
49
|
+
// Audit log failures should never crash the main flow.
|
|
50
|
+
process.stderr.write(`audit log warning: ${err.message}\n`);
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Cross-platform "open URL in default browser".
|
|
2
|
+
// Doesn't shell out for the URL itself — uses spawn with no shell.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
|
|
6
|
+
export function openUrl(url) {
|
|
7
|
+
const platform = process.platform;
|
|
8
|
+
let cmd, args;
|
|
9
|
+
if (platform === 'darwin') {
|
|
10
|
+
cmd = 'open';
|
|
11
|
+
args = [url];
|
|
12
|
+
} else if (platform === 'win32') {
|
|
13
|
+
cmd = 'cmd';
|
|
14
|
+
args = ['/c', 'start', '', url];
|
|
15
|
+
} else {
|
|
16
|
+
cmd = 'xdg-open';
|
|
17
|
+
args = [url];
|
|
18
|
+
}
|
|
19
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
20
|
+
child.unref();
|
|
21
|
+
child.on('error', err => {
|
|
22
|
+
process.stderr.write(`Could not open browser automatically (${err.message}). Open this URL: ${url}\n`);
|
|
23
|
+
});
|
|
24
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Command dispatcher. Each subcommand owns its own argument parsing
|
|
2
|
+
// (via util.parseArgs) so flags can vary cleanly per command.
|
|
3
|
+
|
|
4
|
+
import { backup } from './commands/backup.js';
|
|
5
|
+
import { verify } from './commands/verify.js';
|
|
6
|
+
import { sourcesCmd } from './commands/sources.js';
|
|
7
|
+
import { init } from './commands/init.js';
|
|
8
|
+
|
|
9
|
+
const HELP = `papervault — disaster-recovery kits from your secret sources
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
papervault init Interactive setup → papervault.config.json
|
|
13
|
+
papervault backup [options] Generate a printable kit
|
|
14
|
+
papervault verify <path> Decrypt-check a saved kit
|
|
15
|
+
papervault sources list Show configured backends
|
|
16
|
+
papervault --help
|
|
17
|
+
|
|
18
|
+
Run 'papervault <command> --help' for command-specific options.
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
export async function main(argv) {
|
|
22
|
+
const [command, ...rest] = argv;
|
|
23
|
+
|
|
24
|
+
if (!command || command === '--help' || command === '-h') {
|
|
25
|
+
process.stdout.write(HELP);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
switch (command) {
|
|
30
|
+
case 'init':
|
|
31
|
+
return init(rest);
|
|
32
|
+
case 'backup':
|
|
33
|
+
return backup(rest);
|
|
34
|
+
case 'verify':
|
|
35
|
+
return verify(rest);
|
|
36
|
+
case 'sources':
|
|
37
|
+
return sourcesCmd(rest);
|
|
38
|
+
default:
|
|
39
|
+
process.stderr.write(`Unknown command: ${command}\n\n${HELP}`);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { LIMITS } from '@papervault/core';
|
|
3
|
+
import { resolveSource } from '../sources/index.js';
|
|
4
|
+
import { audit } from '../audit.js';
|
|
5
|
+
import { readConfig, CONFIG_FILENAME } from '../config.js';
|
|
6
|
+
import { runKitFlow } from '../kit-runner.js';
|
|
7
|
+
|
|
8
|
+
const HELP = `papervault backup — generate a printable disaster-recovery kit
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
papervault backup [options]
|
|
12
|
+
|
|
13
|
+
Source (one of):
|
|
14
|
+
--source <uri> file://<path>, azure-kv://<vault-name>, or - for stdin.
|
|
15
|
+
Falls back to ${CONFIG_FILENAME}.source if present, then stdin.
|
|
16
|
+
Azure uses your 'az login' cache (no creds handled by us).
|
|
17
|
+
Other clouds (AWS Secrets Manager, GCP Secret Manager,
|
|
18
|
+
HashiCorp Vault, 1Password) are on the roadmap.
|
|
19
|
+
|
|
20
|
+
Required:
|
|
21
|
+
--threshold N Minimum keys required to unlock the vault
|
|
22
|
+
--shares N Total number of key shares to generate
|
|
23
|
+
|
|
24
|
+
Optional:
|
|
25
|
+
--name <text> Vault name shown on the printed kit
|
|
26
|
+
--select <glob,glob> Filter source secrets by glob pattern (comma-separated)
|
|
27
|
+
--names <a,b,c> Custodian names per share (e.g. "alice,bob,carol")
|
|
28
|
+
--max-secrets N Hard cap on number of secrets (default 20)
|
|
29
|
+
--save <dir> Also write HTML files to <dir>/vault-<id>/
|
|
30
|
+
--no-print Skip the ephemeral print server (only useful with --save)
|
|
31
|
+
--yes Skip interactive confirmation (use in scripts)
|
|
32
|
+
--dry-run List what would be backed up, then exit
|
|
33
|
+
--no-config Ignore ${CONFIG_FILENAME} in the current directory
|
|
34
|
+
--help
|
|
35
|
+
|
|
36
|
+
Config: if ${CONFIG_FILENAME} exists in the current directory, its values
|
|
37
|
+
are used as defaults. CLI flags override config. Run \`papervault init\` to
|
|
38
|
+
create one interactively.
|
|
39
|
+
|
|
40
|
+
Defaults: ephemeral print server, no disk writes.
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const OPTIONS = {
|
|
44
|
+
source: { type: 'string' },
|
|
45
|
+
threshold: { type: 'string' },
|
|
46
|
+
shares: { type: 'string' },
|
|
47
|
+
name: { type: 'string' },
|
|
48
|
+
select: { type: 'string' },
|
|
49
|
+
names: { type: 'string' },
|
|
50
|
+
'max-secrets': { type: 'string' },
|
|
51
|
+
save: { type: 'string' },
|
|
52
|
+
'no-print': { type: 'boolean' },
|
|
53
|
+
yes: { type: 'boolean' },
|
|
54
|
+
'dry-run': { type: 'boolean' },
|
|
55
|
+
'no-config': { type: 'boolean' },
|
|
56
|
+
help: { type: 'boolean' },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function globToRegex(g) {
|
|
60
|
+
const parts = g.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'));
|
|
61
|
+
return new RegExp('^' + parts.join('.*') + '$');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function backup(argv) {
|
|
65
|
+
const { values } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: false });
|
|
66
|
+
|
|
67
|
+
if (values.help) {
|
|
68
|
+
process.stdout.write(HELP);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Layer 1: file config (if present + not --no-config)
|
|
73
|
+
let cfg = null;
|
|
74
|
+
if (!values['no-config']) {
|
|
75
|
+
try { cfg = await readConfig(); }
|
|
76
|
+
catch (err) { process.stderr.write(`Warning: ${err.message}\n`); }
|
|
77
|
+
if (cfg) process.stderr.write(`Using ${CONFIG_FILENAME} (CLI flags override).\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Layer 2: CLI flags override config. Falls back to stdin if nothing set.
|
|
81
|
+
const threshold = Number(values.threshold ?? cfg?.threshold);
|
|
82
|
+
const shares = Number(values.shares ?? cfg?.shares);
|
|
83
|
+
if (!Number.isInteger(threshold) || !Number.isInteger(shares)) {
|
|
84
|
+
throw new Error('--threshold and --shares are required (or set them in ' + CONFIG_FILENAME + '). See --help.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const maxSecrets = Number(values['max-secrets'] ?? cfg?.maxSecrets ?? 20);
|
|
88
|
+
if (!Number.isInteger(maxSecrets) || maxSecrets < 1 || maxSecrets > LIMITS.MAX_KEYS * 5) {
|
|
89
|
+
throw new Error('--max-secrets must be a positive integer.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const sourceUri = values.source ?? cfg?.source ?? '-';
|
|
93
|
+
const src = resolveSource(sourceUri);
|
|
94
|
+
await src.authenticate();
|
|
95
|
+
|
|
96
|
+
// Phase 1: list — see what would be backed up.
|
|
97
|
+
const refs = await src.list();
|
|
98
|
+
|
|
99
|
+
let selectedRefs = refs;
|
|
100
|
+
const effectiveSelect = values.select ?? cfg?.select;
|
|
101
|
+
if (effectiveSelect) {
|
|
102
|
+
const patterns = effectiveSelect.split(',').map(s => globToRegex(s.trim()));
|
|
103
|
+
selectedRefs = refs.filter(r => patterns.some(p => p.test(r.name)));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (selectedRefs.length === 0) {
|
|
107
|
+
throw new Error('No secrets matched the selection.');
|
|
108
|
+
}
|
|
109
|
+
if (selectedRefs.length > maxSecrets) {
|
|
110
|
+
throw new Error(`${selectedRefs.length} secrets matched, but --max-secrets is ${maxSecrets}. ` +
|
|
111
|
+
'Tighten --select or raise --max-secrets explicitly.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.stderr.write(`Source: ${src.uri}\n`);
|
|
115
|
+
process.stderr.write(`Selected ${selectedRefs.length} secret${selectedRefs.length === 1 ? '' : 's'}:\n`);
|
|
116
|
+
for (const r of selectedRefs) {
|
|
117
|
+
process.stderr.write(` - ${r.name} [${r.kind}]\n`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (values['dry-run']) {
|
|
121
|
+
await audit({
|
|
122
|
+
action: 'backup.dryrun',
|
|
123
|
+
sourceUri: src.uri,
|
|
124
|
+
secretCount: selectedRefs.length,
|
|
125
|
+
threshold, shares,
|
|
126
|
+
secretNames: selectedRefs.map(r => r.name),
|
|
127
|
+
outcome: 'success',
|
|
128
|
+
});
|
|
129
|
+
await src.close();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (!values.yes) {
|
|
134
|
+
process.stderr.write(`\nAbout to encrypt + split into ${shares} keys (threshold ${threshold}). ` +
|
|
135
|
+
`Pass --yes to skip this confirmation.\nContinue? [y/N] `);
|
|
136
|
+
const answer = await readOneLineFromStdin();
|
|
137
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
138
|
+
process.stderr.write('Aborted.\n');
|
|
139
|
+
await audit({
|
|
140
|
+
action: 'backup',
|
|
141
|
+
sourceUri: src.uri,
|
|
142
|
+
secretCount: selectedRefs.length,
|
|
143
|
+
threshold, shares,
|
|
144
|
+
outcome: 'aborted',
|
|
145
|
+
});
|
|
146
|
+
await src.close();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Phase 2: fetch only the selected refs. Adapter has already filtered
|
|
152
|
+
// for us, so doc.secrets is exactly what was asked for.
|
|
153
|
+
const doc = await src.fetch(selectedRefs);
|
|
154
|
+
const secrets = doc.secrets;
|
|
155
|
+
|
|
156
|
+
const vaultName = values.name ?? cfg?.vaultName ?? doc.vaultName ?? 'Disaster Recovery Kit';
|
|
157
|
+
const custodianNames = values.names
|
|
158
|
+
? values.names.split(',').map(s => s.trim())
|
|
159
|
+
: (cfg?.custodianNames ?? undefined);
|
|
160
|
+
if (custodianNames && custodianNames.length !== shares) {
|
|
161
|
+
throw new Error(`names list has ${custodianNames.length} entries but shares is ${shares}.`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let result;
|
|
165
|
+
try {
|
|
166
|
+
result = await runKitFlow({
|
|
167
|
+
secrets,
|
|
168
|
+
freeText: doc.freeText,
|
|
169
|
+
vaultName,
|
|
170
|
+
threshold,
|
|
171
|
+
shares,
|
|
172
|
+
custodianNames,
|
|
173
|
+
savePath: values.save ?? cfg?.savePath,
|
|
174
|
+
print: !values['no-print'],
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
await audit({
|
|
178
|
+
action: 'backup',
|
|
179
|
+
sourceUri: src.uri,
|
|
180
|
+
secretCount: selectedRefs.length,
|
|
181
|
+
threshold, shares,
|
|
182
|
+
outcome: 'failed',
|
|
183
|
+
error: err.message,
|
|
184
|
+
});
|
|
185
|
+
await src.close();
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
await src.close();
|
|
189
|
+
|
|
190
|
+
await audit({
|
|
191
|
+
action: 'backup',
|
|
192
|
+
sourceUri: src.uri,
|
|
193
|
+
secretCount: selectedRefs.length,
|
|
194
|
+
threshold, shares,
|
|
195
|
+
vaultId: result.vaultId,
|
|
196
|
+
savedTo: result.savedTo,
|
|
197
|
+
printed: result.printed,
|
|
198
|
+
outcome: 'success',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function readOneLineFromStdin() {
|
|
203
|
+
return new Promise(resolve => {
|
|
204
|
+
let buf = '';
|
|
205
|
+
const onData = (chunk) => {
|
|
206
|
+
buf += chunk.toString('utf8');
|
|
207
|
+
const nl = buf.indexOf('\n');
|
|
208
|
+
if (nl >= 0) {
|
|
209
|
+
process.stdin.off('data', onData);
|
|
210
|
+
process.stdin.pause();
|
|
211
|
+
resolve(buf.slice(0, nl));
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
process.stdin.resume();
|
|
215
|
+
process.stdin.on('data', onData);
|
|
216
|
+
});
|
|
217
|
+
}
|