@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/lib/src/core/backup.js +244 -0
- package/lib/src/core/libVersion.js +1 -1
- package/lib/src/core/passwords.js +88 -12
- package/lib/src/core/secretsStore.js +61 -2
- package/lib/src/core/vault.js +169 -1
- package/package.json +1 -1
- package/src/ctl.js +220 -0
- package/src/daemon.js +35 -2
- package/src/tui/app.js +300 -5
- package/src/tui/i18n.js +109 -2
- package/src/vaultControl.js +48 -0
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 (_) {}
|