@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/src/tui/i18n.js CHANGED
@@ -45,13 +45,15 @@ const es = {
45
45
  sign: 'Firmar como tú',
46
46
  store: 'Guardar tus datos',
47
47
  read: 'Leer tus datos',
48
- admin: 'Administrar el perfil'
48
+ admin: 'Administrar el perfil',
49
+ passwords: 'Pedir tus contraseñas'
49
50
  },
50
51
  capHint: {
51
52
  sign: 'usar tu identidad en las apps del ecosistema',
52
53
  store: 'escribir en tu bóveda (perfil, contenido, datos sensibles)',
53
54
  read: 'ver lo que guardaste',
54
- admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí'
55
+ admin: 'conectar y quitar dispositivos desde ese aparato, sin venir aquí',
56
+ passwords: 'el gestor: pedir una credencial por sitio, nunca la lista entera'
55
57
  },
56
58
  confirmAdmin: (id) => `¿Dejar que ${id} conecte y quite dispositivos sin venir aquí?`,
57
59
  capGiven: (n) => `Concedido: ${n}`,
@@ -230,7 +232,7 @@ const es = {
230
232
  pairAccount: (name, min) => `Cuenta que se comparte: ${name} · válido ~${min} min`,
231
233
  pairScan: 'Escanéalo, o abre esta dirección en el dispositivo:',
232
234
  pairUrl: 'URL: ',
233
- pairPaste: 'O pega este código en vault.dotrino.com/dispositivos:',
235
+ pairPaste: 'O pega este código en vault.dotrino.com/vault:',
234
236
  pairWarning: '⚠ Este código deja LEER tus datos y FIRMAR con tu identidad. No lo compartas.',
235
237
  pairConnected: (id) => `⧗ Se conectó: ${id} — pulsa A y escribe el código que muestra.`,
236
238
  pairWaiting: 'Esperando a que el dispositivo se conecte…',
@@ -341,13 +343,15 @@ const en = {
341
343
  sign: 'Sign as you',
342
344
  store: 'Save your data',
343
345
  read: 'Read your data',
344
- admin: 'Manage the profile'
346
+ admin: 'Manage the profile',
347
+ passwords: 'Ask for your passwords'
345
348
  },
346
349
  capHint: {
347
350
  sign: 'use your identity across the ecosystem apps',
348
351
  store: 'write to your vault (profile, content, sensitive data)',
349
352
  read: 'see what you saved',
350
- admin: 'connect and remove devices from that device, without coming here'
353
+ admin: 'connect and remove devices from that device, without coming here',
354
+ passwords: 'the manager: ask for one credential per site, never the whole list'
351
355
  },
352
356
  confirmAdmin: (id) => `Let ${id} connect and remove devices without coming here?`,
353
357
  capGiven: (n) => `Granted: ${n}`,
@@ -515,7 +519,7 @@ const en = {
515
519
  pairAccount: (name, min) => `Account being shared: ${name} · valid ~${min} min`,
516
520
  pairScan: 'Scan it, or open this address on the device:',
517
521
  pairUrl: 'URL: ',
518
- pairPaste: 'Or paste this code into vault.dotrino.com/dispositivos:',
522
+ pairPaste: 'Or paste this code into vault.dotrino.com/vault:',
519
523
  pairWarning: '⚠ This code lets someone READ your data and SIGN as you. Do not share it.',
520
524
  pairConnected: (id) => `⧗ Connected: ${id} — press A and type the code it shows.`,
521
525
  pairWaiting: 'Waiting for the device to connect…',
package/src/vault.js CHANGED
@@ -23,7 +23,6 @@ import { openStore } from './store.js'
23
23
  import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
24
24
  import { openSecretsStore, assertVar } from './secretsStore.js'
25
25
  import { createApprovals } from './approvals.js'
26
- import { parsePublicKey, sshSignature } from './sshKeys.js'
27
26
  import { makeSealer } from './sealer.js'
28
27
  import { openSealKeys } from './sealKey.js'
29
28
  import { seal } from '../lib/src/sealed.js'
@@ -31,6 +30,13 @@ import { dataDir, ensureDir } from './paths.js'
31
30
  import { atRestFor, machineKey, migrateFile } from './atrest.js'
32
31
  import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
33
32
 
33
+ /**
34
+ * Tope de una respuesta de la bóveda por el transporte. El proxio (`PROXY_MAX_FRAME_BYTES`)
35
+ * corta el frame a 1 MB, y el sobre del proxio (destinatarios, tipos) va por encima de esto:
36
+ * se deja margen en vez de apurar el límite.
37
+ */
38
+ const MAX_REPLY_BYTES = 768 * 1024
39
+
34
40
  /**
35
41
  * Abre UN perfil del vault (una maestra, un dir, una conexión al proxy). El
36
42
  * daemon multi-perfil (`manager.js`) levanta uno de estos por perfil.
@@ -72,7 +78,88 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
72
78
  const store = openStore(dir)
73
79
  const threads = openThreadStore(dir)
74
80
  const approvals = createApprovals()
75
- const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit(g.kind === 'ssh' ? 'ssh.expired' : 'secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
81
+
82
+ /**
83
+ * Pedidos de aprobación que NO son de un cajón de secretos: quien espera es una
84
+ * promesa dentro del vault (la bóveda de contraseñas), no un aparato aguardando un
85
+ * sobre sellado. `id` del pedido → `resolve(boolean)`.
86
+ *
87
+ * Aparte a propósito: `approvals` es un módulo puro y no tiene por qué saber que hay
88
+ * dos clases de espera.
89
+ */
90
+ const waiters = new Map()
91
+ // APARATOS QUE PIDEN APROBACIÓN: una lista de llaves en `approval.json` (cifrado en reposo
92
+ // como todo el dir). Es decisión de esta bóveda, no del acta: es ella la que entrega.
93
+ const approvalFile = path.join(dir, 'approval.json')
94
+ const approvalAtRest = atRestFor(dir)
95
+ const readSupervised = () => { try { const d = JSON.parse(approvalAtRest.decrypt(fs.readFileSync(approvalFile, 'utf8'))); return Array.isArray(d?.members) ? d.members : [] } catch (_) { return [] } }
96
+ const needsApproval = (pub) => readSupervised().includes(pub)
97
+ async function setApproval (pub, on) {
98
+ const cur = new Set(readSupervised())
99
+ if (on) cur.add(pub); else cur.delete(pub)
100
+ fs.writeFileSync(approvalFile, approvalAtRest.encrypt(JSON.stringify({ v: 1, members: [...cur] })), { mode: 0o600 })
101
+ audit('approval', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
102
+ return { approval: !!on }
103
+ }
104
+ // LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
105
+ // reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
106
+ // `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
107
+ // aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
108
+ // además un emparejamiento paralelo al del ecosistema.
109
+ const passwordsFile = path.join(dir, 'passwords.json')
110
+ const passwordsAtRest = atRestFor(dir)
111
+ const readPasswordsFile = () => {
112
+ try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
113
+ }
114
+ const writePasswordsFile = (d) => {
115
+ fs.writeFileSync(passwordsFile, passwordsAtRest.encrypt(JSON.stringify(d)), { mode: 0o600 })
116
+ }
117
+
118
+ /** El almacén que espera `@dotrino/passmanager`. Todo en un archivo, cifrado en reposo. */
119
+ const passwordsStore = {
120
+ async get (k) { return readPasswordsFile()?.data?.[k] },
121
+ async set (k, v) {
122
+ const d = readPasswordsFile() || { v: 1, data: {} }
123
+ d.data = { ...(d.data || {}), [k]: v }
124
+ writePasswordsFile(d)
125
+ },
126
+ }
127
+
128
+ /** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
129
+ const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
130
+
131
+ /**
132
+ * El acta vigente, en caché.
133
+ *
134
+ * `isAllowed`/`encPubOf` del responder son SÍNCRONOS (se llaman por cada mensaje que
135
+ * entra, y leer el acta ahí sería un await por mensaje), así que se refresca aparte.
136
+ * Se relee cada pocos segundos: revocar un aparato tarda eso en cortarle el acceso,
137
+ * no un reinicio.
138
+ */
139
+ let actaCache = null
140
+ const refreshActa = async () => {
141
+ try { actaCache = (await identity.profileActa?.().catch(() => null))?.acta || null } catch (_) {}
142
+ return actaCache
143
+ }
144
+
145
+ /**
146
+ * La llave de la bóveda de contraseñas. Nace con el primer uso y vive cifrada en
147
+ * reposo, como la identidad: aquí no hace falta envolverla a cada aparato porque
148
+ * ningún aparato abre la bóveda — piden de a una y el vault responde.
149
+ */
150
+ async function passwordsKey () {
151
+ const d = readPasswordsFile()
152
+ if (d?.cek) {
153
+ const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
154
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
155
+ }
156
+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
157
+ const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
158
+ writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64') })
159
+ return key
160
+ }
161
+
162
+ const approvalsSweeper = setInterval(() => { for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId }) }, 30 * 1000); approvalsSweeper.unref?.()
76
163
  const secrets = openSecretsStore(dir, {
77
164
  sealer: makeSealer(),
78
165
  // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
@@ -405,7 +492,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
405
492
  const devices = await Promise.all(issued.map(async (x) => ({
406
493
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
407
494
  })))
408
- reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain })
495
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain }))
496
+ }
497
+
498
+ /**
499
+ * NADA DE FRAMES QUE NO CABEN. El proxio corta a 1 MB y cierra la conexión con un 1009:
500
+ * la respuesta no llega, la bóveda reconecta y no queda ni una línea de log de su lado.
501
+ * Así estuvo muda tres días para todo el ecosistema (2026-08-24) mientras el bot social
502
+ * repetía «no tienes ningún node de contenido enrolado».
503
+ *
504
+ * La cadena de actas es lo único que puede crecer sin techo aquí —cada acta es un
505
+ * snapshot completo de los miembros—, así que se recorta por el FINAL: el tramo que sale
506
+ * sigue siendo contiguo desde el `seq` del que pregunta, que es lo que necesita para
507
+ * encadenar, y en la siguiente pregunta seguirá desde donde llegó.
508
+ */
509
+ function fitChain (msg) {
510
+ const size = (m) => JSON.stringify(m).length
511
+ if (size(msg) <= MAX_REPLY_BYTES) return msg
512
+ const full = msg.chain?.length || 0
513
+ while (msg.chain?.length && size(msg) > MAX_REPLY_BYTES) msg.chain = msg.chain.slice(0, -1)
514
+ if (!msg.chain?.length) msg.chain = null
515
+ const left = msg.chain?.length || 0
516
+ log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
517
+ if (size(msg) > MAX_REPLY_BYTES) log(`[vault] ⚠ the reply STILL does not fit (${size(msg)} bytes): the proxy will drop it`)
518
+ return msg
409
519
  }
410
520
 
411
521
  // RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
@@ -489,8 +599,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
489
599
  // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
490
600
  // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
491
601
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
492
- if (['approvals', 'approve', 'deny', 'ssh.keys', 'ssh.key.add', 'ssh.key.rm'].includes(p.data?.op)) return handleApproval(from, p)
493
- if (p.data?.op === 'ssh.sign' || p.data?.op === 'ssh.keys.public') return handleSshRemote(from, p)
602
+ if (['approvals', 'approve', 'deny'].includes(p.data?.op)) return handleApproval(from, p)
494
603
  const ns = p.data?.ns
495
604
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
496
605
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -507,10 +616,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
507
616
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
508
617
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
509
618
  }
510
- // APROBACIÓN POR USO: si el cajón la exige y este aparato no tiene la ventana abierta,
511
- // el pedido se apunta, se avisa a quien aprueba y se contesta «pendiente». La respuesta
512
- // de verdad sale cuando el teléfono firme (`handleApproval`), sellada a la misma `ek`.
513
- if (secrets.policyOf(ns).approval && !approvals.has(ns, chk.device)) {
619
+ // APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
620
+ // claves privadas exige el visto bueno de un aparato con `approve` en CADA petición,
621
+ // que para un servicio bien hecho es una por arranque: pide al (re)iniciar, se queda las
622
+ // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
623
+ // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
624
+ // (`handleApproval`), sellada a la misma `ek`.
625
+ if (needsApproval(chk.device)) {
514
626
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
515
627
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
516
628
  const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
@@ -551,68 +663,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
551
663
  return { body, signature }
552
664
  }
553
665
 
554
- // ---------- LLAVES SSH DEL TELÉFONO (`sshKeys.js`) ----------
555
- // Aquí solo viven las PÚBLICAS: la privada está en el aparato que aprueba. `ssh` en este
556
- // PC habla con el agente del daemon (`sshAgent.js`), y cada firma es un pedido más.
557
- const sshKeysFile = path.join(dir, 'ssh-keys.json')
558
- const atRest = atRestFor(dir)
559
- const readSshKeys = () => { const d = readJsonAt(sshKeysFile); return Array.isArray(d?.keys) ? d.keys : [] }
560
- function readJsonAt (file) { try { return JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8'))) } catch (_) { return null } }
561
- const writeSshKeys = (keys) => fs.writeFileSync(sshKeysFile, atRest.encrypt(JSON.stringify({ v: 1, keys })), { mode: 0o600 })
562
- const sshKeys = () => readSshKeys().map((k) => ({ id: k.id, type: k.type, blob: k.blob, comment: k.comment, deviceId: k.deviceId, addedAt: k.addedAt }))
563
-
564
666
  /**
565
- * FIRMAR UN RETO SSH: lo pide el agente local (el `ssh` del usuario) y lo resuelve el
566
- * teléfono. No hay ventana: cada conexión es un «sí» — para no repetirlo veinte veces
567
- * está `ControlMaster` en el `ssh_config`, que reusa la conexión 15 min.
568
- */
569
- function requestSshSign ({ keyId, data, askedBy = null, onPending = null }) {
570
- const key = readSshKeys().find((k) => k.id === keyId)
571
- if (!key) return Promise.reject(new Error('ssh: unknown key'))
572
- return new Promise((resolve, reject) => {
573
- const pend = approvals.request({
574
- kind: 'ssh', ns: 'ssh', deviceId: askedBy || (fp.slice(0, 4).toUpperCase() + '-' + fp.slice(4, 8).toUpperCase()), label: 'ssh',
575
- ssh: { key: key.id, comment: key.comment, data: Buffer.from(data).toString('base64') },
576
- resolve, reject
577
- })
578
- try { onPending?.(pend) } catch (_) {}
579
- audit('ssh.pending', { key: key.id, id: pend.id, device: askedBy || null })
580
- log(`[vault] ssh: ${key.comment || key.id} is waiting for the phone to sign (${pend.id})`)
581
- identity.profileActa?.().catch(() => null).then((r) => notifyApprovers(pend, r?.acta))
582
- })
583
- }
584
-
585
- /**
586
- * AGENTE SSH DELGADO en otra máquina (`dotrino-env ssh-agent`): un aparato con `vault:sign`
587
- * pide que un reto se firme con una llave del teléfono. No hay nada que proteger en el
588
- * que pide: solo convierte el reto en un pedido; quien firma es el teléfono.
589
- */
590
- async function handleSshRemote (from, p) {
591
- const chk = await verifyChain({
592
- data: p.data, signature: p.signature, cert: p.cert,
593
- expectedScope: SCOPE.SIGN, trustedIssuer: master, revoked: await revocationSet()
594
- })
595
- if (!chk.ok) return denyChain(from, chk, p, 'ssh')
596
- const answer = async (body) => {
597
- body = { ...body, ts: Date.now() }
598
- const { signature } = await identity.signData(body)
599
- reply(from, { type: MSG.SECRETS_RESULT, body, signature })
600
- }
601
- if (p.data.op === 'ssh.keys.public') return answer({ op: 'ssh.keys.public', items: sshKeys().map((k) => ({ id: k.id, blob: k.blob, comment: k.comment })) })
602
- const key = readSshKeys().find((k) => k.id === p.data.key)
603
- if (!key || typeof p.data.data !== 'string') return reply(from, { type: MSG.ERROR, error: 'ssh: unknown key' })
604
- const asker = await deviceIdOf(chk.device).catch(() => null)
605
- let pendId = null
606
- const done = requestSshSign({ keyId: key.id, data: Buffer.from(p.data.data, 'base64'), askedBy: asker, onPending: (pend) => { pendId = pend.id } })
607
- await answer({ op: 'ssh.pending', id: pendId, exp: Date.now() + 5 * 60 * 1000 })
608
- try {
609
- const sig = await done
610
- await answer({ op: 'ssh.sign.result', sig: Buffer.from(sig).toString('base64') })
611
- } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
612
- }
613
-
614
- /**
615
- * PEDIDOS DE APROBACIÓN (cajones con `approval`). Entran por `vault.secrets` con
667
+ * PEDIDOS DE APROBACIÓN (aparatos marcados con `approval on`). Entran por `vault.secrets` con
616
668
  * `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
617
669
  * como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
618
670
  * que decirlo también, para que quitar el permiso surta efecto en el acto.
@@ -636,44 +688,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
636
688
  reply(from, { type: MSG.SECRETS_RESULT, body, signature })
637
689
  }
638
690
  if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
639
- if (op === 'ssh.keys') return answer({ op: 'ssh.keys', items: sshKeys() })
640
- if (op === 'ssh.key.add') {
641
- let k
642
- try { k = parsePublicKey(p.data?.pub) } catch (e) { return reply(from, { type: MSG.ERROR, error: e.message }) }
643
- const keys = readSshKeys().filter((x) => x.id !== k.id)
644
- keys.push({ ...k, deviceId: by, addedAt: Date.now() })
645
- writeSshKeys(keys)
646
- audit('ssh.key.add', { key: k.id, by })
647
- log(`[vault] ssh: key ${k.id} (${k.comment || 'no comment'}) registered by ${by}`)
648
- return answer({ op: 'ssh.key.add', id: k.id, ok: true })
649
- }
650
- if (op === 'ssh.key.rm') {
651
- const keys = readSshKeys(); const n = keys.length
652
- writeSshKeys(keys.filter((x) => x.id !== p.data?.id))
653
- if (keys.length !== n) audit('ssh.key.rm', { key: p.data?.id, by })
654
- return answer({ op: 'ssh.key.rm', ok: true })
655
- }
656
691
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
657
692
  const pend = approvals.take(id)
658
693
  if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
659
- if (pend.kind === 'ssh') {
660
- if (op === 'deny') {
661
- audit('ssh.denied', { key: pend.ssh.key, id, by })
662
- try { pend.reject?.(new Error('ssh: denied from ' + by)) } catch (_) {}
663
- return answer({ op: 'deny.result', id, ok: true })
664
- }
665
- if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
666
- const key = readSshKeys().find((k) => k.id === pend.ssh.key)
667
- let blob
668
- try { blob = sshSignature({ jwk: key.jwk, data: Buffer.from(pend.ssh.data, 'base64'), rawSig: p.data?.sig }) } catch (e) {
669
- try { pend.reject?.(e) } catch (_) {}
670
- audit('rejected', { what: 'ssh.sign', reason: e.message })
671
- return reply(from, { type: MSG.ERROR, error: e.message })
672
- }
673
- audit('ssh.signed', { key: key.id, id, by })
674
- log(`[vault] ssh: ${key.comment || key.id} signed by ${by}`)
675
- try { pend.resolve?.(blob) } catch (_) {}
676
- return answer({ op: 'approve.result', id, ok: true })
694
+ // PEDIDOS QUE NO SON DE UN CAJÓN (hoy: la bóveda de contraseñas). Se resuelve ANTES
695
+ // de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada que sellar,
696
+ // solo una promesa esperando un sí o un no.
697
+ if (waiters.has(id)) {
698
+ const resolver = waiters.get(id)
699
+ waiters.delete(id)
700
+ const ok = op === 'approve'
701
+ audit(ok ? 'passwords.approved' : 'passwords.denied', { device: pend.deviceId, id, by })
702
+ log(`[vault] passwords: request of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
703
+ resolver(ok)
704
+ return answer({ op: `${op}.result`, id, ok: true })
677
705
  }
678
706
  if (op === 'deny') {
679
707
  audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
@@ -682,16 +710,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
682
710
  return answer({ op: 'deny.result', id, ok: true })
683
711
  }
684
712
  if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
685
- const exp = approvals.grant(pend.ns, pend.device)
686
713
  let res
687
714
  try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
688
715
  return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
689
716
  }
690
- audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, until: exp })
691
- log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by} (window until ${new Date(exp).toISOString()})`)
717
+ audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by })
718
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
692
719
  // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
693
720
  try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
694
- return answer({ op: 'approve.result', id, ok: true, exp })
721
+ return answer({ op: 'approve.result', id, ok: true })
695
722
  }
696
723
 
697
724
  /** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
@@ -726,6 +753,101 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
726
753
  }
727
754
  })
728
755
 
756
+ // ----- LA BÓVEDA DE CONTRASEÑAS -----
757
+ //
758
+ // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
759
+ // la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
760
+ // responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
761
+ //
762
+ // Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
763
+ // un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
764
+ // que se olvidan y luego parecen un fallo del gestor.
765
+ let passwords = null
766
+ /**
767
+ * EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
768
+ * (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
769
+ *
770
+ * Va aquí y no en el cliente del transporte porque el cliente del vault es UNO y lo
771
+ * comparte todo: el protocolo de la CA viaja en claro a propósito (un enrolamiento es
772
+ * público hasta que hay cert). Lo que se sella es lo del gestor, y por eso `isSealed`
773
+ * mira su marca y no otra.
774
+ *
775
+ * Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
776
+ * abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
777
+ */
778
+ const passwordSealing = {
779
+ async seal (msg, peerEncPub) {
780
+ if (!peerEncPub) throw Object.assign(new Error('no encryption key'), { code: 'unsealed' })
781
+ return {
782
+ app: 'passmanager',
783
+ // Destinatarios como OBJETOS y no como llaves sueltas: `encrypt` expande cada
784
+ // uno a todos los aparatos de esa persona, y una cadena a secas se le cae por
785
+ // el `continue` sin envolver nada — el sobre salía vacío y sin error.
786
+ sealed: await identity.encrypt([{ encryptionPubkey: peerEncPub }], JSON.stringify(msg)),
787
+ from: await identity.getEncryptionPubkey()
788
+ }
789
+ },
790
+ // `decrypt` devuelve `{ plaintext }`, no la cadena.
791
+ async open (env) { return JSON.parse((await identity.decrypt(env.from, null, env.sealed)).plaintext) },
792
+ isSealed: (m) => !!m && m.app === 'passmanager' && !!m.sealed
793
+ }
794
+ const startPasswordDesk = async () => {
795
+ if (passwords || !passwordDevices().length) return
796
+ const { createPasswordDesk } = await import('./passwords.js')
797
+ client.updateConfig({ sealing: passwordSealing })
798
+ passwords = createPasswordDesk({
799
+ client,
800
+ store: passwordsStore,
801
+ cek: await passwordsKey(),
802
+ // UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
803
+ // Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
804
+ // que haya una segunda lista que acordarse de tocar.
805
+ isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
806
+ encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
807
+ needsApproval: (pub) => needsApproval(pub),
808
+ // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
809
+ // Es el mismo camino que ya recorren los cajones de secretos.
810
+ approve: async ({ pubkey, op }) => {
811
+ const deviceId = await deviceIdOf(pubkey).catch(() => null)
812
+ const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
813
+ const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
814
+ audit('passwords.pending', { device: deviceId, id: pend.id, op })
815
+ log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
816
+ const espera = new Promise((resolve) => {
817
+ waiters.set(pend.id, resolve)
818
+ // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
819
+ // y el aparato recibe un no en vez de esperar sin fin.
820
+ const t = setTimeout(() => {
821
+ if (waiters.delete(pend.id)) {
822
+ audit('passwords.expired', { device: deviceId, id: pend.id })
823
+ resolve(false)
824
+ }
825
+ }, PENDING_TTL_MS)
826
+ t.unref?.()
827
+ })
828
+ await notifyApprovers(pend, actaCache)
829
+ return espera
830
+ },
831
+ audit,
832
+ log,
833
+ }).start()
834
+ log('[vault] passwords: serving %d device(s)', passwordDevices().length)
835
+ }
836
+ try {
837
+ // El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
838
+ // refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
839
+ await refreshActa()
840
+ const actaSweeper = setInterval(() => {
841
+ refreshActa().then(startPasswordDesk).catch(() => {})
842
+ }, 5000)
843
+ actaSweeper.unref?.()
844
+ if (passwordDevices().length) await startPasswordDesk()
845
+ // Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
846
+ else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
847
+ } catch (e) {
848
+ log('[vault] passwords: no se pudo levantar (%s); el resto sigue igual', e.message)
849
+ }
850
+
729
851
  log(`[vault] listo · id ${fp} · ${store.getTree().children.length} nodos`)
730
852
 
731
853
  // ----- API local (CLI/UI de control) -----
@@ -1618,12 +1740,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1618
1740
  stopPairing: desk.stopPairing,
1619
1741
  listPending: desk.listPending,
1620
1742
  // Cajones con aprobación por uso (`approvals.js`).
1621
- secretPolicy: (ns) => secrets.policyOf(ns),
1622
- setSecretPolicy: async (ns, patch) => { const r = secrets.setPolicy(ns, patch); audit('secret.policy', { ns, ...r }); return r },
1623
- secretPolicies: () => secrets.policies(),
1743
+ // Aparatos que piden aprobación al recibir claves (`approvals.js`).
1744
+ needsApproval,
1745
+ setApproval,
1746
+ supervised: () => readSupervised(),
1747
+ // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
1748
+ // llave. Lo que no puede es listarla un aparato.
1749
+ passwordDevices,
1750
+ passwordsVault: () => passwords?.vault || null,
1624
1751
  listApprovals: () => approvals.list(),
1625
- sshKeys,
1626
- requestSshSign,
1627
1752
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
1628
1753
  // dispositivos se entera de que entró alguien, venga de donde venga.
1629
1754
  approveDevice: async (code, adminKey) => {