@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/README.md +25 -31
- package/lib/README.md +6 -5
- package/lib/src/enroll.js +1 -1
- package/lib/src/index.js +124 -7
- package/lib/src/invite.js +6 -4
- package/lib/src/protocol.js +5 -1
- 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 +50 -125
- package/src/daemon.js +11 -13
- package/src/passwords.js +81 -0
- package/src/secretsStore.js +3 -29
- package/src/tui/app.js +1 -1
- package/src/tui/i18n.js +10 -6
- package/src/vault.js +239 -114
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/
|
|
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/
|
|
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
|
-
|
|
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'
|
|
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
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 })
|
|
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
|
|
691
|
-
log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}
|
|
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
|
|
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
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
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) => {
|