@dotrino/vaultd 0.49.1 → 0.51.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/approvals.js CHANGED
@@ -1,38 +1,30 @@
1
1
  /**
2
- * PEDIDOS DE APROBACIÓN y VENTANAS de un cajón con `approval` (puro: sin red ni disco).
2
+ * PEDIDOS DE APROBACIÓN (puro: sin red ni disco).
3
3
  *
4
- * Cuando un cajón exige aprobación por uso, leerlo ya no depende solo de tener el cert:
5
- * el vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su
6
- * firma libera los secretos. Aprobado, se abre una VENTANA para ese aparato y ese cajón
7
- * (15 min): una sesión de trabajo no pide veinte veces. Lo que nadie aprueba vence solo.
4
+ * Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
5
+ * vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
6
+ * libera los secretos. No hay ventana: cada petición es un pedido, y para un servicio bien
7
+ * hecho eso es UNA por arranque (pide al iniciar, se queda las claves en memoria). Lo que
8
+ * nadie aprueba vence solo a los 5 min.
8
9
  *
9
- * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién
10
- * pide y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
10
+ * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide
11
+ * y su llave efímera `ek` (a la que se sella la respuesta). Nunca un valor.
11
12
  */
12
13
  const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
13
14
 
14
15
  export const PENDING_TTL_MS = 5 * 60 * 1000
15
- export const GRANT_TTL_MS = 15 * 60 * 1000
16
16
 
17
- export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS, grantTtlMs = GRANT_TTL_MS } = {}) {
17
+ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
18
18
  /** id → { id, ns, device, deviceId, label, ek, ts, exp } */
19
19
  const pending = new Map()
20
- /** `${ns}|${device}` exp */
21
- const grants = new Map()
22
- const gkey = (ns, device) => ns + '|' + device
23
-
24
- const publicOf = (p) => ({ id: p.id, kind: p.kind, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ...(p.ssh ? { ssh: p.ssh } : {}) })
20
+ const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp })
25
21
 
26
22
  return {
27
- /**
28
- * Apunta un pedido. `kind`: `secrets` (leer un cajón; uno por cajón y aparato: pedir
29
- * otra vez reemplaza al anterior) o `ssh` (firmar un reto SSH con la llave del
30
- * teléfono; cada firma es un pedido, con `ssh: { key, data }` y sus callbacks).
31
- */
32
- request ({ kind = 'secrets', ns, device = null, deviceId, label = '', ek = null, ssh = null, resolve = null, reject = null }) {
33
- if (kind === 'secrets') for (const [id, p] of pending) if (p.kind === 'secrets' && p.ns === ns && p.device === device) pending.delete(id)
23
+ /** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
24
+ request ({ ns, device, deviceId, label = '', ek }) {
25
+ for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
34
26
  const ts = now()
35
- const p = { id: rnd(), kind, ns, device, deviceId, label, ek, ssh, resolve, reject, ts, exp: ts + pendingTtlMs }
27
+ const p = { id: rnd(), ns, device, deviceId, label, ek, ts, exp: ts + pendingTtlMs }
36
28
  pending.set(p.id, p)
37
29
  return publicOf(p)
38
30
  },
@@ -45,25 +37,13 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
45
37
  if (p) pending.delete(id)
46
38
  return p || null
47
39
  },
48
- /** Abre la ventana de ese aparato sobre ese cajón. */
49
- grant (ns, device) { const exp = now() + grantTtlMs; grants.set(gkey(ns, device), exp); return exp },
50
- /** ¿Sigue abierta la ventana? */
51
- has (ns, device) {
52
- const exp = grants.get(gkey(ns, device))
53
- if (exp == null) return false
54
- if (exp <= now()) { grants.delete(gkey(ns, device)); return false }
55
- return true
56
- },
57
- /** Cierra las ventanas de un aparato (al revocarlo, por ejemplo). */
58
- forget (device) { for (const k of [...grants.keys()]) if (k.endsWith('|' + device)) grants.delete(k) },
59
40
  /** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
60
41
  sweep () {
61
42
  const t = now(); const gone = []
62
- for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)); try { p.reject?.(new Error('approval: nobody approved the request in time')) } catch (_) {} }
63
- for (const [k, exp] of grants) if (exp <= t) grants.delete(k)
43
+ for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)) }
64
44
  return gone
65
45
  }
66
46
  }
67
47
  }
68
48
 
69
- export default { createApprovals, PENDING_TTL_MS, GRANT_TTL_MS }
49
+ export default { createApprovals, PENDING_TTL_MS }
package/src/ctl.js CHANGED
@@ -128,7 +128,6 @@ function cmdStatus () {
128
128
  }
129
129
  console.log(' fingerprint : %s', s.fingerprint)
130
130
  console.log(' proxy : %s', s.proxy)
131
- if (s.sshAgent) console.log(' ssh-agent : %s (dotrino-vault ssh)', s.sshAgent)
132
131
  console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
133
132
  console.log(' datos : %s', dir)
134
133
  const profiles = s.profiles || []
@@ -188,6 +187,9 @@ async function cmdPair (args = []) {
188
187
  // `secrets:<ns>`, y los dos se combinan (`--service eco --scope sign` = un bot que
189
188
  // firma como aparato del acta y lee solo su cajón). `admin` no se empareja: se
190
189
  // concede desde el PC (`caps <ID> +administra`).
190
+ // --approval: el aparato que entre pedirá tu aprobación (teléfono) en cada petición de
191
+ // claves privadas. Por defecto NO pide; se cambia después con `caps <ID> +permiso`.
192
+ const approval = args.includes('--approval')
191
193
  const scIdx = args.indexOf('--scope')
192
194
  let scope = null
193
195
  if (scIdx >= 0) {
@@ -238,7 +240,7 @@ async function cmdPair (args = []) {
238
240
  }
239
241
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
240
242
  // se empareja el dispositivo.
241
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
243
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
242
244
  sendSignal(s.pid, 'SIGUSR1')
243
245
 
244
246
  let pair = null
@@ -265,14 +267,14 @@ async function cmdPair (args = []) {
265
267
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
266
268
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
267
269
  console.log('\nO abre esta dirección en el dispositivo:\n ' + url)
268
- console.log('\nO pega este código en vault.dotrino.com/dispositivos :\n ' + b64)
270
+ console.log('\nO pega este código en vault.dotrino.com/vault :\n ' + b64)
269
271
 
270
272
  // --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
271
273
  const saveIdx = args.indexOf('--save')
272
274
  if (saveIdx >= 0) {
273
275
  const next = args[saveIdx + 1]
274
276
  const file = (next && !next.startsWith('-')) ? next : 'dotrino-invite.dpair'
275
- try { fs.writeFileSync(file, url + '\n', { mode: 0o600 }); console.log('\nInvitación guardada en: %s\n (ábrela en vault.dotrino.com/dispositivos → «Abrir imagen o archivo». Es efímera y de un solo uso; no la compartas.)', file) }
277
+ try { fs.writeFileSync(file, url + '\n', { mode: 0o600 }); console.log('\nInvitación guardada en: %s\n (ábrela en vault.dotrino.com/vault → «Abrir imagen o archivo». Es efímera y de un solo uso; no la compartas.)', file) }
276
278
  catch (e) { console.error('No se pudo guardar la invitación:', e.message) }
277
279
  }
278
280
 
@@ -402,7 +404,8 @@ async function cmdMembers () {
402
404
  // se mira quién es quién — y en la lista de variables ya seria tarde.
403
405
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
404
406
  }
405
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
407
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +permiso')
408
+ console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
406
409
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
407
410
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
408
411
  console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
@@ -444,36 +447,53 @@ async function findMember (id) {
444
447
  }
445
448
 
446
449
  /**
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
+ * `dotrino-vault approval <ID> on|off` — ese aparato solo recibe claves privadas con el
451
+ * visto bueno de un aparato con `aprueba` (el teléfono). Es propiedad del APARATO, no del
452
+ * cajón: el VPS desatendido no pide; la PC del dueño sí. Pide en cada petición, que para un
453
+ * servicio bien hecho es una por arranque.
450
454
  */
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
455
+ async function cmdApproval (args = []) {
456
+ const [id, val] = args
457
+ if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
458
+ const m = await findMember(id)
459
+ writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
460
+ sendSignal(requireDaemon().pid, 'SIGUSR2')
461
+ console.log(val === 'on'
462
+ ? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
463
+ : `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
464
+ }
465
+
466
+ /**
467
+ * `dotrino-vault passwords <ID> on|off` — deja (o no) que ese aparato pida contraseñas
468
+ * de tu bóveda.
469
+ *
470
+ * Es una lista de ESTA bóveda, no del acta, por lo mismo que `approval`: es ella la que
471
+ * entrega. El acta manda igual — si revocas el aparato allí, deja de pedir aunque siga
472
+ * en esta lista. Y si además le pones `approval on`, cada arranque suyo te pedirá el
473
+ * visto bueno en el teléfono.
474
+ */
475
+ async function cmdPasswords (args = []) {
476
+ const [id, val] = args
477
+ if (!id || !['on', 'off'].includes(val)) {
478
+ console.error('uso: dotrino-vault passwords <ID> on|off')
479
+ process.exit(1)
480
+ }
481
+ const m = await findMember(id)
482
+ writeReq('secret-request.json', { op: 'passwords', pub: m.pub, id: m.id, label: m.label || '', on: val === 'on' })
483
+ sendSignal(requireDaemon().pid, 'SIGUSR2')
484
+ if (val === 'on') {
485
+ console.log(`Listo: ${m.id} puede pedir contraseñas de tu bóveda (de una en una; nunca la lista entera).`)
486
+ console.log('Reinicia el vault: al arrancar imprime el código de enlace que se pega en la extensión.')
487
+ } else {
488
+ console.log(`Listo: ${m.id} deja de poder pedir contraseñas.`)
457
489
  }
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
490
  }
471
491
 
472
492
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
473
493
  async function cmdCaps (args = []) {
474
494
  const [id, ...changes] = args
475
495
  if (!id || !changes.length) {
476
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
496
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+permiso|-permiso')
477
497
  process.exit(2)
478
498
  }
479
499
  const CAP_BY_WORD = {
@@ -483,8 +503,21 @@ async function cmdCaps (args = []) {
483
503
  const s = requireDaemon()
484
504
  const m = await findMember(id)
485
505
 
486
- const caps = new Set(m.caps)
506
+ // `+permiso` / `-permiso` no es una capacidad del acta: es la marca de la bóveda «este
507
+ // aparato pide aprobación al recibir claves». Se cambia aquí, como cualquier permiso.
508
+ const rest = []
487
509
  for (const c of changes) {
510
+ const w = c.slice(1).toLowerCase()
511
+ if ((c[0] === '+' || c[0] === '-') && (w === 'permiso' || w === 'approval')) {
512
+ writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: c[0] === '+' })
513
+ sendSignal(s.pid, 'SIGUSR2')
514
+ console.log(c[0] === '+' ? `Listo: ${m.id} pedirá tu aprobación en cada petición de claves.` : `Listo: ${m.id} ya no pide aprobación.`)
515
+ await sleep(300)
516
+ } else rest.push(c)
517
+ }
518
+ if (!rest.length) return
519
+ const caps = new Set(m.caps)
520
+ for (const c of rest) {
488
521
  const sign = c[0]
489
522
  const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
490
523
  if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
@@ -737,7 +770,6 @@ async function cmdSecret (rest) {
737
770
  ' dotrino-vault secret device rm <ID> <CLAVE>',
738
771
  ' dotrino-vault secret list',
739
772
  ' 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)',
741
773
  '',
742
774
  'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
743
775
  'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
@@ -848,95 +880,6 @@ async function cmdSecret (rest) {
848
880
  }
849
881
 
850
882
  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
- }
939
-
940
883
  if (sub === 'list') {
941
884
  const d = await signalAndWaitList()
942
885
  const names = d.ns || {}
@@ -1247,6 +1190,7 @@ function help () {
1247
1190
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
1248
1191
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1249
1192
  pair --service <ns> empareja un SERVICIO (proxy, geo…) con acceso SOLO a sus secretos
1193
+ pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1250
1194
  pair --scope <lista> los PERMISOS del cert: sign,read,store,secrets:<ns> (sin esto: sign,read,store;
1251
1195
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1252
1196
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
@@ -1262,6 +1206,10 @@ function help () {
1262
1206
  secret device set <ID> CLAVE=valor …
1263
1207
  secret device import <ID> [archivo.env]
1264
1208
  secret device rm <ID> <CLAVE> borra una variable de ese aparato
1209
+ passwords <ID> on|off deja que ese aparato pida CONTRASEÑAS de tu bóveda
1210
+ (de una en una; nunca la lista entera). Con
1211
+ approval on además te pedirá el visto bueno en el
1212
+ teléfono. Reinicia el vault tras el primer on
1265
1213
  secret list lista los dos cajones: el valor de las públicas,
1266
1214
  tapadas las privadas
1267
1215
  secret show [device] <ns|ID> <CLAVE>
@@ -1335,7 +1283,8 @@ export async function runCtl (argv) {
1335
1283
  case 'caps': return cmdCaps(rest)
1336
1284
  case 'revoke': return cmdRevoke(rest[0])
1337
1285
  case 'secret': return cmdSecret(rest)
1338
- case 'ssh': return cmdSsh(rest)
1286
+ case 'approval': return cmdApproval(rest)
1287
+ case 'passwords': return cmdPasswords(rest)
1339
1288
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1340
1289
  case 'logs': return cmdLogs()
1341
1290
  case 'version':
package/src/daemon.js CHANGED
@@ -17,7 +17,6 @@
17
17
  import fs from 'node:fs'
18
18
  import path from 'node:path'
19
19
  import { startVaultManager } from './manager.js'
20
- import { startSshAgent, defaultSocketPath } from './sshAgent.js'
21
20
  import { dataDir, writeJson, readJson } from './paths.js'
22
21
  import { VERSION } from './version.js'
23
22
 
@@ -64,14 +63,6 @@ export async function runDaemon () {
64
63
 
65
64
  const mgr = await startVaultManager({ root: dir, proxyUrl, onEnrollChallenge })
66
65
 
67
- // --- agente SSH (la llave vive en el teléfono; ver sshAgent.js). `DOTRINO_VAULT_SSH_AGENT=0`
68
- // lo apaga; cualquier otro valor es la ruta del socket.
69
- let sshAgent = null
70
- if (process.env.DOTRINO_VAULT_SSH_AGENT !== '0' && process.platform !== 'win32') {
71
- const socketPath = process.env.DOTRINO_VAULT_SSH_AGENT || defaultSocketPath(dir)
72
- try { sshAgent = startSshAgent({ socketPath, vault: () => mgr.current(), log: console.log }) } catch (e) { console.error('[vault] ssh-agent could not start: %s', e.message) }
73
- }
74
-
75
66
  // --- state.json ---
76
67
  const stateFile = path.join(dir, 'state.json')
77
68
  const daemonVersion = VERSION
@@ -82,7 +73,7 @@ export async function runDaemon () {
82
73
  const cur = mgr.summary().find((p) => p.current) || {}
83
74
  writeJson(stateFile, {
84
75
  v: 2, version: daemonVersion, fingerprint: cur.fingerprint || null, iss: cur.iss || null,
85
- proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(), sshAgent: sshAgent?.socketPath || null,
76
+ proxy: proxyUrl, pid: process.pid, startedAt: new Date().toISOString(),
86
77
  current: mgr.currentId(), profiles: mgr.summary()
87
78
  })
88
79
  }
@@ -127,6 +118,7 @@ export async function runDaemon () {
127
118
 
128
119
  // --- SIGUSR1: iniciar emparejamiento ---
129
120
  const pairFile = path.join(dir, 'pair.json')
121
+ let pendingApproval = false // lo pidió `pair --approval`; se aplica al aprobar
130
122
  const pairReqFile = path.join(dir, 'pair-request.json')
131
123
  async function handlePairingRequest () {
132
124
  try {
@@ -166,7 +158,12 @@ export async function runDaemon () {
166
158
  const scope = asked?.length
167
159
  ? [...new Set(asked)]
168
160
  : isService ? ['vault:secrets:' + pairReq.service] : ['vault:sign', 'vault:read', 'vault:store']
169
- const label = pairReq?.label || (isService ? 'service:' + pairReq.service : 'cli')
161
+ // La etiqueta por defecto de un servicio es su ns a secas: la lista ya marca [servicio «ns»],
162
+ // y el prefijo «service:» confundía (el cajón se llama claude, no service:claude).
163
+ const label = pairReq?.label || (isService ? pairReq.service : 'cli')
164
+ // `pair --approval`: el aparato que entre por esta invitación pedirá el visto bueno del
165
+ // teléfono cada vez que reciba claves privadas (se aplica al aprobarlo, abajo).
166
+ pendingApproval = !!pairReq?.approval
170
167
  // `profile`/`profileName`: la CUENTA del vault a la que entra el dispositivo.
171
168
  // Con varias bóvedas en el mismo daemon, el QR sale de UNA y quien empareja
172
169
  // tiene que verlo (lo muestran la TUI y `dotrino-vault pair`). El nombre viaja
@@ -327,6 +324,8 @@ export async function runDaemon () {
327
324
  const vault = targetOf(appr)
328
325
  if (!vault) throw Object.assign(new Error('profile locked'), { code: 'PROFILE_LOCKED' })
329
326
  const r = await vault.approveDevice(appr.code); rm(pendingEnrollFile); rm(pairFile)
327
+ if (pendingApproval && r?.cert?.sub) { try { await vault.setApproval(r.cert.sub, true); console.log('[vault] the new device will need approval on every key request') } catch (e) { console.error('[vault] could not flag approval: %s', e.message) } }
328
+ pendingApproval = false
330
329
  console.log('[vault] aprobado %s', r.deviceId)
331
330
  answer({ ok: true, deviceId: r.deviceId || null })
332
331
  } catch (e) {
@@ -406,7 +405,12 @@ export async function runDaemon () {
406
405
  console.log('[vault] %d secret(s) applied in one go: %s', changed.length, sec.pub ? 'device' : sec.ns)
407
406
  } else if (sec.op === 'set') { await vault.setSecret(sec.ns, sec.key, sec.value, sec.public); console.log('[vault] secret saved: %s/%s', sec.ns, sec.key) }
408
407
  else if (sec.op === 'rm') { await vault.deleteSecret(sec.ns, sec.key); console.log('[vault] secret deleted: %s/%s', sec.ns, sec.key) }
409
- else if (sec.op === 'policy') { const r = await vault.setSecretPolicy(sec.ns, { approval: !!sec.approval }); console.log('[vault] %s: approval %s', sec.ns, r.approval ? 'REQUIRED (15 min window)' : 'off') }
408
+ else if (sec.op === 'passwords') {
409
+ const r = await vault.setPasswordDevice(sec.pub, { label: sec.label, on: !!sec.on })
410
+ console.log('[vault] passwords: device %s %s', sec.id || '?', sec.on ? 'ALLOWED' : 'removed')
411
+ if (sec.on) console.log('[vault] restart the vault so it starts answering password requests')
412
+ }
413
+ else if (sec.op === 'approval') { const r = await vault.setApproval(sec.pub, !!sec.approval); console.log('[vault] device %s: approval %s', sec.id || '?', r.approval ? 'REQUIRED on every key request' : 'off') }
410
414
  else if (sec.op === 'dev-set') { await vault.setDeviceSecret(sec.pub, sec.key, sec.value, sec.public); console.log('[vault] device secret saved: %s', sec.key) }
411
415
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
412
416
  // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
@@ -616,7 +620,6 @@ export async function runDaemon () {
616
620
  const shutdown = (sig) => {
617
621
  console.log(`\n[vault] ${sig} → deteniendo…`)
618
622
  rm(pairFile); rm(pendingEnrollFile)
619
- try { sshAgent?.close() } catch (_) {}
620
623
  try { mgr.close() } catch (_) {}
621
624
  process.exit(0)
622
625
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault.
3
+ *
4
+ * `dotrino-passmanager serve` es la versión mínima: bóveda propia, aprobación por
5
+ * consola, bitácora propia. Esto es lo mismo pero dentro del vault, que ya tiene lo
6
+ * que allí había que improvisar:
7
+ *
8
+ * · el ACTA dice qué aparatos existen y qué puede cada uno
9
+ * · la APROBACIÓN ya sabe avisar al teléfono y esperar su firma (`caps +aprueba`)
10
+ * · la BITÁCORA ya existe y es la misma que audita firmas y enrolamientos
11
+ *
12
+ * Lo que NO cambia es el protocolo: un aparato pide una credencial por dominio y
13
+ * recibe esa sola. `list` sigue sin existir en remoto.
14
+ *
15
+ * Módulo aislado a propósito: recibe todo lo que necesita y no toca el núcleo. Un
16
+ * fallo aquí no puede tumbar la CA.
17
+ */
18
+
19
+ import { LocalVault, VaultResponder } from '@dotrino/passmanager'
20
+
21
+ export const PASSWORDS_CAP = 'passwords'
22
+
23
+ /**
24
+ * @param {object} opts
25
+ * `client` cliente del proxio del vault, ya identificado
26
+ * `store` `{ get(k), set(k,v) }` donde viven las entradas cifradas
27
+ * `cek` la llave de la bóveda de contraseñas (CryptoKey AES-GCM)
28
+ * `isAllowed(pubkey)` qué aparatos pueden pedir — lo decide el acta, no esto
29
+ * `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
30
+ * `needsApproval(pubkey)` si ese aparato está marcado para aprobar
31
+ * `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
32
+ * `devices()` / `unlink(pubkey)` administración, opcional
33
+ * `audit(op, info)` la bitácora del vault
34
+ */
35
+ export function createPasswordDesk (opts = {}) {
36
+ const {
37
+ client, store, cek,
38
+ isAllowed = () => false,
39
+ encPubOf = () => null,
40
+ needsApproval = () => true,
41
+ approve = async () => false,
42
+ devices = null,
43
+ unlink = null,
44
+ audit = () => {},
45
+ log = () => {},
46
+ } = opts
47
+
48
+ const vault = new LocalVault(store)
49
+ if (cek) vault.unlock(cek)
50
+
51
+ const responder = new VaultResponder({
52
+ client,
53
+ vault,
54
+ isAllowed,
55
+ encPubOf,
56
+ // La aprobación es del APARATO y dura mientras el vault siga encendido, igual que
57
+ // los cajones de secretos: una por arranque, sin ventana que vigilar.
58
+ needsApproval: (op, _payload, pubkey) => op === 'get' && needsApproval(pubkey),
59
+ approve,
60
+ admin: devices && unlink ? { devices, unlink } : null,
61
+ onRequest: (r) => {
62
+ // La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario y
63
+ // el criterio del vault es apuntar la operación, no el payload.
64
+ audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
65
+ if (r.outcome !== 'served') {
66
+ log('[vault] passwords: %s %s', r.op, r.outcome)
67
+ }
68
+ },
69
+ })
70
+
71
+ return {
72
+ start () { responder.start(); return this },
73
+ stop () { responder.stop() },
74
+ /** Al bloquear el perfil, la bóveda de contraseñas se cierra con él. */
75
+ lock () { vault.lock() },
76
+ unlock (nuevaCek) { vault.unlock(nuevaCek) },
77
+ /** Para la consola local: aquí SÍ se puede listar, porque aquí está la llave. */
78
+ vault,
79
+ responder,
80
+ }
81
+ }
@@ -159,11 +159,9 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
159
159
  if (!data) data = { schemaVersion: SCHEMA_VERSION, ns: {}, dev: {}, recovery: null, history: [] }
160
160
  if (!data.dev) data.dev = {}
161
161
  if (!data.history) data.history = []
162
- // 0.47.0/0.48.0 guardaron la política DENTRO del cajón; se saca a `policies` al abrir.
163
- for (const [ns, bag] of Object.entries(data.ns || {})) {
164
- const stray = bag?.policy && typeof bag.policy === 'object' && !('v' in bag.policy) ? bag.policy : null
165
- if (stray) { delete bag.policy; if (stray.approval) { if (!data.policies) data.policies = {}; data.policies[ns] = { approval: true } } }
166
- }
162
+ // 0.47.0/0.48.0 guardaron una «política» DENTRO del cajón (salía como variable): fuera.
163
+ for (const bag of Object.values(data.ns || {})) if (bag?.policy && typeof bag.policy === 'object' && !('v' in bag.policy)) delete bag.policy
164
+ delete data.policies
167
165
 
168
166
  // v3 → v5 y v4 → v5 NO se hacen al abrir, y es deliberado: el daemon tiene que poder
169
167
  // arrancar y SERVIR sin nada (un perfil bloqueado sigue atendiendo a sus agentes). Un
@@ -593,30 +591,6 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
593
591
  for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns, ns)
594
592
  return out
595
593
  },
596
- /**
597
- * POLÍTICA de un cajón. Hoy una sola: `approval` — cada lectura espera el visto bueno
598
- * de un aparato con `approve` (ver `approvals.js`). Los cajones de los servicios que
599
- * corren solos (proxio, geo) van sin ella: no pueden esperar a un teléfono.
600
- *
601
- * Vive APARTE de las variables (`data.policies`), no dentro del cajón: en el formato
602
- * v3 el cajón ES el mapa de variables y cualquier cosa que se meta ahí se reparte como
603
- * si fuera una (pasó: `policy` salió en la lista y en el bundle).
604
- */
605
- policyOf (ns) { assertNs(ns); return { approval: !!data.policies?.[ns]?.approval } },
606
- setPolicy (ns, patch) {
607
- assertNs(ns)
608
- if (!data.policies) data.policies = {}
609
- data.policies[ns] = { ...(data.policies[ns] || {}), ...patch }
610
- if (!data.policies[ns].approval) delete data.policies[ns]
611
- save()
612
- return this.policyOf(ns)
613
- },
614
- /** Solo los cajones con alguna política puesta (ns → política). */
615
- policies () {
616
- const out = {}
617
- for (const [ns, p] of Object.entries(data.policies || {})) if (p?.approval) out[ns] = { approval: true }
618
- return out
619
- },
620
594
  /** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
621
595
  listDevices () {
622
596
  const out = {}
package/src/tui/i18n.js CHANGED
@@ -230,7 +230,7 @@ const es = {
230
230
  pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
231
231
  pairScan: 'Escanéalo, o abre esta dirección en el dispositivo:',
232
232
  pairUrl: 'URL: ',
233
- pairPaste: 'O pega este código en vault.dotrino.com/dispositivos:',
233
+ pairPaste: 'O pega este código en vault.dotrino.com/vault:',
234
234
  pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
235
235
  pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
236
236
  pairWaiting: 'Esperando a que el dispositivo se conecte…',
@@ -515,7 +515,7 @@ const en = {
515
515
  pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
516
516
  pairScan: 'Scan it, or open this address on the device:',
517
517
  pairUrl: 'URL: ',
518
- pairPaste: 'Or paste this code into vault.dotrino.com/dispositivos:',
518
+ pairPaste: 'Or paste this code into vault.dotrino.com/vault:',
519
519
  pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
520
520
  pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
521
521
  pairWaiting: 'Waiting for the device to connect…',