@dotrino/vaultd 0.160.3 → 0.162.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/src/ctl.js CHANGED
@@ -41,6 +41,7 @@ import * as hw from './hwkeys.js'
41
41
  import { isNewer } from './update.js'
42
42
  import { COMM_CN } from '../lib/src/core/commKey.js'
43
43
  import { machineLine } from '@dotrino/identity/machine'
44
+ import * as vc from './vaultControl.js'
44
45
 
45
46
  const dir = dataDir()
46
47
  const stateFile = path.join(dir, 'state.json')
@@ -2305,6 +2306,213 @@ async function cmdTui () {
2305
2306
  await runTui()
2306
2307
  }
2307
2308
 
2309
+ // ---------------------------------------------------------------------------
2310
+ // EL RESPALDO (`docs/respaldo.md`): un archivo cifrado con una contraseña propia con los
2311
+ // valores de los cajones y las contraseñas. Las llaves no van: son de cada aparato.
2312
+ //
2313
+ // Las dos puntas son de bóveda ABIERTA. El archivo lo cierra el daemon, así que lo que
2314
+ // llega aquí ya va cifrado; y al importar, lo que se enseña son nombres y estados.
2315
+ // ---------------------------------------------------------------------------
2316
+
2317
+ const DEVICE_ID_RE = /^[0-9A-F]{4}-[0-9A-F]{4}$/i
2318
+
2319
+ /** Cómo se llama un cajón para quien lee. */
2320
+ function drawerName (owner, device) {
2321
+ if (owner === 'passwords') return 'contraseñas'
2322
+ if (owner === 'ns:@me') return '@me (tu perfil)'
2323
+ if (owner.startsWith('ns:')) return owner.slice(3)
2324
+ const d = device || {}
2325
+ return `aparato ${d.id || pubkeyId(owner.slice(4))}${d.label ? ' (' + d.label + ')' : ''}`
2326
+ }
2327
+
2328
+ /**
2329
+ * De lo que escribe quien usa el comando al nombre del cajón. `devices`: `[{id, owner}]`
2330
+ * para traducir un ID de aparato. Lo que no se reconoce se queda como está y lo rechaza
2331
+ * la bóveda con su nombre (`NO_SUCH_DRAWER`), en vez de adivinar.
2332
+ */
2333
+ function drawerArg (word, devices = []) {
2334
+ const w = String(word)
2335
+ if (/^(contrase(n|ñ)as|passwords)$/i.test(w)) return 'passwords'
2336
+ if (w === '@me' || w === 'perfil' || w === 'ns:@me') return 'ns:@me'
2337
+ if (w.startsWith('ns:') || w.startsWith('dev:')) return w
2338
+ if (DEVICE_ID_RE.test(w)) return devices.find((d) => d.id === w.toUpperCase())?.owner || `dev:?${w.toUpperCase()}`
2339
+ return 'ns:' + w
2340
+ }
2341
+
2342
+ /** Una orden de respaldo al daemon: la misma pieza que usa la TUI (`vaultControl.js`). */
2343
+ function backupRequest (req) {
2344
+ requireDaemon()
2345
+ return vc.backupOp(req, PROFILE || undefined)
2346
+ }
2347
+
2348
+ function backupFail (e) {
2349
+ const msg = {
2350
+ VAULT_LOCKED: 'La bóveda está cerrada. Ábrela con: dotrino-vault unlock',
2351
+ WRONG_PASSWORD: 'Esa contraseña no abre este respaldo.',
2352
+ NOT_A_BACKUP: 'Ese archivo no es un respaldo de la bóveda.',
2353
+ BACKUP_CORRUPT: 'El respaldo está dañado: no se puede leer.',
2354
+ WEAK_PASSWORD: 'La contraseña del respaldo tiene que tener al menos 12 caracteres.',
2355
+ NO_SUCH_DRAWER: `No existe ese cajón: ${(e.missing || []).join(', ')}. Míralos con: dotrino-vault backup list`,
2356
+ NO_REPLY: 'El daemon no respondió.',
2357
+ DAEMON_DOWN: 'El servicio de la bóveda no está corriendo.',
2358
+ PROFILE_LOCKED: 'Perfil bloqueado. Ábrelo con: dotrino-vault unlock'
2359
+ }[e.code]
2360
+ console.error(msg || `No se pudo: ${e.message}`)
2361
+ process.exit(1)
2362
+ }
2363
+
2364
+ async function askBackupPassword ({ twice }) {
2365
+ let pwd
2366
+ try {
2367
+ pwd = await askPassword('Contraseña del respaldo: ')
2368
+ if (twice) {
2369
+ if (pwd.length < 12) { console.error('Tiene que tener al menos 12 caracteres. Varias palabras al azar funcionan bien.'); process.exit(1) }
2370
+ const again = await askPassword('Repítela: ')
2371
+ if (again !== pwd) { console.error('No coinciden.'); process.exit(1) }
2372
+ }
2373
+ } catch (_) {
2374
+ console.error('Hace falta un terminal para escribir la contraseña del respaldo.')
2375
+ process.exit(1)
2376
+ }
2377
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
2378
+ return pwd
2379
+ }
2380
+
2381
+ async function cmdBackup (rest) {
2382
+ const [sub, ...args] = rest
2383
+ const flag = (name) => { const i = args.indexOf(name); if (i < 0) return null; const v = args[i + 1]; args.splice(i, 2); return v }
2384
+ const has = (name) => { const i = args.indexOf(name); if (i < 0) return false; args.splice(i, 1); return true }
2385
+
2386
+ if (sub === 'list') {
2387
+ const r = await backupRequest({ op: 'backup-drawers' }).catch(backupFail)
2388
+ if (!r.drawers.length) { console.log('No hay nada que respaldar todavía.'); return }
2389
+ console.log('\n%sCajones%s\n', B, Z)
2390
+ for (const d of r.drawers) console.log(' %s %s', drawerName(d.owner, d.device).padEnd(36), `${d.count} ${d.owner === 'passwords' ? 'entrada(s)' : 'valor(es)'}`)
2391
+ console.log('\nTodo: dotrino-vault backup export')
2392
+ console.log('Solo algunos: dotrino-vault backup export proxy @me contrasenas <ID>\n')
2393
+ return
2394
+ }
2395
+
2396
+ if (sub === 'export') {
2397
+ let out = flag('-o') || flag('--out')
2398
+ let selected = null
2399
+ if (args.length) {
2400
+ const known = (await backupRequest({ op: 'backup-drawers' }).catch(backupFail)).drawers
2401
+ .filter((d) => d.device).map((d) => ({ id: d.device.id, owner: d.owner }))
2402
+ selected = args.map((a) => drawerArg(a, known))
2403
+ }
2404
+ if (!out) out = `dotrino-vault-backup-${new Date().toISOString().slice(0, 10)}.json`
2405
+ if (fs.existsSync(out)) { console.error('%s ya existe. Elige otro nombre con: -o <archivo>', out); process.exit(1) }
2406
+ const backupPassword = await askBackupPassword({ twice: true })
2407
+ const r = await backupRequest({ op: 'backup-export', drawers: selected, backupPassword }).catch(backupFail)
2408
+ fs.writeFileSync(out, JSON.stringify(r.file, null, 2) + '\n', { mode: 0o600 })
2409
+ console.log('\nRespaldo guardado en %s%s%s\n', B, out, Z)
2410
+ for (const d of r.summary.drawers) console.log(' %s %d', drawerName(d.owner, d.device).padEnd(36), d.count)
2411
+ if (r.summary.passwordsUnreadable) {
2412
+ console.log('\n%s¡OJO!%s %d contraseña(s) NO entran en el respaldo: se guardaron antes de que existiera', B, Z, r.summary.passwordsUnreadable)
2413
+ console.log('la copia de recuperación. Ábrelas una vez desde un aparato con «contraseñas» y repite el respaldo.')
2414
+ }
2415
+ if (r.summary.passwordFieldsWithheld) {
2416
+ console.log('\n%s¡OJO!%s %d campo(s) de contraseñas no se pudieron abrir y faltan en el respaldo.', B, Z, r.summary.passwordFieldsWithheld)
2417
+ }
2418
+ console.log('\nLa contraseña del respaldo no se puede recuperar. Sin ella el archivo no se abre.\n')
2419
+ return
2420
+ }
2421
+
2422
+ if (sub === 'import') {
2423
+ const onConflict = flag('--on-conflict')
2424
+ if (onConflict && onConflict !== 'keep' && onConflict !== 'replace') {
2425
+ console.error('--on-conflict es keep (conserva lo de aquí) o replace (gana el respaldo).'); process.exit(2)
2426
+ }
2427
+ const yes = has('--yes') || has('-y')
2428
+ const maps = []
2429
+ for (let a; (a = flag('--as'));) maps.push(a)
2430
+ const [file, ...wanted] = args
2431
+ if (!file) { console.error('Uso: dotrino-vault backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace]'); process.exit(2) }
2432
+ let sealed
2433
+ try { sealed = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) { console.error('No se pudo leer %s: %s', file, e.message); process.exit(1) }
2434
+ const backupPassword = await askBackupPassword({ twice: false })
2435
+
2436
+ // Primero lo que trae el respaldo, para poder nombrar sus cajones (y sus aparatos).
2437
+ const all = (await backupRequest({ op: 'backup-plan', file: sealed, backupPassword }).catch(backupFail)).plan
2438
+ const fromDevices = all.drawers.filter((d) => d.label?.id).map((d) => ({ id: d.label.id, owner: d.from }))
2439
+ const drawers = wanted.length ? wanted.map((w) => drawerArg(w, fromDevices)) : null
2440
+ const map = {}
2441
+ for (const m of maps) {
2442
+ const [from, to] = m.split('=')
2443
+ if (!from || !to) { console.error('--as va así: --as proxy=proxy-nuevo (o --as <ID>=<ID> para un aparato)'); process.exit(2) }
2444
+ const target = DEVICE_ID_RE.test(to) ? `dev:${(await findMember(to)).pub}` : drawerArg(to)
2445
+ map[drawerArg(from, fromDevices)] = target
2446
+ }
2447
+
2448
+ const plan = (await backupRequest({ op: 'backup-plan', file: sealed, backupPassword, drawers, map }).catch(backupFail)).plan
2449
+ const label = { new: 'nuevo', same: 'igual', conflict: 'CHOCA' }
2450
+ console.log('')
2451
+ for (const d of plan.drawers) {
2452
+ const dest = d.from === d.to ? drawerName(d.to, d.label) : `${drawerName(d.from, d.label)} → ${drawerName(d.to)}`
2453
+ if (d.status === 'no-device') { console.log('%s%s%s no entra: ese aparato no está en esta cuenta (muévelo con --as <ID>=<ID>)', B, dest, Z); continue }
2454
+ console.log('%s%s%s', B, dest, Z)
2455
+ for (const it of d.items) console.log(' %s %s', (label[it.status] || it.status).padEnd(7), it.title ? `${it.title}${it.username ? ' · ' + it.username : ''}` : it.key)
2456
+ }
2457
+ const c = plan.counts
2458
+ console.log('\n%d nuevo(s) · %d igual(es) · %d choque(s)%s\n', c.new, c.same, c.conflict, c.skipped ? ` · ${c.skipped} sin destino` : '')
2459
+
2460
+ // LOS CHOQUES: o los decide la bandera, o se preguntan uno a uno. Sin terminal y sin
2461
+ // bandera se para: decidir por defecto sería pisar (o perder) algo sin que nadie lo vea.
2462
+ const decisions = {}
2463
+ if (c.conflict && !onConflict) {
2464
+ if (!process.stdin.isTTY) {
2465
+ console.error('Hay choques y no dijiste qué hacer. Repite con --on-conflict keep (conserva lo de aquí)')
2466
+ console.error('o --on-conflict replace (gana el respaldo). No se importó nada.')
2467
+ process.exit(1)
2468
+ }
2469
+ let forAll = null
2470
+ for (const d of plan.drawers) {
2471
+ for (const it of d.items) {
2472
+ if (it.status !== 'conflict') continue
2473
+ if (!forAll) {
2474
+ const name = it.title ? `${it.title}${it.username ? ' · ' + it.username : ''}` : it.key
2475
+ const a = (await askText(`${drawerName(d.to, d.label)} · ${name}: [c]onservar lo de aquí, [r]eemplazar, [C]/[R] todos los demás: `)).trim()
2476
+ if (a === 'C') forAll = 'keep'
2477
+ else if (a === 'R') forAll = 'replace'
2478
+ else if (a === 'c' || a === 'r') { decisions[`${d.to} ${it.key}`] = a === 'c' ? 'keep' : 'replace'; continue }
2479
+ else { console.error('Cancelado. No se importó nada.'); process.exit(1) }
2480
+ }
2481
+ decisions[`${d.to} ${it.key}`] = forAll
2482
+ }
2483
+ }
2484
+ }
2485
+ if (!c.new && !Object.values(decisions).includes('replace') && onConflict !== 'replace') { console.log('No hay nada que importar.'); return }
2486
+ if (!yes) {
2487
+ const a = (await askText('¿Importar? [s/N] ')).trim().toLowerCase()
2488
+ if (a !== 's' && a !== 'si' && a !== 'sí') { console.log('No se importó nada.'); return }
2489
+ }
2490
+ let r
2491
+ try {
2492
+ r = await backupRequest({ op: 'backup-import', file: sealed, backupPassword, drawers, map, onConflict, decisions })
2493
+ } catch (e) {
2494
+ if (e.code === 'BACKUP_CONFLICTS') {
2495
+ // La bóveda cambió entre el plan y el sí: hay un choque que no estaba.
2496
+ console.error('Apareció un choque nuevo mientras decidías (la bóveda cambió). No se importó nada; repite la orden.')
2497
+ process.exit(1)
2498
+ }
2499
+ backupFail(e)
2500
+ }
2501
+ console.log('\nImportado: %d escrito(s), %d conservado(s), %d ya igual(es).', r.result.written, r.result.kept, r.result.same)
2502
+ for (const k of r.result.keptPrivate) console.log(' %s · %s se queda privada: aquí ya era privada.', drawerName(k.drawer), k.key)
2503
+ console.log('')
2504
+ return
2505
+ }
2506
+
2507
+ console.error(`Uso:
2508
+ dotrino-vault backup list qué cajones hay
2509
+ dotrino-vault backup export [cajón…] [-o archivo] todo, o solo esos cajones
2510
+ dotrino-vault backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace] [--yes]
2511
+
2512
+ Un cajón es el nombre de un scope (proxy), @me (tu perfil), contrasenas, o el ID de un aparato.`)
2513
+ process.exit(2)
2514
+ }
2515
+
2308
2516
  function help () {
2309
2517
  console.log(`dotrino-vault — control del certificador personal
2310
2518
 
@@ -2369,6 +2577,17 @@ function help () {
2369
2577
  privada, y una privada NO se vuelve pública.
2370
2578
  secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
2371
2579
  secret device visibility <ID> <CLAVE> private
2580
+ backup list qué cajones hay para respaldar (nombres y cuántos valores)
2581
+ backup export [cajón…] [-o archivo]
2582
+ un archivo cifrado con una contraseña propia, con los valores de
2583
+ TODOS los cajones o solo de los que digas (proxy, @me, contrasenas,
2584
+ el ID de un aparato). Las llaves no van: son de cada aparato.
2585
+ Bóveda abierta
2586
+ backup import <archivo> [cajón…] [--as <de>=<a>] [--on-conflict keep|replace] [--yes]
2587
+ lo trae a esta bóveda y lo vuelve a sellar con sus llaves. Primero
2588
+ enseña qué es nuevo, qué es igual y qué choca; un choque se decide
2589
+ (conservar o reemplazar) o no se importa nada. --as mueve un cajón a
2590
+ otro nombre o a otro aparato. Bóveda abierta
2372
2591
  join <invitación> [--name <n>] [--kms <config.json>]
2373
2592
  ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
2374
2593
  Entra con su propia llave, así que después se le puede dar +sella
@@ -2575,6 +2794,7 @@ export async function runCtl (argv) {
2575
2794
  case 'logins': return cmdLogins(rest)
2576
2795
  case 'update': return cmdUpdate(rest)
2577
2796
  case 'secret': return cmdSecret(rest)
2797
+ case 'backup': return cmdBackup(rest)
2578
2798
  // `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
2579
2799
  // quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
2580
2800
  // rechaza en voz alta, igual que `caps +approval`.
package/src/daemon.js CHANGED
@@ -31,6 +31,7 @@ import { readSettings } from './settings.js'
31
31
  import { approversOfAll, firstYes, readAsked, writeAsked, clearAsked, shouldAsk, resultOfNo, readRootTold, writeRootTold, shouldTellRoot } from './updateApproval.js'
32
32
  import { UPDATE_TTL_MS } from '../lib/src/core/approvals.js'
33
33
  import crypto from 'node:crypto'
34
+ import { sealBackup, openBackup } from '../lib/src/core/backup.js'
34
35
 
35
36
  const readJsonSafe = (f) => ipcRead(f, null)
36
37
  const rm = (f) => { try { fs.rmSync(f, { force: true }) } catch (_) {} }
@@ -366,6 +367,12 @@ export async function runDaemon () {
366
367
  let lastSecretValue = null
367
368
  /** Y las versiones anteriores que se acaban de pedir. Mismo camino, mismo volcado. */
368
369
  let lastSecretHistory = null
370
+ /**
371
+ * Lo que contestó la última orden de RESPALDO (`dotrino-vault backup`). Mismo camino que
372
+ * `lastSecretValue`. Lo que viaja nunca es un valor en claro: el respaldo sale YA CIFRADO
373
+ * con su contraseña, y el plan de importar lleva nombres y estados, no valores.
374
+ */
375
+ let lastBackup = null
369
376
  // ENTRAR CON USUARIO Y CONTRASEÑA, desde esta máquina (`dotrino-vault logins`).
370
377
  //
371
378
  // La respuesta NO se llama `logins.json`: ese nombre ya es el almacén del escritorio en
@@ -802,6 +809,26 @@ export async function runDaemon () {
802
809
  const value = await vault.revealSecret(sec.owner, sec.key, ak)
803
810
  lastSecretValue = { owner: sec.owner, key: sec.key, value }
804
811
  }
812
+ // EL RESPALDO. Bóveda abierta (lo comprueba la bóveda) y la contraseña del RESPALDO,
813
+ // que no es la del perfil: con ella se cierra el archivo aquí mismo, para que lo que
814
+ // salga de este proceso ya vaya cifrado.
815
+ else if (sec.op === 'backup-drawers') {
816
+ lastBackup = { op: sec.op, id: sec.id || null, drawers: await vault.backupDrawers() }
817
+ } else if (sec.op === 'backup-export') {
818
+ const { payload, summary } = await vault.exportBackup({ drawers: sec.drawers || null, adminKey: ak })
819
+ lastBackup = { op: sec.op, id: sec.id || null, file: await sealBackup(payload, sec.backupPassword), summary }
820
+ console.log('[vault] backup exported: %d drawer(s)', summary.drawers.length)
821
+ } else if (sec.op === 'backup-plan' || sec.op === 'backup-import') {
822
+ const payload = await openBackup(sec.file, sec.backupPassword)
823
+ const opts = { drawers: sec.drawers || null, map: sec.map || {}, adminKey: ak }
824
+ if (sec.op === 'backup-plan') {
825
+ lastBackup = { op: sec.op, id: sec.id || null, plan: await vault.planBackupImport(payload, opts) }
826
+ } else {
827
+ const result = await vault.importBackup(payload, { ...opts, onConflict: sec.onConflict || null, decisions: sec.decisions || {} })
828
+ lastBackup = { op: sec.op, id: sec.id || null, result }
829
+ console.log('[vault] backup imported: %d value(s) written, %d kept', result.written, result.kept)
830
+ }
831
+ }
805
832
  // Qué versiones anteriores hay (sin valores: son sobres).
806
833
  else if (sec.op === 'history') {
807
834
  lastSecretHistory = { owner: sec.owner || null, key: sec.key || null, items: vault.secretHistory(sec.owner || null, sec.key || null) }
@@ -818,7 +845,11 @@ export async function runDaemon () {
818
845
  } catch (e) {
819
846
  lastSecretError = {
820
847
  error: e.message,
821
- code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED')
848
+ code: e.code || (/wrong password/i.test(e.message) ? 'WRONG_PASSWORD' : 'SECRET_FAILED'),
849
+ // Los choques de una importación viajan con el error: son lo que hay que decidir.
850
+ ...(e.conflicts ? { conflicts: e.conflicts } : {}),
851
+ ...(e.missing ? { missing: e.missing } : {}),
852
+ ...(sec.op?.startsWith('backup-') ? { op: sec.op, id: sec.id || null } : {})
822
853
  }
823
854
  console.error('[vault] secret failed:', e.message)
824
855
  } finally { wipe(ak) }
@@ -928,11 +959,13 @@ export async function runDaemon () {
928
959
  incomplete: await t.vault.incompleteMembers(),
929
960
  ...(lastSecretError ? { secretError: lastSecretError } : {}),
930
961
  ...(lastSecretValue ? { revealed: lastSecretValue } : {}),
931
- ...(lastSecretHistory ? { history: lastSecretHistory } : {})
962
+ ...(lastSecretHistory ? { history: lastSecretHistory } : {}),
963
+ ...(lastBackup ? { backup: lastBackup } : {})
932
964
  })
933
965
  lastSecretError = null
934
966
  lastSecretValue = null
935
967
  lastSecretHistory = null
968
+ lastBackup = null
936
969
  ipcWrite(devFile, { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.listDevices()) })
937
970
  // Acta del perfil: quién es del perfil y qué puede hacer cada uno (`members`/`caps`).
938
971
  try { ipcWrite(path.join(dir, 'acta.json'), { v: 1, at: Date.now(), req: reqId, profile: t.id, ...(await t.vault.profileMembers()), blocked: t.vault.listBlocked?.() || [] }) } catch (_) {}