@dotrino/vaultd 0.49.0 → 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/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,96 @@ 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 es una LISTA DE ESTA BÓVEDA, no del
106
+ // acta — por lo mismo que `approval.json`: es ella la que entrega. El acta sigue
107
+ // mandando en que el aparato exista y no esté revocado, y eso se comprueba aparte.
108
+ const passwordsFile = path.join(dir, 'passwords.json')
109
+ const passwordsAtRest = atRestFor(dir)
110
+ const readPasswordsFile = () => {
111
+ try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
112
+ }
113
+ const writePasswordsFile = (d) => {
114
+ fs.writeFileSync(passwordsFile, passwordsAtRest.encrypt(JSON.stringify(d)), { mode: 0o600 })
115
+ }
116
+
117
+ /** El almacén que espera `@dotrino/passmanager`. Todo en un archivo, cifrado en reposo. */
118
+ const passwordsStore = {
119
+ async get (k) { return readPasswordsFile()?.data?.[k] },
120
+ async set (k, v) {
121
+ const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
122
+ d.data = { ...(d.data || {}), [k]: v }
123
+ writePasswordsFile(d)
124
+ },
125
+ }
126
+
127
+ const passwordDevices = () => readPasswordsFile()?.devices || []
128
+
129
+ /**
130
+ * El acta vigente, en caché.
131
+ *
132
+ * `isAllowed`/`encPubOf` del responder son SÍNCRONOS (se llaman por cada mensaje que
133
+ * entra, y leer el acta ahí sería un await por mensaje), así que se refresca aparte.
134
+ * Se relee cada pocos segundos: revocar un aparato tarda eso en cortarle el acceso,
135
+ * no un reinicio.
136
+ */
137
+ let actaCache = null
138
+ const refreshActa = async () => {
139
+ try { actaCache = (await identity.profileActa?.().catch(() => null))?.acta || null } catch (_) {}
140
+ return actaCache
141
+ }
142
+
143
+ /**
144
+ * La llave de la bóveda de contraseñas. Nace con el primer uso y vive cifrada en
145
+ * reposo, como la identidad: aquí no hace falta envolverla a cada aparato porque
146
+ * ningún aparato abre la bóveda — piden de a una y el vault responde.
147
+ */
148
+ async function passwordsKey () {
149
+ const d = readPasswordsFile()
150
+ if (d?.cek) {
151
+ const raw = Uint8Array.from(Buffer.from(d.cek, 'base64'))
152
+ return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
153
+ }
154
+ const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'])
155
+ const raw = new Uint8Array(await crypto.subtle.exportKey('raw', key))
156
+ writePasswordsFile({ ...(d || { v: 1, data: {} }), cek: Buffer.from(raw).toString('base64'), devices: d?.devices || [] })
157
+ return key
158
+ }
159
+
160
+ /** Autorizar/retirar un aparato para pedir contraseñas (`dotrino-vault passwords`). */
161
+ async function setPasswordDevice (pub, { label = '', on = true } = {}) {
162
+ const d = readPasswordsFile() || { v: 1, data: {}, devices: [] }
163
+ const resto = (d.devices || []).filter((x) => x.pubkey !== pub)
164
+ if (on) resto.push({ pubkey: pub, label: String(label || '').slice(0, 60), ts: Date.now() })
165
+ writePasswordsFile({ ...d, devices: resto })
166
+ audit('passwords.device', { device: await deviceIdOf(pub).catch(() => null), on: !!on })
167
+ return { ok: true }
168
+ }
169
+
170
+ 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
171
  const secrets = openSecretsStore(dir, {
77
172
  sealer: makeSealer(),
78
173
  // A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
@@ -405,7 +500,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
405
500
  const devices = await Promise.all(issued.map(async (x) => ({
406
501
  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
502
  })))
408
- reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain })
503
+ reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain }))
504
+ }
505
+
506
+ /**
507
+ * NADA DE FRAMES QUE NO CABEN. El proxio corta a 1 MB y cierra la conexión con un 1009:
508
+ * la respuesta no llega, la bóveda reconecta y no queda ni una línea de log de su lado.
509
+ * Así estuvo muda tres días para todo el ecosistema (2026-08-24) mientras el bot social
510
+ * repetía «no tienes ningún node de contenido enrolado».
511
+ *
512
+ * La cadena de actas es lo único que puede crecer sin techo aquí —cada acta es un
513
+ * snapshot completo de los miembros—, así que se recorta por el FINAL: el tramo que sale
514
+ * sigue siendo contiguo desde el `seq` del que pregunta, que es lo que necesita para
515
+ * encadenar, y en la siguiente pregunta seguirá desde donde llegó.
516
+ */
517
+ function fitChain (msg) {
518
+ const size = (m) => JSON.stringify(m).length
519
+ if (size(msg) <= MAX_REPLY_BYTES) return msg
520
+ const full = msg.chain?.length || 0
521
+ while (msg.chain?.length && size(msg) > MAX_REPLY_BYTES) msg.chain = msg.chain.slice(0, -1)
522
+ if (!msg.chain?.length) msg.chain = null
523
+ const left = msg.chain?.length || 0
524
+ log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
525
+ if (size(msg) > MAX_REPLY_BYTES) log(`[vault] ⚠ the reply STILL does not fit (${size(msg)} bytes): the proxy will drop it`)
526
+ return msg
409
527
  }
410
528
 
411
529
  // RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
@@ -489,8 +607,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
489
607
  // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
490
608
  // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
491
609
  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)
610
+ if (['approvals', 'approve', 'deny'].includes(p.data?.op)) return handleApproval(from, p)
494
611
  const ns = p.data?.ns
495
612
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
496
613
  if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
@@ -507,10 +624,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
507
624
  audit('rejected', { what: 'secrets', ns, reason: 'cn' })
508
625
  return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
509
626
  }
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)) {
627
+ // APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
628
+ // claves privadas exige el visto bueno de un aparato con `approve` en CADA petición,
629
+ // que para un servicio bien hecho es una por arranque: pide al (re)iniciar, se queda las
630
+ // claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
631
+ // contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
632
+ // (`handleApproval`), sellada a la misma `ek`.
633
+ if (needsApproval(chk.device)) {
514
634
  const deviceId = await deviceIdOf(chk.device).catch(() => null)
515
635
  const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
516
636
  const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
@@ -551,68 +671,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
551
671
  return { body, signature }
552
672
  }
553
673
 
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
- /**
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
674
  /**
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
675
+ * PEDIDOS DE APROBACIÓN (aparatos marcados con `approval on`). Entran por `vault.secrets` con
616
676
  * `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
617
677
  * como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
618
678
  * que decirlo también, para que quitar el permiso surta efecto en el acto.
@@ -636,44 +696,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
636
696
  reply(from, { type: MSG.SECRETS_RESULT, body, signature })
637
697
  }
638
698
  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
699
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
657
700
  const pend = approvals.take(id)
658
701
  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 })
702
+ // PEDIDOS QUE NO SON DE UN CAJÓN (hoy: la bóveda de contraseñas). Se resuelve ANTES
703
+ // de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada que sellar,
704
+ // solo una promesa esperando un sí o un no.
705
+ if (waiters.has(id)) {
706
+ const resolver = waiters.get(id)
707
+ waiters.delete(id)
708
+ const ok = op === 'approve'
709
+ audit(ok ? 'passwords.approved' : 'passwords.denied', { device: pend.deviceId, id, by })
710
+ log(`[vault] passwords: request of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
711
+ resolver(ok)
712
+ return answer({ op: `${op}.result`, id, ok: true })
677
713
  }
678
714
  if (op === 'deny') {
679
715
  audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
@@ -682,16 +718,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
682
718
  return answer({ op: 'deny.result', id, ok: true })
683
719
  }
684
720
  if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
685
- const exp = approvals.grant(pend.ns, pend.device)
686
721
  let res
687
722
  try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
688
723
  return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
689
724
  }
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()})`)
725
+ audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by })
726
+ log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
692
727
  // Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
693
728
  try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
694
- return answer({ op: 'approve.result', id, ok: true, exp })
729
+ return answer({ op: 'approve.result', id, ok: true })
695
730
  }
696
731
 
697
732
  /** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
@@ -726,6 +761,76 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
726
761
  }
727
762
  })
728
763
 
764
+ // ----- LA BÓVEDA DE CONTRASEÑAS -----
765
+ //
766
+ // Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
767
+ // la CA. Solo se levanta si hay algún aparato autorizado — sin eso no hay a quién
768
+ // responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
769
+ let passwords = null
770
+ try {
771
+ if (passwordDevices().length) {
772
+ const { createPasswordDesk } = await import('./passwords.js')
773
+ await refreshActa()
774
+ const actaSweeper = setInterval(refreshActa, 5000)
775
+ actaSweeper.unref?.()
776
+ passwords = createPasswordDesk({
777
+ client,
778
+ store: passwordsStore,
779
+ cek: await passwordsKey(),
780
+ // DOS condiciones, y hacen falta las dos: que esta bóveda lo haya autorizado
781
+ // (su lista) y que el ACTA siga reconociéndolo. Revocar un aparato en el acta
782
+ // le corta esto también, sin tener que acordarse de dos sitios.
783
+ isAllowed: (pub) => {
784
+ if (!passwordDevices().some((d) => d.pubkey === pub)) return false
785
+ return (actaCache?.members || []).some((m) => m.pub === pub)
786
+ },
787
+ encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
788
+ needsApproval: (pub) => needsApproval(pub),
789
+ // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
790
+ // Es el mismo camino que ya recorren los cajones de secretos.
791
+ approve: async ({ pubkey, op }) => {
792
+ const deviceId = await deviceIdOf(pubkey).catch(() => null)
793
+ const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
794
+ const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
795
+ audit('passwords.pending', { device: deviceId, id: pend.id, op })
796
+ log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
797
+ const espera = new Promise((resolve) => {
798
+ waiters.set(pend.id, resolve)
799
+ // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
800
+ // y el aparato recibe un no en vez de esperar sin fin.
801
+ const t = setTimeout(() => {
802
+ if (waiters.delete(pend.id)) {
803
+ audit('passwords.expired', { device: deviceId, id: pend.id })
804
+ resolve(false)
805
+ }
806
+ }, PENDING_TTL_MS)
807
+ t.unref?.()
808
+ })
809
+ await notifyApprovers(pend, actaCache)
810
+ return espera
811
+ },
812
+ audit,
813
+ log,
814
+ }).start()
815
+ log('[vault] passwords: atendiendo peticiones de %d aparato(s)', passwordDevices().length)
816
+ // El código que se pega en la extensión o en la consola web. Lleva las DOS
817
+ // públicas: por la de firma enruta el proxio, a la de cifrado se le sella el
818
+ // contenido. Es público: no hay nada aquí que no pueda verse.
819
+ try {
820
+ const codigo = Buffer.from(JSON.stringify({
821
+ v: 1,
822
+ sign: await client.getPublicKey(),
823
+ enc: await identity.getEncryptionPubkey(),
824
+ })).toString('base64url')
825
+ log('[vault] passwords: enlaza un aparato con este código:\n%s', codigo)
826
+ } catch (e) {
827
+ log('[vault] passwords: no se pudo componer el código de enlace: %s', e.message)
828
+ }
829
+ }
830
+ } catch (e) {
831
+ log('[vault] passwords: no se pudo levantar (%s); el resto sigue igual', e.message)
832
+ }
833
+
729
834
  log(`[vault] listo · id ${fp} · ${store.getTree().children.length} nodos`)
730
835
 
731
836
  // ----- API local (CLI/UI de control) -----
@@ -1618,12 +1723,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1618
1723
  stopPairing: desk.stopPairing,
1619
1724
  listPending: desk.listPending,
1620
1725
  // 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(),
1726
+ // Aparatos que piden aprobación al recibir claves (`approvals.js`).
1727
+ needsApproval,
1728
+ setApproval,
1729
+ supervised: () => readSupervised(),
1730
+ // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
1731
+ // llave. Lo que no puede es listarla un aparato.
1732
+ passwordDevices,
1733
+ setPasswordDevice,
1734
+ passwordsVault: () => passwords?.vault || null,
1624
1735
  listApprovals: () => approvals.list(),
1625
- sshKeys,
1626
- requestSshSign,
1627
1736
  // Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
1628
1737
  // dispositivos se entera de que entró alguien, venga de donde venga.
1629
1738
  approveDevice: async (code, adminKey) => {