@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/README.md +25 -31
- package/lib/README.md +6 -5
- package/lib/src/index.js +124 -7
- package/lib/src/invite.js +6 -4
- package/lib/src/service.js +1 -49
- package/lib/src/sshAgent.js +21 -27
- package/lib/src/sshKeys.js +87 -45
- package/package.json +4 -3
- package/src/approvals.js +16 -36
- package/src/ctl.js +68 -119
- package/src/daemon.js +16 -13
- package/src/passwords.js +81 -0
- package/src/secretsStore.js +3 -29
- package/src/tui/i18n.js +2 -2
- package/src/vault.js +223 -114
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
|
-
|
|
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'
|
|
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
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
691
|
-
log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}
|
|
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
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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) => {
|