@dotrino/vaultd 0.38.0 → 0.49.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 +48 -3
- package/lib/README.md +7 -0
- package/lib/src/admin.js +4 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/enroll.js +12 -3
- package/lib/src/env.js +2 -2
- package/lib/src/protocol.js +12 -1
- package/lib/src/service.js +355 -54
- package/lib/src/sshAgent.js +100 -0
- package/lib/src/sshKeys.js +76 -0
- package/package.json +6 -6
- package/src/approvals.js +69 -0
- package/src/ctl.js +323 -22
- package/src/daemon.js +174 -15
- package/src/manager.js +6 -0
- package/src/profiles.js +82 -9
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +805 -90
- package/src/sshAgent.js +2 -0
- package/src/sshKeys.js +2 -0
- package/src/store.js +3 -1
- package/src/tui/app.js +124 -10
- package/src/tui/i18n.js +33 -6
- package/src/vault.js +839 -35
- package/src/vaultControl.js +50 -14
package/src/vault.js
CHANGED
|
@@ -22,6 +22,10 @@ import { createTransport, masterPubkeyOf } from './transport.js'
|
|
|
22
22
|
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
|
+
import { createApprovals } from './approvals.js'
|
|
26
|
+
import { parsePublicKey, sshSignature } from './sshKeys.js'
|
|
27
|
+
import { makeSealer } from './sealer.js'
|
|
28
|
+
import { openSealKeys } from './sealKey.js'
|
|
25
29
|
import { seal } from '../lib/src/sealed.js'
|
|
26
30
|
import { dataDir, ensureDir } from './paths.js'
|
|
27
31
|
import { atRestFor, machineKey, migrateFile } from './atrest.js'
|
|
@@ -37,7 +41,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
|
37
41
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
38
42
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
39
43
|
*/
|
|
40
|
-
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
|
|
44
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
|
|
41
45
|
ensureDir(dir)
|
|
42
46
|
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
43
47
|
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
@@ -59,12 +63,62 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
59
63
|
try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
|
|
60
64
|
}
|
|
61
65
|
|
|
66
|
+
// LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
|
|
67
|
+
// abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
|
|
68
|
+
// y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
|
|
69
|
+
const sealKeys = openSealKeys(dir)
|
|
70
|
+
identity.setSealKeyProvider?.(() => sealKeys.mint())
|
|
71
|
+
|
|
62
72
|
const store = openStore(dir)
|
|
63
73
|
const threads = openThreadStore(dir)
|
|
64
|
-
const
|
|
74
|
+
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?.()
|
|
76
|
+
const secrets = openSecretsStore(dir, {
|
|
77
|
+
sealer: makeSealer(),
|
|
78
|
+
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
79
|
+
// propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
|
|
80
|
+
// acta, y por eso lo pone el vault: el store no conoce el acta.
|
|
81
|
+
recipients: (owner) => recipientsOf(owner),
|
|
82
|
+
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
83
|
+
signer: (body) => signSeal(body),
|
|
84
|
+
defaultKey: () => new Uint8Array(machineKey(dir))
|
|
85
|
+
})
|
|
86
|
+
// SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
|
|
87
|
+
// §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
|
|
88
|
+
// con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
|
|
89
|
+
// siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
|
|
90
|
+
// Se dice en voz alta: prometer una protección que no está puesta es peor que no
|
|
91
|
+
// tenerla (docs/secretos-sellados.md §2.3).
|
|
92
|
+
try {
|
|
93
|
+
if (!hasPassword()) {
|
|
94
|
+
log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
|
|
95
|
+
log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
|
|
96
|
+
}
|
|
97
|
+
} catch (_) {}
|
|
98
|
+
|
|
65
99
|
const master = await masterPubkeyOf(identity)
|
|
66
100
|
const fp = (await pubkeyId(master)).slice(0, 16)
|
|
67
101
|
|
|
102
|
+
/**
|
|
103
|
+
* El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
|
|
104
|
+
* (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
|
|
105
|
+
* restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
|
|
106
|
+
* máquina y no puede quedar a medias.
|
|
107
|
+
*
|
|
108
|
+
* Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
|
|
109
|
+
* llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
|
|
110
|
+
*/
|
|
111
|
+
try {
|
|
112
|
+
const info = await identity.profileActa?.()
|
|
113
|
+
const acta = info?.acta
|
|
114
|
+
if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
|
|
115
|
+
const r = await identity.rotateSealKey()
|
|
116
|
+
// Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
|
|
117
|
+
// primero y `console.log` no lo sustituye (salía «record #%s 2»).
|
|
118
|
+
log(`[vault] new sealing key in record #${r.seq}`)
|
|
119
|
+
}
|
|
120
|
+
} catch (e) { log('[vault] could not set up the sealing key:', e.message) }
|
|
121
|
+
|
|
68
122
|
const { client } = await createTransport({ identity, dir, url: proxyUrl })
|
|
69
123
|
|
|
70
124
|
async function revocationSet () {
|
|
@@ -138,6 +192,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
138
192
|
const n = secrets.forgetDevice(sub)
|
|
139
193
|
if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
|
|
140
194
|
} catch (e) { log('[vault] could not drop the device variables:', e.message) }
|
|
195
|
+
// Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
|
|
196
|
+
// a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
|
|
197
|
+
// la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
|
|
198
|
+
//
|
|
199
|
+
// Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
|
|
200
|
+
// emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
|
|
201
|
+
// Un interruptor que pide una frase que quizá no tienes a mano no es un
|
|
202
|
+
// interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
|
|
203
|
+
markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
|
|
141
204
|
},
|
|
142
205
|
defaultScope: [SCOPE.READ],
|
|
143
206
|
onChallenge ({ deviceId, scope }) {
|
|
@@ -388,8 +451,46 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
388
451
|
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
389
452
|
// secretos falsos). Replay inerte: cada petición usa una ek nueva.
|
|
390
453
|
// data: { op:'secrets', ns, ek, publickey, ts }
|
|
454
|
+
/**
|
|
455
|
+
* Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
|
|
456
|
+
* suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
|
|
457
|
+
* de variables va indexado por ella — se quedaría sin configuración sin decirlo.
|
|
458
|
+
*/
|
|
459
|
+
async function handleEncKey (from, p) {
|
|
460
|
+
const ns = p.data?.ns
|
|
461
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
|
|
462
|
+
const chk = await verifyChain({
|
|
463
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
464
|
+
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
465
|
+
})
|
|
466
|
+
if (!chk.ok) return denyChain(from, chk, p, 'enckey')
|
|
467
|
+
try {
|
|
468
|
+
await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
|
|
469
|
+
audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
470
|
+
log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
|
|
471
|
+
// Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
|
|
472
|
+
// seguiría sin poder abrir nada hasta la siguiente escritura.
|
|
473
|
+
await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
|
|
474
|
+
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
475
|
+
const { signature } = await identity.signData(body)
|
|
476
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
477
|
+
} catch (e) {
|
|
478
|
+
reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
391
482
|
async function handleSecrets (from, p) {
|
|
392
483
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
484
|
+
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
485
|
+
// un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
|
|
486
|
+
// de identidad y obligaría a re-vendorizar.
|
|
487
|
+
//
|
|
488
|
+
// No exige la contraseña del perfil, y el argumento importa: registrar una llave no
|
|
489
|
+
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
490
|
+
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
491
|
+
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)
|
|
393
494
|
const ns = p.data?.ns
|
|
394
495
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
395
496
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -406,16 +507,202 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
406
507
|
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
407
508
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
408
509
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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)) {
|
|
514
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
515
|
+
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
516
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
|
|
517
|
+
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
518
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
519
|
+
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
520
|
+
const { signature } = await identity.signData(body)
|
|
521
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
522
|
+
await notifyApprovers(pend, record)
|
|
523
|
+
return
|
|
524
|
+
}
|
|
525
|
+
let res
|
|
526
|
+
try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
|
|
413
527
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
414
528
|
}
|
|
529
|
+
audit('secrets', { device: await deviceIdOf(chk.device), ns })
|
|
530
|
+
reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la maestra. */
|
|
534
|
+
async function resultFor (ns, devicePub, ek, record) {
|
|
535
|
+
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
536
|
+
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
537
|
+
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
538
|
+
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
539
|
+
const b = secrets.bundleFor(ns, devicePub)
|
|
540
|
+
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
541
|
+
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
542
|
+
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
543
|
+
// del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
|
|
544
|
+
// de dónde salió lo que abre.
|
|
545
|
+
const payload = b.legacy
|
|
546
|
+
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
|
|
547
|
+
: { sealed: b, acta: record || null }
|
|
548
|
+
const enc = await seal({ ek, payload })
|
|
415
549
|
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
416
550
|
const { signature } = await identity.signData(body)
|
|
417
|
-
|
|
418
|
-
|
|
551
|
+
return { body, signature }
|
|
552
|
+
}
|
|
553
|
+
|
|
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
|
+
/**
|
|
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
|
|
616
|
+
* `op: approvals | approve | deny`, firmados por un aparato con `vault:approve` — que,
|
|
617
|
+
* como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`). El acta tiene
|
|
618
|
+
* que decirlo también, para que quitar el permiso surta efecto en el acto.
|
|
619
|
+
*/
|
|
620
|
+
async function handleApproval (from, p) {
|
|
621
|
+
const op = p.data?.op
|
|
622
|
+
const chk = await verifyChain({
|
|
623
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
624
|
+
expectedScope: SCOPE.APPROVE, trustedIssuer: master, revoked: await revocationSet()
|
|
625
|
+
})
|
|
626
|
+
if (!chk.ok) return denyChain(from, chk, p, 'approval')
|
|
627
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
628
|
+
if (record && !Acta.memberCan(record, chk.device, 'approve')) {
|
|
629
|
+
audit('rejected', { what: 'approval', reason: 'acta' })
|
|
630
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
|
|
631
|
+
}
|
|
632
|
+
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
633
|
+
const answer = async (body) => {
|
|
634
|
+
body = { ...body, ts: Date.now() }
|
|
635
|
+
const { signature } = await identity.signData(body)
|
|
636
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
637
|
+
}
|
|
638
|
+
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
|
+
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
657
|
+
const pend = approvals.take(id)
|
|
658
|
+
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 })
|
|
677
|
+
}
|
|
678
|
+
if (op === 'deny') {
|
|
679
|
+
audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
|
|
680
|
+
log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
|
|
681
|
+
try { client.sendByPubkey(pend.device, { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }) } catch (_) {}
|
|
682
|
+
return answer({ op: 'deny.result', id, ok: true })
|
|
683
|
+
}
|
|
684
|
+
if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
|
|
685
|
+
const exp = approvals.grant(pend.ns, pend.device)
|
|
686
|
+
let res
|
|
687
|
+
try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
|
|
688
|
+
return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
|
|
689
|
+
}
|
|
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()})`)
|
|
692
|
+
// Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
|
|
693
|
+
try { client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res }) } catch (_) {}
|
|
694
|
+
return answer({ op: 'approve.result', id, ok: true, exp })
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Aviso a los aparatos que aprueban (cola del proxio → push nativo si están apagados). */
|
|
698
|
+
async function notifyApprovers (pend, record) {
|
|
699
|
+
try {
|
|
700
|
+
const body = { ev: 'approval', id: pend.id, ns: pend.ns, deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
|
|
701
|
+
const { signature } = await identity.signData(body)
|
|
702
|
+
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
703
|
+
if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
|
|
704
|
+
for (const pub of who) { try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {} }
|
|
705
|
+
} catch (e) { log('[vault] could not notify approvers:', e.message) }
|
|
419
706
|
}
|
|
420
707
|
|
|
421
708
|
client.on('message', async (from, payload) => {
|
|
@@ -431,6 +718,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
431
718
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
432
719
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
433
720
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
721
|
+
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
434
722
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
435
723
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
436
724
|
} catch (e) {
|
|
@@ -577,6 +865,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
577
865
|
* 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
|
|
578
866
|
* transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
|
|
579
867
|
*/
|
|
868
|
+
/**
|
|
869
|
+
* La llave de administración a partir de lo que venga en el sobre de la consola
|
|
870
|
+
* remota. `undefined` si no trae contraseña — entonces el store cae a la de la
|
|
871
|
+
* máquina, que es lo correcto para un perfil que no tiene ninguna.
|
|
872
|
+
*
|
|
873
|
+
* La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
|
|
874
|
+
*/
|
|
875
|
+
/**
|
|
876
|
+
* La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
|
|
877
|
+
* operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
|
|
878
|
+
*/
|
|
879
|
+
async function adminKeyFrom (payload) {
|
|
880
|
+
const pwd = payload?.password
|
|
881
|
+
if (typeof pwd !== 'string' || !pwd) return undefined
|
|
882
|
+
if (typeof deriveAdminKey !== 'function') return undefined
|
|
883
|
+
return deriveAdminKey(pwd)
|
|
884
|
+
}
|
|
885
|
+
|
|
580
886
|
const varsDesk = {
|
|
581
887
|
async list () {
|
|
582
888
|
// `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
|
|
@@ -585,16 +891,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
585
891
|
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
586
892
|
return {
|
|
587
893
|
enc: await identity.sealContent(JSON.stringify({
|
|
588
|
-
ns: listSecrets(), dev: await listDeviceSecrets()
|
|
894
|
+
ns: listSecrets(), dev: await listDeviceSecrets(),
|
|
895
|
+
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
896
|
+
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
897
|
+
incomplete: await incompleteMembers(),
|
|
898
|
+
// Lo que quedó a deber un sellado: quien administra a distancia tiene que
|
|
899
|
+
// poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
|
|
900
|
+
// variable con la contraseña) y la bóveda no.
|
|
901
|
+
pending: await secretDebts(),
|
|
902
|
+
// Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
|
|
903
|
+
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
904
|
+
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
905
|
+
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
906
|
+
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
589
907
|
}))
|
|
590
908
|
}
|
|
591
909
|
},
|
|
592
|
-
async set ({ ns, pub, key, enc, public: isPublic }) {
|
|
910
|
+
async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
|
|
593
911
|
const payload = JSON.parse(await identity.openContent(enc))
|
|
594
912
|
const value = payload?.value
|
|
595
913
|
if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
|
|
596
|
-
|
|
597
|
-
|
|
914
|
+
// NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
|
|
915
|
+
// leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
|
|
916
|
+
if (ns) await setSecret(ns, key, value, isPublic, { by: who })
|
|
917
|
+
else await setDeviceSecret(pub, key, value, isPublic, { by: who })
|
|
598
918
|
return { ok: true, key }
|
|
599
919
|
},
|
|
600
920
|
/**
|
|
@@ -607,7 +927,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
607
927
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
608
928
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
609
929
|
*/
|
|
610
|
-
async setMany ({ ns, pub, enc, public: isPublic }) {
|
|
930
|
+
async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
|
|
611
931
|
const payload = JSON.parse(await identity.openContent(enc))
|
|
612
932
|
const items = payload?.items
|
|
613
933
|
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
|
|
@@ -620,7 +940,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
620
940
|
value: it?.value,
|
|
621
941
|
...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
|
|
622
942
|
}))
|
|
623
|
-
|
|
943
|
+
// NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
|
|
944
|
+
// escritura quedaba al aire y la respuesta salía antes de guardar nada.
|
|
945
|
+
const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
|
|
624
946
|
return { ok: true, keys }
|
|
625
947
|
}
|
|
626
948
|
}
|
|
@@ -694,8 +1016,421 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
694
1016
|
|
|
695
1017
|
// API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
|
|
696
1018
|
// sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
|
|
697
|
-
|
|
698
|
-
|
|
1019
|
+
/**
|
|
1020
|
+
* La llave con la que se abre la copia maestra.
|
|
1021
|
+
*
|
|
1022
|
+
* Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
|
|
1023
|
+
* contraseña se cae a la llave de la máquina**, que es exactamente la protección
|
|
1024
|
+
* que había antes de todo esto: el disco sigue cifrado, pero su material vive en
|
|
1025
|
+
* ese mismo disco, así que una copia del disco lo abre.
|
|
1026
|
+
*
|
|
1027
|
+
* Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
|
|
1028
|
+
* pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
|
|
1029
|
+
* diseño). Prometer una protección que no está puesta es peor que no tenerla.
|
|
1030
|
+
*/
|
|
1031
|
+
const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
|
|
1032
|
+
|
|
1033
|
+
// `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
|
|
1034
|
+
// usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
|
|
1035
|
+
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
1036
|
+
/**
|
|
1037
|
+
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
1038
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
|
|
1039
|
+
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
1040
|
+
* que este diseño quita.
|
|
1041
|
+
*/
|
|
1042
|
+
async function nsMembers (ns) {
|
|
1043
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1044
|
+
return (record?.members || []).filter((m) => m.cn === ns)
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
|
|
1049
|
+
* cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
|
|
1050
|
+
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
1051
|
+
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
1052
|
+
*
|
|
1053
|
+
* La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
1054
|
+
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
1055
|
+
* diseño quita. Hay un test que lo afirma.
|
|
1056
|
+
*/
|
|
1057
|
+
async function adminDevices () {
|
|
1058
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1059
|
+
return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
|
|
1064
|
+
* importa (decidida por el dueño el 2026-08-22):
|
|
1065
|
+
*
|
|
1066
|
+
* **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
|
|
1067
|
+
* hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
|
|
1068
|
+
* token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
|
|
1069
|
+
* alguien se lleve tu portátil con la sesión puesta.
|
|
1070
|
+
*
|
|
1071
|
+
* Dos cosas que hacen que esto se sostenga:
|
|
1072
|
+
*
|
|
1073
|
+
* · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
|
|
1074
|
+
* que la bóveda se negara a entregarlo, pero entonces la protección sería una
|
|
1075
|
+
* política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
|
|
1076
|
+
* llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
|
|
1077
|
+
* se crea no se puede saltar.
|
|
1078
|
+
* · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
|
|
1079
|
+
* dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
|
|
1080
|
+
* se puede forzar esperando a que ese servicio esté caído.
|
|
1081
|
+
*
|
|
1082
|
+
* Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
|
|
1083
|
+
* miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
|
|
1084
|
+
* abre la envoltura de recuperación, que `wrapAll` añade siempre.
|
|
1085
|
+
*/
|
|
1086
|
+
/**
|
|
1087
|
+
* Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
|
|
1088
|
+
* el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
|
|
1089
|
+
* quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
|
|
1090
|
+
* qué pidió; aquí solo se valida el remitente.
|
|
1091
|
+
*/
|
|
1092
|
+
async function handleRewrapOk (payload) {
|
|
1093
|
+
const d = payload?.data
|
|
1094
|
+
if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
|
|
1095
|
+
const signer = payload?.cert?.sub
|
|
1096
|
+
if (!signer) return
|
|
1097
|
+
if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
|
|
1098
|
+
return log('[vault] a handed key arrived BADLY SIGNED: ignored')
|
|
1099
|
+
}
|
|
1100
|
+
// Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
|
|
1101
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1102
|
+
const m = (record?.members || []).find((x) => x.pub === signer)
|
|
1103
|
+
const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
|
|
1104
|
+
if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
|
|
1105
|
+
for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
|
|
1109
|
+
const rewrapWaiters = new Set()
|
|
1110
|
+
const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
|
|
1111
|
+
|
|
1112
|
+
async function recipientsOf (owner) {
|
|
1113
|
+
if (owner.startsWith('ns:')) {
|
|
1114
|
+
const owned = await nsMembers(owner.slice(3))
|
|
1115
|
+
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
|
|
1116
|
+
// administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
1117
|
+
return owned.length ? owned : await adminDevices()
|
|
1118
|
+
}
|
|
1119
|
+
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
1120
|
+
const m = await memberOf(pub)
|
|
1121
|
+
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
1122
|
+
if (m?.cn) return [m]
|
|
1123
|
+
return [...(m ? [m] : []), ...await adminDevices()]
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
|
|
1128
|
+
* del acta junto a la firma: es lo que le dice a quien verifica con qué llave
|
|
1129
|
+
* comprobarla, porque esa llave rota con el acta (§8.9).
|
|
1130
|
+
*
|
|
1131
|
+
* Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
|
|
1132
|
+
* es más importante que poder demostrar después de dónde salió.
|
|
1133
|
+
*/
|
|
1134
|
+
async function signSeal (body) {
|
|
1135
|
+
try {
|
|
1136
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1137
|
+
if (!acta?.sealPub) return null
|
|
1138
|
+
const sig = await sealKeys.sign(acta.sealPub, body)
|
|
1139
|
+
return sig ? { seq: acta.seq, sig } : null
|
|
1140
|
+
} catch (_) { return null }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
|
|
1145
|
+
* del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
|
|
1146
|
+
* abrir y se queda reintentando para siempre, sin decir por qué.
|
|
1147
|
+
*
|
|
1148
|
+
* No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
|
|
1149
|
+
* reiniciaría a todos los nodos del ns para nada.
|
|
1150
|
+
*/
|
|
1151
|
+
/**
|
|
1152
|
+
* Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
|
|
1153
|
+
* el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
|
|
1154
|
+
* consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
|
|
1155
|
+
*
|
|
1156
|
+
* Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
|
|
1157
|
+
* que ya no está podría abrir lo que se escriba mañana.
|
|
1158
|
+
*/
|
|
1159
|
+
async function markRotationDue (sub) {
|
|
1160
|
+
const m = await memberOf(sub)
|
|
1161
|
+
const ns = m?.cn
|
|
1162
|
+
if (!ns) return
|
|
1163
|
+
try {
|
|
1164
|
+
const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
|
|
1165
|
+
if (r?.rotated != null) {
|
|
1166
|
+
log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
|
|
1167
|
+
audit('secret.rotate', { ns, keys: r.rotated })
|
|
1168
|
+
scheduleNotice(ns)
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
store.setSetting(`rotate-due:${ns}`, String(Date.now()))
|
|
1173
|
+
log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
|
|
1174
|
+
audit('secret.rotate-due', { ns, reason: e.message })
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
|
|
1180
|
+
* deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
|
|
1181
|
+
* salen juntas:
|
|
1182
|
+
*
|
|
1183
|
+
* - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
|
|
1184
|
+
* - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
|
|
1185
|
+
*
|
|
1186
|
+
* Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
|
|
1187
|
+
* enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
|
|
1188
|
+
* que además nadie ve.
|
|
1189
|
+
*/
|
|
1190
|
+
/**
|
|
1191
|
+
* Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
|
|
1192
|
+
* las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
|
|
1193
|
+
* llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
|
|
1194
|
+
* hay quien la teclee.
|
|
1195
|
+
*
|
|
1196
|
+
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
1197
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
|
|
1198
|
+
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
1199
|
+
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
1200
|
+
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
1201
|
+
*
|
|
1202
|
+
* @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
|
|
1203
|
+
*/
|
|
1204
|
+
async function incompleteMembers () {
|
|
1205
|
+
if (secrets.isLegacy?.()) return []
|
|
1206
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1207
|
+
const out = []
|
|
1208
|
+
for (const m of record?.members || []) {
|
|
1209
|
+
if (!m.encPub || m.pub === master) continue
|
|
1210
|
+
const owners = {}
|
|
1211
|
+
// Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
|
|
1212
|
+
// puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
|
|
1213
|
+
// «Ver» de la consola le fallaría sin explicar por qué.
|
|
1214
|
+
// Un aparato que administra NO debe tener envoltura de los cajones con dueño
|
|
1215
|
+
// (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
|
|
1216
|
+
// propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
|
|
1217
|
+
const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
|
|
1218
|
+
const drawers = m.cn
|
|
1219
|
+
? [`ns:${m.cn}`, `dev:${m.pub}`]
|
|
1220
|
+
: [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
|
|
1221
|
+
for (const owner of drawers) {
|
|
1222
|
+
const missing = secrets.missingFor(owner, m.pub)
|
|
1223
|
+
if (missing.length) owners[owner] = missing
|
|
1224
|
+
}
|
|
1225
|
+
if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
|
|
1226
|
+
}
|
|
1227
|
+
return out
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
/**
|
|
1231
|
+
* Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
|
|
1232
|
+
* no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
|
|
1233
|
+
* estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
|
|
1234
|
+
*/
|
|
1235
|
+
function rotationsDue () {
|
|
1236
|
+
const out = {}
|
|
1237
|
+
for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
|
|
1238
|
+
// La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
|
|
1239
|
+
// nombre del namespace: una sola forma para quien lo lee.
|
|
1240
|
+
if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
|
|
1241
|
+
}
|
|
1242
|
+
return out
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/**
|
|
1246
|
+
* TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
|
|
1247
|
+
* envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
1248
|
+
* se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
|
|
1249
|
+
* se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
|
|
1250
|
+
* entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
|
|
1251
|
+
* @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
|
|
1252
|
+
*/
|
|
1253
|
+
async function secretDebts () {
|
|
1254
|
+
const out = rotationsDue()
|
|
1255
|
+
for (const m of await incompleteMembers()) {
|
|
1256
|
+
for (const [owner, keys] of Object.entries(m.owners)) {
|
|
1257
|
+
if (out[owner]?.kind === 'rotate') continue
|
|
1258
|
+
out[owner] = out[owner] || { kind: 'rewrap', members: [] }
|
|
1259
|
+
out[owner].members.push({ pub: m.pub, keys })
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return out
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
async function spreadKey (owner, members, adminKey) {
|
|
1266
|
+
if (secrets.isLegacy()) return null
|
|
1267
|
+
// Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
|
|
1268
|
+
// se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
|
|
1269
|
+
// sustituye al reparto en vez de sumarse.
|
|
1270
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1271
|
+
if (ns && store.getSetting(`rotate-due:${ns}`)) {
|
|
1272
|
+
const rot = await secrets.rotate(owner, members, adminKey)
|
|
1273
|
+
store.setSetting(`rotate-due:${ns}`, undefined)
|
|
1274
|
+
log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
|
|
1275
|
+
audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
|
|
1276
|
+
return rot
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
const r = await secrets.rewrap(owner, members, adminKey)
|
|
1280
|
+
if (r?.sinLlave?.length) {
|
|
1281
|
+
log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
|
|
1282
|
+
audit('secret.nokey', { owner, count: r.sinLlave.length })
|
|
1283
|
+
}
|
|
1284
|
+
return r
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
// SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
|
|
1287
|
+
// donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
|
|
1288
|
+
// cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
|
|
1289
|
+
// del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
|
|
1290
|
+
// — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
|
|
1291
|
+
// anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
|
|
1292
|
+
// salde un hermano o el abrir la bóveda.
|
|
1293
|
+
log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
|
|
1294
|
+
audit('secret.rewrap-due', { owner, reason: e.message })
|
|
1295
|
+
throw e
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
|
|
1301
|
+
* a leer. `by` es el aparato que lo escribió, y va al histórico.
|
|
1302
|
+
*/
|
|
1303
|
+
async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
|
|
1304
|
+
await secrets.set(ns, key, value, isPublic, { by })
|
|
1305
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1306
|
+
audit('secret.set', { ns, key }); scheduleNotice(ns)
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
/**
|
|
1310
|
+
* SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
|
|
1311
|
+
* desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
|
|
1312
|
+
* verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
|
|
1313
|
+
* único que la frase guarda (§8.3).
|
|
1314
|
+
*
|
|
1315
|
+
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
1316
|
+
* tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
|
|
1317
|
+
*/
|
|
1318
|
+
/**
|
|
1319
|
+
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
1320
|
+
*
|
|
1321
|
+
* Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
|
|
1322
|
+
* llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
|
|
1323
|
+
* tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
|
|
1324
|
+
*
|
|
1325
|
+
* La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
|
|
1326
|
+
* podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
|
|
1327
|
+
* destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
|
|
1328
|
+
* solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
|
|
1329
|
+
*
|
|
1330
|
+
* @returns {Promise<{ done: number, asked: number }>}
|
|
1331
|
+
*/
|
|
1332
|
+
async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
|
|
1333
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1334
|
+
if (!ns) return { done: 0, asked: 0 }
|
|
1335
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1336
|
+
if (!record) return { done: 0, asked: 0 }
|
|
1337
|
+
// Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
|
|
1338
|
+
const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
|
|
1339
|
+
const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
|
|
1340
|
+
if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
|
|
1341
|
+
|
|
1342
|
+
let done = 0
|
|
1343
|
+
for (const gen of pending) {
|
|
1344
|
+
const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
|
|
1345
|
+
const { signature } = await identity.signData(body)
|
|
1346
|
+
const answer = new Promise((resolve) => {
|
|
1347
|
+
const off = onRewrapOk((d) => {
|
|
1348
|
+
if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
|
|
1349
|
+
off(); resolve(d.wrap)
|
|
1350
|
+
})
|
|
1351
|
+
setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
|
|
1352
|
+
})
|
|
1353
|
+
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
|
|
1354
|
+
log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
|
|
1355
|
+
continue
|
|
1356
|
+
}
|
|
1357
|
+
const wrap = await answer
|
|
1358
|
+
if (!wrap) continue
|
|
1359
|
+
try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
|
|
1360
|
+
log(`[vault] ${owner}: the handed key was refused (${e.message})`)
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (done) {
|
|
1364
|
+
audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
|
|
1365
|
+
log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
|
|
1366
|
+
}
|
|
1367
|
+
return { done, asked: pending.length }
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
async function resealAll (adminKey = null) {
|
|
1371
|
+
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
|
|
1372
|
+
for (const owner of secrets.owners?.() || []) {
|
|
1373
|
+
const before = new Set(secrets.recipientsIn(owner))
|
|
1374
|
+
const members = await recipientsOf(owner)
|
|
1375
|
+
try {
|
|
1376
|
+
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
1377
|
+
const after = new Set(secrets.recipientsIn(owner))
|
|
1378
|
+
out.drawers++
|
|
1379
|
+
out.wrapped += r?.wrapped || 0
|
|
1380
|
+
out.dropped += [...before].filter((p) => !after.has(p)).length
|
|
1381
|
+
for (const p of r?.sinLlave || []) out.sinLlave.push(p)
|
|
1382
|
+
} catch (e) {
|
|
1383
|
+
log(`[vault] ${owner}: could not reseal (${e.message})`)
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
|
|
1387
|
+
return out
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
async function settleSecretDebts (adminKey = null) {
|
|
1391
|
+
const out = {}
|
|
1392
|
+
// Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
|
|
1393
|
+
// envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
|
|
1394
|
+
// por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
|
|
1395
|
+
// pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
|
|
1396
|
+
// que es barato y no depende de que alguien se acordara de apuntarlo.
|
|
1397
|
+
const debts = new Set(Object.keys(rotationsDue()))
|
|
1398
|
+
for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
|
|
1399
|
+
|
|
1400
|
+
for (const owner of debts) {
|
|
1401
|
+
const k = owner.slice(owner.indexOf(':') + 1)
|
|
1402
|
+
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
1403
|
+
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
1404
|
+
// sin poder destapar lo que ella misma acababa de saldar.
|
|
1405
|
+
const members = await recipientsOf(owner)
|
|
1406
|
+
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
1407
|
+
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
1408
|
+
let delegated = 0
|
|
1409
|
+
for (const m of await incompleteMembers()) {
|
|
1410
|
+
if (!m.owners[owner]) continue
|
|
1411
|
+
delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
|
|
1412
|
+
}
|
|
1413
|
+
out[owner] = delegated ? { delegated } : { error: e.message }
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return out
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
|
|
1421
|
+
* Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
|
|
1422
|
+
* recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
|
|
1423
|
+
* anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
|
|
1424
|
+
* guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
|
|
1425
|
+
*/
|
|
1426
|
+
async function settleDebts (owner, membersFn) {
|
|
1427
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1428
|
+
const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
|
|
1429
|
+
(await incompleteMembers()).some((m) => m.owners[owner])
|
|
1430
|
+
if (!owed) return null
|
|
1431
|
+
try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
|
|
1432
|
+
}
|
|
1433
|
+
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
699
1434
|
|
|
700
1435
|
/**
|
|
701
1436
|
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
@@ -716,40 +1451,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
716
1451
|
* @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
|
|
717
1452
|
* estaba no cambia nada, y no tiene por qué reiniciar a nadie).
|
|
718
1453
|
*/
|
|
719
|
-
function applySecrets (ns, items) {
|
|
1454
|
+
async function applySecrets (ns, items, { by = null } = {}) {
|
|
720
1455
|
const list = assertItems(items)
|
|
721
1456
|
const changed = []
|
|
722
|
-
secrets.batch(() => {
|
|
1457
|
+
await secrets.batch(async () => {
|
|
723
1458
|
for (const it of list) {
|
|
724
1459
|
if (it.op === 'rm') {
|
|
725
|
-
if (secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
1460
|
+
if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
726
1461
|
} else {
|
|
727
|
-
secrets.set(ns, it.key, it.value, it.public)
|
|
1462
|
+
await secrets.set(ns, it.key, it.value, it.public, { by })
|
|
728
1463
|
audit('secret.set', { ns, key: it.key })
|
|
729
1464
|
changed.push(it.key)
|
|
730
1465
|
}
|
|
731
1466
|
}
|
|
732
1467
|
})
|
|
733
|
-
if (changed.length)
|
|
1468
|
+
if (changed.length) {
|
|
1469
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1470
|
+
scheduleNotice(ns)
|
|
1471
|
+
}
|
|
734
1472
|
return changed
|
|
735
1473
|
}
|
|
736
1474
|
|
|
737
1475
|
/** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
|
|
738
|
-
async function applyDeviceSecrets (pub, items) {
|
|
1476
|
+
async function applyDeviceSecrets (pub, items, { by = null } = {}) {
|
|
739
1477
|
const list = assertItems(items)
|
|
740
1478
|
const m = await requireService(pub)
|
|
741
1479
|
const changed = []
|
|
742
|
-
secrets.batch(() => {
|
|
1480
|
+
await secrets.batch(async () => {
|
|
743
1481
|
for (const it of list) {
|
|
744
1482
|
if (it.op === 'rm') {
|
|
745
|
-
if (secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
1483
|
+
if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
746
1484
|
} else {
|
|
747
|
-
secrets.setDevice(pub, it.key, it.value, it.public)
|
|
1485
|
+
await secrets.setDevice(pub, it.key, it.value, it.public, { by })
|
|
748
1486
|
changed.push(it.key)
|
|
749
1487
|
}
|
|
750
1488
|
}
|
|
751
1489
|
})
|
|
752
1490
|
if (changed.length) {
|
|
1491
|
+
await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
|
|
753
1492
|
const device = await deviceIdOf(pub).catch(() => null)
|
|
754
1493
|
for (const it of list) {
|
|
755
1494
|
if (!changed.includes(it.key)) continue
|
|
@@ -786,12 +1525,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
786
1525
|
return out
|
|
787
1526
|
}
|
|
788
1527
|
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
789
|
-
function setSecretVisibility (ns, key, isPublic) {
|
|
790
|
-
const ok = secrets.setVisibility(ns, key, isPublic)
|
|
1528
|
+
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
1529
|
+
const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
|
|
791
1530
|
if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
|
|
792
1531
|
return ok
|
|
793
1532
|
}
|
|
794
1533
|
|
|
1534
|
+
/**
|
|
1535
|
+
* El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
|
|
1536
|
+
* camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
|
|
1537
|
+
* este sí pide poder abrir la copia maestra.
|
|
1538
|
+
*/
|
|
1539
|
+
async function openSecrets (ns, devicePub = null, adminKey) {
|
|
1540
|
+
return secrets.openBundle(ns, devicePub, adminKey)
|
|
1541
|
+
}
|
|
1542
|
+
|
|
795
1543
|
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
796
1544
|
async function memberOf (pub) {
|
|
797
1545
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -816,16 +1564,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
816
1564
|
return m
|
|
817
1565
|
}
|
|
818
1566
|
|
|
819
|
-
async function setDeviceSecret (pub, key, value, isPublic) {
|
|
1567
|
+
async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
|
|
820
1568
|
const m = await requireService(pub)
|
|
821
|
-
secrets.setDevice(pub, key, value, isPublic)
|
|
1569
|
+
await secrets.setDevice(pub, key, value, isPublic, { by })
|
|
1570
|
+
await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
|
|
822
1571
|
audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
823
1572
|
scheduleDeviceNotice(pub)
|
|
824
1573
|
}
|
|
825
1574
|
|
|
826
1575
|
async function deleteDeviceSecret (pub, key) {
|
|
827
1576
|
const m = await memberOf(pub)
|
|
828
|
-
const ok = secrets.deleteDevice(pub, key)
|
|
1577
|
+
const ok = await secrets.deleteDevice(pub, key)
|
|
829
1578
|
if (ok) {
|
|
830
1579
|
audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
831
1580
|
scheduleDeviceNotice(pub)
|
|
@@ -833,8 +1582,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
833
1582
|
return ok
|
|
834
1583
|
}
|
|
835
1584
|
|
|
836
|
-
async function setDeviceSecretVisibility (pub, key, isPublic) {
|
|
837
|
-
const ok = secrets.setDeviceVisibility(pub, key, isPublic)
|
|
1585
|
+
async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
|
|
1586
|
+
const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
|
|
838
1587
|
if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
|
|
839
1588
|
return ok
|
|
840
1589
|
}
|
|
@@ -864,14 +1613,38 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
864
1613
|
}
|
|
865
1614
|
|
|
866
1615
|
return {
|
|
867
|
-
identity, client, store, threads, secrets, master, fingerprint: fp,
|
|
1616
|
+
identity, client, store, threads, secrets, master, fingerprint: fp, dir,
|
|
868
1617
|
startPairing: desk.startPairing,
|
|
869
1618
|
stopPairing: desk.stopPairing,
|
|
870
1619
|
listPending: desk.listPending,
|
|
1620
|
+
// 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(),
|
|
1624
|
+
listApprovals: () => approvals.list(),
|
|
1625
|
+
sshKeys,
|
|
1626
|
+
requestSshSign,
|
|
871
1627
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
872
1628
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
873
|
-
approveDevice: async (code) => {
|
|
1629
|
+
approveDevice: async (code, adminKey) => {
|
|
874
1630
|
const r = await desk.approve(code)
|
|
1631
|
+
// Un servicio que ENTRA a un namespace que ya tiene variables necesita su
|
|
1632
|
+
// envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
|
|
1633
|
+
// reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
|
|
1634
|
+
// puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
|
|
1635
|
+
// el archivo vendorizado en el iframe de identidad.
|
|
1636
|
+
const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
|
|
1637
|
+
if (m?.cn) {
|
|
1638
|
+
await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
|
|
1639
|
+
log('[vault] could not hand the key to the new service:', e.message)
|
|
1640
|
+
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
1641
|
+
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|
|
1642
|
+
// recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
|
|
1643
|
+
// a la vista y se salda al abrir la bóveda.
|
|
1644
|
+
const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
|
|
1645
|
+
if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
|
|
1646
|
+
})
|
|
1647
|
+
}
|
|
875
1648
|
await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
|
|
876
1649
|
return r
|
|
877
1650
|
},
|
|
@@ -879,13 +1652,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
879
1652
|
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
880
1653
|
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
881
1654
|
vars: varsDesk,
|
|
882
|
-
setSecret, deleteSecret, listSecrets, setSecretVisibility,
|
|
1655
|
+
setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
1656
|
+
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
1657
|
+
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
1658
|
+
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
1659
|
+
/**
|
|
1660
|
+
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
1661
|
+
* normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
|
|
1662
|
+
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
1663
|
+
*
|
|
1664
|
+
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
1665
|
+
* entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
|
|
1666
|
+
* diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
|
|
1667
|
+
*/
|
|
1668
|
+
resealAll,
|
|
1669
|
+
delegateRewrap,
|
|
1670
|
+
incompleteMembers,
|
|
1671
|
+
migrateSecrets: (membersOf, adminKey) => secrets.migrate(
|
|
1672
|
+
membersOf || ((owner) => recipientsOf(owner)), adminKey
|
|
1673
|
+
),
|
|
1674
|
+
settleSecretDebts,
|
|
1675
|
+
revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
|
|
1676
|
+
secretHistory: (owner, key) => secrets.history(owner, key),
|
|
1677
|
+
revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
|
|
1678
|
+
revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
|
|
1679
|
+
// Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
|
|
1680
|
+
// la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
|
|
1681
|
+
rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
|
|
883
1682
|
setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
|
|
884
1683
|
applySecrets, applyDeviceSecrets,
|
|
885
1684
|
listDevices: () => identity.listDelegations(),
|
|
886
1685
|
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
887
1686
|
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
888
1687
|
profileMembers: () => identity.profileMembers(),
|
|
1688
|
+
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|
|
1689
|
+
// rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
|
|
1690
|
+
rotationsDue,
|
|
1691
|
+
secretDebts,
|
|
889
1692
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
890
1693
|
isMaster: () => identity.isMaster(),
|
|
891
1694
|
setCaps: async (pub, caps) => {
|
|
@@ -920,6 +1723,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
920
1723
|
return r
|
|
921
1724
|
},
|
|
922
1725
|
close () {
|
|
1726
|
+
clearInterval(approvalsSweeper)
|
|
923
1727
|
for (const t of pendingNotices.values()) clearTimeout(t)
|
|
924
1728
|
pendingNotices.clear()
|
|
925
1729
|
try { client.close() } catch (_) {} identity.destroy()
|