@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papervault/cli",
3
- "version": "0.1.0",
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.0"
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(dirname(path), { recursive: true });
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`);
@@ -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`);
@@ -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
- const refs = await src.list();
168
+ azureRefs = await src.list();
165
169
  await src.close();
166
- s.stop(`Connected: ${refs.length} secret${refs.length === 1 ? '' : 's'} visible.`);
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 ? { custodianNames } : {}),
460
- ...(savePath ? { 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
- await writeFile(join(savedTo, `${page.filename}.html`), html, 'utf8');
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',
@@ -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
- const contentType = val.properties?.contentType;
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 {