@dotrino/vaultd 0.49.1 → 0.52.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.49.1",
3
+ "version": "0.52.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -20,8 +20,9 @@
20
20
  "node": ">=20"
21
21
  },
22
22
  "dependencies": {
23
- "@dotrino/identity": "^0.58.0",
24
- "@dotrino/proxy-client": "^0.10.1",
23
+ "@dotrino/identity": "^0.61.0",
24
+ "@dotrino/passmanager": "^0.1.0",
25
+ "@dotrino/proxy-client": "^0.13.1",
25
26
  "ws": "^8.18.0"
26
27
  },
27
28
  "license": "MIT",
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,21 +187,27 @@ 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) {
194
196
  const raw = args[scIdx + 1]
195
197
  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' }
198
+ const ALIAS = { firma: 'sign', lee: 'read', guarda: 'store', contrasenas: 'passwords', 'contraseñas': 'passwords' }
197
199
  scope = []
198
200
  for (const tok of raw.split(',').map((t) => t.trim()).filter(Boolean)) {
199
201
  const t = ALIAS[tok] || tok
200
202
  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
203
  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
204
  if (t === 'sign' || t === 'read' || t === 'store') { scope.push('vault:' + t); continue }
205
+ // El gestor de contraseñas SÍ se empareja con su permiso puesto: es lo único que
206
+ // va a hacer ese aparato, y pedirlo en dos pasos era el paso que nadie daba.
207
+ if (t === 'passwords') { scope.push('vault:passwords'); continue }
203
208
  const m = /^secrets:([a-z0-9-]{1,32})$/.exec(t)
204
209
  if (m) { scope.push('vault:secrets:' + m[1]); continue }
205
- console.error('permiso desconocido: %s (sign | read | store | secrets:<ns>)', tok); process.exit(2)
210
+ console.error('permiso desconocido: %s (sign | read | store | contrasenas | secrets:<ns>)', tok); process.exit(2)
206
211
  }
207
212
  if (service) scope.push('vault:secrets:' + service)
208
213
  scope = [...new Set(scope)]
@@ -238,7 +243,7 @@ async function cmdPair (args = []) {
238
243
  }
239
244
  // La petición se escribe SIEMPRE (aunque no haya --service): lleva a qué perfil
240
245
  // se empareja el dispositivo.
241
- writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}) })
246
+ writeReq('pair-request.json', { ...(service ? { service } : {}), ...(scope ? { scope } : {}), ...(adopt ? { mode: 'adopt' } : {}), ...(approval ? { approval: true } : {}) })
242
247
  sendSignal(s.pid, 'SIGUSR1')
243
248
 
244
249
  let pair = null
@@ -265,14 +270,14 @@ async function cmdPair (args = []) {
265
270
  console.log(`${R}${B}⚠ Este código deja LEER tus datos y FIRMAR con tu identidad.${Z}`)
266
271
  console.log(`${R} NO lo compartas con nadie, ni con "soporte". Solo escanéalo en TU dispositivo.${Z}`)
267
272
  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)
273
+ console.log('\nO pega este código en vault.dotrino.com/vault :\n ' + b64)
269
274
 
270
275
  // --save [archivo]: escribe la invitación (.dpair) para transferirla y abrirla en profile.
271
276
  const saveIdx = args.indexOf('--save')
272
277
  if (saveIdx >= 0) {
273
278
  const next = args[saveIdx + 1]
274
279
  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) }
280
+ 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
281
  catch (e) { console.error('No se pudo guardar la invitación:', e.message) }
277
282
  }
278
283
 
@@ -402,7 +407,8 @@ async function cmdMembers () {
402
407
  // se mira quién es quién — y en la lista de variables ya seria tarde.
403
408
  if (m.cn && !m.canSeal) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
404
409
  }
405
- console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba')
410
+ console.log('\n Cambiar permisos: dotrino-vault caps <ID> +firma | -firma | +guarda | -guarda | +lee | -lee | +administra | +aprueba | +contrasenas | +permiso')
411
+ console.log(' «Permiso»: ese aparato solo recibe claves privadas cuando lo apruebas desde un aparato con «aprueba» (en cada arranque).')
406
412
  console.log(' «Administra» deja conectar y quitar dispositivos desde ese aparato, sin venir aquí.')
407
413
  console.log(' No deja cambiar permisos ni traspasar el mando: eso solo se hace en esta máquina.')
408
414
  console.log(' Los servicios solo pueden abrir las claves de su propio nombre; eso no se cambia aquí.\n')
@@ -444,47 +450,55 @@ async function findMember (id) {
444
450
  }
445
451
 
446
452
  /**
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.
453
+ * `dotrino-vault approval <ID> on|off` — ese aparato solo recibe claves privadas con el
454
+ * visto bueno de un aparato con `aprueba` (el teléfono). Es propiedad del APARATO, no del
455
+ * cajón: el VPS desatendido no pide; la PC del dueño sí. Pide en cada petición, que para un
456
+ * servicio bien hecho es una por arranque.
450
457
  */
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')
458
+ async function cmdApproval (args = []) {
459
+ const [id, val] = args
460
+ if (!id || !['on', 'off'].includes(val)) { console.error('uso: dotrino-vault approval <ID> on|off'); process.exit(2) }
461
+ const m = await findMember(id)
462
+ writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: val === 'on' })
463
+ sendSignal(requireDaemon().pid, 'SIGUSR2')
464
+ console.log(val === 'on'
465
+ ? `Listo: ${m.id} solo recibirá claves privadas cuando un aparato con caps <ID> +aprueba lo apruebe (en cada arranque).`
466
+ : `Listo: ${m.id} vuelve a recibir sus claves sin aprobación.`)
470
467
  }
471
468
 
472
469
  /** `dotrino-vault caps <ID> ±permiso` — cambia lo que puede hacer un dispositivo. */
473
470
  async function cmdCaps (args = []) {
474
471
  const [id, ...changes] = args
475
472
  if (!id || !changes.length) {
476
- console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba')
473
+ console.error('uso: dotrino-vault caps <ID> +firma|-firma|+guarda|-guarda|+lee|-lee|+administra|-administra|+aprueba|-aprueba|+contrasenas|-contrasenas|+permiso|-permiso')
477
474
  process.exit(2)
478
475
  }
479
476
  const CAP_BY_WORD = {
480
477
  firma: 'sign', guarda: 'store', lee: 'read', administra: 'admin', aprueba: 'approve',
481
- sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve'
478
+ // `contraseñas`: el gestor (la extensión, la app del teléfono) puede PEDIR
479
+ // credenciales de a una. Se acepta con y sin tilde: nadie escribe la ñ en una CLI.
480
+ contrasenas: 'passwords', 'contraseñas': 'passwords',
481
+ sign: 'sign', store: 'store', read: 'read', admin: 'admin', approve: 'approve',
482
+ passwords: 'passwords'
482
483
  }
483
484
  const s = requireDaemon()
484
485
  const m = await findMember(id)
485
486
 
486
- const caps = new Set(m.caps)
487
+ // `+permiso` / `-permiso` no es una capacidad del acta: es la marca de la bóveda «este
488
+ // aparato pide aprobación al recibir claves». Se cambia aquí, como cualquier permiso.
489
+ const rest = []
487
490
  for (const c of changes) {
491
+ const w = c.slice(1).toLowerCase()
492
+ if ((c[0] === '+' || c[0] === '-') && (w === 'permiso' || w === 'approval')) {
493
+ writeReq('secret-request.json', { op: 'approval', pub: m.pub, id: m.id, approval: c[0] === '+' })
494
+ sendSignal(s.pid, 'SIGUSR2')
495
+ 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.`)
496
+ await sleep(300)
497
+ } else rest.push(c)
498
+ }
499
+ if (!rest.length) return
500
+ const caps = new Set(m.caps)
501
+ for (const c of rest) {
488
502
  const sign = c[0]
489
503
  const cap = CAP_BY_WORD[c.slice(1).toLowerCase()]
490
504
  if (!cap || (sign !== '+' && sign !== '-')) { console.error('permiso no reconocido: %s', c); process.exit(2) }
@@ -737,7 +751,6 @@ async function cmdSecret (rest) {
737
751
  ' dotrino-vault secret device rm <ID> <CLAVE>',
738
752
  ' dotrino-vault secret list',
739
753
  ' 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
754
  '',
742
755
  'CARGA LA CONFIGURACIÓN DE UN SERVICIO DE UNA VEZ (`set` con varios pares, o `import`):',
743
756
  'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
@@ -848,95 +861,6 @@ async function cmdSecret (rest) {
848
861
  }
849
862
 
850
863
  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
864
  if (sub === 'list') {
941
865
  const d = await signalAndWaitList()
942
866
  const names = d.ns || {}
@@ -1247,7 +1171,8 @@ function help () {
1247
1171
  estrena una cuenta VACÍA en este vault y mete ahí al dispositivo
1248
1172
  (sin la bandera entra a la cuenta activa, o a la de --profile)
1249
1173
  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;
1174
+ pair --approval el aparato que entre pedirá tu aprobación (teléfono) al recibir claves
1175
+ pair --scope <lista> los PERMISOS del cert: sign,read,store,contrasenas,secrets:<ns> (sin esto: sign,read,store;
1251
1176
  se combina con --service: --service eco --scope sign = bot que firma y lee su cajón)
1252
1177
  secret set <ns> <CLAVE> <valor> variable del scope <ns>: la comparten TODOS los
1253
1178
  aparatos del perfil que sirven ese namespace
@@ -1286,7 +1211,7 @@ function help () {
1286
1211
  me tu perfil (nombre, foto, datos) tal como lo tiene la bóveda
1287
1212
  members el acta del perfil: quién es tuyo y qué puede hacer
1288
1213
  label <ID> <nombre> renombra un dispositivo (el nombre con el que lo reconoces)
1289
- caps <ID> ±permiso cambia permisos (+firma -guarda +administra …)
1214
+ caps <ID> ±permiso cambia permisos (+firma -guarda +administra +contrasenas …)
1290
1215
  revoke <ID|nonce> quita un dispositivo (con el ID, todos sus certificados)
1291
1216
  activity [n] bitácora de seguridad: firmas, renovaciones, enrolados, rechazos
1292
1217
  logs últimos logs del servicio
@@ -1335,7 +1260,7 @@ export async function runCtl (argv) {
1335
1260
  case 'caps': return cmdCaps(rest)
1336
1261
  case 'revoke': return cmdRevoke(rest[0])
1337
1262
  case 'secret': return cmdSecret(rest)
1338
- case 'ssh': return cmdSsh(rest)
1263
+ case 'approval': return cmdApproval(rest)
1339
1264
  case 'activity': return cmdActivity(Number(rest[0]) || 30)
1340
1265
  case 'logs': return cmdLogs()
1341
1266
  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,7 @@ 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 === '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
409
  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
410
  else if (sec.op === 'dev-rm') { await vault.deleteDeviceSecret(sec.pub, sec.key); console.log('[vault] device secret deleted: %s', sec.key) }
412
411
  // Saldar lo que quedó a deber: heredarle a un aparato nuevo lo ya guardado y
@@ -616,7 +615,6 @@ export async function runDaemon () {
616
615
  const shutdown = (sig) => {
617
616
  console.log(`\n[vault] ${sig} → deteniendo…`)
618
617
  rm(pairFile); rm(pendingEnrollFile)
619
- try { sshAgent?.close() } catch (_) {}
620
618
  try { mgr.close() } catch (_) {}
621
619
  process.exit(0)
622
620
  }
@@ -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/app.js CHANGED
@@ -318,7 +318,7 @@ function pairModeRows (st, t) {
318
318
  * una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
319
319
  * a ese aparato meter y sacar dispositivos sin venir aquí.
320
320
  */
321
- const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
321
+ const CAPS_ORDER = ['sign', 'store', 'read', 'admin', 'passwords']
322
322
 
323
323
  function capsRows (st, t) {
324
324
  const i = L(st)