@dotrino/vaultd 0.38.0 → 0.46.2
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 +4 -1
- package/lib/src/admin.js +4 -0
- package/lib/src/atrest.js +0 -0
- package/lib/src/enroll.js +12 -3
- package/lib/src/protocol.js +10 -0
- package/lib/src/service.js +288 -52
- package/package.json +6 -6
- package/src/ctl.js +273 -17
- package/src/daemon.js +161 -14
- 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 +777 -91
- package/src/store.js +3 -1
- package/src/tui/app.js +119 -9
- package/src/tui/i18n.js +31 -6
- package/src/vault.js +647 -30
- package/src/vaultControl.js +50 -14
package/src/vault.js
CHANGED
|
@@ -22,6 +22,8 @@ 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 { makeSealer } from './sealer.js'
|
|
26
|
+
import { openSealKeys } from './sealKey.js'
|
|
25
27
|
import { seal } from '../lib/src/sealed.js'
|
|
26
28
|
import { dataDir, ensureDir } from './paths.js'
|
|
27
29
|
import { atRestFor, machineKey, migrateFile } from './atrest.js'
|
|
@@ -37,7 +39,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
|
37
39
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
38
40
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
39
41
|
*/
|
|
40
|
-
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
|
|
42
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
|
|
41
43
|
ensureDir(dir)
|
|
42
44
|
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
43
45
|
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
@@ -59,12 +61,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
59
61
|
try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
|
|
65
|
+
// abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
|
|
66
|
+
// y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
|
|
67
|
+
const sealKeys = openSealKeys(dir)
|
|
68
|
+
identity.setSealKeyProvider?.(() => sealKeys.mint())
|
|
69
|
+
|
|
62
70
|
const store = openStore(dir)
|
|
63
71
|
const threads = openThreadStore(dir)
|
|
64
|
-
const secrets = openSecretsStore(dir
|
|
72
|
+
const secrets = openSecretsStore(dir, {
|
|
73
|
+
sealer: makeSealer(),
|
|
74
|
+
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
75
|
+
// propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
|
|
76
|
+
// acta, y por eso lo pone el vault: el store no conoce el acta.
|
|
77
|
+
recipients: (owner) => recipientsOf(owner),
|
|
78
|
+
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
79
|
+
signer: (body) => signSeal(body),
|
|
80
|
+
defaultKey: () => new Uint8Array(machineKey(dir))
|
|
81
|
+
})
|
|
82
|
+
// SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
|
|
83
|
+
// §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
|
|
84
|
+
// con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
|
|
85
|
+
// siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
|
|
86
|
+
// Se dice en voz alta: prometer una protección que no está puesta es peor que no
|
|
87
|
+
// tenerla (docs/secretos-sellados.md §2.3).
|
|
88
|
+
try {
|
|
89
|
+
if (!hasPassword()) {
|
|
90
|
+
log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
|
|
91
|
+
log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
|
|
92
|
+
}
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
|
|
65
95
|
const master = await masterPubkeyOf(identity)
|
|
66
96
|
const fp = (await pubkeyId(master)).slice(0, 16)
|
|
67
97
|
|
|
98
|
+
/**
|
|
99
|
+
* El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
|
|
100
|
+
* (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
|
|
101
|
+
* restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
|
|
102
|
+
* máquina y no puede quedar a medias.
|
|
103
|
+
*
|
|
104
|
+
* Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
|
|
105
|
+
* llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
|
|
106
|
+
*/
|
|
107
|
+
try {
|
|
108
|
+
const info = await identity.profileActa?.()
|
|
109
|
+
const acta = info?.acta
|
|
110
|
+
if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
|
|
111
|
+
const r = await identity.rotateSealKey()
|
|
112
|
+
// Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
|
|
113
|
+
// primero y `console.log` no lo sustituye (salía «record #%s 2»).
|
|
114
|
+
log(`[vault] new sealing key in record #${r.seq}`)
|
|
115
|
+
}
|
|
116
|
+
} catch (e) { log('[vault] could not set up the sealing key:', e.message) }
|
|
117
|
+
|
|
68
118
|
const { client } = await createTransport({ identity, dir, url: proxyUrl })
|
|
69
119
|
|
|
70
120
|
async function revocationSet () {
|
|
@@ -138,6 +188,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
138
188
|
const n = secrets.forgetDevice(sub)
|
|
139
189
|
if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
|
|
140
190
|
} catch (e) { log('[vault] could not drop the device variables:', e.message) }
|
|
191
|
+
// Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
|
|
192
|
+
// a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
|
|
193
|
+
// la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
|
|
194
|
+
//
|
|
195
|
+
// Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
|
|
196
|
+
// emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
|
|
197
|
+
// Un interruptor que pide una frase que quizá no tienes a mano no es un
|
|
198
|
+
// interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
|
|
199
|
+
markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
|
|
141
200
|
},
|
|
142
201
|
defaultScope: [SCOPE.READ],
|
|
143
202
|
onChallenge ({ deviceId, scope }) {
|
|
@@ -388,8 +447,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
388
447
|
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
389
448
|
// secretos falsos). Replay inerte: cada petición usa una ek nueva.
|
|
390
449
|
// data: { op:'secrets', ns, ek, publickey, ts }
|
|
450
|
+
/**
|
|
451
|
+
* Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
|
|
452
|
+
* suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
|
|
453
|
+
* de variables va indexado por ella — se quedaría sin configuración sin decirlo.
|
|
454
|
+
*/
|
|
455
|
+
async function handleEncKey (from, p) {
|
|
456
|
+
const ns = p.data?.ns
|
|
457
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
|
|
458
|
+
const chk = await verifyChain({
|
|
459
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
460
|
+
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
461
|
+
})
|
|
462
|
+
if (!chk.ok) return denyChain(from, chk, p, 'enckey')
|
|
463
|
+
try {
|
|
464
|
+
await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
|
|
465
|
+
audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
466
|
+
log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
|
|
467
|
+
// Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
|
|
468
|
+
// seguiría sin poder abrir nada hasta la siguiente escritura.
|
|
469
|
+
await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
|
|
470
|
+
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
471
|
+
const { signature } = await identity.signData(body)
|
|
472
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
473
|
+
} catch (e) {
|
|
474
|
+
reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
391
478
|
async function handleSecrets (from, p) {
|
|
392
479
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
480
|
+
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
481
|
+
// un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
|
|
482
|
+
// de identidad y obligaría a re-vendorizar.
|
|
483
|
+
//
|
|
484
|
+
// No exige la contraseña del perfil, y el argumento importa: registrar una llave no
|
|
485
|
+
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
486
|
+
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
487
|
+
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
393
488
|
const ns = p.data?.ns
|
|
394
489
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
395
490
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -408,7 +503,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
408
503
|
}
|
|
409
504
|
let enc
|
|
410
505
|
try {
|
|
411
|
-
|
|
506
|
+
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
507
|
+
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
508
|
+
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
509
|
+
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
510
|
+
const b = secrets.bundleFor(ns, chk.device)
|
|
511
|
+
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
512
|
+
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
513
|
+
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
514
|
+
// del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
|
|
515
|
+
// de dónde salió lo que abre.
|
|
516
|
+
const payload = b.legacy
|
|
517
|
+
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
|
|
518
|
+
: { sealed: b, acta: record || null }
|
|
519
|
+
enc = await seal({ ek: p.data.ek, payload })
|
|
412
520
|
} catch (e) {
|
|
413
521
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
414
522
|
}
|
|
@@ -431,6 +539,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
431
539
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
432
540
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
433
541
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
542
|
+
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
434
543
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
435
544
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
436
545
|
} catch (e) {
|
|
@@ -577,6 +686,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
577
686
|
* 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
|
|
578
687
|
* transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
|
|
579
688
|
*/
|
|
689
|
+
/**
|
|
690
|
+
* La llave de administración a partir de lo que venga en el sobre de la consola
|
|
691
|
+
* remota. `undefined` si no trae contraseña — entonces el store cae a la de la
|
|
692
|
+
* máquina, que es lo correcto para un perfil que no tiene ninguna.
|
|
693
|
+
*
|
|
694
|
+
* La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
|
|
698
|
+
* operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
|
|
699
|
+
*/
|
|
700
|
+
async function adminKeyFrom (payload) {
|
|
701
|
+
const pwd = payload?.password
|
|
702
|
+
if (typeof pwd !== 'string' || !pwd) return undefined
|
|
703
|
+
if (typeof deriveAdminKey !== 'function') return undefined
|
|
704
|
+
return deriveAdminKey(pwd)
|
|
705
|
+
}
|
|
706
|
+
|
|
580
707
|
const varsDesk = {
|
|
581
708
|
async list () {
|
|
582
709
|
// `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
|
|
@@ -585,16 +712,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
585
712
|
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
586
713
|
return {
|
|
587
714
|
enc: await identity.sealContent(JSON.stringify({
|
|
588
|
-
ns: listSecrets(), dev: await listDeviceSecrets()
|
|
715
|
+
ns: listSecrets(), dev: await listDeviceSecrets(),
|
|
716
|
+
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
717
|
+
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
718
|
+
incomplete: await incompleteMembers(),
|
|
719
|
+
// Lo que quedó a deber un sellado: quien administra a distancia tiene que
|
|
720
|
+
// poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
|
|
721
|
+
// variable con la contraseña) y la bóveda no.
|
|
722
|
+
pending: await secretDebts(),
|
|
723
|
+
// Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
|
|
724
|
+
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
725
|
+
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
726
|
+
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
727
|
+
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
589
728
|
}))
|
|
590
729
|
}
|
|
591
730
|
},
|
|
592
|
-
async set ({ ns, pub, key, enc, public: isPublic }) {
|
|
731
|
+
async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
|
|
593
732
|
const payload = JSON.parse(await identity.openContent(enc))
|
|
594
733
|
const value = payload?.value
|
|
595
734
|
if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
|
|
596
|
-
|
|
597
|
-
|
|
735
|
+
// NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
|
|
736
|
+
// leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
|
|
737
|
+
if (ns) await setSecret(ns, key, value, isPublic, { by: who })
|
|
738
|
+
else await setDeviceSecret(pub, key, value, isPublic, { by: who })
|
|
598
739
|
return { ok: true, key }
|
|
599
740
|
},
|
|
600
741
|
/**
|
|
@@ -607,7 +748,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
607
748
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
608
749
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
609
750
|
*/
|
|
610
|
-
async setMany ({ ns, pub, enc, public: isPublic }) {
|
|
751
|
+
async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
|
|
611
752
|
const payload = JSON.parse(await identity.openContent(enc))
|
|
612
753
|
const items = payload?.items
|
|
613
754
|
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
|
|
@@ -620,7 +761,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
620
761
|
value: it?.value,
|
|
621
762
|
...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
|
|
622
763
|
}))
|
|
623
|
-
|
|
764
|
+
// NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
|
|
765
|
+
// escritura quedaba al aire y la respuesta salía antes de guardar nada.
|
|
766
|
+
const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
|
|
624
767
|
return { ok: true, keys }
|
|
625
768
|
}
|
|
626
769
|
}
|
|
@@ -694,8 +837,421 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
694
837
|
|
|
695
838
|
// API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
|
|
696
839
|
// sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
|
|
697
|
-
|
|
698
|
-
|
|
840
|
+
/**
|
|
841
|
+
* La llave con la que se abre la copia maestra.
|
|
842
|
+
*
|
|
843
|
+
* Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
|
|
844
|
+
* contraseña se cae a la llave de la máquina**, que es exactamente la protección
|
|
845
|
+
* que había antes de todo esto: el disco sigue cifrado, pero su material vive en
|
|
846
|
+
* ese mismo disco, así que una copia del disco lo abre.
|
|
847
|
+
*
|
|
848
|
+
* Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
|
|
849
|
+
* pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
|
|
850
|
+
* diseño). Prometer una protección que no está puesta es peor que no tenerla.
|
|
851
|
+
*/
|
|
852
|
+
const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
|
|
853
|
+
|
|
854
|
+
// `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
|
|
855
|
+
// usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
|
|
856
|
+
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
857
|
+
/**
|
|
858
|
+
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
859
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
|
|
860
|
+
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
861
|
+
* que este diseño quita.
|
|
862
|
+
*/
|
|
863
|
+
async function nsMembers (ns) {
|
|
864
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
865
|
+
return (record?.members || []).filter((m) => m.cn === ns)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
|
|
870
|
+
* cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
|
|
871
|
+
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
872
|
+
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
873
|
+
*
|
|
874
|
+
* La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
875
|
+
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
876
|
+
* diseño quita. Hay un test que lo afirma.
|
|
877
|
+
*/
|
|
878
|
+
async function adminDevices () {
|
|
879
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
880
|
+
return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
|
|
885
|
+
* importa (decidida por el dueño el 2026-08-22):
|
|
886
|
+
*
|
|
887
|
+
* **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
|
|
888
|
+
* hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
|
|
889
|
+
* token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
|
|
890
|
+
* alguien se lleve tu portátil con la sesión puesta.
|
|
891
|
+
*
|
|
892
|
+
* Dos cosas que hacen que esto se sostenga:
|
|
893
|
+
*
|
|
894
|
+
* · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
|
|
895
|
+
* que la bóveda se negara a entregarlo, pero entonces la protección sería una
|
|
896
|
+
* política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
|
|
897
|
+
* llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
|
|
898
|
+
* se crea no se puede saltar.
|
|
899
|
+
* · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
|
|
900
|
+
* dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
|
|
901
|
+
* se puede forzar esperando a que ese servicio esté caído.
|
|
902
|
+
*
|
|
903
|
+
* Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
|
|
904
|
+
* miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
|
|
905
|
+
* abre la envoltura de recuperación, que `wrapAll` añade siempre.
|
|
906
|
+
*/
|
|
907
|
+
/**
|
|
908
|
+
* Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
|
|
909
|
+
* el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
|
|
910
|
+
* quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
|
|
911
|
+
* qué pidió; aquí solo se valida el remitente.
|
|
912
|
+
*/
|
|
913
|
+
async function handleRewrapOk (payload) {
|
|
914
|
+
const d = payload?.data
|
|
915
|
+
if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
|
|
916
|
+
const signer = payload?.cert?.sub
|
|
917
|
+
if (!signer) return
|
|
918
|
+
if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
|
|
919
|
+
return log('[vault] a handed key arrived BADLY SIGNED: ignored')
|
|
920
|
+
}
|
|
921
|
+
// Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
|
|
922
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
923
|
+
const m = (record?.members || []).find((x) => x.pub === signer)
|
|
924
|
+
const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
|
|
925
|
+
if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
|
|
926
|
+
for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
|
|
930
|
+
const rewrapWaiters = new Set()
|
|
931
|
+
const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
|
|
932
|
+
|
|
933
|
+
async function recipientsOf (owner) {
|
|
934
|
+
if (owner.startsWith('ns:')) {
|
|
935
|
+
const owned = await nsMembers(owner.slice(3))
|
|
936
|
+
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
|
|
937
|
+
// administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
938
|
+
return owned.length ? owned : await adminDevices()
|
|
939
|
+
}
|
|
940
|
+
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
941
|
+
const m = await memberOf(pub)
|
|
942
|
+
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
943
|
+
if (m?.cn) return [m]
|
|
944
|
+
return [...(m ? [m] : []), ...await adminDevices()]
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
|
|
949
|
+
* del acta junto a la firma: es lo que le dice a quien verifica con qué llave
|
|
950
|
+
* comprobarla, porque esa llave rota con el acta (§8.9).
|
|
951
|
+
*
|
|
952
|
+
* Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
|
|
953
|
+
* es más importante que poder demostrar después de dónde salió.
|
|
954
|
+
*/
|
|
955
|
+
async function signSeal (body) {
|
|
956
|
+
try {
|
|
957
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
958
|
+
if (!acta?.sealPub) return null
|
|
959
|
+
const sig = await sealKeys.sign(acta.sealPub, body)
|
|
960
|
+
return sig ? { seq: acta.seq, sig } : null
|
|
961
|
+
} catch (_) { return null }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
|
|
966
|
+
* del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
|
|
967
|
+
* abrir y se queda reintentando para siempre, sin decir por qué.
|
|
968
|
+
*
|
|
969
|
+
* No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
|
|
970
|
+
* reiniciaría a todos los nodos del ns para nada.
|
|
971
|
+
*/
|
|
972
|
+
/**
|
|
973
|
+
* Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
|
|
974
|
+
* el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
|
|
975
|
+
* consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
|
|
976
|
+
*
|
|
977
|
+
* Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
|
|
978
|
+
* que ya no está podría abrir lo que se escriba mañana.
|
|
979
|
+
*/
|
|
980
|
+
async function markRotationDue (sub) {
|
|
981
|
+
const m = await memberOf(sub)
|
|
982
|
+
const ns = m?.cn
|
|
983
|
+
if (!ns) return
|
|
984
|
+
try {
|
|
985
|
+
const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
|
|
986
|
+
if (r?.rotated != null) {
|
|
987
|
+
log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
|
|
988
|
+
audit('secret.rotate', { ns, keys: r.rotated })
|
|
989
|
+
scheduleNotice(ns)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
} catch (e) {
|
|
993
|
+
store.setSetting(`rotate-due:${ns}`, String(Date.now()))
|
|
994
|
+
log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
|
|
995
|
+
audit('secret.rotate-due', { ns, reason: e.message })
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
|
|
1001
|
+
* deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
|
|
1002
|
+
* salen juntas:
|
|
1003
|
+
*
|
|
1004
|
+
* - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
|
|
1005
|
+
* - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
|
|
1006
|
+
*
|
|
1007
|
+
* Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
|
|
1008
|
+
* enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
|
|
1009
|
+
* que además nadie ve.
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
|
|
1013
|
+
* las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
|
|
1014
|
+
* llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
|
|
1015
|
+
* hay quien la teclee.
|
|
1016
|
+
*
|
|
1017
|
+
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
1018
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
|
|
1019
|
+
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
1020
|
+
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
1021
|
+
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
1022
|
+
*
|
|
1023
|
+
* @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
|
|
1024
|
+
*/
|
|
1025
|
+
async function incompleteMembers () {
|
|
1026
|
+
if (secrets.isLegacy?.()) return []
|
|
1027
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1028
|
+
const out = []
|
|
1029
|
+
for (const m of record?.members || []) {
|
|
1030
|
+
if (!m.encPub || m.pub === master) continue
|
|
1031
|
+
const owners = {}
|
|
1032
|
+
// Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
|
|
1033
|
+
// puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
|
|
1034
|
+
// «Ver» de la consola le fallaría sin explicar por qué.
|
|
1035
|
+
// Un aparato que administra NO debe tener envoltura de los cajones con dueño
|
|
1036
|
+
// (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
|
|
1037
|
+
// propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
|
|
1038
|
+
const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
|
|
1039
|
+
const drawers = m.cn
|
|
1040
|
+
? [`ns:${m.cn}`, `dev:${m.pub}`]
|
|
1041
|
+
: [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
|
|
1042
|
+
for (const owner of drawers) {
|
|
1043
|
+
const missing = secrets.missingFor(owner, m.pub)
|
|
1044
|
+
if (missing.length) owners[owner] = missing
|
|
1045
|
+
}
|
|
1046
|
+
if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
|
|
1047
|
+
}
|
|
1048
|
+
return out
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
|
|
1053
|
+
* no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
|
|
1054
|
+
* estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
|
|
1055
|
+
*/
|
|
1056
|
+
function rotationsDue () {
|
|
1057
|
+
const out = {}
|
|
1058
|
+
for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
|
|
1059
|
+
// La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
|
|
1060
|
+
// nombre del namespace: una sola forma para quien lo lee.
|
|
1061
|
+
if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
|
|
1062
|
+
}
|
|
1063
|
+
return out
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
|
|
1068
|
+
* envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
1069
|
+
* se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
|
|
1070
|
+
* se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
|
|
1071
|
+
* entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
|
|
1072
|
+
* @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
|
|
1073
|
+
*/
|
|
1074
|
+
async function secretDebts () {
|
|
1075
|
+
const out = rotationsDue()
|
|
1076
|
+
for (const m of await incompleteMembers()) {
|
|
1077
|
+
for (const [owner, keys] of Object.entries(m.owners)) {
|
|
1078
|
+
if (out[owner]?.kind === 'rotate') continue
|
|
1079
|
+
out[owner] = out[owner] || { kind: 'rewrap', members: [] }
|
|
1080
|
+
out[owner].members.push({ pub: m.pub, keys })
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return out
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function spreadKey (owner, members, adminKey) {
|
|
1087
|
+
if (secrets.isLegacy()) return null
|
|
1088
|
+
// Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
|
|
1089
|
+
// se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
|
|
1090
|
+
// sustituye al reparto en vez de sumarse.
|
|
1091
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1092
|
+
if (ns && store.getSetting(`rotate-due:${ns}`)) {
|
|
1093
|
+
const rot = await secrets.rotate(owner, members, adminKey)
|
|
1094
|
+
store.setSetting(`rotate-due:${ns}`, undefined)
|
|
1095
|
+
log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
|
|
1096
|
+
audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
|
|
1097
|
+
return rot
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const r = await secrets.rewrap(owner, members, adminKey)
|
|
1101
|
+
if (r?.sinLlave?.length) {
|
|
1102
|
+
log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
|
|
1103
|
+
audit('secret.nokey', { owner, count: r.sinLlave.length })
|
|
1104
|
+
}
|
|
1105
|
+
return r
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
// SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
|
|
1108
|
+
// donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
|
|
1109
|
+
// cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
|
|
1110
|
+
// del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
|
|
1111
|
+
// — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
|
|
1112
|
+
// anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
|
|
1113
|
+
// salde un hermano o el abrir la bóveda.
|
|
1114
|
+
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`)
|
|
1115
|
+
audit('secret.rewrap-due', { owner, reason: e.message })
|
|
1116
|
+
throw e
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
|
|
1122
|
+
* a leer. `by` es el aparato que lo escribió, y va al histórico.
|
|
1123
|
+
*/
|
|
1124
|
+
async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
|
|
1125
|
+
await secrets.set(ns, key, value, isPublic, { by })
|
|
1126
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1127
|
+
audit('secret.set', { ns, key }); scheduleNotice(ns)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
|
|
1132
|
+
* desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
|
|
1133
|
+
* verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
|
|
1134
|
+
* único que la frase guarda (§8.3).
|
|
1135
|
+
*
|
|
1136
|
+
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
1137
|
+
* tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
|
|
1138
|
+
*/
|
|
1139
|
+
/**
|
|
1140
|
+
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
1141
|
+
*
|
|
1142
|
+
* Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
|
|
1143
|
+
* llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
|
|
1144
|
+
* tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
|
|
1145
|
+
*
|
|
1146
|
+
* La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
|
|
1147
|
+
* podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
|
|
1148
|
+
* destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
|
|
1149
|
+
* solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
|
|
1150
|
+
*
|
|
1151
|
+
* @returns {Promise<{ done: number, asked: number }>}
|
|
1152
|
+
*/
|
|
1153
|
+
async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
|
|
1154
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1155
|
+
if (!ns) return { done: 0, asked: 0 }
|
|
1156
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1157
|
+
if (!record) return { done: 0, asked: 0 }
|
|
1158
|
+
// Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
|
|
1159
|
+
const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
|
|
1160
|
+
const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
|
|
1161
|
+
if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
|
|
1162
|
+
|
|
1163
|
+
let done = 0
|
|
1164
|
+
for (const gen of pending) {
|
|
1165
|
+
const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
|
|
1166
|
+
const { signature } = await identity.signData(body)
|
|
1167
|
+
const answer = new Promise((resolve) => {
|
|
1168
|
+
const off = onRewrapOk((d) => {
|
|
1169
|
+
if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
|
|
1170
|
+
off(); resolve(d.wrap)
|
|
1171
|
+
})
|
|
1172
|
+
setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
|
|
1173
|
+
})
|
|
1174
|
+
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
|
|
1175
|
+
log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
|
|
1176
|
+
continue
|
|
1177
|
+
}
|
|
1178
|
+
const wrap = await answer
|
|
1179
|
+
if (!wrap) continue
|
|
1180
|
+
try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
|
|
1181
|
+
log(`[vault] ${owner}: the handed key was refused (${e.message})`)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (done) {
|
|
1185
|
+
audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
|
|
1186
|
+
log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
|
|
1187
|
+
}
|
|
1188
|
+
return { done, asked: pending.length }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function resealAll (adminKey = null) {
|
|
1192
|
+
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
|
|
1193
|
+
for (const owner of secrets.owners?.() || []) {
|
|
1194
|
+
const before = new Set(secrets.recipientsIn(owner))
|
|
1195
|
+
const members = await recipientsOf(owner)
|
|
1196
|
+
try {
|
|
1197
|
+
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
1198
|
+
const after = new Set(secrets.recipientsIn(owner))
|
|
1199
|
+
out.drawers++
|
|
1200
|
+
out.wrapped += r?.wrapped || 0
|
|
1201
|
+
out.dropped += [...before].filter((p) => !after.has(p)).length
|
|
1202
|
+
for (const p of r?.sinLlave || []) out.sinLlave.push(p)
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
log(`[vault] ${owner}: could not reseal (${e.message})`)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
|
|
1208
|
+
return out
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function settleSecretDebts (adminKey = null) {
|
|
1212
|
+
const out = {}
|
|
1213
|
+
// Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
|
|
1214
|
+
// envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
|
|
1215
|
+
// por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
|
|
1216
|
+
// pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
|
|
1217
|
+
// que es barato y no depende de que alguien se acordara de apuntarlo.
|
|
1218
|
+
const debts = new Set(Object.keys(rotationsDue()))
|
|
1219
|
+
for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
|
|
1220
|
+
|
|
1221
|
+
for (const owner of debts) {
|
|
1222
|
+
const k = owner.slice(owner.indexOf(':') + 1)
|
|
1223
|
+
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
1224
|
+
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
1225
|
+
// sin poder destapar lo que ella misma acababa de saldar.
|
|
1226
|
+
const members = await recipientsOf(owner)
|
|
1227
|
+
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
1228
|
+
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
1229
|
+
let delegated = 0
|
|
1230
|
+
for (const m of await incompleteMembers()) {
|
|
1231
|
+
if (!m.owners[owner]) continue
|
|
1232
|
+
delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
|
|
1233
|
+
}
|
|
1234
|
+
out[owner] = delegated ? { delegated } : { error: e.message }
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return out
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
|
|
1242
|
+
* Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
|
|
1243
|
+
* recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
|
|
1244
|
+
* anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
|
|
1245
|
+
* guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
|
|
1246
|
+
*/
|
|
1247
|
+
async function settleDebts (owner, membersFn) {
|
|
1248
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1249
|
+
const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
|
|
1250
|
+
(await incompleteMembers()).some((m) => m.owners[owner])
|
|
1251
|
+
if (!owed) return null
|
|
1252
|
+
try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
|
|
1253
|
+
}
|
|
1254
|
+
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
699
1255
|
|
|
700
1256
|
/**
|
|
701
1257
|
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
@@ -716,40 +1272,44 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
716
1272
|
* @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
|
|
717
1273
|
* estaba no cambia nada, y no tiene por qué reiniciar a nadie).
|
|
718
1274
|
*/
|
|
719
|
-
function applySecrets (ns, items) {
|
|
1275
|
+
async function applySecrets (ns, items, { by = null } = {}) {
|
|
720
1276
|
const list = assertItems(items)
|
|
721
1277
|
const changed = []
|
|
722
|
-
secrets.batch(() => {
|
|
1278
|
+
await secrets.batch(async () => {
|
|
723
1279
|
for (const it of list) {
|
|
724
1280
|
if (it.op === 'rm') {
|
|
725
|
-
if (secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
1281
|
+
if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
726
1282
|
} else {
|
|
727
|
-
secrets.set(ns, it.key, it.value, it.public)
|
|
1283
|
+
await secrets.set(ns, it.key, it.value, it.public, { by })
|
|
728
1284
|
audit('secret.set', { ns, key: it.key })
|
|
729
1285
|
changed.push(it.key)
|
|
730
1286
|
}
|
|
731
1287
|
}
|
|
732
1288
|
})
|
|
733
|
-
if (changed.length)
|
|
1289
|
+
if (changed.length) {
|
|
1290
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1291
|
+
scheduleNotice(ns)
|
|
1292
|
+
}
|
|
734
1293
|
return changed
|
|
735
1294
|
}
|
|
736
1295
|
|
|
737
1296
|
/** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
|
|
738
|
-
async function applyDeviceSecrets (pub, items) {
|
|
1297
|
+
async function applyDeviceSecrets (pub, items, { by = null } = {}) {
|
|
739
1298
|
const list = assertItems(items)
|
|
740
1299
|
const m = await requireService(pub)
|
|
741
1300
|
const changed = []
|
|
742
|
-
secrets.batch(() => {
|
|
1301
|
+
await secrets.batch(async () => {
|
|
743
1302
|
for (const it of list) {
|
|
744
1303
|
if (it.op === 'rm') {
|
|
745
|
-
if (secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
1304
|
+
if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
746
1305
|
} else {
|
|
747
|
-
secrets.setDevice(pub, it.key, it.value, it.public)
|
|
1306
|
+
await secrets.setDevice(pub, it.key, it.value, it.public, { by })
|
|
748
1307
|
changed.push(it.key)
|
|
749
1308
|
}
|
|
750
1309
|
}
|
|
751
1310
|
})
|
|
752
1311
|
if (changed.length) {
|
|
1312
|
+
await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
|
|
753
1313
|
const device = await deviceIdOf(pub).catch(() => null)
|
|
754
1314
|
for (const it of list) {
|
|
755
1315
|
if (!changed.includes(it.key)) continue
|
|
@@ -786,12 +1346,21 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
786
1346
|
return out
|
|
787
1347
|
}
|
|
788
1348
|
/** 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)
|
|
1349
|
+
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
1350
|
+
const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
|
|
791
1351
|
if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
|
|
792
1352
|
return ok
|
|
793
1353
|
}
|
|
794
1354
|
|
|
1355
|
+
/**
|
|
1356
|
+
* El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
|
|
1357
|
+
* camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
|
|
1358
|
+
* este sí pide poder abrir la copia maestra.
|
|
1359
|
+
*/
|
|
1360
|
+
async function openSecrets (ns, devicePub = null, adminKey) {
|
|
1361
|
+
return secrets.openBundle(ns, devicePub, adminKey)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
795
1364
|
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
796
1365
|
async function memberOf (pub) {
|
|
797
1366
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -816,16 +1385,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
816
1385
|
return m
|
|
817
1386
|
}
|
|
818
1387
|
|
|
819
|
-
async function setDeviceSecret (pub, key, value, isPublic) {
|
|
1388
|
+
async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
|
|
820
1389
|
const m = await requireService(pub)
|
|
821
|
-
secrets.setDevice(pub, key, value, isPublic)
|
|
1390
|
+
await secrets.setDevice(pub, key, value, isPublic, { by })
|
|
1391
|
+
await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
|
|
822
1392
|
audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
823
1393
|
scheduleDeviceNotice(pub)
|
|
824
1394
|
}
|
|
825
1395
|
|
|
826
1396
|
async function deleteDeviceSecret (pub, key) {
|
|
827
1397
|
const m = await memberOf(pub)
|
|
828
|
-
const ok = secrets.deleteDevice(pub, key)
|
|
1398
|
+
const ok = await secrets.deleteDevice(pub, key)
|
|
829
1399
|
if (ok) {
|
|
830
1400
|
audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
831
1401
|
scheduleDeviceNotice(pub)
|
|
@@ -833,8 +1403,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
833
1403
|
return ok
|
|
834
1404
|
}
|
|
835
1405
|
|
|
836
|
-
async function setDeviceSecretVisibility (pub, key, isPublic) {
|
|
837
|
-
const ok = secrets.setDeviceVisibility(pub, key, isPublic)
|
|
1406
|
+
async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
|
|
1407
|
+
const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
|
|
838
1408
|
if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
|
|
839
1409
|
return ok
|
|
840
1410
|
}
|
|
@@ -864,14 +1434,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
864
1434
|
}
|
|
865
1435
|
|
|
866
1436
|
return {
|
|
867
|
-
identity, client, store, threads, secrets, master, fingerprint: fp,
|
|
1437
|
+
identity, client, store, threads, secrets, master, fingerprint: fp, dir,
|
|
868
1438
|
startPairing: desk.startPairing,
|
|
869
1439
|
stopPairing: desk.stopPairing,
|
|
870
1440
|
listPending: desk.listPending,
|
|
871
1441
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
872
1442
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
873
|
-
approveDevice: async (code) => {
|
|
1443
|
+
approveDevice: async (code, adminKey) => {
|
|
874
1444
|
const r = await desk.approve(code)
|
|
1445
|
+
// Un servicio que ENTRA a un namespace que ya tiene variables necesita su
|
|
1446
|
+
// envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
|
|
1447
|
+
// reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
|
|
1448
|
+
// puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
|
|
1449
|
+
// el archivo vendorizado en el iframe de identidad.
|
|
1450
|
+
const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
|
|
1451
|
+
if (m?.cn) {
|
|
1452
|
+
await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
|
|
1453
|
+
log('[vault] could not hand the key to the new service:', e.message)
|
|
1454
|
+
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
1455
|
+
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|
|
1456
|
+
// recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
|
|
1457
|
+
// a la vista y se salda al abrir la bóveda.
|
|
1458
|
+
const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
|
|
1459
|
+
if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
|
|
1460
|
+
})
|
|
1461
|
+
}
|
|
875
1462
|
await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
|
|
876
1463
|
return r
|
|
877
1464
|
},
|
|
@@ -879,13 +1466,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
879
1466
|
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
880
1467
|
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
881
1468
|
vars: varsDesk,
|
|
882
|
-
setSecret, deleteSecret, listSecrets, setSecretVisibility,
|
|
1469
|
+
setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
1470
|
+
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
1471
|
+
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
1472
|
+
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
1473
|
+
/**
|
|
1474
|
+
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
1475
|
+
* normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
|
|
1476
|
+
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
1477
|
+
*
|
|
1478
|
+
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
1479
|
+
* entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
|
|
1480
|
+
* diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
|
|
1481
|
+
*/
|
|
1482
|
+
resealAll,
|
|
1483
|
+
delegateRewrap,
|
|
1484
|
+
incompleteMembers,
|
|
1485
|
+
migrateSecrets: (membersOf, adminKey) => secrets.migrate(
|
|
1486
|
+
membersOf || ((owner) => recipientsOf(owner)), adminKey
|
|
1487
|
+
),
|
|
1488
|
+
settleSecretDebts,
|
|
1489
|
+
revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
|
|
1490
|
+
secretHistory: (owner, key) => secrets.history(owner, key),
|
|
1491
|
+
revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
|
|
1492
|
+
revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
|
|
1493
|
+
// Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
|
|
1494
|
+
// la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
|
|
1495
|
+
rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
|
|
883
1496
|
setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
|
|
884
1497
|
applySecrets, applyDeviceSecrets,
|
|
885
1498
|
listDevices: () => identity.listDelegations(),
|
|
886
1499
|
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
887
1500
|
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
888
1501
|
profileMembers: () => identity.profileMembers(),
|
|
1502
|
+
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|
|
1503
|
+
// rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
|
|
1504
|
+
rotationsDue,
|
|
1505
|
+
secretDebts,
|
|
889
1506
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
890
1507
|
isMaster: () => identity.isMaster(),
|
|
891
1508
|
setCaps: async (pub, caps) => {
|