@dotrino/vaultd 0.38.0 → 0.49.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
@@ -23,9 +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'
28
29
  import { assertVar } from './secretsStore.js'
30
+ import { isValidSecretsNs } from './protocol.js'
29
31
  import { parseEnvText, PAIR_RE } from '../lib/src/envtext.js'
30
32
  import { qrToString } from './qr.js'
31
33
  import { encodeInvite, inviteUrl } from '../lib/src/invite.js'
@@ -126,6 +128,7 @@ function cmdStatus () {
126
128
  }
127
129
  console.log(' fingerprint : %s', s.fingerprint)
128
130
  console.log(' proxy : %s', s.proxy)
131
+ if (s.sshAgent) console.log(' ssh-agent : %s (dotrino-vault ssh)', s.sshAgent)
129
132
  console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
130
133
  console.log(' datos : %s', dir)
131
134
  const profiles = s.profiles || []
@@ -179,6 +182,31 @@ async function cmdPair (args = []) {
179
182
  console.error('uso: dotrino-vault pair --service <ns> (ns en minúsculas, p.ej. proxy)'); process.exit(2)
180
183
  }
181
184
  }
185
+ // --scope <lista>: los PERMISOS del cert, y nada más. No hay tipos de aparato
186
+ // (2026-08-22, dueño): un aparato es un aparato y lo que puede hacer. Sin --scope,
187
+ // el juego de siempre (sign,read,store); `--service <ns>` es el atajo de
188
+ // `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
189
+ // firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
190
+ // concede desde el PC (`caps <ID> +administra`).
191
+ const scIdx = args.indexOf('--scope')
192
+ let scope = null
193
+ if (scIdx >= 0) {
194
+ const raw = args[scIdx + 1]
195
+ if (!raw || raw.startsWith('-')) { console.error('uso: dotrino-vault pair --scope sign,read,store,secrets:<ns>'); process.exit(2) }
196
+ const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store' }
197
+ scope = []
198
+ for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
199
+ const t = ALIAS[tok] || tok
200
+ 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) }
201
+ 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) }
202
+ if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
203
+ const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
204
+ if (m) { scope.push('vault:secrets:' + m[1]); continue }
205
+ console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
206
+ }
207
+ if (service) scope.push('vault:secrets:' + service)
208
+ scope = [...new Set(scope)]
209
+ }
182
210
  // `--new-account [nombre]`: la otra respuesta a «¿a qué cuenta entra?». En vez de
183
211
  // meter el dispositivo en una cuenta que ya vive aquí, se ESTRENA una (vacía) y
184
212
  // entra a ella; las demás no se tocan. En la TUI esto es una pregunta con sus
@@ -210,7 +238,7 @@ async function cmdPair (args = []) {
210
238
  }
211
239
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
212
240
  // se empareja el dispositivo.
213
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
241
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
214
242
  sendSignal(s.pid, 'SIGUSR1')
215
243
 
216
244
  let pair = null
@@ -354,7 +382,7 @@ async function cmdMembers () {
354
382
  assertOpen(record)
355
383
  if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
356
384
 
357
- const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}` }
385
+ const CAP = { sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves', admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}` }
358
386
  // El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
359
387
  // pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
360
388
  // misma que se enseña al emparejar y en la lista de miembros.
@@ -369,8 +397,12 @@ async function cmdMembers () {
369
397
  ].filter(Boolean)
370
398
  const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
371
399
  console.log(' %s %s%s\n %s', m.id, who, marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
400
+ // Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
401
+ // selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
402
+ // se mira quién es quién — y en la lista de variables ya seria tarde.
403
+ if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
372
404
  }
373
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra')
405
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
374
406
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
375
407
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
376
408
  console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
@@ -411,16 +443,42 @@ async function findMember (id) {
411
443
  return m
412
444
  }
413
445
 
446
+ /**
447
+ * `dotrino-vault ssh` — cómo usar la llave SSH del teléfono desde este PC. La llave
448
+ * privada no está aquí: el daemon expone un agente SSH y cada firma la aprueba el
449
+ * teléfono (`caps <ID> +aprueba`). Imprime lo que hay que poner en el shell.
450
+ */
451
+ async function cmdSsh (args = []) {
452
+ const s = requireDaemon()
453
+ if (!s.sshAgent) { console.error('El daemon no tiene el agente SSH activo (DOTRINO_VAULT_SSH_AGENT=0, o Windows).'); process.exit(1) }
454
+ if (args[0] === 'keys' || args[0] === '-L') {
455
+ try { execFileSync('ssh-add', ['-L'], { env: { ...process.env, SSH_AUTH_SOCK: s.sshAgent }, stdio: 'inherit' }) } catch (_) { process.exit(1) }
456
+ return
457
+ }
458
+ console.log('# La llave SSH vive en tu teléfono; este PC solo le pide la firma. Pon en tu shell:')
459
+ console.log('export SSH_AUTH_SOCK=%s', s.sshAgent)
460
+ console.log('')
461
+ console.log('# Registra la llave desde el teléfono (vault.dotrino.com → Llave SSH) y pega su línea')
462
+ console.log('# pública en ~/.ssh/authorized_keys de cada servidor. Para no aprobar cada comando,')
463
+ console.log('# reusa la conexión 15 min en ~/.ssh/config:')
464
+ console.log('# Host *')
465
+ console.log('# ControlMaster auto')
466
+ console.log('# ControlPath ~/.ssh/cm-%r@%h:%p')
467
+ console.log('# ControlPersist 15m')
468
+ console.log('')
469
+ console.log('# Ver las llaves registradas: dotrino-vault ssh keys')
470
+ }
471
+
414
472
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
415
473
  async function cmdCaps (args = []) {
416
474
  const [id, ...changes] = args
417
475
  if (!id || !changes.length) {
418
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra')
476
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
419
477
  process.exit(2)
420
478
  }
421
479
  const CAP_BY_WORD = {
422
- firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin',
423
- sign: 'sign', store: 'store', read: 'read', admin: 'admin'
480
+ firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
481
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve'
424
482
  }
425
483
  const s = requireDaemon()
426
484
  const m = await findMember(id)
@@ -601,6 +659,37 @@ async function cmdSecret (rest) {
601
659
  })
602
660
  const [sub, ...args] = rest2
603
661
  const s = requireDaemon()
662
+
663
+ /**
664
+ * La contraseña del perfil, y SOLO si el perfil la tiene.
665
+ *
666
+ * Desde v5 la piden únicamente las operaciones que **LEEN** un valor: verlo, cambiarle
667
+ * la visibilidad (para enseñarlo hay que sacarlo), convertir el archivo y saldar lo que
668
+ * quedó a deber. **Guardar no la pide**: sellar solo necesita las públicas de quien va
669
+ * a leer (`docs/secretos-sellados.md` §8.1). Borrar tampoco, porque tirar un sobre no
670
+ * obliga a abrirlo.
671
+ */
672
+ let cachedPwd
673
+ const adminPassword = async () => {
674
+ if (cachedPwd !== undefined) return cachedPwd
675
+ const d = await profileRequest('list')
676
+ const list = Array.isArray(d.profiles) ? d.profiles : []
677
+ const p = PROFILE
678
+ ? list.find((x) => x.id === PROFILE || x.name === PROFILE)
679
+ : (list.find((x) => x.current) || list[0])
680
+ if (!p?.protected) { cachedPwd = null; return null }
681
+ // Nunca por argumento: quedaría en `ps` y en el historial de la shell. Si no hay
682
+ // terminal se dice qué falta, en vez de fallar con «el daemon no aplicó el cambio».
683
+ let pwd
684
+ try { pwd = await askPassword('Contraseña del perfil: ') } catch (_) {
685
+ console.error('Este perfil tiene contraseña y hace falta para guardar una variable.')
686
+ console.error('Ejecútalo desde un terminal (por ssh, con -t).')
687
+ process.exit(1)
688
+ }
689
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
690
+ cachedPwd = pwd
691
+ return pwd
692
+ }
604
693
  const secretsListFile = path.join(dir, 'secrets-list.json')
605
694
  const signalAndWaitList = async () => {
606
695
  try { fs.rmSync(secretsListFile, { force: true }) } catch (_) {}
@@ -615,6 +704,26 @@ async function cmdSecret (rest) {
615
704
  }
616
705
  console.error('El daemon no respondió.'); process.exit(1)
617
706
  }
707
+ /**
708
+ * SELLA el almacén: pasa un `secrets.json` v3 (valores que esta bóveda puede leer) a
709
+ * v4 (cada privada cifrada a la llave de su destinatario). Corre UNA vez.
710
+ *
711
+ * Es una operación con nombre propio y no un efecto de desbloquear, porque es el
712
+ * punto de no retorno: a partir de aquí la contraseña es lo ÚNICO que puede
713
+ * re-sellar, y perderla impide rotar una variable o sumar un aparato al namespace.
714
+ * Deja `secrets.json.v3.bak` al lado para poder volver.
715
+ */
716
+ async function secretMigrate () {
717
+ console.log('Esto SELLA las variables privadas a la llave de cada aparato.')
718
+ console.log('A partir de aquí, esta contraseña es lo único que puede volver a sellarlas:')
719
+ console.log('si la pierdes no podrás rotar una variable ni sumar un aparato al namespace.')
720
+ console.log('Se deja una copia en secrets.json.v3.bak por si hay que volver.\n')
721
+ const pwd = await askPassword('Contraseña del perfil: ')
722
+ if (!pwd) { console.error('Cancelado.'); process.exit(1) }
723
+ writeReq('secret-request.json', { op: 'migrate', password: pwd })
724
+ console.log('\nPedido enviado. Mira el resultado con: journalctl --user -u dotrino-vault -n 20')
725
+ }
726
+
618
727
  const USAGE = [
619
728
  'uso: dotrino-vault secret set <ns> <CLAVE> <valor> [--public|--private]',
620
729
  ' (la comparten todos los aparatos del ns)',
@@ -627,6 +736,8 @@ async function cmdSecret (rest) {
627
736
  ' dotrino-vault secret device import <ID> [archivo.env]',
628
737
  ' dotrino-vault secret device rm <ID> <CLAVE>',
629
738
  ' dotrino-vault secret list',
739
+ ' dotrino-vault secret migrate SELLA los secretos (una vez)',
740
+ ' dotrino-vault secret policy <ns> approval on|off cada lectura espera tu aprobación (teléfono)',
630
741
  '',
631
742
  'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
632
743
  'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
@@ -651,13 +762,16 @@ async function cmdSecret (rest) {
651
762
  process.exit(2)
652
763
  }
653
764
  const withVisibility = items.map((it) => (isPublic === undefined ? it : { ...it, public: isPublic }))
654
- writeReq('secret-request.json', pub ? { op: 'batch', pub, items: withVisibility } : { op: 'batch', ns, items: withVisibility })
765
+ // Guardar NO pide la contraseña (§8.1).
766
+ const base = pub ? { op: 'batch', pub, items: withVisibility } : { op: 'batch', ns, items: withVisibility }
767
+ writeReq('secret-request.json', base)
655
768
  const d = await signalAndWaitList()
656
769
  const list = pub
657
770
  ? ((Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === pub)?.keys || [])
658
771
  : (d.ns?.[ns] || [])
659
772
  const missing = items.filter((it) => !has(list, it.key)).map((it) => it.key)
660
773
  if (missing.length) {
774
+ if (d?.secretError) return noSeAplico(d)
661
775
  console.error('El daemon no guardó: %s (revisa: dotrino-vault logs)', missing.join(', '))
662
776
  process.exit(1)
663
777
  }
@@ -688,7 +802,140 @@ async function cmdSecret (rest) {
688
802
  * dueño, era lo único que la marca no significaba). La privada no se muestra.
689
803
  */
690
804
  const printVar = (k) => console.log(' · %s %s', k.key, k.public ? `${k.value ?? ''} (pública)` : '••••••')
805
+ /**
806
+ * Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
807
+ * único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
808
+ * escribir mal la contraseña.
809
+ */
810
+ const noSeAplico = (d) => {
811
+ if (d?.secretError?.code === 'WRONG_PASSWORD') console.error('Contraseña incorrecta.')
812
+ else if (d?.secretError?.error) console.error('No se aplicó: %s', d.secretError.error)
813
+ else console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)')
814
+ process.exit(1)
815
+ }
691
816
  const has = (list, key) => (list || []).some((x) => x.key === key)
817
+ /**
818
+ * Los cajones que quedaron a deber un sellado. Sale ARRIBA del todo en rojo porque no
819
+ * es un detalle: mientras esté ahí, los aparatos de ese cajón NO están leyendo sus
820
+ * variables, y la bóveda no puede arreglarlo sola — necesita la contraseña.
821
+ */
822
+ /**
823
+ * Un perfil SIN contraseña abre sus variables privadas con la llave de esta máquina,
824
+ * cuyo material vive en este mismo disco: una copia del disco las abre. Es un default
825
+ * deliberado —tiene que seguir funcionando— pero NO es lo mismo, y callarlo es
826
+ * exactamente el error del comentario mentiroso de `atrest.js`.
827
+ */
828
+ const printNoPassword = async () => {
829
+ const d = await profileRequest('list')
830
+ const list = Array.isArray(d.profiles) ? d.profiles : []
831
+ const p = PROFILE ? list.find((x) => x.id === PROFILE || x.name === PROFILE) : (list.find((x) => x.current) || list[0])
832
+ if (!p || p.protected) return
833
+ console.log('%sEste perfil no tiene contraseña%s: las privadas se abren con la llave de ESTA', B, Z)
834
+ console.log('máquina, cuyo material vive en este mismo disco — o sea que una copia del disco')
835
+ console.log('las abre. Ponle una con: dotrino-vault profile password\n')
836
+ }
837
+ const printPending = (d) => {
838
+ const pend = Object.entries(d?.pending || {})
839
+ if (!pend.length) return
840
+ console.error('%sHay %d cajón(es) sin sellar%s:', R, pend.length, Z)
841
+ for (const [owner, info] of pend) {
842
+ console.error(' · %s — %s', owner, info?.kind === 'rotate'
843
+ ? 'se fue un miembro y no se pudo rotar su llave'
844
+ : 'entró un aparato y no se le pudo entregar la llave')
845
+ }
846
+ console.error('Sus aparatos NO leen sus variables. Se arregla guardando una variable')
847
+ console.error('de ese cajón con la contraseña: dotrino-vault secret set <ns> <CLAVE> <valor>\n')
848
+ }
849
+
850
+ if (sub === 'migrate') return secretMigrate()
851
+ // `secret policy <ns> approval on|off`: el cajón pasa a pedir el visto bueno de un
852
+ // aparato con `aprueba` (el teléfono) en cada lectura, con ventana de 15 min.
853
+ if (sub === 'policy') {
854
+ const [ns, what, val] = args
855
+ if (!isValidSecretsNs(ns || '') || what !== 'approval' || !['on', 'off'].includes(val)) {
856
+ console.error('uso: dotrino-vault secret policy <ns> approval on|off'); process.exit(2)
857
+ }
858
+ writeReq('secret-request.json', { op: 'policy', ns, approval: val === 'on' })
859
+ sendSignal(s.pid, 'SIGUSR2')
860
+ console.log(val === 'on'
861
+ ? `Listo: cada lectura de «${ns}» esperará la aprobación de un aparato con caps <ID> +aprueba (ventana de 15 min).`
862
+ : `Listo: «${ns}» vuelve a entregarse sin aprobación.`)
863
+ return
864
+ }
865
+
866
+ // --- VER un valor, su histórico y volver atrás ---------------------------------
867
+ // Las tres van juntas porque son la misma idea: ver es lo único que la contraseña
868
+ // guarda, y revertir es ver + volver a guardar (§8.3/§8.4).
869
+ const ownerDe = async (kind, ref) => {
870
+ if (kind === 'ns') return `ns:${ref}`
871
+ const m = await findMember(ref)
872
+ return `dev:${m.pub}`
873
+ }
874
+
875
+ if (sub === 'show' || sub === 'history' || sub === 'revert') {
876
+ // `secret show device <ID> <CLAVE>` mira el cajón de un aparato.
877
+ const esDev = args[0] === 'device'
878
+ const [ref, key, extra] = esDev ? args.slice(1) : args
879
+ if (!ref || (sub !== 'history' && !key)) {
880
+ console.error('uso: dotrino-vault secret %s [device] <ns|ID> <CLAVE>%s', sub, sub === 'revert' ? ' <marca>' : '')
881
+ process.exit(2)
882
+ }
883
+ const owner = await ownerDe(esDev ? 'dev' : 'ns', ref)
884
+
885
+ if (sub === 'history') {
886
+ writeReq('secret-request.json', { op: 'history', owner, key: key || null })
887
+ const d = await signalAndWaitList()
888
+ const items = d.history?.items || []
889
+ if (!items.length) { console.log('No hay versiones anteriores de %s%s.', owner, key ? `/${key}` : ''); return }
890
+ console.log('\n%sVersiones anteriores%s (la de arriba es la más reciente)\n', B, Z)
891
+ for (const h of items) {
892
+ // Sin `by` no es que falte un dato: es que se escribió AQUÍ, desde esta máquina.
893
+ // Lo que llega por la consola remota sí trae de qué aparato vino.
894
+ console.log(' %s %s%s%s %s%s', new Date(h.ts).toISOString(), B, h.key, Z,
895
+ h.by ? `desde ${h.by.slice(0, 12)}…` : 'desde esta máquina', h.signed ? '' : ' (sobre sin firma)')
896
+ }
897
+ console.log('\nPara volver a una: dotrino-vault secret revert %s%s <CLAVE> <marca>\n',
898
+ esDev ? 'device ' : '', ref)
899
+ return
900
+ }
901
+
902
+ if (sub === 'revert') {
903
+ if (!extra) { console.error('uso: dotrino-vault secret revert [device] <ns|ID> <CLAVE> <marca>'); process.exit(2) }
904
+ const ts = Number.isFinite(Number(extra)) ? Number(extra) : Date.parse(extra)
905
+ if (!Number.isFinite(ts)) { console.error('La marca es la que enseña `secret history` (fecha ISO).'); process.exit(2) }
906
+ const password = await adminPassword()
907
+ writeReq('secret-request.json', { op: 'revert', owner, key, ts, ...(password ? { password } : {}) })
908
+ const d = await signalAndWaitList()
909
+ if (d?.secretError) return noSeAplico(d)
910
+ console.log('Restaurada: %s/%s', owner, key)
911
+ return
912
+ }
913
+
914
+ // `show`: el valor. Es lo único que la contraseña guarda en esta máquina.
915
+ const password = await adminPassword()
916
+ writeReq('secret-request.json', { op: 'reveal', owner, key, ...(password ? { password } : {}) })
917
+ const d = await signalAndWaitList()
918
+ if (d?.secretError) return noSeAplico(d)
919
+ if (d.revealed?.owner !== owner || d.revealed?.key !== key) {
920
+ console.error('El daemon no devolvió el valor (revisa: dotrino-vault logs)')
921
+ process.exit(1)
922
+ }
923
+ if (d.revealed.value == null) { console.error('No existe esa variable.'); process.exit(1) }
924
+ console.log(d.revealed.value)
925
+ return
926
+ }
927
+
928
+ // Saldar lo que quedó a deber: heredarle a un aparato lo ya guardado, rotar de verdad.
929
+ if (sub === 'settle') {
930
+ const password = await adminPassword()
931
+ writeReq('secret-request.json', { op: 'settle', ...(password ? { password } : {}) })
932
+ const d = await signalAndWaitList()
933
+ if (d?.secretError) return noSeAplico(d)
934
+ const pend = Object.keys(d.pending || {})
935
+ if (!pend.length) console.log('Nada pendiente.')
936
+ else console.log('Siguen pendientes: %s', pend.join(', '))
937
+ return
938
+ }
692
939
 
693
940
  if (sub === 'list') {
694
941
  const d = await signalAndWaitList()
@@ -711,6 +958,8 @@ async function cmdSecret (rest) {
711
958
  for (const k of x.keys) printVar(k)
712
959
  }
713
960
  console.log('\n(pública) = su valor se puede ver desde la consola remota. Las demás no salen de aquí.\n')
961
+ printPending(d)
962
+ await printNoPassword()
714
963
  return
715
964
  }
716
965
 
@@ -744,11 +993,13 @@ async function cmdSecret (rest) {
744
993
  : op === 'rm'
745
994
  ? { op: 'dev-rm', pub: m.pub, key }
746
995
  : { op: 'dev-vis', pub: m.pub, key, public: wantsPublic(value, USAGE) }
747
- writeReq('secret-request.json', req)
996
+ // Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
997
+ const password = op === 'vis' ? await adminPassword() : null
998
+ writeReq('secret-request.json', password ? { ...req, password } : req)
748
999
  const d = await signalAndWaitList()
749
1000
  const keys = (Array.isArray(d.dev) ? d.dev : []).find((x) => x.pub === m.pub)?.keys || []
750
1001
  const ok = op === 'rm' ? !has(keys, key) : has(keys, key)
751
- if (!ok) { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
1002
+ if (!ok) return noSeAplico(d)
752
1003
  if (op === 'rm') console.log('Variable borrada: %s/%s', m.id, key)
753
1004
  else console.log('Variable guardada: %s/%s%s', m.id, key, (keys.find((x) => x.key === key)?.public) ? ' (pública)' : '')
754
1005
  return
@@ -782,11 +1033,13 @@ async function cmdSecret (rest) {
782
1033
  : sub === 'rm'
783
1034
  ? { op: 'rm', ns, key }
784
1035
  : { op: 'vis', ns, key, public: wantsPublic(value, USAGE) }
785
- writeReq('secret-request.json', req)
1036
+ // Solo la visibilidad pide la frase: enseñar un valor obliga a sacarlo.
1037
+ const password = sub === 'visibility' ? await adminPassword() : null
1038
+ writeReq('secret-request.json', password ? { ...req, password } : req)
786
1039
  const d = await signalAndWaitList()
787
1040
  const list = d.ns?.[ns] || []
788
1041
  const ok = sub === 'rm' ? !has(list, key) : has(list, key)
789
- if (!ok) { console.error('El daemon no aplicó el cambio (revisa: dotrino-vault logs)'); process.exit(1) }
1042
+ if (!ok) return noSeAplico(d)
790
1043
  if (sub === 'rm') console.log('Secreto borrado: %s/%s', ns, key)
791
1044
  else console.log('Secreto guardado: %s/%s%s', ns, key, (list.find((x) => x.key === key)?.public) ? ' (pública)' : '')
792
1045
  return
@@ -807,13 +1060,24 @@ function wantsPublic (word, usage) {
807
1060
  */
808
1061
  function askPassword (prompt) {
809
1062
  return new Promise((resolve, reject) => {
810
- const stdin = process.stdin
811
- if (!stdin.isTTY) return reject(new Error('a terminal is required to type the password'))
1063
+ // Se lee del TERMINAL, no de la entrada estándar. `secret import` ya usa stdin para
1064
+ // el `.env` (`cat .env | dotrino-vault secret import proxy`) y aun así hay que poder
1065
+ // escribir la contraseña; lo mismo vale para cualquier tubería.
1066
+ let stdin = process.stdin
1067
+ let own = null
1068
+ if (!stdin.isTTY) {
1069
+ try {
1070
+ own = fs.openSync('/dev/tty', 'r')
1071
+ stdin = new tty.ReadStream(own)
1072
+ } catch (_) { return reject(new Error('a terminal is required to type the password')) }
1073
+ }
812
1074
  process.stdout.write(prompt)
813
1075
  stdin.setRawMode(true); stdin.resume(); stdin.setEncoding('utf8')
814
1076
  let buf = ''
815
1077
  const done = (err, val) => {
816
- stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData)
1078
+ try { stdin.setRawMode(false) } catch (_) {}
1079
+ stdin.removeListener('data', onData)
1080
+ if (own !== null) { try { stdin.destroy() } catch (_) {} } else stdin.pause()
817
1081
  process.stdout.write('\n')
818
1082
  err ? reject(err) : resolve(val)
819
1083
  }
@@ -904,14 +1168,37 @@ async function cmdProfile (rest) {
904
1168
  }
905
1169
  case 'password': {
906
1170
  const action = args[0]
907
- if (action === 'rm') { reportProfiles(await profileRequest('password-rm')); return }
1171
+ // QUITARLA pide la actual, y no por trámite: con ella se abre la copia maestra de
1172
+ // los secretos para volver a cerrarla con la llave de esta máquina. Sin ese paso,
1173
+ // quitar la contraseña dejaría las variables privadas ilegibles para siempre.
1174
+ if (action === 'rm') {
1175
+ console.log('Al quitarla, las variables privadas pasan a abrirse con la llave de ESTA')
1176
+ console.log('máquina: siguen cifradas en el disco, pero su material vive en ese mismo')
1177
+ console.log('disco, así que una copia del disco las abre. Los aparatos no se enteran.')
1178
+ const cur = await askPassword('\nContraseña actual: ')
1179
+ if (!cur) { console.error('Cancelado.'); process.exit(1) }
1180
+ reportProfiles(await profileRequest('password-rm', { password: cur }))
1181
+ return
1182
+ }
908
1183
  if (action && action !== 'set') { console.error('uso: dotrino-vault profile password [set|rm]'); process.exit(2) }
909
- console.log('La contraseña solo se pide para EDITAR el perfil. Tus dispositivos siguen')
910
- console.log('funcionando (firmando y leyendo) aunque el perfil esté bloqueado.')
911
- const pwd = await askPassword('\nContraseña nueva (mínimo 4): ')
1184
+ console.log('La contraseña se pide para EDITAR el perfil y para escribir variables')
1185
+ console.log('privadas. Tus dispositivos siguen funcionando (firmando, leyendo y')
1186
+ console.log('recibiendo su configuración) aunque el perfil esté bloqueado.')
1187
+ // Si ya hay una, hace falta para poder abrir la copia maestra y re-sellarla. El
1188
+ // camino recomendado para CAMBIARLA es quitarla y ponerla: así cada paso pide
1189
+ // solo lo que necesita.
1190
+ const actual = await askPassword('\nContraseña actual (vacío si no tiene): ')
1191
+ // Se pide una FRASE, y se dice cómo sacarla, porque lo que da fuerza es la
1192
+ // longitud y que no la elija un humano: un modismo tiene la entropía de un
1193
+ // modismo, no la de su longitud. Desde el sellado, esta frase es lo único que
1194
+ // separa una copia del disco de las variables privadas.
1195
+ console.log('\nUsa VARIAS PALABRAS AL AZAR (mínimo 12 caracteres). Para que las elija')
1196
+ console.log('la máquina y no tú:')
1197
+ console.log(" grep -x '[a-z]\\{4,8\\}' /usr/share/dict/words | shuf -n5 | paste -sd-")
1198
+ const pwd = await askPassword('\nContraseña nueva: ')
912
1199
  const again = await askPassword('Repítela: ')
913
1200
  if (pwd !== again) { console.error('Las contraseñas no coinciden.'); process.exit(1) }
914
- reportProfiles(await profileRequest('password-set', { password: pwd }))
1201
+ reportProfiles(await profileRequest('password-set', { password: pwd, current: actual || undefined }))
915
1202
  return
916
1203
  }
917
1204
  default:
@@ -960,6 +1247,8 @@ function help () {
960
1247
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
961
1248
  (sin la bandera entra a la cuenta activa, o a la de --profile)
962
1249
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1250
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
1251
+ se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
963
1252
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
964
1253
  aparatos del perfil que sirven ese namespace
965
1254
  secret set <ns> CLAVE=valor CLAVE2=valor2 …
@@ -975,10 +1264,21 @@ function help () {
975
1264
  secret device rm <ID> <CLAVE> borra una variable de ese aparato
976
1265
  secret list lista los dos cajones: el valor de las públicas,
977
1266
  tapadas las privadas
1267
+ secret show [device] <ns|ID> <CLAVE>
1268
+ VE el valor de una privada. Es lo único que pide la
1269
+ contraseña del perfil: en esta máquina es lo único
1270
+ que la separa de una copia del disco
1271
+ secret history [device] <ns|ID> [CLAVE]
1272
+ las versiones anteriores (quién y cuándo)
1273
+ secret revert [device] <ns|ID> <CLAVE> <marca>
1274
+ vuelve a una versión anterior
1275
+ secret settle salda lo pendiente: hereda lo ya guardado a un
1276
+ aparato nuevo y rota de verdad el cajón del que se fue
978
1277
  --public | --private (al hacer un set) si el VALOR puede salir de esta
979
- máquina hacia la consola remota. Se nace privada.
980
- secret visibility <ns> <CLAVE> public|private cambia eso sin tocar el valor
981
- secret device visibility <ID> <CLAVE> public|private
1278
+ máquina hacia la consola remota. Se nace privada,
1279
+ y una privada NO se vuelve pública (bórrala y créala).
1280
+ secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
1281
+ secret device visibility <ID> <CLAVE> private
982
1282
  pending muestra el dispositivo pendiente + su código a comparar
983
1283
  approve <código> aprueba el dispositivo tipeando el código que MUESTRA (el vault no lo sabe)
984
1284
  reject <deviceId> rechaza un dispositivo pendiente
@@ -1035,6 +1335,7 @@ export async function runCtl (argv) {
1035
1335
  case 'caps': return cmdCaps(rest)
1036
1336
  case 'revoke': return cmdRevoke(rest[0])
1037
1337
  case 'secret': return cmdSecret(rest)
1338
+ case 'ssh': return cmdSsh(rest)
1038
1339
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1039
1340
  case 'logs': return cmdLogs()
1040
1341
  case 'version':