@dotrino/vaultd 0.26.2 → 0.46.2

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
@@ -23,8 +23,11 @@
23
23
  import fs from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import { execFileSync } from 'node:child_process'
26
+ import tty from 'node:tty'
26
27
  import { pubkeyId } from '@dotrino/identity/capabilities'
27
28
  import { dataDir, readJson } from './paths.js'
29
+ import { assertVar } from './secretsStore.js'
30
+ import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
28
31
  import { qrToString } from './qr.js'
29
32
  import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
30
33
  import { VERSION } from './version.js'
@@ -49,7 +52,18 @@ function takeProfileFlag (args) {
49
52
  }
50
53
  /** Campo `profile` de las peticiones al daemon (omitido = perfil activo). */
51
54
  const withProfile = (obj) => (PROFILE ? { ...obj, profile: PROFILE } : obj)
52
- const writeReq = (name, obj) => fs.writeFileSync(path.join(dir, name), JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
55
+ /**
56
+ * Deja una petición para el daemon. ATÓMICA (escribir aparte y renombrar): el daemon
57
+ * vigila la carpeta con `fs.watch`, que avisa al CREAR el archivo y no al terminar de
58
+ * escribirlo, así que escrito en el sitio se puede leer a medias — y una petición que no
59
+ * parsea se pierde con todos sus datos.
60
+ */
61
+ const writeReq = (name, obj) => {
62
+ const dest = path.join(dir, name)
63
+ const tmp = dest + '.tmp'
64
+ fs.writeFileSync(tmp, JSON.stringify(withProfile({ ...obj, at: Date.now() })), { mode: 0o600 })
65
+ fs.renameSync(tmp, dest)
66
+ }
53
67
 
54
68
  const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
55
69
  // La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
@@ -60,13 +74,13 @@ const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
60
74
  * contenedor, así que decir siempre «systemctl» manda a la mitad de la gente a un
61
75
  * comando que no existe.
62
76
  */
63
- const EN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
64
- const START_HINT = EN_DOCKER
77
+ const IN_DOCKER = !!process.env.DOTRINO_IN_DOCKER
78
+ const START_HINT = IN_DOCKER
65
79
  ? 'docker start dotrino-vault'
66
80
  : process.platform === 'linux'
67
81
  ? 'systemctl --user start dotrino-vault'
68
82
  : 'dotrino-vaultd (o: npx -y @dotrino/vaultd)'
69
- const RESTART_HINT = EN_DOCKER
83
+ const RESTART_HINT = IN_DOCKER
70
84
  ? 'docker restart dotrino-vault'
71
85
  : process.platform === 'linux'
72
86
  ? 'systemctl --user restart dotrino-vault'
@@ -88,7 +102,7 @@ function alive (pid) { try { return !!pid && (process.kill(pid, 0) || true) } ca
88
102
  * vigila la carpeta igualmente, así que la petición se atiende en cuanto se escribe el
89
103
  * archivo; la señal solo se ahorra la latencia del watcher.
90
104
  */
91
- function avisar (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
105
+ function sendSignal (pid, sig) { try { process.kill(pid, sig) } catch (_) { /* Windows, o el daemon ya se enteró */ } }
92
106
  function sleep (ms) { return new Promise((r) => setTimeout(r, ms)) }
93
107
  function requireDaemon () {
94
108
  const s = readState()
@@ -138,6 +152,20 @@ function showChallenge (pe) {
138
152
  console.log(' Si no reconoces este dispositivo: dotrino-vault reject %s\n', pe.deviceId)
139
153
  }
140
154
 
155
+ /**
156
+ * EL CANDADO. Un perfil con contraseña y bloqueado no se ve ni se toca desde esta consola:
157
+ * el daemon contesta `locked` y sin contenido, y aquí se corta con un mensaje que dice qué
158
+ * hacer. Los aparatos ya emparejados siguen atendidos — lo que está cerrado es esta
159
+ * pantalla, no la bóveda.
160
+ */
161
+ function assertOpen (d) {
162
+ if (d?.locked) {
163
+ console.error('Perfil bloqueado. Ábrelo con: dotrino-vault unlock')
164
+ process.exit(1)
165
+ }
166
+ return d
167
+ }
168
+
141
169
  async function cmdPair (args = []) {
142
170
  const s = requireDaemon()
143
171
  try { fs.rmSync(pairFile, { force: true }) } catch (_) {}
@@ -152,6 +180,30 @@ async function cmdPair (args = []) {
152
180
  console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
153
181
  }
154
182
  }
183
+ // --scope <lista>: los PERMISOS del cert, y nada más. No hay tipos de aparato
184
+ // (2026-08-22, dueño): un aparato es un aparato y lo que puede hacer. Sin --scope,
185
+ // el juego de siempre (sign,read,store); `--service <ns>` es el atajo de
186
+ // `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
187
+ // firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
188
+ // concede desde el PC (`caps <ID> +administra`).
189
+ const scIdx = args.indexOf('--scope')
190
+ let scope = null
191
+ if (scIdx >= 0) {
192
+ const raw = args[scIdx + 1]
193
+ if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
194
+ const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
195
+ scope = []
196
+ for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
197
+ const t = ALIAS[tok] || tok
198
+ 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) }
199
+ if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
200
+ const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
201
+ if (m) { scope.push('vault:secrets:' + m[1]); continue }
202
+ console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
203
+ }
204
+ if (service) scope.push('vault:secrets:' + service)
205
+ scope = [...new Set(scope)]
206
+ }
155
207
  // `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
156
208
  // meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
157
209
  // entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
@@ -183,11 +235,16 @@ async function cmdPair (args = []) {
183
235
  }
184
236
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
185
237
  // se empareja el dispositivo.
186
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
187
- avisar(s.pid, 'SIGUSR1')
238
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
239
+ sendSignal(s.pid, 'SIGUSR1')
188
240
 
189
241
  let pair = null
190
- for (let i = 0; i < 50; i++) { await sleep(100); const p = readJson(pairFile, null); if (p?.expiresAt > Date.now()) { pair = p; break } }
242
+ for (let i = 0; i < 50; i++) {
243
+ await sleep(100)
244
+ const p = readJson(pairFile, null)
245
+ assertOpen(p) // el candado se contesta por el mismo archivo, para no dejar esperando
246
+ if (p?.expiresAt > Date.now()) { pair = p; break }
247
+ }
191
248
  if (!pair) { console.error('No se recibió respuesta del daemon para el emparejamiento.'); process.exit(1) }
192
249
 
193
250
  // Una sola forma para las dos cosas: la invitación compacta (base64url de ~100
@@ -237,7 +294,7 @@ function cmdApprove (code) {
237
294
  if (!code) { console.error('uso: dotrino-vault approve <código> (los dígitos que muestra el dispositivo)'); process.exit(2) }
238
295
  const s = requireDaemon()
239
296
  writeReq('approve-request.json', { code: String(code) })
240
- avisar(s.pid, 'SIGUSR2')
297
+ sendSignal(s.pid, 'SIGUSR2')
241
298
  console.log('Aprobando con el código %s… verifica con: dotrino-vault devices', code)
242
299
  }
243
300
 
@@ -245,7 +302,7 @@ function cmdReject (deviceId) {
245
302
  if (!deviceId) { console.error('uso: dotrino-vault reject <deviceId>'); process.exit(2) }
246
303
  const s = requireDaemon()
247
304
  writeReq('reject-request.json', { deviceId })
248
- avisar(s.pid, 'SIGUSR2')
305
+ sendSignal(s.pid, 'SIGUSR2')
249
306
  console.log('Rechazado %s.', deviceId)
250
307
  }
251
308
 
@@ -265,12 +322,13 @@ async function cmdMe () {
265
322
  const meFile = path.join(dir, 'me.json')
266
323
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
267
324
  writeReq('me-request.json', {})
268
- avisar(s.pid, 'SIGUSR2')
325
+ sendSignal(s.pid, 'SIGUSR2')
269
326
  let dump = null
270
327
  for (let n = 0; n < 50; n++) { await sleep(100); const d = readJson(meFile, null); if (d?.at) { dump = d; break } }
271
328
  // El volcado es contenido del usuario: se lee y se BORRA, no se queda ahí suelto.
272
329
  try { fs.rmSync(meFile, { force: true }) } catch (_) {}
273
330
  if (!dump) { console.error('La bóveda no respondió. ¿Está corriendo? dotrino-vault status'); process.exit(1) }
331
+ assertOpen(dump)
274
332
 
275
333
  const me = dump.me
276
334
  if (!me) {
@@ -279,8 +337,8 @@ async function cmdMe () {
279
337
  return
280
338
  }
281
339
 
282
- const cuando = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
283
- console.log('\n%sPerfil%s · actualizado %s\n', B, Z, cuando)
340
+ const when = me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—'
341
+ console.log('\n%sPerfil%s · actualizado %s\n', B, Z, when)
284
342
  console.log(' nombre : %s', me.nickname || '(sin nombre)')
285
343
  console.log(' foto : %s', me.avatar
286
344
  ? `sí · ${me.avatar.type || 'desconocido'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
@@ -289,17 +347,17 @@ async function cmdMe () {
289
347
  // Los campos estándar. `visible` es del usuario: teléfono y dirección nacen ocultos.
290
348
  const STD = [['nombres', 'nombres'], ['apellidos', 'apellidos'], ['email', 'correo'],
291
349
  ['telefono', 'teléfono'], ['direccion', 'dirección']]
292
- const puestos = STD.filter(([k]) => me[k])
293
- if (puestos.length) {
350
+ const filled = STD.filter(([k]) => me[k])
351
+ if (filled.length) {
294
352
  console.log('')
295
- for (const [k, etiqueta] of puestos) {
296
- console.log(' %s: %s%s', etiqueta.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
353
+ for (const [k, label] of filled) {
354
+ console.log(' %s: %s%s', label.padEnd(12), me[k], me[k + 'Visible'] === false ? ' (oculto)' : '')
297
355
  }
298
356
  }
299
- for (const [titulo, lista] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
300
- if (!Array.isArray(lista) || !lista.length) continue
301
- console.log('\n %s:', titulo)
302
- for (const x of lista) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
357
+ for (const [title, list] of [['Enlaces', me.links], ['Otros datos', me.fields]]) {
358
+ if (!Array.isArray(list) || !list.length) continue
359
+ console.log('\n %s:', title)
360
+ for (const x of list) console.log(' %s %s%s', (x.type || x.label || '').padEnd(12), x.value, x.visible === false ? ' (oculto)' : '')
303
361
  }
304
362
 
305
363
  console.log('')
@@ -311,30 +369,35 @@ async function cmdMe () {
311
369
  */
312
370
  async function cmdMembers () {
313
371
  const s = requireDaemon()
314
- const actaFile = path.join(dataDir(), 'acta.json')
315
- try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
372
+ const recordFile = path.join(dataDir(), 'acta.json')
373
+ try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
316
374
  writeReq('dump-request.json', {})
317
- avisar(s.pid, 'SIGUSR2')
318
- let acta = null
319
- for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
320
- if (!acta) { console.error('El daemon no respondió.'); process.exit(1) }
321
- if (!acta.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
375
+ sendSignal(s.pid, 'SIGUSR2')
376
+ let record = null
377
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
378
+ if (!record) { console.error('El daemon no respondió.'); process.exit(1) }
379
+ assertOpen(record)
380
+ if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
322
381
 
323
382
  const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
324
383
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
325
384
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
326
385
  // misma que se enseña al emparejar y en la lista de miembros.
327
- const perfilId = await deviceIdOf(acta.profileId).catch(() => '????-????')
328
- console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, perfilId, acta.seq)
329
- for (const m of acta.members) {
330
- const quien = m.label || m.id
331
- const marcas = [
386
+ const profileId = await deviceIdOf(record.profileId).catch(() => '????-????')
387
+ console.log('\n%sPerfil%s %s · acta #%d\n', B, Z, profileId, record.seq)
388
+ for (const m of record.members) {
389
+ const who = m.label || m.id
390
+ const marks = [
332
391
  m.isMaster ? `${B}Master${Z}` : null,
333
392
  m.isMe ? 'este dispositivo' : null,
334
393
  m.cn ? `servicio «${m.cn}»` : null
335
394
  ].filter(Boolean)
336
395
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
337
- console.log(' %s %s%s\n %s', m.id, quien, marcas.length ? ' [' + marcas.join(' · ') + ']' : '', caps)
396
+ console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
397
+ // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
398
+ // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
399
+ // se mira quién es quién — y en la lista de variables ya seria tarde.
400
+ if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
338
401
  }
339
402
  console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
340
403
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
@@ -350,55 +413,56 @@ async function cmdMembers () {
350
413
  * tener que revocar y volver a emparejar.
351
414
  */
352
415
  async function cmdLabel (args = []) {
353
- const [id, ...resto] = args
354
- const nombre = resto.join(' ').trim()
355
- if (!id || !nombre) {
416
+ const [id, ...rest] = args
417
+ const name = rest.join(' ').trim()
418
+ if (!id || !name) {
356
419
  console.error('uso: dotrino-vault label <ID> <nombre> (p.ej. label AB12-CD34 "Teléfono de casa")')
357
420
  process.exit(2)
358
421
  }
359
- const m = await buscarMiembro(id)
360
- writeReq('label-request.json', { pub: m.pub, label: nombre })
361
- avisar(requireDaemon().pid, 'SIGUSR2')
362
- console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, nombre.slice(0, 60))
422
+ const m = await findMember(id)
423
+ writeReq('label-request.json', { pub: m.pub, label: name })
424
+ sendSignal(requireDaemon().pid, 'SIGUSR2')
425
+ console.log('Listo: %s ahora se llama «%s». Compruébalo con: dotrino-vault members', m.id, name.slice(0, 60))
363
426
  }
364
427
 
365
428
  /** Busca un miembro del acta por su identificador (AB12-CD34) o se rinde con un mensaje claro. */
366
- async function buscarMiembro (id) {
429
+ async function findMember (id) {
367
430
  const s = requireDaemon()
368
- const actaFile = path.join(dataDir(), 'acta.json')
369
- try { fs.rmSync(actaFile, { force: true }) } catch (_) {}
431
+ const recordFile = path.join(dataDir(), 'acta.json')
432
+ try { fs.rmSync(recordFile, { force: true }) } catch (_) {}
370
433
  writeReq('dump-request.json', {})
371
- avisar(s.pid, 'SIGUSR2')
372
- let acta = null
373
- for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(actaFile, null); if (a?.at) { acta = a; break } }
374
- const m = acta?.members?.find((x) => x.id === String(id).toUpperCase())
434
+ sendSignal(s.pid, 'SIGUSR2')
435
+ let record = null
436
+ for (let i = 0; i < 50; i++) { await sleep(100); const a = readJson(recordFile, null); if (a?.at) { record = a; break } }
437
+ assertOpen(record)
438
+ const m = record?.members?.find((x) => x.id === String(id).toUpperCase())
375
439
  if (!m) { console.error('No hay ningún dispositivo con ese identificador. Míralos con: dotrino-vault members'); process.exit(1) }
376
440
  return m
377
441
  }
378
442
 
379
443
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
380
444
  async function cmdCaps (args = []) {
381
- const [id, ...cambios] = args
382
- if (!id || !cambios.length) {
445
+ const [id, ...changes] = args
446
+ if (!id || !changes.length) {
383
447
  console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
384
448
  process.exit(2)
385
449
  }
386
- const NOMBRE = {
450
+ const CAP_BY_WORD = {
387
451
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
388
452
  sign: 'sign', store: 'store', read: 'read', admin: 'admin'
389
453
  }
390
454
  const s = requireDaemon()
391
- const m = await buscarMiembro(id)
455
+ const m = await findMember(id)
392
456
 
393
457
  const caps = new Set(m.caps)
394
- for (const c of cambios) {
395
- const signo = c[0]
396
- const cap = NOMBRE[c.slice(1).toLowerCase()]
397
- if (!cap || (signo !== '+' && signo !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
398
- if (signo === '+') caps.add(cap); else caps.delete(cap)
458
+ for (const c of changes) {
459
+ const sign = c[0]
460
+ const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
461
+ if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
462
+ if (sign === '+') caps.add(cap); else caps.delete(cap)
399
463
  }
400
464
  writeReq('caps-request.json', { pub: m.pub, caps: [...caps] })
401
- avisar(s.pid, 'SIGUSR2')
465
+ sendSignal(s.pid, 'SIGUSR2')
402
466
  console.log('Listo. Compruébalo con: dotrino-vault members')
403
467
  }
404
468
 
@@ -406,25 +470,26 @@ async function cmdDevices () {
406
470
  const s = requireDaemon()
407
471
  try { fs.rmSync(devFile, { force: true }) } catch (_) {}
408
472
  writeReq('dump-request.json', {}) // de qué perfil queremos los dispositivos
409
- avisar(s.pid, 'SIGUSR2')
473
+ sendSignal(s.pid, 'SIGUSR2')
410
474
  let snap = null
411
475
  for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(devFile, null); if (d?.at) { snap = d; break } }
412
476
  if (!snap) { console.error('El daemon no respondió.'); process.exit(1) }
477
+ assertOpen(snap)
413
478
  const revoked = snap.revoked || []
414
- const fuera = new Set(revoked.map((r) => r?.nonce || r))
479
+ const revokedSet = new Set(revoked.map((r) => r?.nonce || r))
415
480
  // UN APARATO, UNA LÍNEA. El daemon lleva la cuenta por CERTIFICADO —correcto para él,
416
481
  // porque revocar es revocar un papel—, pero renovar emite uno nuevo cada 30 días: un
417
482
  // aparato de un año salía doce veces, y los ya retirados seguían contando como
418
483
  // enrolados. Se agrupa por llave y se dice cuántos certificados tiene.
419
- const porLlave = new Map()
484
+ const byKey = new Map()
420
485
  for (const d of (snap.issued || snap.active || snap.delegations || [])) {
421
- if (d.revokedAt || fuera.has(d.nonce)) continue
422
- const clave = d.sub || d.nonce
423
- const y = porLlave.get(clave)
424
- if (!y) porLlave.set(clave, { ...d, certs: 1 })
486
+ if (d.revokedAt || revokedSet.has(d.nonce)) continue
487
+ const key = d.sub || d.nonce
488
+ const y = byKey.get(key)
489
+ if (!y) byKey.set(key, { ...d, certs: 1 })
425
490
  else { y.certs++; if ((d.exp || 0) > (y.exp || 0)) Object.assign(y, { ...d, certs: y.certs }) }
426
491
  }
427
- const active = [...porLlave.values()]
492
+ const active = [...byKey.values()]
428
493
  console.log('Dispositivos enrolados: %d', active.length)
429
494
  for (const d of active) {
430
495
  const did = d.sub ? await deviceIdOf(d.sub) : '????-????'
@@ -454,14 +519,14 @@ async function cmdRevoke (arg) {
454
519
  const esId = /^[0-9a-f]{4}-?[0-9a-f]{4}$/i.test(arg)
455
520
  const s = requireDaemon()
456
521
  if (esId) {
457
- const m = await buscarMiembro(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
522
+ const m = await findMember(arg.toUpperCase().includes('-') ? arg.toUpperCase() : arg.toUpperCase().replace(/(.{4})(.{4})/, '$1-$2'))
458
523
  writeReq('revoke-request.json', { sub: m.pub })
459
- avisar(s.pid, 'SIGUSR2')
524
+ sendSignal(s.pid, 'SIGUSR2')
460
525
  console.log('Quitado %s (todos sus certificados). Se autoborrará al reconectar. Verifica: dotrino-vault devices', m.id)
461
526
  return
462
527
  }
463
528
  writeReq('revoke-request.json', { nonce: arg })
464
- avisar(s.pid, 'SIGUSR2')
529
+ sendSignal(s.pid, 'SIGUSR2')
465
530
  console.log('Revocación enviada para nonce=%s. El dispositivo se autoborrará al reconectar. Verifica: dotrino-vault devices', arg)
466
531
  }
467
532
 
@@ -478,6 +543,10 @@ function profileDir () {
478
543
  ? list.find((x) => x.id === PROFILE || (x.name || '').toLowerCase() === ref)
479
544
  : (list.find((x) => x.current) || list[0])
480
545
  if (!p) { console.error('el perfil no existe: %s', PROFILE); process.exit(1) }
546
+ // El candado también aquí: esto es la única puerta que lee el directorio del perfil sin
547
+ // pasar por el daemon (la bitácora), y sin esta línea `activity` seguía contando quién
548
+ // firmó y cuándo con la bóveda cerrada.
549
+ assertOpen(p)
481
550
  return path.join(dir, 'p', p.id)
482
551
  }
483
552
 
@@ -500,43 +569,445 @@ function cmdActivity (n = 30) {
500
569
  }
501
570
  }
502
571
 
503
- // Secretos de servicios: se cargan aquí (el dueño, en el PC del vault) y los
504
- // leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
572
+ // Variables de entorno de los servicios: se cargan aquí (el dueño, en el PC del vault) y
573
+ // las leen los SERVICIOS enrolados con `pair --service <ns>`. Nunca se listan valores.
574
+ //
575
+ // DOS CAJONES: las del SCOPE (`secret set <ns> …`) las comparten todos los aparatos que
576
+ // sirven ese namespace; las del APARATO (`secret device set <ID> …`) las lee solo ese
577
+ // aparato y PISAN a las del scope con el mismo nombre. Ahí va lo que cambia de máquina a
578
+ // máquina (el puerto, la URL pública) sin tener que partir el ns en uno por servidor.
579
+ /**
580
+ * `KEY=valor KEY2=valor2` — la forma de CARGAR VARIAS de una vez. Devuelve `null` si no
581
+ * son todos pares, para que la forma clásica de tres argumentos (`set ns CLAVE valor`,
582
+ * donde el valor puede llevar espacios y hasta un `=`) siga funcionando igual.
583
+ */
584
+ function asPairs (args) {
585
+ if (!args.length) return null
586
+ const out = []
587
+ for (const a of args) {
588
+ const m = PAIR_RE.exec(a)
589
+ if (!m) return null
590
+ out.push({ op: 'set', key: m[1], value: m[2] })
591
+ }
592
+ return out
593
+ }
594
+
595
+ /** Un `.env` con un problema no se carga A MEDIAS: se dice qué línea y no se escribe nada. */
596
+ function abortEnv (errors) {
597
+ console.error('%sNo se cargó nada%s:', R, Z)
598
+ for (const e of errors) console.error(' · %s', envErrorText(e))
599
+ process.exit(2)
600
+ }
601
+
602
+ /** Los códigos del lector de `.env`, en la lengua del CLI. */
603
+ function envErrorText (e) {
604
+ if (e.code === 'shape') return `línea ${e.line}: no tiene la forma CLAVE=valor`
605
+ if (e.code === 'key') return `línea ${e.line}: «${e.key}» va en MAYÚSCULAS_CON_GUION_BAJO`
606
+ if (e.code === 'novalue') return `línea ${e.line}: ${e.key} no tiene valor (para quitarla: secret rm)`
607
+ if (e.code === 'dup') return `línea ${e.line}: ${e.key} ya venía en la línea ${e.first}`
608
+ return 'no hay ninguna variable que cargar'
609
+ }
610
+
611
+ /** El problema de una variable, en español, o `null`. Las reglas son las del cajón. */
612
+ function problemWith (key, value) {
613
+ try { assertVar(key, value); return null } catch (e) {
614
+ if (/invalid key/.test(e.message)) return 'el nombre va en MAYÚSCULAS_CON_GUION_BAJO (p. ej. TURN_KEY_ID)'
615
+ if (/non-empty/.test(e.message)) return 'no tiene valor (para quitarla: secret rm)'
616
+ if (/too long/.test(e.message)) return 'el valor es demasiado largo'
617
+ return e.message
618
+ }
619
+ }
620
+
505
621
  async function cmdSecret (rest) {
506
- const [sub, ns, key, ...valueParts] = rest
622
+ // --public / --private: si el VALOR puede salir de esta máquina hacia la consola remota.
623
+ // Se sacan de la línea antes de partirla, para que puedan ir en cualquier posición y no
624
+ // se cuelen dentro del valor (que es lo último y puede llevar espacios).
625
+ let isPublic
626
+ const rest2 = rest.filter((a) => {
627
+ if (a === '--public') { isPublic = true; return false }
628
+ if (a === '--private') { isPublic = false; return false }
629
+ return true
630
+ })
631
+ const [sub, ...args] = rest2
507
632
  const s = requireDaemon()
633
+
634
+ /**
635
+ * La contraseña del perfil, y SOLO si el perfil la tiene.
636
+ *
637
+ * Desde v5 la piden únicamente las operaciones que **LEEN** un valor: verlo, cambiarle
638
+ * la visibilidad (para enseñarlo hay que sacarlo), convertir el archivo y saldar lo que
639
+ * quedó a deber. **Guardar no la pide**: sellar solo necesita las públicas de quien va
640
+ * a leer (`docs/secretos-sellados.md` §8.1). Borrar tampoco, porque tirar un sobre no
641
+ * obliga a abrirlo.
642
+ */
643
+ let cachedPwd
644
+ const adminPassword = async () => {
645
+ if (cachedPwd !== undefined) return cachedPwd
646
+ const d = await profileRequest('list')
647
+ const list = Array.isArray(d.profiles) ? d.profiles : []
648
+ const p = PROFILE
649
+ ? list.find((x) => x.id === PROFILE || x.name === PROFILE)
650
+ : (list.find((x) => x.current) || list[0])
651
+ if (!p?.protected) { cachedPwd = null; return null }
652
+ // Nunca por argumento: quedaría en `ps` y en el historial de la shell. Si no hay
653
+ // terminal se dice qué falta, en vez de fallar con «el daemon no aplicó el cambio».
654
+ let pwd
655
+ try { pwd = await askPassword('Contraseña del perfil: ') } catch (_) {
656
+ console.error('Este perfil tiene contraseña y hace falta para guardar una variable.')
657
+ console.error('Ejecútalo desde un terminal (por ssh, con -t).')
658
+ process.exit(1)
659
+ }
660
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
661
+ cachedPwd = pwd
662
+ return pwd
663
+ }
508
664
  const secretsListFile = path.join(dir, 'secrets-list.json')
509
665
  const signalAndWaitList = async () => {
510
666
  try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
511
667
  writeReq('dump-request.json', {}) // de qué perfil son los secretos
512
- avisar(s.pid, 'SIGUSR2')
513
- for (let i = 0; i < 50; i++) { await sleep(100); const d = readJson(secretsListFile, null); if (d?.at) return d }
668
+ sendSignal(s.pid, 'SIGUSR2')
669
+ for (let i = 0; i < 50; i++) {
670
+ await sleep(100)
671
+ const d = readJson(secretsListFile, null)
672
+ // El volcado lleva el valor de las públicas: se borra en cuanto se tiene, para que no
673
+ // se quede esperando en el disco a que copien la carpeta (ver `daemon.js`).
674
+ if (d?.at) { try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {} ; return assertOpen(d) }
675
+ }
514
676
  console.error('El daemon no respondió.'); process.exit(1)
515
677
  }
678
+ /**
679
+ * SELLA el almacén: pasa un `secrets.json` v3 (valores que esta bóveda puede leer) a
680
+ * v4 (cada privada cifrada a la llave de su destinatario). Corre UNA vez.
681
+ *
682
+ * Es una operación con nombre propio y no un efecto de desbloquear, porque es el
683
+ * punto de no retorno: a partir de aquí la contraseña es lo ÚNICO que puede
684
+ * re-sellar, y perderla impide rotar una variable o sumar un aparato al namespace.
685
+ * Deja `secrets.json.v3.bak` al lado para poder volver.
686
+ */
687
+ async function secretMigrate () {
688
+ console.log('Esto SELLA las variables privadas a la llave de cada aparato.')
689
+ console.log('A partir de aquí, esta contraseña es lo único que puede volver a sellarlas:')
690
+ console.log('si la pierdes no podrás rotar una variable ni sumar un aparato al namespace.')
691
+ console.log('Se deja una copia en secrets.json.v3.bak por si hay que volver.\n')
692
+ const pwd = await askPassword('Contraseña del perfil: ')
693
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
694
+ writeReq('secret-request.json', { op: 'migrate', password: pwd })
695
+ console.log('\nPedido enviado. Mira el resultado con: journalctl --user -u dotrino-vault -n 20')
696
+ }
697
+
698
+ const USAGE = [
699
+ 'uso: dotrino-vault secret set <ns> <CLAVE> <valor> [--public|--private]',
700
+ ' (la comparten todos los aparatos del ns)',
701
+ ' dotrino-vault secret set <ns> CLAVE=valor [CLAVE2=valor2 …] varias DE UNA VEZ',
702
+ ' dotrino-vault secret import <ns> [archivo.env] desde un .env (o stdin)',
703
+ ' dotrino-vault secret rm <ns> <CLAVE>',
704
+ ' dotrino-vault secret device set <ID> <CLAVE> <valor> [--public|--private]',
705
+ ' (solo la lee ese aparato, y pisa a la del ns)',
706
+ ' dotrino-vault secret device set <ID> CLAVE=valor [CLAVE2=valor2 …]',
707
+ ' dotrino-vault secret device import <ID> [archivo.env]',
708
+ ' dotrino-vault secret device rm <ID> <CLAVE>',
709
+ ' dotrino-vault secret list',
710
+ ' dotrino-vault secret migrate SELLA los secretos (una vez)',
711
+ '',
712
+ 'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
713
+ 'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
714
+ 'cambio de configuración y el servicio se reinicia a media carga.',
715
+ '',
716
+ 'Pública o privada dice UNA cosa: si el VALOR puede salir de esta máquina hacia la',
717
+ 'consola remota (vault.dotrino.com). Se nace privada. El servicio recibe las dos igual.'
718
+ ].join('\n')
719
+
720
+ /**
721
+ * Manda un grupo entero al daemon y comprueba que llegó completo.
722
+ *
723
+ * Se valida TODO aquí antes de escribir nada: si una variable del archivo está mal, no
724
+ * se carga ninguna. Media configuración aplicada es peor que ninguna, porque el
725
+ * servicio arranca con ella y parece que funcionó.
726
+ */
727
+ const sendBatch = async ({ ns = null, pub = null, items, where }) => {
728
+ const bad = items.map((it) => [it.key, problemWith(it.key, it.value)]).filter(([, p]) => p)
729
+ if (bad.length) {
730
+ console.error('%sNo se cargó nada%s. Revisa:', R, Z)
731
+ for (const [key, p] of bad) console.error(' · %s: %s', key, p)
732
+ process.exit(2)
733
+ }
734
+ const withVisibility = items.map((it) => (isPublic === undefined ? it : { ...it, public: isPublic }))
735
+ // Guardar NO pide la contraseña (§8.1).
736
+ const base = pub ? { op: 'batch', pub, items: withVisibility } : { op: 'batch', ns, items: withVisibility }
737
+ writeReq('secret-request.json', base)
738
+ const d = await signalAndWaitList()
739
+ const list = pub
740
+ ? ((Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === pub)?.keys || [])
741
+ : (d.ns?.[ns] || [])
742
+ const missing = items.filter((it) => !has(list, it.key)).map((it) => it.key)
743
+ if (missing.length) {
744
+ if (d?.secretError) return noSeAplico(d)
745
+ console.error('El daemon no guardó: %s (revisa: dotrino-vault logs)', missing.join(', '))
746
+ process.exit(1)
747
+ }
748
+ console.log('%d variables guardadas en %s%s%s%s', items.length, B, where, Z,
749
+ isPublic === undefined ? '' : isPublic ? ' (públicas)' : ' (privadas)')
750
+ console.log('Un solo aviso de cambio: el servicio se reinicia una vez, con todo puesto.')
751
+ }
752
+
753
+ /** El texto del `.env`: de un archivo, o de la entrada estándar si no se da ninguno. */
754
+ const readEnvText = (file) => {
755
+ if (file) {
756
+ try { return fs.readFileSync(file, 'utf8') } catch (e) {
757
+ console.error('No se pudo leer %s: %s', file, e.message); process.exit(1)
758
+ }
759
+ }
760
+ if (process.stdin.isTTY) {
761
+ console.error('%s\n\nimport necesita un archivo, o el .env por la entrada estándar:', USAGE)
762
+ console.error(' dotrino-vault secret import proxy .env')
763
+ console.error(' cat .env | dotrino-vault secret import proxy')
764
+ process.exit(2)
765
+ }
766
+ return fs.readFileSync(0, 'utf8')
767
+ }
768
+
769
+ /**
770
+ * Una variable en la lista: su nombre y su valor. La PÚBLICA enseña el suyo (pública
771
+ * quiere decir que ese valor puede salir de esta máquina: taparlo aquí, delante de su
772
+ * dueño, era lo único que la marca no significaba). La privada no se muestra.
773
+ */
774
+ const printVar = (k) => console.log(' · %s %s', k.key, k.public ? `${k.value ?? ''} (pública)` : '••••••')
775
+ /**
776
+ * Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
777
+ * único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
778
+ * escribir mal la contraseña.
779
+ */
780
+ const noSeAplico = (d) => {
781
+ if (d?.secretError?.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta.')
782
+ else if (d?.secretError?.error) console.error('No se aplicó: %s', d.secretError.error)
783
+ else console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)')
784
+ process.exit(1)
785
+ }
786
+ const has = (list, key) => (list || []).some((x) => x.key === key)
787
+ /**
788
+ * Los cajones que quedaron a deber un sellado. Sale ARRIBA del todo en rojo porque no
789
+ * es un detalle: mientras esté ahí, los aparatos de ese cajón NO están leyendo sus
790
+ * variables, y la bóveda no puede arreglarlo sola — necesita la contraseña.
791
+ */
792
+ /**
793
+ * Un perfil SIN contraseña abre sus variables privadas con la llave de esta máquina,
794
+ * cuyo material vive en este mismo disco: una copia del disco las abre. Es un default
795
+ * deliberado —tiene que seguir funcionando— pero NO es lo mismo, y callarlo es
796
+ * exactamente el error del comentario mentiroso de `atrest.js`.
797
+ */
798
+ const printNoPassword = async () => {
799
+ const d = await profileRequest('list')
800
+ const list = Array.isArray(d.profiles) ? d.profiles : []
801
+ const p = PROFILE ? list.find((x) => x.id === PROFILE || x.name === PROFILE) : (list.find((x) => x.current) || list[0])
802
+ if (!p || p.protected) return
803
+ console.log('%sEste perfil no tiene contraseña%s: las privadas se abren con la llave de ESTA', B, Z)
804
+ console.log('máquina, cuyo material vive en este mismo disco — o sea que una copia del disco')
805
+ console.log('las abre. Ponle una con: dotrino-vault profile password\n')
806
+ }
807
+ const printPending = (d) => {
808
+ const pend = Object.entries(d?.pending || {})
809
+ if (!pend.length) return
810
+ console.error('%sHay %d cajón(es) sin sellar%s:', R, pend.length, Z)
811
+ for (const [owner, info] of pend) {
812
+ console.error(' · %s — %s', owner, info?.kind === 'rotate'
813
+ ? 'se fue un miembro y no se pudo rotar su llave'
814
+ : 'entró un aparato y no se le pudo entregar la llave')
815
+ }
816
+ console.error('Sus aparatos NO leen sus variables. Se arregla guardando una variable')
817
+ console.error('de ese cajón con la contraseña: dotrino-vault secret set <ns> <CLAVE> <valor>\n')
818
+ }
819
+
820
+ if (sub === 'migrate') return secretMigrate()
821
+
822
+ // --- VER un valor, su histórico y volver atrás ---------------------------------
823
+ // Las tres van juntas porque son la misma idea: ver es lo único que la contraseña
824
+ // guarda, y revertir es ver + volver a guardar (§8.3/§8.4).
825
+ const ownerDe = async (kind, ref) => {
826
+ if (kind === 'ns') return `ns:${ref}`
827
+ const m = await findMember(ref)
828
+ return `dev:${m.pub}`
829
+ }
830
+
831
+ if (sub === 'show' || sub === 'history' || sub === 'revert') {
832
+ // `secret show device <ID> <CLAVE>` mira el cajón de un aparato.
833
+ const esDev = args[0] === 'device'
834
+ const [ref, key, extra] = esDev ? args.slice(1) : args
835
+ if (!ref || (sub !== 'history' && !key)) {
836
+ console.error('uso: dotrino-vault secret %s [device] <ns|ID> <CLAVE>%s', sub, sub === 'revert' ? ' <marca>' : '')
837
+ process.exit(2)
838
+ }
839
+ const owner = await ownerDe(esDev ? 'dev' : 'ns', ref)
840
+
841
+ if (sub === 'history') {
842
+ writeReq('secret-request.json', { op: 'history', owner, key: key || null })
843
+ const d = await signalAndWaitList()
844
+ const items = d.history?.items || []
845
+ if (!items.length) { console.log('No hay versiones anteriores de %s%s.', owner, key ? `/${key}` : ''); return }
846
+ console.log('\n%sVersiones anteriores%s (la de arriba es la más reciente)\n', B, Z)
847
+ for (const h of items) {
848
+ // Sin `by` no es que falte un dato: es que se escribió AQUÍ, desde esta máquina.
849
+ // Lo que llega por la consola remota sí trae de qué aparato vino.
850
+ console.log(' %s %s%s%s %s%s', new Date(h.ts).toISOString(), B, h.key, Z,
851
+ h.by ? `desde ${h.by.slice(0, 12)}…` : 'desde esta máquina', h.signed ? '' : ' (sobre sin firma)')
852
+ }
853
+ console.log('\nPara volver a una: dotrino-vault secret revert %s%s <CLAVE> <marca>\n',
854
+ esDev ? 'device ' : '', ref)
855
+ return
856
+ }
857
+
858
+ if (sub === 'revert') {
859
+ if (!extra) { console.error('uso: dotrino-vault secret revert [device] <ns|ID> <CLAVE> <marca>'); process.exit(2) }
860
+ const ts = Number.isFinite(Number(extra)) ? Number(extra) : Date.parse(extra)
861
+ if (!Number.isFinite(ts)) { console.error('La marca es la que enseña `secret history` (fecha ISO).'); process.exit(2) }
862
+ const password = await adminPassword()
863
+ writeReq('secret-request.json', { op: 'revert', owner, key, ts, ...(password ? { password } : {}) })
864
+ const d = await signalAndWaitList()
865
+ if (d?.secretError) return noSeAplico(d)
866
+ console.log('Restaurada: %s/%s', owner, key)
867
+ return
868
+ }
869
+
870
+ // `show`: el valor. Es lo único que la contraseña guarda en esta máquina.
871
+ const password = await adminPassword()
872
+ writeReq('secret-request.json', { op: 'reveal', owner, key, ...(password ? { password } : {}) })
873
+ const d = await signalAndWaitList()
874
+ if (d?.secretError) return noSeAplico(d)
875
+ if (d.revealed?.owner !== owner || d.revealed?.key !== key) {
876
+ console.error('El daemon no devolvió el valor (revisa: dotrino-vault logs)')
877
+ process.exit(1)
878
+ }
879
+ if (d.revealed.value == null) { console.error('No existe esa variable.'); process.exit(1) }
880
+ console.log(d.revealed.value)
881
+ return
882
+ }
883
+
884
+ // Saldar lo que quedó a deber: heredarle a un aparato lo ya guardado, rotar de verdad.
885
+ if (sub === 'settle') {
886
+ const password = await adminPassword()
887
+ writeReq('secret-request.json', { op: 'settle', ...(password ? { password } : {}) })
888
+ const d = await signalAndWaitList()
889
+ if (d?.secretError) return noSeAplico(d)
890
+ const pend = Object.keys(d.pending || {})
891
+ if (!pend.length) console.log('Nada pendiente.')
892
+ else console.log('Siguen pendientes: %s', pend.join(', '))
893
+ return
894
+ }
895
+
516
896
  if (sub === 'list') {
517
897
  const d = await signalAndWaitList()
518
898
  const names = d.ns || {}
519
899
  const nss = Object.keys(names)
520
- if (!nss.length) { console.log('No hay secretos guardados. Agrega uno: dotrino-vault secret set <ns> <CLAVE> <valor>'); return }
900
+ const dev = Array.isArray(d.dev) ? d.dev : []
901
+ if (!nss.length && !dev.length) {
902
+ console.log('No hay variables guardadas. Agrega una: dotrino-vault secret set <ns> <CLAVE> <valor>')
903
+ return
904
+ }
905
+ if (nss.length) console.log('\n%sPor scope%s (las comparten todos los aparatos del perfil)\n', B, Z)
521
906
  for (const n of nss) {
522
907
  console.log('%s%s%s (scope vault:secrets:%s)', B, n, Z, n)
523
- for (const k of names[n]) console.log(' · %s', k)
908
+ for (const k of names[n]) printVar(k)
909
+ }
910
+ if (dev.length) console.log('\n%sPor aparato%s (solo las lee ese aparato; pisan a las del scope)\n', B, Z)
911
+ for (const x of dev) {
912
+ const who = [x.label, x.cn ? `servicio «${x.cn}»` : null, x.orphan ? 'YA NO ESTÁ EN EL ACTA' : null].filter(Boolean).join(' · ')
913
+ console.log('%s%s%s%s', B, x.id, Z, who ? ' ' + who : '')
914
+ for (const k of x.keys) printVar(k)
915
+ }
916
+ console.log('\n(pública) = su valor se puede ver desde la consola remota. Las demás no salen de aquí.\n')
917
+ printPending(d)
918
+ await printNoPassword()
919
+ return
920
+ }
921
+
922
+ // Por APARATO: `secret device set|rm|visibility <ID> <CLAVE> [valor|public|private]`.
923
+ if (sub === 'device') {
924
+ const [op, id, key, ...valueParts] = args
925
+ const value = valueParts.join(' ')
926
+ const ops = ['set', 'rm', 'visibility', 'import']
927
+ const asGroup = op === 'import' || (op === 'set' && !!asPairs(args.slice(2)))
928
+ if (!ops.includes(op) || !id || (!asGroup && (!key || (op === 'set' && !value)))) { console.error(USAGE); process.exit(2) }
929
+ const m = await findMember(id)
930
+ // Se avisa aquí, con nombre y apellido, en vez de dejar que el daemon lo rechace y la
931
+ // CLI diga «no aplicó el cambio»: quien escribe esto quiere saber POR QUÉ no vale.
932
+ if (!m.cn) {
933
+ console.error('%s no es un servicio, y solo los servicios leen variables.', m.id)
934
+ console.error('Empareja el servicio con: dotrino-vault pair --service <ns>')
935
+ process.exit(1)
524
936
  }
937
+ if (op === 'import') {
938
+ const { items, errors } = parseEnvText(readEnvText(args[2]))
939
+ if (errors.length) return abortEnv(errors)
940
+ return sendBatch({ pub: m.pub, items, where: m.id })
941
+ }
942
+ if (asGroup) return sendBatch({ pub: m.pub, items: asPairs(args.slice(2)), where: m.id })
943
+ if (key && PAIR_RE.test(key) && args.length > 3) {
944
+ console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable.', USAGE)
945
+ process.exit(2)
946
+ }
947
+ const req = op === 'set'
948
+ ? { op: 'dev-set', pub: m.pub, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
949
+ : op === 'rm'
950
+ ? { op: 'dev-rm', pub: m.pub, key }
951
+ : { op: 'dev-vis', pub: m.pub, key, public: wantsPublic(value, USAGE) }
952
+ // Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
953
+ const password = op === 'vis' ? await adminPassword() : null
954
+ writeReq('secret-request.json', password ? { ...req, password } : req)
955
+ const d = await signalAndWaitList()
956
+ const keys = (Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === m.pub)?.keys || []
957
+ const ok = op === 'rm' ? !has(keys, key) : has(keys, key)
958
+ if (!ok) return noSeAplico(d)
959
+ if (op === 'rm') console.log('Variable borrada: %s/%s', m.id, key)
960
+ else console.log('Variable guardada: %s/%s%s', m.id, key, (keys.find((x) => x.key === key)?.public) ? ' (pública)' : '')
525
961
  return
526
962
  }
527
- if (sub === 'set' || sub === 'rm') {
963
+
964
+ // Desde un `.env`: el caso real de estrenar un servicio, y el que de una en una
965
+ // reiniciaba al agente una vez por variable.
966
+ if (sub === 'import') {
967
+ const [ns, file] = args
968
+ if (!ns) { console.error(USAGE); process.exit(2) }
969
+ const { items, errors } = parseEnvText(readEnvText(file))
970
+ if (errors.length) return abortEnv(errors)
971
+ return sendBatch({ ns, items, where: ns })
972
+ }
973
+
974
+ if (sub === 'set' || sub === 'rm' || sub === 'visibility') {
975
+ const [ns, key, ...valueParts] = args
528
976
  const value = valueParts.join(' ')
529
- if (!ns || !key || (sub === 'set' && !value)) {
530
- console.error('uso: dotrino-vault secret set <ns> <CLAVE> <valor>\n dotrino-vault secret rm <ns> <CLAVE>'); process.exit(2)
977
+ // `set <ns> CLAVE=valor CLAVE2=valor2` varias de una vez, un solo aviso.
978
+ const pairs = sub === 'set' ? asPairs(args.slice(1)) : null
979
+ if (ns && pairs) return sendBatch({ ns, items: pairs, where: ns })
980
+ // Mezclar las dos formas (`K1=v1 CLAVE valor`) no es ninguna de las dos: mejor
981
+ // decirlo que guardar una variable llamada «K1=v1».
982
+ if (sub === 'set' && key && PAIR_RE.test(key) && args.length > 2) {
983
+ console.error('%s\n\nO todos los argumentos son CLAVE=valor, o es una sola variable: set <ns> <CLAVE> <valor>.', USAGE)
984
+ process.exit(2)
531
985
  }
532
- writeReq('secret-request.json', sub === 'set' ? { op: 'set', ns, key, value } : { op: 'rm', ns, key })
986
+ if (!ns || !key || (sub === 'set' && !value)) { console.error(USAGE); process.exit(2) }
987
+ const req = sub === 'set'
988
+ ? { op: 'set', ns, key, value, ...(isPublic === undefined ? {} : { public: isPublic }) }
989
+ : sub === 'rm'
990
+ ? { op: 'rm', ns, key }
991
+ : { op: 'vis', ns, key, public: wantsPublic(value, USAGE) }
992
+ // Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
993
+ const password = sub === 'visibility' ? await adminPassword() : null
994
+ writeReq('secret-request.json', password ? { ...req, password } : req)
533
995
  const d = await signalAndWaitList()
534
- const ok = sub === 'set' ? (d.ns?.[ns] || []).includes(key) : !(d.ns?.[ns] || []).includes(key)
535
- if (ok) console.log(sub === 'set' ? 'Secreto guardado: %s/%s' : 'Secreto borrado: %s/%s', ns, key)
536
- else { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
996
+ const list = d.ns?.[ns] || []
997
+ const ok = sub === 'rm' ? !has(list, key) : has(list, key)
998
+ if (!ok) return noSeAplico(d)
999
+ if (sub === 'rm') console.log('Secreto borrado: %s/%s', ns, key)
1000
+ else console.log('Secreto guardado: %s/%s%s', ns, key, (list.find((x) => x.key === key)?.public) ? ' (pública)' : '')
537
1001
  return
538
1002
  }
539
- console.error('uso: dotrino-vault secret {set|rm|list}'); process.exit(2)
1003
+ console.error(USAGE); process.exit(2)
1004
+ }
1005
+
1006
+ /** `visibility … public|private` — el único argumento que acepta, y sin adivinar. */
1007
+ function wantsPublic (word, usage) {
1008
+ if (word === 'public') return true
1009
+ if (word === 'private') return false
1010
+ console.error('%s\n\nvisibility acepta «public» o «private».', usage); process.exit(2)
540
1011
  }
541
1012
 
542
1013
  /**
@@ -545,20 +1016,31 @@ async function cmdSecret (rest) {
545
1016
  */
546
1017
  function askPassword (prompt) {
547
1018
  return new Promise((resolve, reject) => {
548
- const stdin = process.stdin
549
- if (!stdin.isTTY) return reject(new Error('hace falta un terminal para escribir la contraseña'))
1019
+ // Se lee del TERMINAL, no de la entrada estándar. `secret import` ya usa stdin para
1020
+ // el `.env` (`cat .env | dotrino-vault secret import proxy`) y aun así hay que poder
1021
+ // escribir la contraseña; lo mismo vale para cualquier tubería.
1022
+ let stdin = process.stdin
1023
+ let own = null
1024
+ if (!stdin.isTTY) {
1025
+ try {
1026
+ own = fs.openSync('/dev/tty', 'r')
1027
+ stdin = new tty.ReadStream(own)
1028
+ } catch (_) { return reject(new Error('a terminal is required to type the password')) }
1029
+ }
550
1030
  process.stdout.write(prompt)
551
1031
  stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
552
1032
  let buf = ''
553
1033
  const done = (err, val) => {
554
- stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData)
1034
+ try { stdin.setRawMode(false) } catch (_) {}
1035
+ stdin.removeListener('data', onData)
1036
+ if (own !== null) { try { stdin.destroy() } catch (_) {} } else stdin.pause()
555
1037
  process.stdout.write('\n')
556
1038
  err ? reject(err) : resolve(val)
557
1039
  }
558
1040
  const onData = (ch) => {
559
1041
  for (const c of ch) {
560
1042
  if (c === '\n' || c === '\r' || c === '\u0004') return done(null, buf) // Enter / Ctrl-D
561
- if (c === '\u0003') return done(new Error('cancelado')) // Ctrl-C
1043
+ if (c === '\u0003') return done(new Error('cancelled')) // Ctrl-C
562
1044
  if (c === '\u007f' || c === '\b') { buf = buf.slice(0, -1); continue } // borrar
563
1045
  buf += c
564
1046
  }
@@ -576,7 +1058,7 @@ async function profileRequest (op, extra = {}) {
576
1058
  const s = requireDaemon()
577
1059
  try { fs.rmSync(profilesFile, { force: true }) } catch (_) {}
578
1060
  writeReq('profile-request.json', { op, ...extra })
579
- avisar(s.pid, 'SIGUSR2')
1061
+ sendSignal(s.pid, 'SIGUSR2')
580
1062
  for (let i = 0; i < 100; i++) {
581
1063
  await sleep(100)
582
1064
  const d = readJson(profilesFile, null)
@@ -586,7 +1068,14 @@ async function profileRequest (op, extra = {}) {
586
1068
  }
587
1069
 
588
1070
  function reportProfiles (d) {
589
- if (d.error) { console.error('%s', d.error); process.exit(1) }
1071
+ if (d.error) {
1072
+ // Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
1073
+ // resto se reenvía tal cual (son diagnósticos del servicio).
1074
+ if (d.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta%s.', d.tries ? ` — van ${d.tries} intentos fallidos` : '')
1075
+ else if (d.code === 'TOO_MANY_TRIES') console.error('Demasiados intentos: espera %s s antes de volver a probar.', d.waitSec || '?')
1076
+ else console.error('%s', d.error)
1077
+ process.exit(1)
1078
+ }
590
1079
  if (d.done) console.log('%s', d.done)
591
1080
  return d
592
1081
  }
@@ -635,14 +1124,37 @@ async function cmdProfile (rest) {
635
1124
  }
636
1125
  case 'password': {
637
1126
  const action = args[0]
638
- if (action === 'rm') { reportProfiles(await profileRequest('password-rm')); return }
1127
+ // QUITARLA pide la actual, y no por trámite: con ella se abre la copia maestra de
1128
+ // los secretos para volver a cerrarla con la llave de esta máquina. Sin ese paso,
1129
+ // quitar la contraseña dejaría las variables privadas ilegibles para siempre.
1130
+ if (action === 'rm') {
1131
+ console.log('Al quitarla, las variables privadas pasan a abrirse con la llave de ESTA')
1132
+ console.log('máquina: siguen cifradas en el disco, pero su material vive en ese mismo')
1133
+ console.log('disco, así que una copia del disco las abre. Los aparatos no se enteran.')
1134
+ const cur = await askPassword('\nContraseña actual: ')
1135
+ if (!cur) { console.error('Cancelado.'); process.exit(1) }
1136
+ reportProfiles(await profileRequest('password-rm', { password: cur }))
1137
+ return
1138
+ }
639
1139
  if (action && action !== 'set') { console.error('uso: dotrino-vault profile password [set|rm]'); process.exit(2) }
640
- console.log('La contraseña solo se pide para EDITAR el perfil. Tus dispositivos siguen')
641
- console.log('funcionando (firmando y leyendo) aunque el perfil esté bloqueado.')
642
- const pwd = await askPassword('\nContraseña nueva (mínimo 4): ')
1140
+ console.log('La contraseña se pide para EDITAR el perfil y para escribir variables')
1141
+ console.log('privadas. Tus dispositivos siguen funcionando (firmando, leyendo y')
1142
+ console.log('recibiendo su configuración) aunque el perfil esté bloqueado.')
1143
+ // Si ya hay una, hace falta para poder abrir la copia maestra y re-sellarla. El
1144
+ // camino recomendado para CAMBIARLA es quitarla y ponerla: así cada paso pide
1145
+ // solo lo que necesita.
1146
+ const actual = await askPassword('\nContraseña actual (vacío si no tiene): ')
1147
+ // Se pide una FRASE, y se dice cómo sacarla, porque lo que da fuerza es la
1148
+ // longitud y que no la elija un humano: un modismo tiene la entropía de un
1149
+ // modismo, no la de su longitud. Desde el sellado, esta frase es lo único que
1150
+ // separa una copia del disco de las variables privadas.
1151
+ console.log('\nUsa VARIAS PALABRAS AL AZAR (mínimo 12 caracteres). Para que las elija')
1152
+ console.log('la máquina y no tú:')
1153
+ console.log(" grep -x '[a-z]\\{4,8\\}' /usr/share/dict/words | shuf -n5 | paste -sd-")
1154
+ const pwd = await askPassword('\nContraseña nueva: ')
643
1155
  const again = await askPassword('Repítela: ')
644
1156
  if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
645
- reportProfiles(await profileRequest('password-set', { password: pwd }))
1157
+ reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
646
1158
  return
647
1159
  }
648
1160
  default:
@@ -691,9 +1203,38 @@ function help () {
691
1203
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
692
1204
  (sin la bandera entra a la cuenta activa, o a la de --profile)
693
1205
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
694
- secret set <ns> <CLAVE> <valor> guarda un secreto para el servicio <ns>
695
- secret rm <ns> <CLAVE> borra un secreto
696
- secret list lista nombres de secretos (nunca valores)
1206
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
1207
+ se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1208
+ secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1209
+ aparatos del perfil que sirven ese namespace
1210
+ secret set <ns> CLAVE=valor CLAVE2=valor2 …
1211
+ carga VARIAS de una vez: se aplican juntas y el
1212
+ servicio recibe UN solo aviso (se reinicia una vez)
1213
+ secret import <ns> [archivo.env] lo mismo desde un .env (o por la entrada estándar)
1214
+ secret rm <ns> <CLAVE> borra una variable del scope
1215
+ secret device set <ID> <CLAVE> <valor>
1216
+ variable de UN aparato: solo la lee él, y pisa a la
1217
+ del scope que se llame igual (puerto, URL pública…)
1218
+ secret device set <ID> CLAVE=valor …
1219
+ secret device import <ID> [archivo.env]
1220
+ secret device rm <ID> <CLAVE> borra una variable de ese aparato
1221
+ secret list lista los dos cajones: el valor de las públicas,
1222
+ tapadas las privadas
1223
+ secret show [device] <ns|ID> <CLAVE>
1224
+ VE el valor de una privada. Es lo único que pide la
1225
+ contraseña del perfil: en esta máquina es lo único
1226
+ que la separa de una copia del disco
1227
+ secret history [device] <ns|ID> [CLAVE]
1228
+ las versiones anteriores (quién y cuándo)
1229
+ secret revert [device] <ns|ID> <CLAVE> <marca>
1230
+ vuelve a una versión anterior
1231
+ secret settle salda lo pendiente: hereda lo ya guardado a un
1232
+ aparato nuevo y rota de verdad el cajón del que se fue
1233
+ --public | --private (al hacer un set) si el VALOR puede salir de esta
1234
+ máquina hacia la consola remota. Se nace privada,
1235
+ y una privada NO se vuelve pública (bórrala y créala).
1236
+ secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
1237
+ secret device visibility <ID> <CLAVE> private
697
1238
  pending muestra el dispositivo pendiente + su código a comparar
698
1239
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
699
1240
  reject <deviceId> rechaza un dispositivo pendiente