@dotrino/vaultd 0.83.0 → 0.85.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/lib/src/admin.js +90 -9
- package/lib/src/env.js +26 -8
- package/lib/src/service.js +14 -5
- package/package.json +1 -1
- package/src/secretsStore.js +154 -27
- package/src/vault.js +183 -42
package/lib/src/admin.js
CHANGED
|
@@ -110,6 +110,21 @@ export const AUDIT_MAX = 500
|
|
|
110
110
|
* @param {()=>boolean} [o.isLocked] candado del perfil. Cerrado, solo se atiende
|
|
111
111
|
* `ADMIN_OPS_WHILE_LOCKED`; lo demás responde con el código `vault-locked`.
|
|
112
112
|
*/
|
|
113
|
+
/**
|
|
114
|
+
* EL CUERPO QUE FIRMA EL AUTOR DE UN SOBRE. Uno solo, para los dos lados.
|
|
115
|
+
*
|
|
116
|
+
* Quien escribe firma esto y la bóveda verifica esto MISMO. Si cada lado se armara su
|
|
117
|
+
* versión, la firma dejaría de cuadrar el día que alguien añada un campo —y el síntoma
|
|
118
|
+
* sería «la firma no verifica», que manda a mirar la cripto en vez del formato.
|
|
119
|
+
*
|
|
120
|
+
* Lleva el CAJÓN y la CLAVE, no solo el sobre: sin ellos, una firma válida para
|
|
121
|
+
* `ns:aws/TOKEN` valdría igual para `ns:proxy/TOKEN`, que es exactamente el sobre colado
|
|
122
|
+
* fuera de su sitio que esto viene a impedir.
|
|
123
|
+
*/
|
|
124
|
+
export function authorBody (owner, key, e, ts) {
|
|
125
|
+
return { op: 'var.author', owner, key, iv: e?.iv || null, ct: e?.ct || null, ts }
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
export function createAdminDesk ({
|
|
114
129
|
desk, verify, readActivity = () => [], deviceIdOf, vars = null,
|
|
115
130
|
notify = async () => {}, audit = () => {}, isLocked = () => false,
|
|
@@ -205,14 +220,14 @@ export function createAdminDesk ({
|
|
|
205
220
|
// Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
|
|
206
221
|
// mejor que reventar con un TypeError que no explica qué falta actualizar.
|
|
207
222
|
if (typeof vars.setMany !== 'function') return { ok: false, error: 'admin: this vault cannot save several variables at once (update it)' }
|
|
208
|
-
if (!data.
|
|
209
|
-
return { ok: false, error: 'admin: var.setMany needs
|
|
223
|
+
if (!Array.isArray(data.items) || !data.items.length) {
|
|
224
|
+
return { ok: false, error: 'admin: var.setMany needs `items`, each with its key and its sealed envelope' }
|
|
210
225
|
}
|
|
211
226
|
const result = await vars.setMany({
|
|
212
227
|
ns: toScope ? data.ns : null,
|
|
213
228
|
pub: toDevice ? data.pub : null,
|
|
214
|
-
|
|
215
|
-
|
|
229
|
+
items: data.items,
|
|
230
|
+
caller: chk.device,
|
|
216
231
|
by
|
|
217
232
|
})
|
|
218
233
|
const keys = result?.keys || []
|
|
@@ -222,17 +237,22 @@ export function createAdminDesk ({
|
|
|
222
237
|
}
|
|
223
238
|
|
|
224
239
|
if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
|
|
225
|
-
if (!data.
|
|
226
|
-
// El valor NUNCA viaja en claro
|
|
227
|
-
// no
|
|
228
|
-
|
|
240
|
+
if (!data.sealed || typeof data.sealed !== 'object') {
|
|
241
|
+
// El valor NUNCA viaja en claro, y desde 2026-09-02 tampoco sellado al perfil: el
|
|
242
|
+
// sobre llega HECHO y la bóveda no lo abre. Si llega de otra forma es un error de
|
|
243
|
+
// quien llama, no algo que se pueda «arreglar» aceptándolo.
|
|
244
|
+
return { ok: false, error: 'admin: var.set needs the value already sealed (`sealed`); build it with buildSealedVar()' }
|
|
229
245
|
}
|
|
230
246
|
const result = await vars.set({
|
|
231
247
|
ns: toScope ? data.ns : null,
|
|
232
248
|
pub: toDevice ? data.pub : null,
|
|
233
249
|
key: data.key,
|
|
234
|
-
|
|
250
|
+
sealed: data.sealed,
|
|
235
251
|
public: typeof data.public === 'boolean' ? data.public : undefined,
|
|
252
|
+
// LA LLAVE DE QUIEN LLAMA, no solo su nombre corto. `by` es el `deviceId`, que
|
|
253
|
+
// vale para el registro pero no para comprobar una firma: el sobre trae la de su
|
|
254
|
+
// AUTOR y hay que verificarla contra la llave de quien lo manda.
|
|
255
|
+
caller: chk.device,
|
|
236
256
|
by
|
|
237
257
|
})
|
|
238
258
|
audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
|
|
@@ -276,3 +296,64 @@ export function createAdminDesk ({
|
|
|
276
296
|
}
|
|
277
297
|
|
|
278
298
|
export default { createAdminDesk, ADMIN_OPS, ADMIN_OPS_WHILE_LOCKED, ADMIN_NONCE_TTL_MS }
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* FABRICAR EL SOBRE DE UNA VARIABLE — del lado de quien escribe.
|
|
302
|
+
*
|
|
303
|
+
* La bóveda ya no ve el valor (dueño, 2026-09-01): recibe el sobre hecho, comprueba lo que
|
|
304
|
+
* puede sin la llave —quién lo firma y a quién envuelve— y lo guarda. Esto es lo que hay que
|
|
305
|
+
* mandarle, y vive aquí para que no lo rearme cada consola por su cuenta.
|
|
306
|
+
*
|
|
307
|
+
* El orden importa y no es casual:
|
|
308
|
+
* 1. se pide a la BÓVEDA a quién hay que envolver (`var.recipients`). La lista sale del
|
|
309
|
+
* acta y la sabe ella; tener dos sitios respondiendo a eso es cómo se deja fuera a
|
|
310
|
+
* alguien sin que nadie se entere.
|
|
311
|
+
* 2. CEK nueva y el valor cifrado con ella;
|
|
312
|
+
* 3. esa CEK envuelta a la PÚBLICA de cada destinatario, más la de recuperación —sin la
|
|
313
|
+
* cual el cajón nacería ilegible para siempre;
|
|
314
|
+
* 4. y la firma del AUTOR sobre el cajón, la clave y el sobre.
|
|
315
|
+
*
|
|
316
|
+
* QUIÉN FIRMA SE PASA COMO FUNCIÓN, no como llave. En el navegador la privada del aparato
|
|
317
|
+
* vive dentro del iframe de identidad y NO SALE de ahí —esa es media garantía del diseño—,
|
|
318
|
+
* así que la consola no puede firmar aquí: pide la firma y ya. En Node, donde la llave sí
|
|
319
|
+
* está a mano, se envuelve `signWithDevice` en dos líneas. Un solo camino para los dos.
|
|
320
|
+
*
|
|
321
|
+
* @param {{recipients:{recoveryPub:string, members:Array<{pub:string,encPub:string}>},
|
|
322
|
+
* owner:string, key:string, value:string,
|
|
323
|
+
* author:{publickey:string, sign:(body:any)=>Promise<{signature:string}>}}} opts
|
|
324
|
+
*/
|
|
325
|
+
export async function buildSealedVar ({ recipients, owner, key, value, author } = {}) {
|
|
326
|
+
const { makeContentKey, encryptWithCek, wrapForMember } = await import('@dotrino/identity/content')
|
|
327
|
+
if (!recipients?.recoveryPub) throw new Error('buildSealedVar: ask the vault for the recipients first (var.recipients)')
|
|
328
|
+
if (typeof value !== 'string' || !value) throw new Error('buildSealedVar: the value must be a non-empty string')
|
|
329
|
+
if (typeof author?.sign !== 'function' || typeof author?.publickey !== 'string') {
|
|
330
|
+
throw new Error('buildSealedVar: author needs { publickey, sign(body) }')
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const cek = await makeContentKey()
|
|
334
|
+
const e = await encryptWithCek({ cek, gen: 0, plaintext: value })
|
|
335
|
+
// Envolver solo necesita PÚBLICAS: por eso esto se puede hacer en un navegador sin que
|
|
336
|
+
// ninguna llave privada ande suelta.
|
|
337
|
+
const wraps = { '#recovery': await wrapForMember({ cek, memberEncPub: recipients.recoveryPub }) }
|
|
338
|
+
for (const m of recipients.members || []) {
|
|
339
|
+
wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub })
|
|
340
|
+
}
|
|
341
|
+
const ts = Date.now()
|
|
342
|
+
const { signature } = await author.sign(authorBody(owner, key, e, ts))
|
|
343
|
+
return { e, wraps, author: { pub: author.publickey, sig: signature, ts } }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* El `author` de `buildSealedVar` a partir de una llave de aparato de Node (la que trae
|
|
348
|
+
* `makeDeviceKey` o un `service-identity.json`). En el navegador no se usa: allí la firma
|
|
349
|
+
* la da el iframe.
|
|
350
|
+
*/
|
|
351
|
+
export function authorFromDeviceKey (device) {
|
|
352
|
+
return {
|
|
353
|
+
publickey: device.publickey,
|
|
354
|
+
sign: async (body) => {
|
|
355
|
+
const { signWithDevice } = await import('@dotrino/identity/capabilities')
|
|
356
|
+
return signWithDevice({ ...device, data: body })
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
package/lib/src/env.js
CHANGED
|
@@ -53,11 +53,26 @@ export const isValidVaultLabel = (p) => typeof p === 'string' && /^[a-z0-9-]{1,3
|
|
|
53
53
|
/** Directorio de la identidad del servicio `ns` bajo la etiqueta de bóveda `vault`. */
|
|
54
54
|
export function serviceDir (vault, ns) {
|
|
55
55
|
if (process.env.DOTRINO_ENV_DIR) return process.env.DOTRINO_ENV_DIR
|
|
56
|
-
if (!isValidVaultLabel(vault)) {
|
|
57
|
-
throw new Error('missing vault label: pass --vault <nombre> (use [a-z0-9-]{1,32}). It is a label of YOUR machine: it says which vault this drawer came from')
|
|
58
|
-
}
|
|
59
56
|
if (!isValidSecretsNs(ns)) throw new Error('invalid ns (use [a-z0-9-]{1,32}, e.g. "myapp")')
|
|
60
|
-
return path.join(serviceRoot(), vault, ns)
|
|
57
|
+
if (isValidVaultLabel(vault)) return path.join(serviceRoot(), vault, ns)
|
|
58
|
+
// SIN `--vault`: SE BUSCA, Y SOLO SE EXIGE SI HAY EMPATE (dueño, 2026-09-01).
|
|
59
|
+
//
|
|
60
|
+
// La etiqueta se hizo obligatoria para que dos bóvedas pudieran tener un cajón que se
|
|
61
|
+
// llame igual sin pisarse. Eso lo arregla la RUTA, no la bandera: con un solo enrolamiento
|
|
62
|
+
// de ese `ns` no hay nada que desambiguar y pedirla es fricción sin nada a cambio.
|
|
63
|
+
//
|
|
64
|
+
// Con dos, en cambio, elegir por el usuario sería adivinar cuál de sus cuentas usar — así
|
|
65
|
+
// que se para y se dice cuáles hay. Eso es lo contrario de un repliegue: no se resuelve
|
|
66
|
+
// una ambigüedad a la brava, se señala.
|
|
67
|
+
const donde = listEnrolled().filter((e) => !e.legacy && e.ns === ns)
|
|
68
|
+
if (donde.length === 1) return path.join(serviceRoot(), donde[0].vault, ns)
|
|
69
|
+
if (donde.length > 1) {
|
|
70
|
+
throw new Error(`"${ns}" is enrolled in more than one vault (${donde.map((e) => e.vault).join(', ')}): pick one with --vault <label>`)
|
|
71
|
+
}
|
|
72
|
+
const otros = listEnrolled().filter((e) => !e.legacy)
|
|
73
|
+
throw new Error(otros.length
|
|
74
|
+
? `"${ns}" is not enrolled on this machine (there is: ${otros.map((e) => `${e.vault}/${e.ns}`).join(', ')})`
|
|
75
|
+
: `"${ns}" is not enrolled on this machine: run \`dotrino-env enroll --vault <label> --ns ${ns}\` first`)
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
/** La etiqueta de bóveda: `--vault`, o `DOTRINO_ENV_VAULT`. */
|
|
@@ -139,18 +154,21 @@ let lastApplied = null
|
|
|
139
154
|
* @param {boolean} [opts.wait] `true` (default) = si el vault no está, ESPERA (reintenta) en vez de fallar.
|
|
140
155
|
* @param {string[]} [opts.required] Claves que deben venir; si falta alguna, lanza.
|
|
141
156
|
* @param {(e:Error, ms:number)=>void} [opts.onRetry]
|
|
157
|
+
* @param {boolean} [opts.publicOnly] Solo las variables PÚBLICAS. No hay claves privadas de
|
|
158
|
+
* por medio, así que la bóveda no pide aprobación: sirve para un arranque que solo
|
|
159
|
+
* necesita configuración (una URL, un puerto) y no debe despertar a nadie.
|
|
142
160
|
* @returns {Promise<{ns, secrets, injected:string[], overridden:string[], skipped:string[]}>}
|
|
143
161
|
* `overridden` = las que YA tenían otro valor en el entorno y el vault pisó. Es
|
|
144
162
|
* el dato que delata un `.env` rancio, así que se reporta en vez de callarse.
|
|
145
163
|
*/
|
|
146
|
-
export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending } = {}) {
|
|
164
|
+
export async function loadEnv ({ ns, vault, dir, override, wait = true, required = [], onRetry, onPending, publicOnly = false } = {}) {
|
|
147
165
|
if (override === undefined) override = overrideByDefault()
|
|
148
166
|
ns = resolveNs(ns)
|
|
149
|
-
// La
|
|
150
|
-
//
|
|
167
|
+
// La etiqueta de bóveda es OPCIONAL: `serviceDir` busca el `ns` entre las que hay y solo
|
|
168
|
+
// la exige si está enrolado en más de una (ahí sí hay que elegir, y elige el dueño).
|
|
151
169
|
dir = dir || serviceDir(resolveVaultLabel(vault), ns)
|
|
152
170
|
const load = wait ? waitForSecrets : fetchSecrets
|
|
153
|
-
const secrets = await load({ dir, ns, onRetry, onPending })
|
|
171
|
+
const secrets = await load({ dir, ns, onRetry, onPending, publicOnly })
|
|
154
172
|
|
|
155
173
|
const missing = required.filter((k) => !(k in secrets))
|
|
156
174
|
if (missing.length) {
|
package/lib/src/service.js
CHANGED
|
@@ -421,7 +421,7 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
|
|
|
421
421
|
* Renueva el cert automáticamente si está por vencer (best-effort).
|
|
422
422
|
* @returns {Promise<Record<string,string>>} secretos KEY→valor
|
|
423
423
|
*/
|
|
424
|
-
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS } = {}) {
|
|
424
|
+
export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, timeoutMs = 30000, onPending, onCert, approvalTimeoutMs = APPROVAL_TIMEOUT_MS, publicOnly = false } = {}) {
|
|
425
425
|
let saved = null
|
|
426
426
|
if (dir) saved = readServiceIdentity(dir)
|
|
427
427
|
// Sin `dir`: la identidad viene entera por parámetros — es el caso de un agente
|
|
@@ -499,7 +499,9 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
499
499
|
}
|
|
500
500
|
|
|
501
501
|
const eph = await makeEphemeralKey()
|
|
502
|
-
|
|
502
|
+
// `publicOnly` VIAJA FIRMADO (va dentro de `data`), así que nadie lo puede quitar ni
|
|
503
|
+
// poner por el camino. Quien decide qué se manda es la bóveda; esto solo es la petición.
|
|
504
|
+
const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now(), ...(publicOnly ? { publicOnly: true } : {}) }
|
|
503
505
|
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
504
506
|
const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
|
|
505
507
|
client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data, signature, cert })
|
|
@@ -660,7 +662,14 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
660
662
|
|
|
661
663
|
const out = {}
|
|
662
664
|
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
663
|
-
|
|
665
|
+
// UNA PÚBLICA SE ABRE IGUAL QUE UNA PRIVADA (dueño, 2026-09-02). Aquí había un atajo
|
|
666
|
+
// —`if (e.pub) out[key] = e.v`— porque una pública se guardaba en claro. Ya no: `pub`
|
|
667
|
+
// solo dice si se despacha sin aprobación, y el sobre es el mismo. Dejar el atajo
|
|
668
|
+
// sería aceptar un valor en claro dentro de un paquete que se supone todo sellado, o
|
|
669
|
+
// sea la puerta para colar uno.
|
|
670
|
+
if (typeof e.v === 'string' && !e.e) {
|
|
671
|
+
throw Object.assign(new Error(`${key} arrived in the clear: since 2026-09-02 every variable travels sealed`), { code: 'plaintext-var' })
|
|
672
|
+
}
|
|
664
673
|
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
665
674
|
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
666
675
|
const gen = e.gen ?? e.e?.gen ?? 0
|
|
@@ -1022,11 +1031,11 @@ export function isFinal (e) {
|
|
|
1022
1031
|
return /not enrolled|invalid ns|unauthorized: (revoked|expired|scope|cn|untrusted-issuer|cert-device-mismatch|denied)/.test(e?.message || '')
|
|
1023
1032
|
}
|
|
1024
1033
|
|
|
1025
|
-
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert } = {}) {
|
|
1034
|
+
export async function waitForSecrets ({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, retryMs = 5000, maxRetryMs = 60000, onRetry, onPending, onCert, publicOnly = false } = {}) {
|
|
1026
1035
|
let delay = retryMs
|
|
1027
1036
|
for (;;) {
|
|
1028
1037
|
try {
|
|
1029
|
-
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert })
|
|
1038
|
+
return await fetchSecrets({ dir, ns, proxyUrl, masterPubkey, device, cert, enc, onPending, onCert, publicOnly })
|
|
1030
1039
|
} catch (e) {
|
|
1031
1040
|
if (isFinal(e)) throw e
|
|
1032
1041
|
try { onRetry?.(e, delay) } catch (_) {}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.85.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
|
@@ -209,9 +209,12 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
209
209
|
/** Los NOMBRES con su visibilidad: es lo que ve cualquier lista. Nunca valores. */
|
|
210
210
|
const names = (bag, k) => Object.entries(varsOf(bag, k)).map(([key, e]) => ({ key, public: !!e.pub }))
|
|
211
211
|
/** Solo las PÚBLICAS, con valor: lo único que puede salir de esta máquina en claro. */
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
212
|
+
/**
|
|
213
|
+
* LOS NOMBRES de las públicas. Ya no hay valores que devolver: desde 2026-09-02 una
|
|
214
|
+
* pública se guarda en sobre igual que una privada, y `pub` solo dice si se despacha sin
|
|
215
|
+
* aprobación. Para VER un valor hay que poder abrirlo, como con cualquier otro.
|
|
216
|
+
*/
|
|
217
|
+
const publics = (bag, k) => Object.keys(varsOf(bag, k)).filter((key) => varsOf(bag, k)[key].pub)
|
|
215
218
|
/**
|
|
216
219
|
* Las entradas TAL CUAL van al bundle: la pública con su valor, la privada con su
|
|
217
220
|
* sobre y su firma. No se descifra nada aquí — de eso vive todo esto.
|
|
@@ -260,7 +263,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
260
263
|
const gcKeyring = (bag, k, owner) => {
|
|
261
264
|
if (!bag[k]) return
|
|
262
265
|
const vivos = new Set()
|
|
263
|
-
|
|
266
|
+
// TAMBIÉN LAS PÚBLICAS. Se saltaban porque antes no tenían generación —se guardaban en
|
|
267
|
+
// claro—, así que su llave se recogía por «no la usa nadie» y la variable quedaba
|
|
268
|
+
// ilegible. Desde 2026-09-02 van en sobre como todas y cuentan como todas.
|
|
269
|
+
for (const e of Object.values(bag[k].vars || {})) if (e.gen != null) vivos.add(e.gen)
|
|
264
270
|
for (const h of data.history) if (h.owner === owner && h.gen != null) vivos.add(h.gen)
|
|
265
271
|
bag[k].keyring = (bag[k].keyring || []).filter((g) => vivos.has(g.gen))
|
|
266
272
|
}
|
|
@@ -329,10 +335,25 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
329
335
|
* Van TODAS sus generaciones, no solo la última, porque desde v5 cada variable puede
|
|
330
336
|
* venir de una escritura distinta. En v3 devuelve los valores en claro, como siempre.
|
|
331
337
|
*/
|
|
332
|
-
|
|
338
|
+
/**
|
|
339
|
+
* @param {string} ns
|
|
340
|
+
* @param {string|null} devicePub
|
|
341
|
+
* @param {{publicOnly?: boolean}} [opts] `publicOnly`: SOLO las públicas — pero CON sus
|
|
342
|
+
* envolturas, porque desde 2026-09-02 van en sobre igual que las privadas. Lo que las
|
|
343
|
+
* distingue es solo que se despachan sin aprobación (ver `handleSecrets`); aquí se
|
|
344
|
+
* devolvía el paquete sin llavero, y entonces no había con qué abrirlas.
|
|
345
|
+
*/
|
|
346
|
+
bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
|
|
333
347
|
assertNs(ns)
|
|
334
|
-
const
|
|
348
|
+
const todo = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
|
|
349
|
+
// EL FILTRO LO HACE LA BÓVEDA, no quien pide. Si mandara todo y el cliente eligiera,
|
|
350
|
+
// pedir «solo públicas» sería la forma de saltarse la aprobación y llevarse las
|
|
351
|
+
// privadas igual — o sea justo lo contrario de lo que se quiere.
|
|
352
|
+
const entries = publicOnly
|
|
353
|
+
? Object.fromEntries(Object.entries(todo).filter(([, e]) => e.pub))
|
|
354
|
+
: todo
|
|
335
355
|
if (isLegacy()) return { legacy: true, entries }
|
|
356
|
+
|
|
336
357
|
const misWraps = (bag, k) => (bag[k]?.keyring || [])
|
|
337
358
|
.filter((g) => g.wraps?.[devicePub])
|
|
338
359
|
.map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
|
|
@@ -357,7 +378,9 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
357
378
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
358
379
|
const e = varsOf(bag, k)[key]
|
|
359
380
|
if (!e) return null
|
|
360
|
-
|
|
381
|
+
// UNA PÚBLICA SE ABRE IGUAL QUE UNA PRIVADA (dueño, 2026-09-02). Aquí había un atajo
|
|
382
|
+
// —`if (e.pub) return e.v`— porque una pública se guardaba en claro. Ya no: la marca
|
|
383
|
+
// solo dice si se despacha sin aprobación, y para VER un valor hay que poder abrirlo.
|
|
361
384
|
if (isLegacy()) return e.v
|
|
362
385
|
const priv = await openRecovery(adminKey)
|
|
363
386
|
const cek = await this._cekOf(bag, k, e.gen, priv)
|
|
@@ -386,7 +409,8 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
386
409
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
387
410
|
const out = []
|
|
388
411
|
for (const [key, e] of Object.entries(varsOf(bag, k))) {
|
|
389
|
-
|
|
412
|
+
// También las públicas: desde 2026-09-02 van en sobre, así que a quien no tenga su
|
|
413
|
+
// envoltura le falta igual que con una privada.
|
|
390
414
|
const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
|
|
391
415
|
if (!g?.wraps?.[memberPub]) out.push(key)
|
|
392
416
|
}
|
|
@@ -408,7 +432,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
408
432
|
if (isLegacy()) return []
|
|
409
433
|
const [kind, k] = splitOwner(owner)
|
|
410
434
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
411
|
-
const inUse = new Set(Object.values(varsOf(bag, k)).
|
|
435
|
+
const inUse = new Set(Object.values(varsOf(bag, k)).map((e) => e.gen))
|
|
412
436
|
const out = []
|
|
413
437
|
for (const g of bag[k]?.keyring || []) {
|
|
414
438
|
if (!inUse.has(g.gen) || g.wraps?.[memberPub]) continue
|
|
@@ -499,10 +523,15 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
499
523
|
if (isLegacy()) { vars[key] = { v: value, pub }; save(); return }
|
|
500
524
|
if (needsMigration()) throw new NeedsMigration()
|
|
501
525
|
|
|
502
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
526
|
+
// PÚBLICA Y PRIVADA SE GUARDAN IGUAL (dueño, 2026-09-02: «las variables públicas
|
|
527
|
+
// igual, codificadas en sobres… la única diferencia es si se despachan o no, son
|
|
528
|
+
// políticas, dales el mismo tratamiento de seguridad»).
|
|
529
|
+
//
|
|
530
|
+
// Antes una pública se guardaba EN CLARO, y eso mezclaba dos cosas que no tienen que
|
|
531
|
+
// ver: quién puede recibirla (política) y cómo se guarda (seguridad). El precio era
|
|
532
|
+
// un valor legible en el disco y un camino aparte en cada sitio que las tocaba.
|
|
533
|
+
// Ahora `pub` es solo la marca de despacho: se lleva sin aprobación (`--public`).
|
|
534
|
+
needSealer('write a variable')
|
|
506
535
|
await ensureRecovery(null)
|
|
507
536
|
|
|
508
537
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
@@ -515,13 +544,102 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
515
544
|
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
516
545
|
|
|
517
546
|
pushHistory(owner, key, before, by)
|
|
518
|
-
vars[key] = { pub
|
|
547
|
+
vars[key] = { pub, owner, gen, e, seal, at: Date.now(), by: by || null }
|
|
519
548
|
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
|
|
520
549
|
gcKeyring(bag, k, owner)
|
|
521
550
|
save()
|
|
522
551
|
return { gen, sinLlave }
|
|
523
552
|
},
|
|
524
553
|
|
|
554
|
+
/**
|
|
555
|
+
* GUARDAR UN SOBRE YA HECHO, SIN ABRIRLO (dueño, 2026-09-01).
|
|
556
|
+
*
|
|
557
|
+
* «La bóveda cerrada no puede ver el valor; debe confiar en la firma del admin y en el
|
|
558
|
+
* contenido de esos sobres, y es la razón por la que al abrir la bóveda rehace los
|
|
559
|
+
* sobres: por si alguno tiene alguna incoherencia.»
|
|
560
|
+
*
|
|
561
|
+
* Antes, escribir hacía un rodeo absurdo: la consola sellaba el valor AL PERFIL, la
|
|
562
|
+
* bóveda lo ABRÍA para sacarlo en claro y `_putRaw` lo volvía a cerrar. Ese descifrado
|
|
563
|
+
* era el único motivo por el que la llave de cifrado del perfil tenía que estar
|
|
564
|
+
* accesible con la bóveda cerrada — o sea, la razón por la que una copia del disco
|
|
565
|
+
* abría todos los sobres dirigidos al perfil.
|
|
566
|
+
*
|
|
567
|
+
* Aquí no se abre nada: llega el valor ya cifrado con una CEK nueva y esa CEK ya
|
|
568
|
+
* envuelta para cada destinatario. Lo único que se comprueba es lo que se puede
|
|
569
|
+
* comprobar sin la llave — la FORMA, y sobre todo que venga la envoltura de
|
|
570
|
+
* recuperación: sin ella el cajón nace ilegible para siempre, y eso no se arregla
|
|
571
|
+
* después ni abriendo la bóveda.
|
|
572
|
+
*
|
|
573
|
+
* @param {string} owner `ns:<scope>` o `dev:<pub>`
|
|
574
|
+
* @param {{e:{iv:string,ct:string,gen?:number}, wraps:Object}} sobre
|
|
575
|
+
*/
|
|
576
|
+
async putSealed (owner, key, sobre, { by = null, public: isPublic = false } = {}) {
|
|
577
|
+
return enFila(() => this._putSealedRaw(owner, key, sobre, by, !!isPublic))
|
|
578
|
+
},
|
|
579
|
+
/** @private */
|
|
580
|
+
async _putSealedRaw (owner, key, sobre, by, isPublic) {
|
|
581
|
+
if (isLegacy()) throw new Error('putSealed: this store is still v3 (run `migrate` first)')
|
|
582
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
583
|
+
const [kind, k] = splitOwner(owner)
|
|
584
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
585
|
+
const e = sobre?.e; const wraps = sobre?.wraps
|
|
586
|
+
if (!e || typeof e.iv !== 'string' || typeof e.ct !== 'string') {
|
|
587
|
+
throw new Error('putSealed: the envelope must carry `iv` and `ct`')
|
|
588
|
+
}
|
|
589
|
+
if (!wraps || typeof wraps !== 'object' || !wraps[RECOVERY]) {
|
|
590
|
+
// SIN LA ENVOLTURA DE RECUPERACIÓN EL CAJÓN NACE MUERTO. Es la que abre la frase, y
|
|
591
|
+
// la única que puede rehacer las demás: si falta, ni el dueño con su contraseña
|
|
592
|
+
// podría reenvolverlo para nadie. Se rechaza antes de tocar el disco.
|
|
593
|
+
throw new Error('putSealed: the wraps must include the recovery one (#recovery), or the drawer could never be re-wrapped')
|
|
594
|
+
}
|
|
595
|
+
const vars = ensureBag(bag, k)
|
|
596
|
+
const before = vars[key]
|
|
597
|
+
// Una PRIVADA no se vuelve pública: cambiar eso es cambiar quién la recibe sin
|
|
598
|
+
// aprobación, y no puede colarse en una escritura normal.
|
|
599
|
+
if (before && !before.pub && isPublic) throw new PrivateStaysPrivate(key)
|
|
600
|
+
|
|
601
|
+
// LA GENERACIÓN LA PONE EL ALMACÉN, no quien escribe: es la que ordena el llavero, y
|
|
602
|
+
// dejar que la eligiera el que llega permitiría pisar una anterior.
|
|
603
|
+
const gen = (topGen(bag, k)?.gen || 0) + 1
|
|
604
|
+
// La FIRMA sigue siendo de la bóveda —dice que este sobre salió de aquí y con qué
|
|
605
|
+
// acta (§8.8)— y se hace con la llave de SELLADO, que funciona con el perfil cerrado.
|
|
606
|
+
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
607
|
+
|
|
608
|
+
pushHistory(owner, key, before, by)
|
|
609
|
+
vars[key] = { pub: isPublic, owner, gen, e: { ...e, gen }, seal, at: Date.now(), by: by || null }
|
|
610
|
+
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
|
|
611
|
+
gcKeyring(bag, k, owner)
|
|
612
|
+
save()
|
|
613
|
+
return { gen }
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* ¿EXISTE YA ESTA VARIABLE? Sin abrirla ni decir qué vale.
|
|
618
|
+
*
|
|
619
|
+
* Hace falta para la regla de «un servicio solo rellena lo que falta, no pisa lo que
|
|
620
|
+
* hay» (dueño, 2026-09-01): quien decide eso necesita saber si había algo, y nada más.
|
|
621
|
+
*/
|
|
622
|
+
has (owner, key) {
|
|
623
|
+
const [kind, k] = splitOwner(owner)
|
|
624
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
625
|
+
return key in varsOf(bag, k)
|
|
626
|
+
},
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* PARA QUIÉN hay que envolver, con su llave pública de cifrado, más la de recuperación.
|
|
630
|
+
* Es lo que necesita quien va a fabricar el sobre — y se lo dice la BÓVEDA, para que no
|
|
631
|
+
* haya dos sitios respondiendo a «quién puede abrir este cajón» (la lista la sabe el
|
|
632
|
+
* acta, y de ahí sale `recipients`).
|
|
633
|
+
*/
|
|
634
|
+
async recipientsFor (owner) {
|
|
635
|
+
await ensureRecovery(null)
|
|
636
|
+
const members = (await recipients?.(owner)) || []
|
|
637
|
+
return {
|
|
638
|
+
recoveryPub: data.recovery.pub,
|
|
639
|
+
members: members.filter((m) => m.encPub).map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
|
|
525
643
|
async delete (ns, key) {
|
|
526
644
|
assertNs(ns)
|
|
527
645
|
return this._drop(data.ns, ns, `ns:${ns}`, key)
|
|
@@ -573,13 +691,21 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
573
691
|
if (!e) return false
|
|
574
692
|
const want = !!isPublic
|
|
575
693
|
if (!!e.pub === want) return true
|
|
694
|
+
// DE PRIVADA A PÚBLICA NO EXISTE, y no es una limitación técnica: hacer despachable
|
|
695
|
+
// sin aprobación algo que se guardó como secreto no puede ser una casilla. Se crea
|
|
696
|
+
// otra variable a propósito.
|
|
576
697
|
if (!e.pub) throw new PrivateStaysPrivate(key)
|
|
577
698
|
if (isLegacy()) { e.pub = false; save(); return true }
|
|
578
699
|
if (needsMigration()) throw new NeedsMigration()
|
|
579
700
|
|
|
580
|
-
//
|
|
581
|
-
|
|
582
|
-
|
|
701
|
+
// DE PÚBLICA A PRIVADA ES CAMBIAR LA MARCA, Y NADA MÁS (dueño, 2026-09-02).
|
|
702
|
+
//
|
|
703
|
+
// Antes había que reescribir la variable entera —generación nueva, sobre nuevo—
|
|
704
|
+
// porque una pública se guardaba EN CLARO y taparla significaba cifrarla por primera
|
|
705
|
+
// vez. Ya no: las dos se guardan igual desde el principio, así que aquí solo cambia
|
|
706
|
+
// a quién se le despacha sin aprobación. Reescribir ahora sería trabajo (y una
|
|
707
|
+
// generación) para no cambiar nada de lo guardado.
|
|
708
|
+
e.pub = false
|
|
583
709
|
save()
|
|
584
710
|
return true
|
|
585
711
|
},
|
|
@@ -609,12 +735,12 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
609
735
|
return Object.keys(topGen(bag, k)?.wraps || {})
|
|
610
736
|
},
|
|
611
737
|
|
|
612
|
-
/**
|
|
738
|
+
/** Los NOMBRES de las públicas de un scope. Ya no hay valor que devolver: van en sobre. */
|
|
613
739
|
publicOf (ns) {
|
|
614
740
|
assertNs(ns)
|
|
615
741
|
return publics(data.ns, ns)
|
|
616
742
|
},
|
|
617
|
-
/**
|
|
743
|
+
/** Los NOMBRES de las públicas de un aparato. */
|
|
618
744
|
publicOfDevice (pub) {
|
|
619
745
|
assertPub(pub)
|
|
620
746
|
return publics(data.dev, pub)
|
|
@@ -879,7 +1005,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
879
1005
|
for (const [key, e] of Object.entries(entradas)) {
|
|
880
1006
|
const value = await claro(owner, e)
|
|
881
1007
|
antes[`${owner}\u0000${key}`] = value
|
|
882
|
-
|
|
1008
|
+
// LA PÚBLICA TAMBIÉN SE SELLA (dueño, 2026-09-02). Aquí se dejaba en claro,
|
|
1009
|
+
// así que un archivo migrado se quedaba con valores legibles en el disco —
|
|
1010
|
+
// exactamente lo que la conversión venía a quitar. La marca se conserva: dice
|
|
1011
|
+
// a quién se le despacha sin aprobación, no cómo se guarda.
|
|
883
1012
|
if (!cek) {
|
|
884
1013
|
cek = await sealer.newKey()
|
|
885
1014
|
const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, (await membersOf(owner)) || [])
|
|
@@ -889,7 +1018,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
889
1018
|
}
|
|
890
1019
|
const sobre = await sealer.encrypt(cek, value, gen)
|
|
891
1020
|
const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
|
|
892
|
-
vars[key] = { pub:
|
|
1021
|
+
vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: Date.now(), by: null }
|
|
893
1022
|
}
|
|
894
1023
|
dst[k] = { vars, keyring }
|
|
895
1024
|
}
|
|
@@ -901,12 +1030,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
901
1030
|
for (const [k, bag] of Object.entries(dst)) {
|
|
902
1031
|
const owner = `${kind}:${k}`
|
|
903
1032
|
for (const [key, e] of Object.entries(bag.vars)) {
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
abierto = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
|
|
909
|
-
}
|
|
1033
|
+
// Se abre TODO, también las públicas: ahora van selladas igual, así que la
|
|
1034
|
+
// comprobación de que la migración no cambió ningún valor vale para las dos.
|
|
1035
|
+
const g = bag.keyring.find((x) => x.gen === e.gen)
|
|
1036
|
+
const abierto = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
|
|
910
1037
|
if (abierto !== antes[`${owner}\u0000${key}`]) {
|
|
911
1038
|
throw new Error(`secrets: the migration check failed on ${owner}/${key}; nothing was touched`)
|
|
912
1039
|
}
|
package/src/vault.js
CHANGED
|
@@ -16,14 +16,14 @@ import { Identity } from '@dotrino/identity/node'
|
|
|
16
16
|
import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
17
17
|
import * as Acta from '@dotrino/identity/acta'
|
|
18
18
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
19
|
-
import { createAdminDesk } from '../lib/src/admin.js'
|
|
19
|
+
import { createAdminDesk, authorBody } from '../lib/src/admin.js'
|
|
20
20
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
21
21
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
22
22
|
import { startSealersPublisher } from './sealers.js'
|
|
23
23
|
import { assertKeyOwnsDir } from './keyowner.js'
|
|
24
24
|
import { openStore } from './store.js'
|
|
25
25
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
26
|
-
import { openSecretsStore, assertVar } from './secretsStore.js'
|
|
26
|
+
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP } from './secretsStore.js'
|
|
27
27
|
// `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
|
|
28
28
|
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
29
29
|
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
@@ -852,7 +852,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
852
852
|
// claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
|
|
853
853
|
// contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
|
|
854
854
|
// (`handleApproval`), sellada a la misma `ek`.
|
|
855
|
-
|
|
855
|
+
// SOLO PÚBLICAS: NO SE PIDE APROBACIÓN (dueño, 2026-09-01).
|
|
856
|
+
//
|
|
857
|
+
// La aprobación existe para soltar CLAVES PRIVADAS. Una variable pública está guardada
|
|
858
|
+
// en claro —eso es lo que significa marcarla— y ya la ve cualquiera que administre, así
|
|
859
|
+
// que hacer sonar el teléfono del dueño para entregarla es molestarlo por nada.
|
|
860
|
+
//
|
|
861
|
+
// Lo que NO se relaja: quien pide sigue teniendo que ser un miembro identificado, con su
|
|
862
|
+
// papel y con el `cn` de este cajón (las dos comprobaciones de arriba). Y el filtro lo
|
|
863
|
+
// hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
|
|
864
|
+
// cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
|
|
865
|
+
const publicOnly = p.data?.publicOnly === true
|
|
866
|
+
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
856
867
|
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
857
868
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
858
869
|
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek })
|
|
@@ -866,20 +877,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
866
877
|
return
|
|
867
878
|
}
|
|
868
879
|
let res
|
|
869
|
-
try { res = await resultFor(ns, chk.device, p.data.ek, record) } catch (e) {
|
|
880
|
+
try { res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly }) } catch (e) {
|
|
870
881
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
871
882
|
}
|
|
872
|
-
audit('secrets', { device: await deviceIdOf(chk.device), ns })
|
|
883
|
+
audit('secrets', { device: await deviceIdOf(chk.device), ns, ...(publicOnly ? { publicOnly: true } : {}) })
|
|
873
884
|
reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
874
885
|
}
|
|
875
886
|
|
|
876
|
-
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la
|
|
877
|
-
async function resultFor (ns, devicePub, ek, record) {
|
|
887
|
+
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la llave de sellado. */
|
|
888
|
+
async function resultFor (ns, devicePub, ek, record, { publicOnly = false } = {}) {
|
|
878
889
|
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
879
890
|
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
880
891
|
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
881
892
|
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
882
|
-
const b = secrets.bundleFor(ns, devicePub)
|
|
893
|
+
const b = secrets.bundleFor(ns, devicePub, { publicOnly })
|
|
883
894
|
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
884
895
|
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
885
896
|
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
@@ -897,7 +908,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
897
908
|
// como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
|
|
898
909
|
// aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
|
|
899
910
|
// bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
|
|
900
|
-
if (!b.legacy) {
|
|
911
|
+
if (!b.legacy && !publicOnly) {
|
|
901
912
|
const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
|
|
902
913
|
if (faltan.length) {
|
|
903
914
|
const quien = await deviceIdOf(devicePub).catch(() => '????-????')
|
|
@@ -1370,15 +1381,59 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1370
1381
|
}))
|
|
1371
1382
|
}
|
|
1372
1383
|
},
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1384
|
+
/**
|
|
1385
|
+
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
|
|
1386
|
+
* (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
|
|
1387
|
+
* exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
|
|
1388
|
+
*/
|
|
1389
|
+
async recipients ({ ns, pub }) {
|
|
1390
|
+
return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`)
|
|
1391
|
+
},
|
|
1392
|
+
/**
|
|
1393
|
+
* GUARDAR UNA VARIABLE **SIN VERLA** (dueño, 2026-09-01).
|
|
1394
|
+
*
|
|
1395
|
+
* «La bóveda cerrada no puede ver el valor; debe confiar en la firma del admin y en el
|
|
1396
|
+
* contenido de esos sobres, y es la razón por la que al abrir la bóveda rehace los
|
|
1397
|
+
* sobres: por si alguno tiene alguna incoherencia.»
|
|
1398
|
+
*
|
|
1399
|
+
* Antes esto abría el sobre para sacar el valor en claro y volvía a cerrarlo. Ese
|
|
1400
|
+
* rodeo era el ÚNICO motivo por el que la llave de cifrado del perfil tenía que estar
|
|
1401
|
+
* accesible con la bóveda cerrada — o sea, la razón por la que una copia del disco
|
|
1402
|
+
* abría todo lo dirigido al perfil.
|
|
1403
|
+
*
|
|
1404
|
+
* Ahora llega hecho: el valor cifrado con una CEK nueva y esa CEK envuelta para cada
|
|
1405
|
+
* destinatario. Aquí se comprueba lo que se puede comprobar SIN la llave: que las
|
|
1406
|
+
* envolturas cubren exactamente a quien dice el acta. Lo que no se puede comprobar
|
|
1407
|
+
* —que dentro haya lo que dice— lo corrige el repaso al abrir la bóveda.
|
|
1408
|
+
*
|
|
1409
|
+
* Una PÚBLICA sigue viajando en claro y por el camino de siempre: marcarla pública es
|
|
1410
|
+
* precisamente decir que no hay nada que ocultar.
|
|
1411
|
+
*/
|
|
1412
|
+
async set ({ ns, pub, key, sealed, caller = null, public: isPublic, by: who = null }) {
|
|
1413
|
+
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
1414
|
+
if (sealed) {
|
|
1415
|
+
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
1416
|
+
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
1417
|
+
// marca solo decide si se entrega sin aprobación.
|
|
1418
|
+
await verificarAutor(owner, key, sealed, caller)
|
|
1419
|
+
await verificarDestinatarios(owner, sealed.wraps)
|
|
1420
|
+
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
1421
|
+
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
1422
|
+
await settleDebts(owner)
|
|
1423
|
+
return { ok: true, key }
|
|
1424
|
+
}
|
|
1425
|
+
// NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
|
|
1426
|
+
// que hacer retrocompatibilidad y es una regla del estado actual del proyecto, por eso
|
|
1427
|
+
// quedan luego backdoors»).
|
|
1428
|
+
//
|
|
1429
|
+
// Aquí estaba el `enc`: la consola sellaba el valor AL PERFIL y la bóveda lo ABRÍA
|
|
1430
|
+
// para volver a cerrarlo. Ese descifrado era el único motivo por el que la llave de
|
|
1431
|
+
// cifrado del perfil tenía que estar accesible con la bóveda cerrada — o sea, la
|
|
1432
|
+
// razón por la que una copia del disco abría todo lo dirigido al perfil. Dejarlo
|
|
1433
|
+
// «por si acaso» habría dejado el agujero entero abierto con otro nombre.
|
|
1434
|
+
throw Object.assign(
|
|
1435
|
+
new Error('var.set: the value must come already sealed (`sealed`); the old `enc` path is gone — build it with buildSealedVar()'),
|
|
1436
|
+
{ code: 'needs-sealed' })
|
|
1382
1437
|
},
|
|
1383
1438
|
/**
|
|
1384
1439
|
* VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
|
|
@@ -1390,22 +1445,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1390
1445
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
1391
1446
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
1392
1447
|
*/
|
|
1393
|
-
async setMany ({ ns, pub,
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
//
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
1401
|
-
|
|
1402
|
-
key
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
}
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
1408
|
-
|
|
1448
|
+
async setMany ({ ns, pub, items, caller = null, by: who = null }) {
|
|
1449
|
+
// CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
|
|
1450
|
+
// comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
|
|
1451
|
+
// `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
|
|
1452
|
+
// a tener la llave de cifrado accesible con la bóveda cerrada.
|
|
1453
|
+
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
|
|
1454
|
+
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
1455
|
+
const keys = []
|
|
1456
|
+
for (const it of items) {
|
|
1457
|
+
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
1458
|
+
await verificarAutor(owner, it.key, it.sealed, caller)
|
|
1459
|
+
await verificarDestinatarios(owner, it.sealed.wraps)
|
|
1460
|
+
}
|
|
1461
|
+
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
1462
|
+
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
1463
|
+
for (const it of items) {
|
|
1464
|
+
await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
|
|
1465
|
+
keys.push(it.key)
|
|
1466
|
+
}
|
|
1467
|
+
audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
|
|
1468
|
+
await settleDebts(owner)
|
|
1409
1469
|
return { ok: true, keys }
|
|
1410
1470
|
}
|
|
1411
1471
|
}
|
|
@@ -1926,6 +1986,88 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1926
1986
|
* `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
|
|
1927
1987
|
* normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
|
|
1928
1988
|
*/
|
|
1989
|
+
/**
|
|
1990
|
+
* ¿QUIÉN HIZO ESTE SOBRE? Tiene que decirlo, y tiene que ser un MIEMBRO DEL ACTA.
|
|
1991
|
+
*
|
|
1992
|
+
* Regla del dueño (2026-09-01): «los sobres deben traer información de quién los hizo,
|
|
1993
|
+
* deberían tener la firma para que no se cuelen sobres firmados por cualquiera; solo
|
|
1994
|
+
* puede haber sobres firmados por miembros del acta».
|
|
1995
|
+
*
|
|
1996
|
+
* La petición ya viene firmada y comprobada (`verifyChain` + el acta), así que un extraño
|
|
1997
|
+
* no llega hasta aquí. Esto es otra cosa y hace falta igual: la bóveda guarda unos bytes
|
|
1998
|
+
* que NO PUEDE LEER, y sin esta firma lo único que ataba esos bytes a un autor era el
|
|
1999
|
+
* momento de la llamada. Con ella, el registro guardado se explica solo — se puede
|
|
2000
|
+
* comprobar después, en frío, sin fiarse de ningún log.
|
|
2001
|
+
*
|
|
2002
|
+
* El autor tiene que ser QUIEN LLAMA. Aceptar un sobre firmado por otro sería admitir un
|
|
2003
|
+
* relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
|
|
2004
|
+
* las dos comprobaciones lo note.
|
|
2005
|
+
*/
|
|
2006
|
+
async function verificarAutor (owner, key, sealed, caller) {
|
|
2007
|
+
const a = sealed?.author
|
|
2008
|
+
if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
|
|
2009
|
+
throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
|
|
2010
|
+
}
|
|
2011
|
+
if (caller && a.pub !== caller) {
|
|
2012
|
+
throw new Error('var.set: the envelope was signed by someone other than the caller — a relayed envelope is not accepted')
|
|
2013
|
+
}
|
|
2014
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
2015
|
+
const m = (record?.members || []).find((x) => x.pub === a.pub)
|
|
2016
|
+
if (!record || !m) {
|
|
2017
|
+
throw new Error('var.set: the envelope is signed by a key the record does not name')
|
|
2018
|
+
}
|
|
2019
|
+
// UN SERVICIO SOLO PARA SU CAJÓN (dueño, 2026-09-01: «un cn no puede poner un sobre
|
|
2020
|
+
// faltante fuera de su cn»). Es la misma regla que ya aplica el reparto delegado
|
|
2021
|
+
// (`handleRewrapOk`), y tiene que valer también aquí: si no, un servicio con `admin`
|
|
2022
|
+
// podría escribir en el cajón de otro. Quien administra sin `cn` sí puede: ese es su
|
|
2023
|
+
// trabajo, y para eso se le concede a mano.
|
|
2024
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
2025
|
+
if (m.cn && ns && m.cn !== ns) {
|
|
2026
|
+
throw new Error(`var.set: "${m.cn}" cannot write into "${owner}" — a service only authors envelopes for its own drawer`)
|
|
2027
|
+
}
|
|
2028
|
+
// Y UN SERVICIO NO PISA LO QUE YA HAY (dueño, 2026-09-01: «un cn no puede sobrescribir
|
|
2029
|
+
// un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
|
|
2030
|
+
// crear los que faltan nomás»).
|
|
2031
|
+
//
|
|
2032
|
+
// Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
|
|
2033
|
+
// servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
|
|
2034
|
+
// basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
|
|
2035
|
+
// quita nada a nadie; reemplazar sí, y eso es de quien administra.
|
|
2036
|
+
if (m.cn && secrets.has?.(owner, key)) {
|
|
2037
|
+
throw new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — a service only fills in what is missing; replacing is for who administers`)
|
|
2038
|
+
}
|
|
2039
|
+
const cuerpo = authorBody(owner, key, sealed.e, a.ts)
|
|
2040
|
+
if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {
|
|
2041
|
+
throw new Error('var.set: the author signature does not verify over this envelope')
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
/**
|
|
2046
|
+
* ¿ESTE SOBRE ENVUELVE A QUIEN TOCA? Lo único que se puede comprobar sin la llave.
|
|
2047
|
+
*
|
|
2048
|
+
* No se puede saber si dentro de cada envoltura está la CEK correcta —para eso habría que
|
|
2049
|
+
* abrirla, que es justo lo que ya no se hace—, pero sí QUIÉN puede abrirlas. Y eso ya
|
|
2050
|
+
* ataja lo que importa: que no falte nadie (un servicio que arrancaría sin configuración)
|
|
2051
|
+
* y que no sobre nadie (alguien a quien el acta no nombra leyendo el cajón).
|
|
2052
|
+
*
|
|
2053
|
+
* Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
|
|
2054
|
+
*/
|
|
2055
|
+
async function verificarDestinatarios (owner, wraps) {
|
|
2056
|
+
if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
|
|
2057
|
+
const { recoveryPub, members } = await secrets.recipientsFor(owner)
|
|
2058
|
+
if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
|
|
2059
|
+
const deben = new Set(members.map((m) => m.pub))
|
|
2060
|
+
const traidos = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
|
|
2061
|
+
const faltan = [...deben].filter((p) => !traidos.has(p))
|
|
2062
|
+
const sobran = [...traidos].filter((p) => !deben.has(p))
|
|
2063
|
+
if (faltan.length) {
|
|
2064
|
+
throw new Error(`var.set: the envelope leaves ${faltan.length} member(s) of "${owner}" out — they could not read it (ask the vault for the recipients with \`var.recipients\`)`)
|
|
2065
|
+
}
|
|
2066
|
+
if (sobran.length) {
|
|
2067
|
+
throw new Error(`var.set: the envelope wraps for ${sobran.length} key(s) the record does not name for "${owner}"`)
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
1929
2071
|
async function refreshWraps (motivo) {
|
|
1930
2072
|
// HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
|
|
1931
2073
|
//
|
|
@@ -2160,12 +2302,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2160
2302
|
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
2161
2303
|
*/
|
|
2162
2304
|
function listSecrets () {
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
return out
|
|
2305
|
+
// YA NO SE ENSEÑA EL VALOR DE UNA PÚBLICA (dueño, 2026-09-02). Desde que todas van en
|
|
2306
|
+
// sobre, «pública» dejó de significar «en claro»: dice a quién se le despacha sin
|
|
2307
|
+
// aprobación, nada más. Para ver un valor hay que poder abrirlo, igual que cualquiera —
|
|
2308
|
+
// y en un cajón con dueño, quien administra deliberadamente no puede.
|
|
2309
|
+
return secrets.list()
|
|
2169
2310
|
}
|
|
2170
2311
|
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
2171
2312
|
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
@@ -2242,13 +2383,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2242
2383
|
const out = []
|
|
2243
2384
|
for (const [pub, keys] of Object.entries(secrets.listDevices())) {
|
|
2244
2385
|
const m = members.find((x) => x.pub === pub) || null
|
|
2245
|
-
const values = secrets.publicOfDevice(pub)
|
|
2246
2386
|
out.push({
|
|
2247
2387
|
pub,
|
|
2248
2388
|
id: m?.id || await deviceIdOf(pub).catch(() => null),
|
|
2249
2389
|
label: m?.label || '',
|
|
2250
2390
|
cn: m?.cn || null,
|
|
2251
|
-
|
|
2391
|
+
// Sin valores: desde 2026-09-02 una pública también va en sobre (ver `listSecrets`).
|
|
2392
|
+
keys,
|
|
2252
2393
|
orphan: !!members.length && !m
|
|
2253
2394
|
})
|
|
2254
2395
|
}
|