@dotrino/vaultd 0.86.0 → 0.87.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/package.json +1 -1
- package/src/secretsStore.js +22 -6
- package/src/vault.js +44 -18
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.87.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
|
|
6
6
|
"bin": {
|
package/src/secretsStore.js
CHANGED
|
@@ -296,8 +296,13 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
296
296
|
}
|
|
297
297
|
|
|
298
298
|
/** Los destinatarios de un cajón: los que dice el acta, más la copia de recuperación. */
|
|
299
|
-
|
|
300
|
-
|
|
299
|
+
/**
|
|
300
|
+
* @param {boolean} isPublic Una PÚBLICA se envuelve además para quien administra, aunque
|
|
301
|
+
* el cajón tenga dueño (dueño, 2026-09-02). Puede variar por variable porque cada
|
|
302
|
+
* escritura estrena su propia CEK: el llavero guarda una generación por valor.
|
|
303
|
+
*/
|
|
304
|
+
const wrapAll = async (cek, owner, isPublic = false) => {
|
|
305
|
+
const members = (await recipients?.(owner, { public: isPublic })) || []
|
|
301
306
|
const { wraps, sinLlave } = await sealer.wrapFor(cek, members)
|
|
302
307
|
wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
303
308
|
return { wraps, sinLlave }
|
|
@@ -537,7 +542,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
537
542
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
538
543
|
const cek = await sealer.newKey()
|
|
539
544
|
const gen = (topGen(bag, k)?.gen || 0) + 1
|
|
540
|
-
const { wraps, sinLlave } = await wrapAll(cek, owner)
|
|
545
|
+
const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
|
|
541
546
|
const e = await sealer.encrypt(cek, value, gen)
|
|
542
547
|
// La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
|
|
543
548
|
// hay con qué firmar, el sobre sale sin firma: guardar es más importante.
|
|
@@ -631,9 +636,9 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
631
636
|
* haya dos sitios respondiendo a «quién puede abrir este cajón» (la lista la sabe el
|
|
632
637
|
* acta, y de ahí sale `recipients`).
|
|
633
638
|
*/
|
|
634
|
-
async recipientsFor (owner) {
|
|
639
|
+
async recipientsFor (owner, { public: isPublic = false } = {}) {
|
|
635
640
|
await ensureRecovery(null)
|
|
636
|
-
const members = (await recipients?.(owner)) || []
|
|
641
|
+
const members = (await recipients?.(owner, { public: isPublic })) || []
|
|
637
642
|
return {
|
|
638
643
|
recoveryPub: data.recovery.pub,
|
|
639
644
|
members: members.filter((m) => m.encPub).map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
@@ -796,6 +801,13 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
796
801
|
return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
|
|
797
802
|
},
|
|
798
803
|
/** @private */
|
|
804
|
+
/**
|
|
805
|
+
* @param {Array|((isPublic:boolean)=>Promise<Array>)} members A quién envolver. Puede ser
|
|
806
|
+
* una FUNCIÓN de la visibilidad: desde 2026-09-02 una pública se envuelve además para
|
|
807
|
+
* quien administra, y eso se decide por variable — cada escritura estrena su CEK, así
|
|
808
|
+
* que el llavero tiene una generación por valor. Con una lista fija se aplicaría la
|
|
809
|
+
* misma a todas y se le daría a la consola la envoltura de una privada con dueño.
|
|
810
|
+
*/
|
|
799
811
|
async _rewrap (owner, members, adminKey = null, { exact = false } = {}) {
|
|
800
812
|
needSealer('re-wrap the key of a drawer')
|
|
801
813
|
if (needsMigration()) throw new NeedsMigration()
|
|
@@ -805,11 +817,15 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
805
817
|
const priv = await openRecovery(adminKey)
|
|
806
818
|
let wrapped = 0
|
|
807
819
|
const sinLlave = new Set()
|
|
820
|
+
// De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
|
|
821
|
+
const visibilidad = new Map()
|
|
822
|
+
for (const e of Object.values(varsOf(bag, k))) if (e.gen != null) visibilidad.set(e.gen, !!e.pub)
|
|
823
|
+
const paraGen = async (gen) => (typeof members === 'function' ? members(visibilidad.get(gen) === true) : members)
|
|
808
824
|
for (const g of bag[k].keyring || []) {
|
|
809
825
|
const w = g.wraps?.[RECOVERY]
|
|
810
826
|
if (!w) continue
|
|
811
827
|
const cek = await sealer.openWrapWith(priv, w)
|
|
812
|
-
const r = await sealer.wrapFor(cek,
|
|
828
|
+
const r = await sealer.wrapFor(cek, await paraGen(g.gen))
|
|
813
829
|
// `exact`: el llavero queda con lo que dice el acta y NADA más. Es lo que hace
|
|
814
830
|
// falta para rehacerlo al abrir la bóveda:
|
|
815
831
|
// · una envoltura basura que metió alguien se reemplaza por la buena;
|
package/src/vault.js
CHANGED
|
@@ -197,7 +197,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
197
197
|
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
198
198
|
// propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
|
|
199
199
|
// acta, y por eso lo pone el vault: el store no conoce el acta.
|
|
200
|
-
recipients: (owner) => recipientsOf(owner),
|
|
200
|
+
recipients: (owner, opts) => recipientsOf(owner, opts),
|
|
201
201
|
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
202
202
|
signer: (body) => signSeal(body),
|
|
203
203
|
// CON QUÉ se cierra la copia de recuperación cuando quien llama no trajo la frase:
|
|
@@ -808,7 +808,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
808
808
|
// Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
|
|
809
809
|
// abrirlas, que es el mismo agujero por otra puerta.
|
|
810
810
|
for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
|
|
811
|
-
await spreadKey(owner,
|
|
811
|
+
await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }))
|
|
812
812
|
.catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
|
|
813
813
|
}
|
|
814
814
|
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
@@ -1386,8 +1386,12 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1386
1386
|
* (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
|
|
1387
1387
|
* exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
|
|
1388
1388
|
*/
|
|
1389
|
-
async recipients ({ ns, pub }) {
|
|
1390
|
-
|
|
1389
|
+
async recipients ({ ns, pub, public: isPublic }) {
|
|
1390
|
+
// LA VISIBILIDAD CAMBIA LA LISTA: una pública se envuelve además para quien
|
|
1391
|
+
// administra, así que hay que decirlo al preguntar. Pedirla sin decirlo y luego
|
|
1392
|
+
// guardarla como pública produciría un sobre que la consola no puede abrir — y la
|
|
1393
|
+
// bóveda lo rechazaría, que es lo correcto pero desorienta.
|
|
1394
|
+
return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`, { public: !!isPublic })
|
|
1391
1395
|
},
|
|
1392
1396
|
/**
|
|
1393
1397
|
* GUARDAR UNA VARIABLE **SIN VERLA** (dueño, 2026-09-01).
|
|
@@ -1416,7 +1420,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1416
1420
|
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
1417
1421
|
// marca solo decide si se entrega sin aprobación.
|
|
1418
1422
|
await verificarAutor(owner, key, sealed, caller)
|
|
1419
|
-
await verificarDestinatarios(owner, sealed.wraps)
|
|
1423
|
+
await verificarDestinatarios(owner, sealed.wraps, !!isPublic)
|
|
1420
1424
|
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
1421
1425
|
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
1422
1426
|
await settleDebts(owner)
|
|
@@ -1456,7 +1460,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1456
1460
|
for (const it of items) {
|
|
1457
1461
|
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
1458
1462
|
await verificarAutor(owner, it.key, it.sealed, caller)
|
|
1459
|
-
await verificarDestinatarios(owner, it.sealed.wraps)
|
|
1463
|
+
await verificarDestinatarios(owner, it.sealed.wraps, !!it.public)
|
|
1460
1464
|
}
|
|
1461
1465
|
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
1462
1466
|
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
@@ -1660,24 +1664,43 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1660
1664
|
}
|
|
1661
1665
|
|
|
1662
1666
|
/** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
|
|
1667
|
+
/** Sin repetidos por llave: un aparato puede entrar por dos vías (dueño y admin). */
|
|
1668
|
+
const dedup = (miembros) => {
|
|
1669
|
+
const vistos = new Set()
|
|
1670
|
+
return miembros.filter((m) => m && !vistos.has(m.pub) && vistos.add(m.pub))
|
|
1671
|
+
}
|
|
1663
1672
|
const conCoselladores = (base, co) => {
|
|
1664
1673
|
const vistos = new Set(base.map((m) => m.pub))
|
|
1665
1674
|
return [...base, ...co.filter((m) => !vistos.has(m.pub))]
|
|
1666
1675
|
}
|
|
1667
1676
|
|
|
1668
|
-
async function recipientsOf (owner) {
|
|
1677
|
+
async function recipientsOf (owner, { public: isPublic = false } = {}) {
|
|
1669
1678
|
const co = await cosealerMembers()
|
|
1679
|
+
// UNA PÚBLICA SE ENVUELVE TAMBIÉN PARA QUIEN ADMINISTRA (dueño, 2026-09-02: «la consola
|
|
1680
|
+
// debe poder leer todas las variables… debe tener todos los sobres; la diferencia es si
|
|
1681
|
+
// el vault se los envía para la lectura o no» → «cambia la regla y di que se envuelven
|
|
1682
|
+
// las públicas»).
|
|
1683
|
+
//
|
|
1684
|
+
// Puede decidirse variable a variable porque cada escritura estrena su propia CEK: el
|
|
1685
|
+
// llavero guarda una generación por valor, no una por cajón.
|
|
1686
|
+
//
|
|
1687
|
+
// Y la PRIVADA de un cajón con dueño sigue sin envoltura para la consola, que es la
|
|
1688
|
+
// regla de agosto y su motivo no ha cambiado: el token de R2 no tiene por qué poder
|
|
1689
|
+
// abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
|
|
1690
|
+
// para quien administra — puede leerla— además de que se despacha sin aprobación.
|
|
1691
|
+
const admins = isPublic ? await adminDevices() : []
|
|
1670
1692
|
if (owner.startsWith('ns:')) {
|
|
1671
1693
|
const owned = await nsMembers(owner.slice(3))
|
|
1672
|
-
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está)
|
|
1673
|
-
//
|
|
1674
|
-
|
|
1694
|
+
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
|
|
1695
|
+
// SIEMPRE: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
1696
|
+
const base = owned.length ? [...owned, ...admins] : await adminDevices()
|
|
1697
|
+
return conCoselladores(dedup(base), co)
|
|
1675
1698
|
}
|
|
1676
1699
|
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
1677
1700
|
const m = await memberOf(pub)
|
|
1678
1701
|
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
1679
|
-
if (m?.cn) return conCoselladores([m], co)
|
|
1680
|
-
return conCoselladores([...(m ? [m] : []), ...await adminDevices()], co)
|
|
1702
|
+
if (m?.cn) return conCoselladores(dedup([m, ...admins]), co)
|
|
1703
|
+
return conCoselladores(dedup([...(m ? [m] : []), ...await adminDevices()]), co)
|
|
1681
1704
|
}
|
|
1682
1705
|
|
|
1683
1706
|
/**
|
|
@@ -2052,9 +2075,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2052
2075
|
*
|
|
2053
2076
|
* Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
|
|
2054
2077
|
*/
|
|
2055
|
-
async function verificarDestinatarios (owner, wraps) {
|
|
2078
|
+
async function verificarDestinatarios (owner, wraps, isPublic = false) {
|
|
2056
2079
|
if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
|
|
2057
|
-
const { recoveryPub, members } = await secrets.recipientsFor(owner)
|
|
2080
|
+
const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
|
|
2058
2081
|
if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
|
|
2059
2082
|
const deben = new Set(members.map((m) => m.pub))
|
|
2060
2083
|
const traidos = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
|
|
@@ -2146,7 +2169,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2146
2169
|
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
2147
2170
|
for (const owner of secrets.owners?.() || []) {
|
|
2148
2171
|
const before = new Set(secrets.recipientsIn(owner))
|
|
2149
|
-
|
|
2172
|
+
// POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
|
|
2173
|
+
// administra. Con una sola lista se le daría también la de una privada con dueño, que
|
|
2174
|
+
// es justo lo que no puede pasar.
|
|
2175
|
+
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
2150
2176
|
try {
|
|
2151
2177
|
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
2152
2178
|
const after = new Set(secrets.recipientsIn(owner))
|
|
@@ -2179,7 +2205,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2179
2205
|
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
2180
2206
|
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
2181
2207
|
// sin poder destapar lo que ella misma acababa de saldar.
|
|
2182
|
-
const members =
|
|
2208
|
+
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
2183
2209
|
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
2184
2210
|
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
2185
2211
|
let delegated = 0
|
|
@@ -2213,7 +2239,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2213
2239
|
// (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
|
|
2214
2240
|
// para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
|
|
2215
2241
|
// siempre así: una se actualiza y la otra no.
|
|
2216
|
-
try { return await spreadKey(owner,
|
|
2242
|
+
try { return await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }), null) } catch (_) { return null }
|
|
2217
2243
|
}
|
|
2218
2244
|
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
2219
2245
|
|
|
@@ -2448,7 +2474,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2448
2474
|
// envolver hay que envolver siempre para todos los selladores, para que siempre
|
|
2449
2475
|
// tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
|
|
2450
2476
|
// puerta dejaba a los selladores con la envoltura vieja.
|
|
2451
|
-
await spreadKey(`ns:${m.cn}`,
|
|
2477
|
+
await spreadKey(`ns:${m.cn}`, (esPublica) => recipientsOf(`ns:${m.cn}`, { public: esPublica }), adminKey).catch(async (e) => {
|
|
2452
2478
|
log('[vault] could not hand the key to the new service:', e.message)
|
|
2453
2479
|
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
2454
2480
|
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|