@papervault/cli 0.1.0 → 0.1.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/package.json +2 -2
- package/src/audit.js +12 -2
- package/src/commands/backup.js +26 -1
- package/src/commands/init.js +54 -6
- package/src/kit-runner.js +12 -4
- package/src/server.js +18 -0
- package/src/sources/azure.js +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papervault/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"@azure/identity": "^4.4.1",
|
|
55
55
|
"@azure/keyvault-secrets": "^4.9.0",
|
|
56
56
|
"@clack/prompts": "^0.7.0",
|
|
57
|
-
"@papervault/core": "^0.1.
|
|
57
|
+
"@papervault/core": "^0.1.1"
|
|
58
58
|
}
|
|
59
59
|
}
|
package/src/audit.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Default location: ~/.papervault/audit.log
|
|
3
3
|
// Format: one JSON object per line (jsonl) — easy to grep + parse.
|
|
4
4
|
|
|
5
|
-
import { appendFile, mkdir } from 'node:fs/promises';
|
|
5
|
+
import { appendFile, mkdir, chmod } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
@@ -25,8 +25,15 @@ function auditPath() {
|
|
|
25
25
|
*/
|
|
26
26
|
export async function audit(entry) {
|
|
27
27
|
const path = auditPath();
|
|
28
|
+
const dir = dirname(path);
|
|
29
|
+
// Owner-only on dir + file. The audit log itself never contains secret
|
|
30
|
+
// values, but it does contain action types, source URIs, timestamps,
|
|
31
|
+
// and (with PAPERVAULT_LOG_NAMES=1) plaintext secret names. On
|
|
32
|
+
// shared systems, world-readable would leak metadata.
|
|
28
33
|
try {
|
|
29
|
-
await mkdir(
|
|
34
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
// mkdir doesn't change perms on an existing dir; ensure it anyway.
|
|
36
|
+
try { await chmod(dir, 0o700); } catch {}
|
|
30
37
|
} catch { /* dir might exist; OK */ }
|
|
31
38
|
|
|
32
39
|
const logNamesPlaintext = process.env.PAPERVAULT_LOG_NAMES === '1';
|
|
@@ -45,6 +52,9 @@ export async function audit(entry) {
|
|
|
45
52
|
safe.ts = new Date().toISOString();
|
|
46
53
|
try {
|
|
47
54
|
await appendFile(path, JSON.stringify(safe) + '\n', 'utf8');
|
|
55
|
+
// appendFile creates with mode based on umask if file didn't exist;
|
|
56
|
+
// chmod after to guarantee 0o600 regardless.
|
|
57
|
+
try { await chmod(path, 0o600); } catch {}
|
|
48
58
|
} catch (err) {
|
|
49
59
|
// Audit log failures should never crash the main flow.
|
|
50
60
|
process.stderr.write(`audit log warning: ${err.message}\n`);
|
package/src/commands/backup.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
+
import * as clack from '@clack/prompts';
|
|
2
3
|
import { LIMITS } from '@papervault/core';
|
|
3
4
|
import { resolveSource } from '../sources/index.js';
|
|
4
5
|
import { audit } from '../audit.js';
|
|
@@ -24,6 +25,9 @@ Required:
|
|
|
24
25
|
Optional:
|
|
25
26
|
--name <text> Vault name shown on the printed kit
|
|
26
27
|
--select <glob,glob> Filter source secrets by glob pattern (comma-separated)
|
|
28
|
+
--interactive After listing+filtering, show a multiselect so you can
|
|
29
|
+
narrow the picks before fetching values. Useful for
|
|
30
|
+
cloud sources with many entries.
|
|
27
31
|
--names <a,b,c> Custodian names per share (e.g. "alice,bob,carol")
|
|
28
32
|
--max-secrets N Hard cap on number of secrets (default 20)
|
|
29
33
|
--save <dir> Also write HTML files to <dir>/vault-<id>/
|
|
@@ -46,6 +50,7 @@ const OPTIONS = {
|
|
|
46
50
|
shares: { type: 'string' },
|
|
47
51
|
name: { type: 'string' },
|
|
48
52
|
select: { type: 'string' },
|
|
53
|
+
interactive: { type: 'boolean' },
|
|
49
54
|
names: { type: 'string' },
|
|
50
55
|
'max-secrets': { type: 'string' },
|
|
51
56
|
save: { type: 'string' },
|
|
@@ -106,9 +111,29 @@ export async function backup(argv) {
|
|
|
106
111
|
if (selectedRefs.length === 0) {
|
|
107
112
|
throw new Error('No secrets matched the selection.');
|
|
108
113
|
}
|
|
114
|
+
|
|
115
|
+
// --interactive: let the user narrow down before we hit max_secrets or
|
|
116
|
+
// fetch any values. Runs AFTER --select so the glob can pre-filter; the
|
|
117
|
+
// multiselect picks from what's left. Skip if only one match — there's
|
|
118
|
+
// nothing to pick.
|
|
119
|
+
if (values.interactive && selectedRefs.length > 1) {
|
|
120
|
+
const picked = await clack.multiselect({
|
|
121
|
+
message: `Pick which to back up (${selectedRefs.length} matches):`,
|
|
122
|
+
options: selectedRefs.map(r => ({ value: r.name, label: `${r.name} [${r.kind}]` })),
|
|
123
|
+
required: true,
|
|
124
|
+
});
|
|
125
|
+
if (clack.isCancel(picked)) {
|
|
126
|
+
process.stderr.write('Aborted.\n');
|
|
127
|
+
await src.close();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const pickedSet = new Set(picked);
|
|
131
|
+
selectedRefs = selectedRefs.filter(r => pickedSet.has(r.name));
|
|
132
|
+
}
|
|
133
|
+
|
|
109
134
|
if (selectedRefs.length > maxSecrets) {
|
|
110
135
|
throw new Error(`${selectedRefs.length} secrets matched, but --max-secrets is ${maxSecrets}. ` +
|
|
111
|
-
'Tighten --select or raise --max-secrets explicitly.');
|
|
136
|
+
'Tighten --select, use --interactive to pick fewer, or raise --max-secrets explicitly.');
|
|
112
137
|
}
|
|
113
138
|
|
|
114
139
|
process.stderr.write(`Source: ${src.uri}\n`);
|
package/src/commands/init.js
CHANGED
|
@@ -123,6 +123,9 @@ export async function init(argv) {
|
|
|
123
123
|
|
|
124
124
|
// ---------- Recurring-source modes: Azure / JSON / stdin ----------
|
|
125
125
|
let sourceUri;
|
|
126
|
+
// Set by the Azure flow's optional multiselect; gets persisted to the
|
|
127
|
+
// generated config as `select`. Other source types don't populate it.
|
|
128
|
+
let initSelect = null;
|
|
126
129
|
if (sourceType === 'file') {
|
|
127
130
|
const pth = cancelIf(await p.text({
|
|
128
131
|
message: 'Path to the JSON file with your secrets:',
|
|
@@ -158,12 +161,13 @@ export async function init(argv) {
|
|
|
158
161
|
if (checkAuth) {
|
|
159
162
|
const s = p.spinner();
|
|
160
163
|
s.start(`Connecting to ${sourceUri}…`);
|
|
164
|
+
let azureRefs = null;
|
|
161
165
|
try {
|
|
162
166
|
const src = resolveSource(sourceUri);
|
|
163
167
|
await src.authenticate();
|
|
164
|
-
|
|
168
|
+
azureRefs = await src.list();
|
|
165
169
|
await src.close();
|
|
166
|
-
s.stop(`Connected: ${
|
|
170
|
+
s.stop(`Connected: ${azureRefs.length} secret${azureRefs.length === 1 ? '' : 's'} visible.`);
|
|
167
171
|
} catch (err) {
|
|
168
172
|
s.stop('Auth check failed.');
|
|
169
173
|
p.log.warn(err.message.split('\n')[0]);
|
|
@@ -173,10 +177,47 @@ export async function init(argv) {
|
|
|
173
177
|
}));
|
|
174
178
|
if (!skip) { p.cancel('Aborted.'); return; }
|
|
175
179
|
}
|
|
180
|
+
|
|
181
|
+
// Multiselect which secrets the saved config should target.
|
|
182
|
+
// Skip only if the listing failed or the KV is empty. Even 1
|
|
183
|
+
// secret might be too big for the vault, so always offer the
|
|
184
|
+
// choice when there's something to pick.
|
|
185
|
+
if (azureRefs && azureRefs.length > 0) {
|
|
186
|
+
p.log.info(
|
|
187
|
+
`Vaults cap at ${LIMITS.MAX_STORAGE} chars of user content total (so QR codes stay scannable). ` +
|
|
188
|
+
`Pick which secrets the backup should include — you can leave it empty to back up everything.`
|
|
189
|
+
);
|
|
190
|
+
const lowerBound = azureRefs.reduce((sum, r) => sum + r.name.length, 0);
|
|
191
|
+
const roughEstimate = lowerBound + azureRefs.length * 60;
|
|
192
|
+
if (roughEstimate > LIMITS.MAX_STORAGE) {
|
|
193
|
+
p.log.warn(
|
|
194
|
+
`Heads up: rough estimate is ${lowerBound}–${roughEstimate} chars across all ${azureRefs.length} secrets, ` +
|
|
195
|
+
`likely above the ${LIMITS.MAX_STORAGE} limit. Pick a subset below.`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// Pre-check what the existing config already selected so an
|
|
199
|
+
// edit-mode init doesn't drop prior intent.
|
|
200
|
+
const previouslySelected = prefill?.select
|
|
201
|
+
? new Set(prefill.select.split(',').map(s => s.trim()).filter(Boolean))
|
|
202
|
+
: null;
|
|
203
|
+
const initialValues = previouslySelected
|
|
204
|
+
? azureRefs.map(r => r.name).filter(n => previouslySelected.has(n))
|
|
205
|
+
: undefined;
|
|
206
|
+
const picked = cancelIf(await p.multiselect({
|
|
207
|
+
message: `Secrets to back up (none = all ${azureRefs.length}):`,
|
|
208
|
+
options: azureRefs.map(r => ({ value: r.name, label: r.name })),
|
|
209
|
+
initialValues,
|
|
210
|
+
required: false,
|
|
211
|
+
}));
|
|
212
|
+
if (picked.length > 0 && picked.length < azureRefs.length) {
|
|
213
|
+
initSelect = picked.join(',');
|
|
214
|
+
p.log.success(`Selected ${picked.length} of ${azureRefs.length}; saving as 'select' in the config.`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
176
217
|
}
|
|
177
218
|
}
|
|
178
219
|
|
|
179
|
-
const cfg = await collectVaultConfig({ prefill, sourceUri });
|
|
220
|
+
const cfg = await collectVaultConfig({ prefill, sourceUri, initSelect });
|
|
180
221
|
p.note(formatConfigPreview(cfg), 'Config preview');
|
|
181
222
|
|
|
182
223
|
const confirm = cancelIf(await p.confirm({
|
|
@@ -380,7 +421,7 @@ export function parseEnvFile(content) {
|
|
|
380
421
|
// Shared: vault config (shares, threshold, name, custodians, save path)
|
|
381
422
|
// =====================================================================
|
|
382
423
|
|
|
383
|
-
async function collectVaultConfig({ prefill, sourceUri }) {
|
|
424
|
+
async function collectVaultConfig({ prefill, sourceUri, initSelect = null }) {
|
|
384
425
|
const shares = Number(cancelIf(await p.text({
|
|
385
426
|
message: 'How many key shares should the vault be split into?',
|
|
386
427
|
placeholder: '3',
|
|
@@ -453,11 +494,17 @@ async function collectVaultConfig({ prefill, sourceUri }) {
|
|
|
453
494
|
})).trim());
|
|
454
495
|
}
|
|
455
496
|
|
|
497
|
+
// Precedence: explicit user selection (from init's multiselect step) wins
|
|
498
|
+
// over whatever was in the prefilled config. None of the prompts above ask
|
|
499
|
+
// for `select`, so the only sources are initSelect or prefill.
|
|
500
|
+
const effectiveSelect = initSelect ?? prefill?.select ?? null;
|
|
501
|
+
|
|
456
502
|
return {
|
|
457
503
|
source: sourceUri,
|
|
458
504
|
threshold, shares, vaultName,
|
|
459
|
-
...(custodianNames
|
|
460
|
-
...(savePath
|
|
505
|
+
...(custodianNames ? { custodianNames } : {}),
|
|
506
|
+
...(savePath ? { savePath } : {}),
|
|
507
|
+
...(effectiveSelect ? { select: effectiveSelect } : {}),
|
|
461
508
|
};
|
|
462
509
|
}
|
|
463
510
|
|
|
@@ -466,6 +513,7 @@ function formatConfigPreview(cfg) {
|
|
|
466
513
|
`source ${cfg.source}`,
|
|
467
514
|
`vault ${cfg.vaultName}`,
|
|
468
515
|
`unlock ${cfg.threshold} of ${cfg.shares} shares`,
|
|
516
|
+
cfg.select ? `select ${cfg.select}` : null,
|
|
469
517
|
cfg.custodianNames ? `custodians ${cfg.custodianNames.join(', ')}` : null,
|
|
470
518
|
cfg.savePath ? `savePath ${cfg.savePath}` : null,
|
|
471
519
|
].filter(Boolean).join('\n');
|
package/src/kit-runner.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Keeps the in-memory-only posture consistent: secrets only touch disk if
|
|
6
6
|
// the caller explicitly opts in via savePath.
|
|
7
7
|
|
|
8
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { mkdir, writeFile, chmod } from 'node:fs/promises';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { createKit } from '@papervault/core';
|
|
11
11
|
|
|
@@ -41,17 +41,25 @@ export async function runKitFlow(opts) {
|
|
|
41
41
|
|
|
42
42
|
// Optional disk save (opt-in). Saved files have the auto-print script
|
|
43
43
|
// stripped — they're for archival, not for the immediate dialog.
|
|
44
|
+
// Restrict perms to owner-only — these HTML pages contain QR codes that,
|
|
45
|
+
// with the threshold-of-N key shares, decrypt to your secrets.
|
|
46
|
+
// World-readable would let other local users scan and unlock.
|
|
44
47
|
let savedTo = null;
|
|
45
48
|
if (savePath) {
|
|
46
49
|
savedTo = resolve(savePath, `vault-${kit.vaultId}`);
|
|
47
|
-
await mkdir(savedTo, { recursive: true });
|
|
50
|
+
await mkdir(savedTo, { recursive: true, mode: 0o700 });
|
|
51
|
+
try { await chmod(savedTo, 0o700); } catch { /* ok on filesystems that don't support it */ }
|
|
48
52
|
for (const page of kit.pages) {
|
|
49
53
|
const html = page.html.replace(
|
|
50
54
|
/<script>window\.addEventListener\('load',.*?<\/script>/g, ''
|
|
51
55
|
);
|
|
52
|
-
|
|
56
|
+
const filePath = join(savedTo, `${page.filename}.html`);
|
|
57
|
+
await writeFile(filePath, html, { encoding: 'utf8', mode: 0o600 });
|
|
58
|
+
// writeFile only applies `mode` when CREATING the file; chmod
|
|
59
|
+
// after to enforce regardless of pre-existing perms or umask.
|
|
60
|
+
try { await chmod(filePath, 0o600); } catch {}
|
|
53
61
|
}
|
|
54
|
-
process.stderr.write(`\nSaved ${kit.pages.length} files to ${savedTo}\n`);
|
|
62
|
+
process.stderr.write(`\nSaved ${kit.pages.length} files to ${savedTo} (mode 0600)\n`);
|
|
55
63
|
}
|
|
56
64
|
|
|
57
65
|
if (!print) {
|
package/src/server.js
CHANGED
|
@@ -46,6 +46,24 @@ export function servePrintKit(kit) {
|
|
|
46
46
|
res.writeHead(403); res.end(); return;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// DNS rebinding defense: an attacker page can rebind its own hostname
|
|
50
|
+
// to 127.0.0.1 in the victim's resolver, then bypass same-origin to
|
|
51
|
+
// read our responses. The Host header is set by the browser to
|
|
52
|
+
// whatever URL the page used, so a rebinding attack arrives with a
|
|
53
|
+
// non-localhost Host. Reject anything that isn't an explicit
|
|
54
|
+
// loopback address (with optional port).
|
|
55
|
+
const addr = server.address();
|
|
56
|
+
const expectedPort = addr ? addr.port : null;
|
|
57
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
58
|
+
const allowedHosts = new Set(expectedPort != null ? [
|
|
59
|
+
`127.0.0.1:${expectedPort}`,
|
|
60
|
+
`localhost:${expectedPort}`,
|
|
61
|
+
`[::1]:${expectedPort}`,
|
|
62
|
+
] : []);
|
|
63
|
+
if (!allowedHosts.has(host)) {
|
|
64
|
+
res.writeHead(403); res.end(); return;
|
|
65
|
+
}
|
|
66
|
+
|
|
49
67
|
// CSP: deny everything except inline (we control all the HTML).
|
|
50
68
|
// No external network, no scripts from elsewhere, no images outside data: URIs.
|
|
51
69
|
res.setHeader('Content-Security-Policy',
|
package/src/sources/azure.js
CHANGED
|
@@ -126,13 +126,14 @@ export function createAzureKVSource(vaultName, opts = {}) {
|
|
|
126
126
|
} catch (err) {
|
|
127
127
|
throw new Error(`azure-kv: failed to fetch secret "${r.name}" — ${err.message ?? err}`);
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
// Deliberately do NOT auto-populate `notes` from contentType:
|
|
130
|
+
// every char counts against the 300-char vault budget, and the
|
|
131
|
+
// user didn't ask for that metadata. If they want notes, they
|
|
132
|
+
// can pre-export to a file:// source and edit.
|
|
130
133
|
secrets.push({
|
|
131
134
|
kind: 'apikey',
|
|
132
135
|
service: r.name,
|
|
133
136
|
key: val.value,
|
|
134
|
-
secret: '',
|
|
135
|
-
notes: contentType ? `contentType: ${contentType}` : '',
|
|
136
137
|
});
|
|
137
138
|
}
|
|
138
139
|
return {
|