@dotrino/vaultd 0.52.0 → 0.56.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.
@@ -458,8 +458,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
458
458
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
459
459
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
460
460
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
461
- const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
462
- if (!okPending) throw new Error('invalid master signature on the pending reply')
461
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
463
462
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
464
463
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
465
464
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
@@ -467,14 +466,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
467
466
  if (res.type === MSG.ERROR) throw new Error(res.error)
468
467
  }
469
468
 
470
- // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
469
+ // AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
470
+ // (`sealPub`, buscada por `seq`), que es la misma regla que ya gobierna los sobres.
471
+ // La maestra solo sella el acta y reenvuelve; servir no es cosa suya, y por eso puede
472
+ // quedarse cerrada.
471
473
  const body = res.body
472
474
  if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
473
475
  if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
474
- const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
475
- if (!ok) throw new Error('invalid master signature on the secrets reply')
476
476
 
477
+ // Se abre ANTES de comprobar la firma, y no es un descuido: el sobre de fuera va
478
+ // sellado a la efímera que acabamos de estrenar, así que abrirlo no prueba nada ni
479
+ // confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
480
+ // dentro se usa hasta después de verificar.
477
481
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
482
+ await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
478
483
 
479
484
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
480
485
  // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
@@ -499,6 +504,40 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
499
504
  * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
500
505
  * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
501
506
  */
507
+ /**
508
+ * ¿QUIÉN CONTESTÓ, Y PUEDE? La misma regla que gobierna los sobres, aplicada al
509
+ * transporte: **el acta dice qué llave firma qué**.
510
+ *
511
+ * La maestra no entra aquí. Sus dos trabajos son sellar el acta y reenvolver los sobres
512
+ * de todos los aparatos; servir una petición no es ninguno de los dos, así que exigir su
513
+ * firma la obligaba a estar despierta todo el tiempo — y de paso hacía imposible que
514
+ * sirviera nadie más.
515
+ *
516
+ * Lo que se comprueba, en orden:
517
+ * 1. el acta que llega va firmada por la maestra que este agente lleva pineada desde
518
+ * que se enroló (o por la que aquella nombró al traspasar);
519
+ * 2. la firma del cuerpo cuadra con la llave de sellado que ESA acta nombra para el
520
+ * `seq` con el que se firmó.
521
+ *
522
+ * Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
523
+ * quisiera.
524
+ */
525
+ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
526
+ if (!seal?.sig || typeof seal.seq !== 'number') {
527
+ throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
528
+ }
529
+ if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
530
+ if (acta.sealedBy !== masterPubkey) {
531
+ throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
532
+ }
533
+ if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
534
+ const pub = sealKeyAt(acta, seal.seq)
535
+ if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
536
+ if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
537
+ throw new Error('the reply signature does not check out against the key the record names')
538
+ }
539
+ }
540
+
502
541
  /**
503
542
  * ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
504
543
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.52.0",
3
+ "version": "0.56.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,8 +20,8 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.61.0",
24
- "@dotrino/passmanager": "^0.1.0",
23
+ "@dotrino/identity": "^0.63.0",
24
+ "@dotrino/passmanager": "^0.9.0",
25
25
  "@dotrino/proxy-client": "^0.13.1",
26
26
  "ws": "^8.18.0"
27
27
  },
@@ -44,7 +44,7 @@
44
44
  "!lib/src/types.d.ts"
45
45
  ],
46
46
  "devDependencies": {
47
- "@dotrino/remote-agent": "^0.3.2",
47
+ "@dotrino/remote-agent": "^0.5.3",
48
48
  "@types/node": "^22.0.0",
49
49
  "typescript": "5.9.3"
50
50
  }
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()
@@ -190,6 +191,32 @@ async function cmdPair (args = []) {
190
191
  // --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
191
192
  // claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
192
193
  const approval = args.includes('--approval')
194
+ // --admin: el aparato que entre por esta invitación podrá ADMINISTRAR (la consola).
195
+ // El QR no lleva nada: es una nota local de la bóveda y el permiso se aplica al aprobar
196
+ // con el código que tecleas aquí. Ahorra el `caps <ID> +administra` de después, que en un
197
+ // contenedor es otro `docker exec` y una vuelta a buscar el ID.
198
+ const admin = args.includes('--admin')
199
+ // --quiet: escupe SOLO la invitación (una línea) y termina. Sin QR y sin esperar.
200
+ // Para desplegar: en un contenedor no hay quien mire un QR pintado en una terminal, y un
201
+ // `pair` que se queda 2 minutos y medio esperando no se puede meter en un script.
202
+ const quiet = args.includes('--quiet')
203
+ // --kms <config.json>: el sitio que se va a crear (--new-account o --adopt) NACE con
204
+ // su clave de disco en el KMS. Va aquí y no en un paso posterior porque es el único
205
+ // momento que sirve: la llave de este aparato se genera al crear el perfil, y una
206
+ // migración posterior no deshace que haya existido bajo la clave de la máquina.
207
+ const kmsIdx = args.indexOf('--kms')
208
+ let pairKek = null
209
+ if (kmsIdx >= 0) {
210
+ const f = args[kmsIdx + 1]
211
+ if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault pair --adopt --kms <config.json>'); process.exit(2) }
212
+ try { pairKek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
213
+ console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
214
+ }
215
+ try { probeKek(dataDir(), pairKek) } catch (e) {
216
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
217
+ console.error('No se creó ninguna cuenta.'); process.exit(1)
218
+ }
219
+ }
193
220
  const scIdx = args.indexOf('--scope')
194
221
  let scope = null
195
222
  if (scIdx >= 0) {
@@ -201,6 +228,7 @@ async function cmdPair (args = []) {
201
228
  const t = ALIAS[tok] || tok
202
229
  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
230
  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) }
231
+ 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
232
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
205
233
  // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
206
234
  // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
@@ -220,11 +248,11 @@ async function cmdPair (args = []) {
220
248
  if (naIdx >= 0) {
221
249
  const next = args[naIdx + 1]
222
250
  const name = (next && !next.startsWith('-')) ? next : `cuenta ${new Date().toISOString().slice(0, 10)}`
223
- const d = await profileRequest('add', { name })
251
+ const d = await profileRequest('add', { name, ...(pairKek ? { kek: pairKek } : {}) })
224
252
  if (d.error) { console.error('%s', d.error); process.exit(1) }
225
253
  if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
226
254
  PROFILE = d.id // el emparejamiento y los comandos siguientes apuntan a ELLA
227
- console.log('Cuenta nueva: %s (%s)', name, d.id)
255
+ console.log('Cuenta nueva%s: %s (%s)', pairKek ? ' (clave del disco en el KMS)' : '', name, d.id)
228
256
  }
229
257
  // `--adopt [nombre]`: la TERCERA respuesta a «¿de qué cuenta hablamos?» — el camino A.
230
258
  // Aquí la cuenta NO sale de esta bóveda: la trae el aparato y esta bóveda pasa a
@@ -235,15 +263,28 @@ async function cmdPair (args = []) {
235
263
  if (adopt) {
236
264
  const next = args[adIdx + 1]
237
265
  const name = (next && !next.startsWith('-')) ? next : `cuenta del dispositivo`
238
- const d = await profileRequest('add', { name, adopt: true })
266
+ const d = await profileRequest('add', { name, adopt: true, ...(pairKek ? { kek: pairKek } : {}) })
239
267
  if (d.error) { console.error('%s', d.error); process.exit(1) }
240
268
  if (!d.id) { console.error('El daemon no dijo qué cuenta creó.'); process.exit(1) }
241
269
  PROFILE = d.id
242
- console.log('Cuenta a la espera de adoptar la del dispositivo: %s (%s)', name, d.id)
270
+ 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
271
  }
272
+ // `--kms` sin un sitio que crear no hace NADA, y callárselo es lo peor que se puede
273
+ // hacer aquí: el dueño se quedaría creyendo que su aparato nació en el KMS.
274
+ if (pairKek && !adopt && naIdx < 0) {
275
+ console.error('--kms solo sirve cuando se crea el sitio, porque la llave del aparato')
276
+ console.error('se genera en ese momento. Úsalo con una de las dos:')
277
+ console.error(' dotrino-vault pair --adopt --kms <config.json> (la cuenta la trae el aparato)')
278
+ console.error(' dotrino-vault pair --new-account --kms <config.json> (cuenta nueva, nacida aquí)')
279
+ console.error('')
280
+ console.error('Emparejar contra una cuenta que ya vive en esta bóveda no cambia su clave de')
281
+ console.error('disco: esa ya se escribió cuando se creó. Ver docs/llaves-de-hardware.md.')
282
+ process.exit(2)
283
+ }
284
+
244
285
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
245
286
  // se empareja el dispositivo.
246
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
287
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}), ...(admin ? { admin: true } : {}) })
247
288
  sendSignal(s.pid, 'SIGUSR1')
248
289
 
249
290
  let pair = null
@@ -264,9 +305,12 @@ async function cmdPair (args = []) {
264
305
  // UNA (la activa, o la de --profile). Decirlo evita enrolar el dispositivo en la
265
306
  // equivocada; es la misma línea que muestra la TUI.
266
307
  const acct = pair.profileName || pair.profile
308
+ // --quiet: la invitación y nada más, para poder capturarla desde un script.
309
+ if (quiet) { console.log(url); return }
267
310
  if (acct) console.log('\nCuenta que se comparte: %s%s', acct, pair.profileName && pair.profile ? ` (${pair.profile})` : '')
268
311
  console.log('\nEscanea este QR con el dispositivo que quieres conectar (válido %d min):\n', mins)
269
312
  console.log(qrToString(url)) // el QR abre la consola de dispositivos y empareja solo
313
+ if (admin) console.log(`${R}${B}⚠ Y además podrá ADMINISTRAR: admitir y quitar aparatos.${Z}`)
270
314
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
271
315
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
272
316
  console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
@@ -306,6 +350,80 @@ function cmdApprove (code) {
306
350
  console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
307
351
  }
308
352
 
353
+ /**
354
+ * `dotrino-vault join <invitación>` — ESTA bóveda entra en la cuenta de OTRA.
355
+ *
356
+ * Es el papel contrario a `pair`: aquí no se invita, se acepta. Y lo que entra en el acta
357
+ * es la llave de ESTA bóveda, no una de aparato inventada — por eso después se le puede
358
+ * dar `+sella` y que sea el respaldo de verdad de la otra.
359
+ *
360
+ * El código que sale por pantalla hay que TIPEARLO en la otra bóveda (`approve`): una
361
+ * invitación interceptada no basta para entrar.
362
+ */
363
+ async function cmdJoin (rest) {
364
+ const args = rest || []
365
+ // `--name <n>`: cómo se va a llamar aquí la cuenta ajena. Es un perfil MÁS de esta
366
+ // bóveda, y en una máquina que respalda a varias hace falta distinguirlas.
367
+ const nIdx = args.indexOf('--name')
368
+ const name = nIdx >= 0 && args[nIdx + 1] && !args[nIdx + 1].startsWith('-') ? args[nIdx + 1] : null
369
+ // `--kms <config.json>`: el perfil que se cree para la cuenta ajena NACE con su clave de
370
+ // disco en el KMS. Va aquí por la misma razón que en `pair`: es el único momento que
371
+ // sirve, porque la llave de esta bóveda para esa cuenta se genera al crear el perfil.
372
+ const kIdx = args.indexOf('--kms')
373
+ let kek = null
374
+ if (kIdx >= 0) {
375
+ const f = args[kIdx + 1]
376
+ if (!f || f.startsWith('-')) { console.error('uso: dotrino-vault join <invitación> --kms <config.json>'); process.exit(2) }
377
+ try { kek = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (e) {
378
+ console.error('No se pudo leer %s: %s', f, e.message); process.exit(2)
379
+ }
380
+ try { probeKek(dataDir(), kek) } catch (e) {
381
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
382
+ console.error('No se creó ninguna cuenta.'); process.exit(1)
383
+ }
384
+ }
385
+ // Lo que queda tras quitar las banderas Y SUS VALORES es la invitación.
386
+ const consumidos = new Set()
387
+ for (const [i, v] of [[nIdx, name], [kIdx, kek]]) if (i >= 0) { consumidos.add(i); if (v != null) consumidos.add(i + 1) }
388
+ const texto = args.filter((a, i) => !consumidos.has(i) && !a.startsWith('-')).join(' ').trim()
389
+ if (!texto) {
390
+ console.error('uso: dotrino-vault join <invitación> [--name <n>] [--kms <config.json>]')
391
+ console.error(' (la invitación es lo que imprime «pair» en la otra bóveda)')
392
+ process.exit(2)
393
+ }
394
+ const qr = parseInvite(texto)
395
+ if (!qr?.sn || !qr?.iss || !qr?.proxy) {
396
+ console.error('Esa invitación no se entiende. Pega la línea completa que imprime «dotrino-vault pair».')
397
+ process.exit(2)
398
+ }
399
+ const s = requireDaemon()
400
+ const res = path.join(dir, 'join.json')
401
+ try { fs.rmSync(res, { force: true }) } catch (_) {}
402
+ writeReq('join-request.json', { qr, label: 'bóveda', ...(name ? { name } : {}), ...(kek ? { kek } : {}) })
403
+ sendSignal(s.pid, 'SIGUSR2')
404
+
405
+ console.log('Entrando en la cuenta de la otra bóveda…')
406
+ let visto = null
407
+ for (let i = 0; i < 900; i++) { // hasta 3 min: hay un humano tipeando al otro lado
408
+ await sleep(200)
409
+ const d = readJson(res, null)
410
+ if (!d) continue
411
+ if (d.code && d.code !== visto) {
412
+ visto = d.code
413
+ console.log('\n Tipea este código en la OTRA bóveda: dotrino-vault approve %s\n', d.code)
414
+ }
415
+ if (d.state === 'done') {
416
+ console.log('Listo: esta bóveda ya es miembro de esa cuenta (acta #%s).', d.seq ?? '?')
417
+ if (d.profile) console.log('La cuenta vive aquí como el perfil %s (dotrino-vault profile ls).', d.profile)
418
+ console.log('Para que además pueda SELLAR, en la otra: dotrino-vault caps <ID> +sella')
419
+ return
420
+ }
421
+ if (d.state === 'error') { console.error('No se pudo entrar: %s', d.error); process.exit(1) }
422
+ }
423
+ console.error('Se agotó la espera. ¿Se aprobó el código en la otra bóveda?')
424
+ process.exit(1)
425
+ }
426
+
309
427
  function cmdReject (deviceId) {
310
428
  if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
311
429
  const s = requireDaemon()
@@ -407,7 +525,7 @@ async function cmdMembers () {
407
525
  // se mira quién es quién — y en la lista de variables ya seria tarde.
408
526
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
409
527
  }
410
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
528
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +sella | +permiso')
411
529
  console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
412
530
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
413
531
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
@@ -470,11 +588,15 @@ async function cmdApproval (args = []) {
470
588
  async function cmdCaps (args = []) {
471
589
  const [id, ...changes] = args
472
590
  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')
591
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+sella|-sella|+permiso|-permiso')
474
592
  process.exit(2)
475
593
  }
476
594
  const CAP_BY_WORD = {
477
595
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
596
+ // `sella`: SELLAR EL ACTA. Es lo que convierte a otra bóveda en respaldo de esta —
597
+ // podrá admitir aparatos y cambiar permisos si esta se pierde. No es un traspaso:
598
+ // quien manda sigue mandando. Como `administra`, no se empareja: se concede aquí.
599
+ sella: 'sealer', sealer: 'sealer',
478
600
  // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
479
601
  // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
480
602
  contrasenas: 'passwords', 'contraseñas': 'passwords',
@@ -593,6 +715,124 @@ function profileDir () {
593
715
  return path.join(dir, 'p', p.id)
594
716
  }
595
717
 
718
+ // DE DÓNDE SALE la clave que cifra el disco (`lib/src/kek.js`). Por defecto se deriva de
719
+ // esta máquina; se puede pasar a un KMS, y entonces una copia del disco deja de servir.
720
+ function cmdAtrest (rest) {
721
+ // OJO: la clave es POR PERFIL. `dir` aquí dentro es el del perfil al que apunta el
722
+ // comando (--profile, o el activo); la raíz de la bóveda es otra cosa y tiene la suya.
723
+ const vaultRoot = dataDir()
724
+ const dir = profileDir()
725
+ const sub = rest[0] || 'status'
726
+
727
+ if (sub === 'status') {
728
+ const cfg = readKekConfig(dir)
729
+ const archivos = encryptedFilesIn(dir)
730
+ if (cfg.provider === 'machine') {
731
+ console.log('Clave del disco: derivada de ESTA máquina (machine-id + salt).')
732
+ console.log(' Aviso: el material vive en el mismo disco que los datos, así que una')
733
+ console.log(' copia del disco entero la abre. Para cerrarlo: dotrino-vault atrest rekey <config.json>')
734
+ } else {
735
+ console.log('Clave del disco: envuelta por ' + (cfg.label || cfg.wrap?.cmd || 'un programa externo') + '.')
736
+ console.log(' Una copia del disco NO basta: hace falta además poder desenvolverla.')
737
+ }
738
+ console.log('Archivos cifrados: ' + (archivos.length ? archivos.join(', ') : 'ninguno todavía'))
739
+
740
+ // ES POR PERFIL, y eso hay que verlo: en la misma bóveda un perfil puede ir con KMS
741
+ // y el de al lado con la clave de la máquina. Sin esta lista, «lo puse en el KMS» es
742
+ // una creencia y no un hecho comprobable.
743
+ const otros = (readState().profiles || [])
744
+ if (otros.length > 1) {
745
+ console.log('')
746
+ console.log('Los demás perfiles de esta bóveda (cada uno lleva su propia clave):')
747
+ for (const q of otros) {
748
+ let pv = 'machine'
749
+ try { pv = readKekConfig(path.join(vaultRoot, 'p', q.id)).provider } catch (_) { pv = '?' }
750
+ const marca = q.current ? ' (activo)' : ''
751
+ console.log(' ' + (q.name || q.id) + marca + ': ' + (pv === 'machine' ? 'esta máquina' : 'programa externo / KMS'))
752
+ }
753
+ }
754
+ // El REGISTRO de perfiles vive en la raíz y tiene su propia clave: poner un perfil en
755
+ // el KMS no lo cubre. Dentro está el verificador del candado y la lista de perfiles,
756
+ // no el contenido de ninguno — pero conviene no creer que ya está protegido.
757
+ let raiz = 'machine'
758
+ try { raiz = readKekConfig(vaultRoot).provider } catch (_) {}
759
+ if (raiz === 'machine' && cfg.provider !== 'machine') {
760
+ console.log('')
761
+ console.log('Ojo: el registro de perfiles (la raíz de la bóveda) sigue con la clave de')
762
+ console.log('esta máquina. No guarda el contenido de ningún perfil, pero no está en el KMS.')
763
+ }
764
+ return
765
+ }
766
+
767
+ if (sub === 'test') {
768
+ // Comprueba el ida y vuelta SIN tocar los datos: es lo que hay que correr ANTES de
769
+ // un rekey, y lo que dice si el KMS sigue respondiendo.
770
+ try {
771
+ const r = probeKek(dir)
772
+ if (r.provider === 'machine') console.log('OK — proveedor «machine»: no hay nada externo que probar.')
773
+ else console.log('OK — ' + (r.label || 'el programa externo') + ' envuelve y desenvuelve (' + r.wrappedBytes + ' bytes).')
774
+ } catch (e) {
775
+ console.error('FALLA (' + (e.code || 'error') + '): ' + e.message)
776
+ process.exitCode = 1
777
+ }
778
+ return
779
+ }
780
+
781
+ if (sub === 'rekey') {
782
+ const file = rest[1]
783
+ if (!file) {
784
+ console.error('Falta el archivo de configuración: dotrino-vault atrest rekey <config.json>')
785
+ console.error('Para volver a la clave de la máquina: dotrino-vault atrest rekey --machine')
786
+ process.exitCode = 1; return
787
+ }
788
+ let cfg
789
+ if (file === '--machine') cfg = { provider: 'machine' }
790
+ else {
791
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) {
792
+ console.error('No se pudo leer ' + file + ': ' + e.message); process.exitCode = 1; return
793
+ }
794
+ }
795
+ // FRENO: recifrar un perfil que YA tiene identidad no le da raíz de hardware, y
796
+ // dejar creer que sí es peor que no ofrecerlo. Su maestra se escribió bajo la clave
797
+ // vieja; cualquier copia del disco anterior a este momento la sigue abriendo, y eso
798
+ // no hay recifrado que lo deshaga. Lo que sí sirve está en el mensaje.
799
+ const tieneIdentidad = fs.existsSync(path.join(dir, 'identity.json'))
800
+ if (tieneIdentidad && cfg.provider !== 'machine' && !rest.includes('--anyway')) {
801
+ console.error('Este perfil ya tiene identidad, así que recifrarlo NO le da raíz en el KMS.')
802
+ console.error('')
803
+ console.error('Su maestra se generó y se escribió bajo la clave de esta máquina. Una copia')
804
+ console.error('del disco anterior a este momento la sigue abriendo, para siempre, y eso no')
805
+ console.error('lo deshace ningún recifrado: solo protege de aquí en adelante.')
806
+ console.error('')
807
+ console.error('Para una identidad con raíz en el KMS, tiene que NACER así:')
808
+ console.error(' 1. dotrino-vault profile add <nombre> --kms <config.json>')
809
+ console.error(' 2. enrólalo al acta de la cuenta como un aparato más')
810
+ console.error(' 3. pásale el sellado y revoca el aparato viejo')
811
+ console.error(' (el profileId no cambia: la génesis sigue siendo el nombre de la cuenta)')
812
+ console.error('')
813
+ console.error('Si aun así quieres recifrar —porque el disco nunca salió de tu control—:')
814
+ console.error(' dotrino-vault atrest rekey %s --anyway', file)
815
+ process.exitCode = 1; return
816
+ }
817
+ try {
818
+ const r = rekeyDir(dir, cfg)
819
+ console.log('Listo: ' + r.from + ' → ' + r.to + '. Recifrados ' + r.files.length + ' archivos.')
820
+ if (r.backups.length) {
821
+ console.log('Copias de seguridad (bórralas cuando compruebes que todo abre):')
822
+ for (const b of r.backups) console.log(' ' + b)
823
+ }
824
+ console.log('Reinicia el servicio para que tome la clave nueva.')
825
+ } catch (e) {
826
+ console.error('NO se cambió nada (' + (e.code || 'error') + '): ' + e.message)
827
+ process.exitCode = 1
828
+ }
829
+ return
830
+ }
831
+
832
+ console.error('Uso: dotrino-vault atrest [status|test|rekey <config.json>|rekey --machine]')
833
+ process.exitCode = 1
834
+ }
835
+
596
836
  // Bitácora de actividad de seguridad (quién firmó/renovó/enroló y qué se rechazó).
597
837
  function cmdActivity (n = 30) {
598
838
  const f = path.join(profileDir(), 'activity.log')
@@ -1049,7 +1289,13 @@ function reportProfiles (d) {
1049
1289
  }
1050
1290
 
1051
1291
  async function cmdProfile (rest) {
1052
- const [sub, ...args] = rest
1292
+ const [sub, ...rawArgs] = rest
1293
+ // `--kms <archivo>` se saca ANTES de armar el nombre: el nombre se compone juntando
1294
+ // los argumentos sueltos, así que si no se quita acabaría llamándose «midevault --kms
1295
+ // cfg.json».
1296
+ const kmsAt = rawArgs.indexOf('--kms')
1297
+ const kmsFile = kmsAt !== -1 ? rawArgs[kmsAt + 1] : null
1298
+ const args = kmsAt !== -1 ? rawArgs.filter((_, i) => i !== kmsAt && i !== kmsAt + 1) : rawArgs
1053
1299
  const name = args.join(' ').trim()
1054
1300
  switch (sub || 'ls') {
1055
1301
  case 'ls': {
@@ -1061,8 +1307,24 @@ async function cmdProfile (rest) {
1061
1307
  return
1062
1308
  }
1063
1309
  case 'add': {
1064
- if (!name) { console.error('uso: dotrino-vault profile add <nombre>'); process.exit(2) }
1065
- reportProfiles(await profileRequest('add', { name }))
1310
+ if (!name) { console.error('uso: dotrino-vault profile add <nombre> [--kms <config.json>]'); process.exit(2) }
1311
+ // NACER con el KMS es la única forma de que la maestra no haya existido nunca bajo
1312
+ // la clave de esta máquina. Migrar después no da lo mismo y no se ofrece como si
1313
+ // lo diera (ver el freno de `atrest rekey`).
1314
+ let kek = null
1315
+ if (kmsAt !== -1) {
1316
+ if (!kmsFile || kmsFile.startsWith('-')) { console.error('uso: --kms <config.json>'); process.exit(2) }
1317
+ try { kek = JSON.parse(fs.readFileSync(kmsFile, 'utf8')) } catch (e) {
1318
+ console.error('No se pudo leer %s: %s', kmsFile, e.message); process.exit(2)
1319
+ }
1320
+ // Probar aquí ANTES de mandar la orden: si el KMS no responde, mejor enterarse
1321
+ // sin haber creado nada.
1322
+ try { probeKek(dataDir(), kek) } catch (e) {
1323
+ console.error('El KMS no respondió (%s): %s', e.code || 'error', e.message)
1324
+ console.error('No se creó ningún perfil.'); process.exit(1)
1325
+ }
1326
+ }
1327
+ reportProfiles(await profileRequest('add', { name, ...(kek ? { kek } : {}) }))
1066
1328
  console.log('Conecta un dispositivo a este perfil: dotrino-vault pair --profile "%s"', name)
1067
1329
  return
1068
1330
  }
@@ -1141,8 +1403,13 @@ function askText (prompt) {
1141
1403
 
1142
1404
  async function cmdUnlock () {
1143
1405
  const pwd = await askPassword('Contraseña del perfil: ')
1144
- reportProfiles(await profileRequest('unlock', { password: pwd }))
1145
- console.log('Ya puedes editar el perfil. Se vuelve a bloquear al reiniciar el servicio (o con: dotrino-vault lock).')
1406
+ // El daemon devuelve cuánto aguanta abierto: se dice AQUÍ, al abrirlo, que es cuando
1407
+ // sirve de algo. Encontrárselo cerrado sin haberlo leído nunca parece una avería.
1408
+ const out = await profileRequest('unlock', { password: pwd })
1409
+ reportProfiles(out)
1410
+ const min = Math.max(1, Math.round((out?.autoLockMs || 5 * 60 * 1000) / 60000))
1411
+ console.log(`Ya puedes editar el perfil. Se vuelve a bloquear solo tras ${min} min sin usarse` +
1412
+ ' (o al reiniciar el servicio, o con: dotrino-vault lock).')
1146
1413
  }
1147
1414
 
1148
1415
  async function cmdLock () {
@@ -1167,11 +1434,19 @@ function help () {
1167
1434
  tui interfaz de terminal a pantalla completa (bóvedas, pares, secretos)
1168
1435
  status estado del servicio + fingerprint
1169
1436
  pair [--save <f>] inicia un emparejamiento (QR + espera); --save escribe la invitación (.dpair)
1437
+ pair --kms <config.json>
1438
+ el sitio que se cree (--adopt o --new-account) NACE con su clave
1439
+ de disco en el KMS. Es el único momento que sirve: la llave del
1440
+ aparato se genera al crear el perfil
1170
1441
  pair --new-account [nombre]
1171
1442
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
1172
1443
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1173
1444
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1174
1445
  pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1446
+ pair --admin el aparato que entre podrá ADMINISTRAR (es lo que es una consola).
1447
+ El QR no lleva nada: el permiso se aplica al aprobar el código aquí
1448
+ pair --quiet escupe SOLO la invitación (una línea) y termina: sin QR y sin
1449
+ esperar. Para desplegar (un contenedor no mira un QR en pantalla)
1175
1450
  pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1176
1451
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1177
1452
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
@@ -1204,6 +1479,14 @@ function help () {
1204
1479
  y una privada NO se vuelve pública (bórrala y créala).
1205
1480
  secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
1206
1481
  secret device visibility <ID> <CLAVE> private
1482
+ join <invitación> [--name <n>] [--kms <config.json>]
1483
+ ESTA bóveda ENTRA en la cuenta de otra (el papel contrario a pair).
1484
+ Entra con su propia llave, así que después se le puede dar +sella
1485
+ y ser el respaldo de esa cuenta. La cuenta ajena queda aquí como un
1486
+ PERFIL más (--name la nombra; --kms le pone la clave de disco en el
1487
+ KMS, y solo vale ahora: el perfil se está creando)
1488
+ Al desplegar, esto mismo va por el entorno y sin entrar a nada:
1489
+ DOTRINO_JOIN (o DOTRINO_JOIN_FILE) + DOTRINO_JOIN_NAME
1207
1490
  pending muestra el dispositivo pendiente + su código a comparar
1208
1491
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
1209
1492
  reject <deviceId> rechaza un dispositivo pendiente
@@ -1212,7 +1495,14 @@ function help () {
1212
1495
  members el acta del perfil: quién es tuyo y qué puede hacer
1213
1496
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
1214
1497
  caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
1498
+ +sella = OTRA BÓVEDA que puede sellar el acta de esta cuenta, para
1499
+ que perder una máquina no se la lleve. No es un traspaso
1215
1500
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1501
+ atrest status de dónde sale la clave que cifra el disco (esta máquina, o un KMS)
1502
+ atrest test comprueba que el KMS envuelve y desenvuelve, SIN tocar los datos
1503
+ atrest rekey <f> cambia de proveedor: descifra con la vieja y recifra con la nueva
1504
+ (--machine para volver a la clave de esta máquina). Editar
1505
+ atrest.json a mano NO vale: dejaría el perfil ilegible
1216
1506
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1217
1507
  logs últimos logs del servicio
1218
1508
  version muestra la versión instalada
@@ -1220,6 +1510,10 @@ function help () {
1220
1510
  Perfiles (varias identidades tuyas en el mismo PC; todas atienden a la vez):
1221
1511
  profile ls lista los perfiles (* = el activo, el destino por defecto)
1222
1512
  profile add <nombre> crea un perfil (identidad nueva, vacía)
1513
+ profile add <nombre> --kms <f> ...y su clave de disco NACE en el KMS que diga <f>.
1514
+ Es la única forma de que la maestra no exista nunca
1515
+ bajo la clave de esta máquina: migrar después no da
1516
+ lo mismo (una copia vieja del disco la sigue abriendo)
1223
1517
  profile use <id|nombre> elige el perfil activo
1224
1518
  profile rename <nombre> renombra un perfil
1225
1519
  profile rm <id|nombre> BORRA un perfil y su identidad (irreversible)
@@ -1231,7 +1525,8 @@ dispositivos siguen firmando y leyendo aunque esté bloqueado):
1231
1525
  profile password [set] pone o cambia la contraseña
1232
1526
  profile password rm la quita
1233
1527
  unlock desbloquea para poder editar
1234
- lock vuelve a bloquear (también al reiniciar el servicio)
1528
+ lock vuelve a bloquear (también solo, a los 5 min sin
1529
+ usarse, y al reiniciar el servicio)
1235
1530
 
1236
1531
  Arrancar y parar, según dónde corra:
1237
1532
  Linux (servicio) systemctl --user {start,stop,restart} dotrino-vault
@@ -1250,6 +1545,7 @@ export async function runCtl (argv) {
1250
1545
  case 'lock': return cmdLock()
1251
1546
  case 'status': return cmdStatus()
1252
1547
  case 'pair': return cmdPair(rest)
1548
+ case 'join': return cmdJoin(rest)
1253
1549
  case 'pending': return cmdPending()
1254
1550
  case 'approve': return cmdApprove(rest[0])
1255
1551
  case 'reject': return cmdReject(rest[0])
@@ -1261,6 +1557,7 @@ export async function runCtl (argv) {
1261
1557
  case 'revoke': return cmdRevoke(rest[0])
1262
1558
  case 'secret': return cmdSecret(rest)
1263
1559
  case 'approval': return cmdApproval(rest)
1560
+ case 'atrest': return cmdAtrest(rest)
1264
1561
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1265
1562
  case 'logs': return cmdLogs()
1266
1563
  case 'version':