@dotrino/vaultd 0.53.0 → 0.58.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
@@ -30,7 +30,8 @@ import { assertVar } from './secretsStore.js'
30
30
  import { isValidSecretsNs } from './protocol.js'
31
31
  import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
32
32
  import { qrToString } from './qr.js'
33
- import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
33
+ import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
34
+ import { readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
34
35
  import { VERSION } from './version.js'
35
36
 
36
37
  const dir = dataDir()
@@ -67,6 +68,20 @@ const writeReq = (name, obj) => {
67
68
  }
68
69
 
69
70
  const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
71
+ const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
72
+
73
+ /**
74
+ * La fecha en que entró un aparato, corta y en local. Sin hora: lo que se busca al mirar
75
+ * esta lista es «¿de cuándo es este?», no el minuto exacto — y una fecha larga en cada
76
+ * línea convierte la lista en un muro.
77
+ *
78
+ * En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
79
+ * en medio de una frase en español, que es peor que cualquiera de los dos por separado.
80
+ */
81
+ function fechaCorta (ms) {
82
+ try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
83
+ catch (_) { return '' }
84
+ }
70
85
  // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
71
86
 
72
87
  /**
@@ -190,6 +205,32 @@ async function cmdPair (args = []) {
190
205
  // --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
191
206
  // claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
192
207
  const approval = args.includes('--approval')
208
+ // --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
209
+ // El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
210
+ // con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
211
+ // contenedor es otro `docker exec` y una vuelta a buscar el ID.
212
+ const admin = args.includes('--admin')
213
+ // --quiet: escupe SOLO la invitación (una línea) y termina. Sin QR y sin esperar.
214
+ // Para desplegar: en un contenedor no hay quien mire un QR pintado en una terminal, y un
215
+ // `pair` que se queda 2 minutos y medio esperando no se puede meter en un script.
216
+ const quiet = args.includes('--quiet')
217
+ // --kms <config.json>: el sitio que se va a crear (--new-account o --adopt) NACE con
218
+ // su clave de disco en el KMS. Va aquí y no en un paso posterior porque es el único
219
+ // momento que sirve: la llave de este aparato se genera al crear el perfil, y una
220
+ // migración posterior no deshace que haya existido bajo la clave de la máquina.
221
+ const kmsIdx = args.indexOf('--kms')
222
+ let pairKek = null
223
+ if (kmsIdx >= 0) {
224
+ const f = args[kmsIdx + 1]
225
+ if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault pair --adopt --kms <config.json>'); process.exit(2) }
226
+ try { pairKek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
227
+ console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
228
+ }
229
+ try { probeKek(dataDir(), pairKek) } catch (e) {
230
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
231
+ console.error('No se creó ninguna cuenta.'); process.exit(1)
232
+ }
233
+ }
193
234
  const scIdx = args.indexOf('--scope')
194
235
  let scope = null
195
236
  if (scIdx >= 0) {
@@ -201,6 +242,7 @@ async function cmdPair (args = []) {
201
242
  const t = ALIAS[tok] || tok
202
243
  if (t === 'admin' || t === 'administra') { console.error('`admin` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +administra'); process.exit(2) }
203
244
  if (t === 'approve' || t === 'aprueba') { console.error('`approve` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +aprueba'); process.exit(2) }
245
+ if (t === 'sealer' || t === 'sella') { console.error('`sella` no se empareja: concédelo desde el PC con dotrino-vault caps <ID> +sella'); process.exit(2) }
204
246
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
205
247
  // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
206
248
  // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
@@ -220,11 +262,11 @@ async function cmdPair (args = []) {
220
262
  if (naIdx >= 0) {
221
263
  const next = args[naIdx + 1]
222
264
  const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
223
- const d = await profileRequest('add', { name })
265
+ const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
224
266
  if (d.error) { console.error('%s', d.error); process.exit(1) }
225
267
  if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
226
268
  PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
227
- console.log('Cuenta nueva: %s (%s)', name, d.id)
269
+ console.log('Cuenta nueva%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
228
270
  }
229
271
  // `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
230
272
  // Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
@@ -235,15 +277,28 @@ async function cmdPair (args = []) {
235
277
  if (adopt) {
236
278
  const next = args[adIdx + 1]
237
279
  const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
238
- const d = await profileRequest('add', { name, adopt: true })
280
+ const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
239
281
  if (d.error) { console.error('%s', d.error); process.exit(1) }
240
282
  if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
241
283
  PROFILE = d.id
242
- console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
284
+ console.log('Cuenta a la espera de adoptar la del dispositivo%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
243
285
  }
286
+ // `--kms` sin un sitio que crear no hace NADA, y callárselo es lo peor que se puede
287
+ // hacer aquí: el dueño se quedaría creyendo que su aparato nació en el KMS.
288
+ if (pairKek && !adopt && naIdx < 0) {
289
+ console.error('--kms solo sirve cuando se crea el sitio, porque la llave del aparato')
290
+ console.error('se genera en ese momento. Úsalo con una de las dos:')
291
+ console.error(' dotrino-vault pair --adopt --kms <config.json> (la cuenta la trae el aparato)')
292
+ console.error(' dotrino-vault pair --new-account --kms <config.json> (cuenta nueva, nacida aquí)')
293
+ console.error('')
294
+ console.error('Emparejar contra una cuenta que ya vive en esta bóveda no cambia su clave de')
295
+ console.error('disco: esa ya se escribió cuando se creó. Ver docs/llaves-de-hardware.md.')
296
+ process.exit(2)
297
+ }
298
+
244
299
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
245
300
  // se empareja el dispositivo.
246
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
301
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
247
302
  sendSignal(s.pid, 'SIGUSR1')
248
303
 
249
304
  let pair = null
@@ -264,9 +319,12 @@ async function cmdPair (args = []) {
264
319
  // UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
265
320
  // equivocada; es la misma línea que muestra la TUI.
266
321
  const acct = pair.profileName || pair.profile
322
+ // --quiet: la invitación y nada más, para poder capturarla desde un script.
323
+ if (quiet) { console.log(url); return }
267
324
  if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
268
325
  console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
269
326
  console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
327
+ if (admin) console.log(`${R}${B}⚠ Y además podrá ADMINISTRAR: admitir y quitar aparatos.${Z}`)
270
328
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
271
329
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
272
330
  console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
@@ -306,6 +364,80 @@ function cmdApprove (code) {
306
364
  console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
307
365
  }
308
366
 
367
+ /**
368
+ * `dotrino-vault join <invitación>` — ESTA bóveda entra en la cuenta de OTRA.
369
+ *
370
+ * Es el papel contrario a `pair`: aquí no se invita, se acepta. Y lo que entra en el acta
371
+ * es la llave de ESTA bóveda, no una de aparato inventada — por eso después se le puede
372
+ * dar `+sella` y que sea el respaldo de verdad de la otra.
373
+ *
374
+ * El código que sale por pantalla hay que TIPEARLO en la otra bóveda (`approve`): una
375
+ * invitación interceptada no basta para entrar.
376
+ */
377
+ async function cmdJoin (rest) {
378
+ const args = rest || []
379
+ // `--name <n>`: cómo se va a llamar aquí la cuenta ajena. Es un perfil MÁS de esta
380
+ // bóveda, y en una máquina que respalda a varias hace falta distinguirlas.
381
+ const nIdx = args.indexOf('--name')
382
+ const name = nIdx >= 0 && args[nIdx + 1] && !args[nIdx + 1].startsWith('-') ? args[nIdx + 1] : null
383
+ // `--kms <config.json>`: el perfil que se cree para la cuenta ajena NACE con su clave de
384
+ // disco en el KMS. Va aquí por la misma razón que en `pair`: es el único momento que
385
+ // sirve, porque la llave de esta bóveda para esa cuenta se genera al crear el perfil.
386
+ const kIdx = args.indexOf('--kms')
387
+ let kek = null
388
+ if (kIdx >= 0) {
389
+ const f = args[kIdx + 1]
390
+ if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault join <invitación> --kms <config.json>'); process.exit(2) }
391
+ try { kek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
392
+ console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
393
+ }
394
+ try { probeKek(dataDir(), kek) } catch (e) {
395
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
396
+ console.error('No se creó ninguna cuenta.'); process.exit(1)
397
+ }
398
+ }
399
+ // Lo que queda tras quitar las banderas Y SUS VALORES es la invitación.
400
+ const consumidos = new Set()
401
+ for (const [i, v] of [[nIdx, name], [kIdx, kek]]) if (i >= 0) { consumidos.add(i); if (v != null) consumidos.add(i + 1) }
402
+ const texto = args.filter((a, i) => !consumidos.has(i) && !a.startsWith('-')).join(' ').trim()
403
+ if (!texto) {
404
+ console.error('uso: dotrino-vault join <invitación> [--name <n>] [--kms <config.json>]')
405
+ console.error(' (la invitación es lo que imprime «pair» en la otra bóveda)')
406
+ process.exit(2)
407
+ }
408
+ const qr = parseInvite(texto)
409
+ if (!qr?.sn || !qr?.iss || !qr?.proxy) {
410
+ console.error('Esa invitación no se entiende. Pega la línea completa que imprime «dotrino-vault pair».')
411
+ process.exit(2)
412
+ }
413
+ const s = requireDaemon()
414
+ const res = path.join(dir, 'join.json')
415
+ try { fs.rmSync(res, { force: true }) } catch (_) {}
416
+ writeReq('join-request.json', { qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
417
+ sendSignal(s.pid, 'SIGUSR2')
418
+
419
+ console.log('Entrando en la cuenta de la otra bóveda…')
420
+ let visto = null
421
+ for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
422
+ await sleep(200)
423
+ const d = readJson(res, null)
424
+ if (!d) continue
425
+ if (d.code && d.code !== visto) {
426
+ visto = d.code
427
+ console.log('\n Tipea este código en la OTRA bóveda: dotrino-vault approve %s\n', d.code)
428
+ }
429
+ if (d.state === 'done') {
430
+ console.log('Listo: esta bóveda ya es miembro de esa cuenta (acta #%s).', d.seq ?? '?')
431
+ if (d.profile) console.log('La cuenta vive aquí como el perfil %s (dotrino-vault profile ls).', d.profile)
432
+ console.log('Para que además pueda SELLAR, en la otra: dotrino-vault caps <ID> +sella')
433
+ return
434
+ }
435
+ if (d.state === 'error') { console.error('No se pudo entrar: %s', d.error); process.exit(1) }
436
+ }
437
+ console.error('Se agotó la espera. ¿Se aprobó el código en la otra bóveda?')
438
+ process.exit(1)
439
+ }
440
+
309
441
  function cmdReject (deviceId) {
310
442
  if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
311
443
  const s = requireDaemon()
@@ -387,7 +519,14 @@ async function cmdMembers () {
387
519
  assertOpen(record)
388
520
  if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
389
521
 
390
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}` }
522
+ // `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
523
+ // español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
524
+ // puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
525
+ const CAP = {
526
+ sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
527
+ admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
528
+ sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
529
+ }
391
530
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
392
531
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
393
532
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -401,13 +540,19 @@ async function cmdMembers () {
401
540
  m.cn ? `servicio «${m.cn}»` : null
402
541
  ].filter(Boolean)
403
542
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
404
- console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
543
+ // CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
544
+ // distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
545
+ // es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
546
+ // conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
547
+ console.log(' %s %s%s%s\n %s', m.id, who,
548
+ m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
549
+ marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
405
550
  // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
406
551
  // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
407
552
  // se mira quién es quién — y en la lista de variables ya seria tarde.
408
553
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
409
554
  }
410
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
555
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +permiso')
411
556
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
412
557
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
413
558
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -470,11 +615,15 @@ async function cmdApproval (args = []) {
470
615
  async function cmdCaps (args = []) {
471
616
  const [id, ...changes] = args
472
617
  if (!id || !changes.length) {
473
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
618
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+permiso|-permiso')
474
619
  process.exit(2)
475
620
  }
476
621
  const CAP_BY_WORD = {
477
622
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
623
+ // `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
624
+ // podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
625
+ // quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
626
+ sella: 'sealer', sealer: 'sealer',
478
627
  // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
479
628
  // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
480
629
  contrasenas: 'passwords', 'contraseñas': 'passwords',
@@ -593,6 +742,124 @@ function profileDir () {
593
742
  return path.join(dir, 'p', p.id)
594
743
  }
595
744
 
745
+ // DE DÓNDE SALE la clave que cifra el disco (`lib/src/kek.js`). Por defecto se deriva de
746
+ // esta máquina; se puede pasar a un KMS, y entonces una copia del disco deja de servir.
747
+ function cmdAtrest (rest) {
748
+ // OJO: la clave es POR PERFIL. `dir` aquí dentro es el del perfil al que apunta el
749
+ // comando (--profile, o el activo); la raíz de la bóveda es otra cosa y tiene la suya.
750
+ const vaultRoot = dataDir()
751
+ const dir = profileDir()
752
+ const sub = rest[0] || 'status'
753
+
754
+ if (sub === 'status') {
755
+ const cfg = readKekConfig(dir)
756
+ const archivos = encryptedFilesIn(dir)
757
+ if (cfg.provider === 'machine') {
758
+ console.log('Clave del disco: derivada de ESTA máquina (machine-id + salt).')
759
+ console.log(' Aviso: el material vive en el mismo disco que los datos, así que una')
760
+ console.log(' copia del disco entero la abre. Para cerrarlo: dotrino-vault atrest rekey <config.json>')
761
+ } else {
762
+ console.log('Clave del disco: envuelta por ' + (cfg.label || cfg.wrap?.cmd || 'un programa externo') + '.')
763
+ console.log(' Una copia del disco NO basta: hace falta además poder desenvolverla.')
764
+ }
765
+ console.log('Archivos cifrados: ' + (archivos.length ? archivos.join(', ') : 'ninguno todavía'))
766
+
767
+ // ES POR PERFIL, y eso hay que verlo: en la misma bóveda un perfil puede ir con KMS
768
+ // y el de al lado con la clave de la máquina. Sin esta lista, «lo puse en el KMS» es
769
+ // una creencia y no un hecho comprobable.
770
+ const otros = (readState().profiles || [])
771
+ if (otros.length > 1) {
772
+ console.log('')
773
+ console.log('Los demás perfiles de esta bóveda (cada uno lleva su propia clave):')
774
+ for (const q of otros) {
775
+ let pv = 'machine'
776
+ try { pv = readKekConfig(path.join(vaultRoot, 'p', q.id)).provider } catch (_) { pv = '?' }
777
+ const marca = q.current ? ' (activo)' : ''
778
+ console.log(' ' + (q.name || q.id) + marca + ': ' + (pv === 'machine' ? 'esta máquina' : 'programa externo / KMS'))
779
+ }
780
+ }
781
+ // El REGISTRO de perfiles vive en la raíz y tiene su propia clave: poner un perfil en
782
+ // el KMS no lo cubre. Dentro está el verificador del candado y la lista de perfiles,
783
+ // no el contenido de ninguno — pero conviene no creer que ya está protegido.
784
+ let raiz = 'machine'
785
+ try { raiz = readKekConfig(vaultRoot).provider } catch (_) {}
786
+ if (raiz === 'machine' && cfg.provider !== 'machine') {
787
+ console.log('')
788
+ console.log('Ojo: el registro de perfiles (la raíz de la bóveda) sigue con la clave de')
789
+ console.log('esta máquina. No guarda el contenido de ningún perfil, pero no está en el KMS.')
790
+ }
791
+ return
792
+ }
793
+
794
+ if (sub === 'test') {
795
+ // Comprueba el ida y vuelta SIN tocar los datos: es lo que hay que correr ANTES de
796
+ // un rekey, y lo que dice si el KMS sigue respondiendo.
797
+ try {
798
+ const r = probeKek(dir)
799
+ if (r.provider === 'machine') console.log('OK — proveedor «machine»: no hay nada externo que probar.')
800
+ else console.log('OK — ' + (r.label || 'el programa externo') + ' envuelve y desenvuelve (' + r.wrappedBytes + ' bytes).')
801
+ } catch (e) {
802
+ console.error('FALLA (' + (e.code || 'error') + '): ' + e.message)
803
+ process.exitCode = 1
804
+ }
805
+ return
806
+ }
807
+
808
+ if (sub === 'rekey') {
809
+ const file = rest[1]
810
+ if (!file) {
811
+ console.error('Falta el archivo de configuración: dotrino-vault atrest rekey <config.json>')
812
+ console.error('Para volver a la clave de la máquina: dotrino-vault atrest rekey --machine')
813
+ process.exitCode = 1; return
814
+ }
815
+ let cfg
816
+ if (file === '--machine') cfg = { provider: 'machine' }
817
+ else {
818
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
819
+ console.error('No se pudo leer ' + file + ': ' + e.message); process.exitCode = 1; return
820
+ }
821
+ }
822
+ // FRENO: recifrar un perfil que YA tiene identidad no le da raíz de hardware, y
823
+ // dejar creer que sí es peor que no ofrecerlo. Su maestra se escribió bajo la clave
824
+ // vieja; cualquier copia del disco anterior a este momento la sigue abriendo, y eso
825
+ // no hay recifrado que lo deshaga. Lo que sí sirve está en el mensaje.
826
+ const tieneIdentidad = fs.existsSync(path.join(dir, 'identity.json'))
827
+ if (tieneIdentidad && cfg.provider !== 'machine' && !rest.includes('--anyway')) {
828
+ console.error('Este perfil ya tiene identidad, así que recifrarlo NO le da raíz en el KMS.')
829
+ console.error('')
830
+ console.error('Su maestra se generó y se escribió bajo la clave de esta máquina. Una copia')
831
+ console.error('del disco anterior a este momento la sigue abriendo, para siempre, y eso no')
832
+ console.error('lo deshace ningún recifrado: solo protege de aquí en adelante.')
833
+ console.error('')
834
+ console.error('Para una identidad con raíz en el KMS, tiene que NACER así:')
835
+ console.error(' 1. dotrino-vault profile add <nombre> --kms <config.json>')
836
+ console.error(' 2. enrólalo al acta de la cuenta como un aparato más')
837
+ console.error(' 3. pásale el sellado y revoca el aparato viejo')
838
+ console.error(' (el profileId no cambia: la génesis sigue siendo el nombre de la cuenta)')
839
+ console.error('')
840
+ console.error('Si aun así quieres recifrar —porque el disco nunca salió de tu control—:')
841
+ console.error(' dotrino-vault atrest rekey %s --anyway', file)
842
+ process.exitCode = 1; return
843
+ }
844
+ try {
845
+ const r = rekeyDir(dir, cfg)
846
+ console.log('Listo: ' + r.from + ' → ' + r.to + '. Recifrados ' + r.files.length + ' archivos.')
847
+ if (r.backups.length) {
848
+ console.log('Copias de seguridad (bórralas cuando compruebes que todo abre):')
849
+ for (const b of r.backups) console.log(' ' + b)
850
+ }
851
+ console.log('Reinicia el servicio para que tome la clave nueva.')
852
+ } catch (e) {
853
+ console.error('NO se cambió nada (' + (e.code || 'error') + '): ' + e.message)
854
+ process.exitCode = 1
855
+ }
856
+ return
857
+ }
858
+
859
+ console.error('Uso: dotrino-vault atrest [status|test|rekey <config.json>|rekey --machine]')
860
+ process.exitCode = 1
861
+ }
862
+
596
863
  // Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
597
864
  function cmdActivity (n = 30) {
598
865
  const f = path.join(profileDir(), 'activity.log')
@@ -1049,7 +1316,13 @@ function reportProfiles (d) {
1049
1316
  }
1050
1317
 
1051
1318
  async function cmdProfile (rest) {
1052
- const [sub, ...args] = rest
1319
+ const [sub, ...rawArgs] = rest
1320
+ // `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
1321
+ // los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
1322
+ // cfg.json».
1323
+ const kmsAt = rawArgs.indexOf('--kms')
1324
+ const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
1325
+ const args = kmsAt !== -1 ? rawArgs.filter((_, i) => i !== kmsAt && i !== kmsAt + 1) : rawArgs
1053
1326
  const name = args.join(' ').trim()
1054
1327
  switch (sub || 'ls') {
1055
1328
  case 'ls': {
@@ -1061,8 +1334,24 @@ async function cmdProfile (rest) {
1061
1334
  return
1062
1335
  }
1063
1336
  case 'add': {
1064
- if (!name) { console.error('uso: dotrino-vault profile add <nombre>'); process.exit(2) }
1065
- reportProfiles(await profileRequest('add', { name }))
1337
+ if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>]'); process.exit(2) }
1338
+ // NACER con el KMS es la única forma de que la maestra no haya existido nunca bajo
1339
+ // la clave de esta máquina. Migrar después no da lo mismo y no se ofrece como si
1340
+ // lo diera (ver el freno de `atrest rekey`).
1341
+ let kek = null
1342
+ if (kmsAt !== -1) {
1343
+ if (!kmsFile || kmsFile.startsWith('-')) { console.error('uso: --kms <config.json>'); process.exit(2) }
1344
+ try { kek = JSON.parse(fs.readFileSync(kmsFile, 'utf8')) } catch (e) {
1345
+ console.error('No se pudo leer %s: %s', kmsFile, e.message); process.exit(2)
1346
+ }
1347
+ // Probar aquí ANTES de mandar la orden: si el KMS no responde, mejor enterarse
1348
+ // sin haber creado nada.
1349
+ try { probeKek(dataDir(), kek) } catch (e) {
1350
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
1351
+ console.error('No se creó ningún perfil.'); process.exit(1)
1352
+ }
1353
+ }
1354
+ reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
1066
1355
  console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
1067
1356
  return
1068
1357
  }
@@ -1172,11 +1461,19 @@ function help () {
1172
1461
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
1173
1462
  status estado del servicio + fingerprint
1174
1463
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
1464
+ pair --kms <config.json>
1465
+ el sitio que se cree (--adopt o --new-account) NACE con su clave
1466
+ de disco en el KMS. Es el único momento que sirve: la llave del
1467
+ aparato se genera al crear el perfil
1175
1468
  pair --new-account [nombre]
1176
1469
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
1177
1470
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1178
1471
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1179
1472
  pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1473
+ pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
1474
+ El QR no lleva nada: el permiso se aplica al aprobar el código aquí
1475
+ pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
1476
+ esperar. Para desplegar (un contenedor no mira un QR en pantalla)
1180
1477
  pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1181
1478
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1182
1479
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
@@ -1209,6 +1506,14 @@ function help () {
1209
1506
  y una privada NO se vuelve pública (bórrala y créala).
1210
1507
  secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
1211
1508
  secret device visibility <ID> <CLAVE> private
1509
+ join <invitación> [--name <n>] [--kms <config.json>]
1510
+ ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
1511
+ Entra con su propia llave, así que después se le puede dar +sella
1512
+ y ser el respaldo de esa cuenta. La cuenta ajena queda aquí como un
1513
+ PERFIL más (--name la nombra; --kms le pone la clave de disco en el
1514
+ KMS, y solo vale ahora: el perfil se está creando)
1515
+ Al desplegar, esto mismo va por el entorno y sin entrar a nada:
1516
+ DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
1212
1517
  pending muestra el dispositivo pendiente + su código a comparar
1213
1518
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
1214
1519
  reject <deviceId> rechaza un dispositivo pendiente
@@ -1217,7 +1522,14 @@ function help () {
1217
1522
  members el acta del perfil: quién es tuyo y qué puede hacer
1218
1523
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
1219
1524
  caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
1525
+ +sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
1526
+ que perder una máquina no se la lleve. No es un traspaso
1220
1527
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1528
+ atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
1529
+ atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
1530
+ atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
1531
+ (--machine para volver a la clave de esta máquina). Editar
1532
+ atrest.json a mano NO vale: dejaría el perfil ilegible
1221
1533
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1222
1534
  logs últimos logs del servicio
1223
1535
  version muestra la versión instalada
@@ -1225,6 +1537,10 @@ function help () {
1225
1537
  Perfiles (varias identidades tuyas en el mismo PC; todas atienden a la vez):
1226
1538
  profile ls lista los perfiles (* = el activo, el destino por defecto)
1227
1539
  profile add <nombre> crea un perfil (identidad nueva, vacía)
1540
+ profile add <nombre> --kms <f> ...y su clave de disco NACE en el KMS que diga <f>.
1541
+ Es la única forma de que la maestra no exista nunca
1542
+ bajo la clave de esta máquina: migrar después no da
1543
+ lo mismo (una copia vieja del disco la sigue abriendo)
1228
1544
  profile use <id|nombre> elige el perfil activo
1229
1545
  profile rename <nombre> renombra un perfil
1230
1546
  profile rm <id|nombre> BORRA un perfil y su identidad (irreversible)
@@ -1256,6 +1572,7 @@ export async function runCtl (argv) {
1256
1572
  case 'lock': return cmdLock()
1257
1573
  case 'status': return cmdStatus()
1258
1574
  case 'pair': return cmdPair(rest)
1575
+ case 'join': return cmdJoin(rest)
1259
1576
  case 'pending': return cmdPending()
1260
1577
  case 'approve': return cmdApprove(rest[0])
1261
1578
  case 'reject': return cmdReject(rest[0])
@@ -1267,6 +1584,7 @@ export async function runCtl (argv) {
1267
1584
  case 'revoke': return cmdRevoke(rest[0])
1268
1585
  case 'secret': return cmdSecret(rest)
1269
1586
  case 'approval': return cmdApproval(rest)
1587
+ case 'atrest': return cmdAtrest(rest)
1270
1588
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1271
1589
  case 'logs': return cmdLogs()
1272
1590
  case 'version':