@dotrino/vaultd 0.84.0 → 0.86.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/service.js +8 -1
- package/package.json +1 -1
- package/src/ctl.js +19 -11
- package/src/secretsStore.js +142 -30
- package/src/vault.js +165 -35
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/service.js
CHANGED
|
@@ -662,7 +662,14 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
|
|
|
662
662
|
|
|
663
663
|
const out = {}
|
|
664
664
|
for (const [key, e] of Object.entries(sealed.entries || {})) {
|
|
665
|
-
|
|
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
|
+
}
|
|
666
673
|
// `owner` dice de qué cajón salió, y `gen` con qué llave de ese cajón se abre.
|
|
667
674
|
const cual = String(e.owner || '').startsWith('dev:') ? 'dev' : 'ns'
|
|
668
675
|
const gen = e.gen ?? e.e?.gen ?? 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.86.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/ctl.js
CHANGED
|
@@ -960,7 +960,9 @@ function problemWith (key, value) {
|
|
|
960
960
|
}
|
|
961
961
|
|
|
962
962
|
async function cmdSecret (rest) {
|
|
963
|
-
// --public / --private: si
|
|
963
|
+
// --public / --private: si al entregarla se pide APROBACIÓN. NO cambia cómo se guarda —
|
|
964
|
+
// desde 2026-09-02 las dos van selladas igual (dueño: «la única diferencia es si se
|
|
965
|
+
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»).
|
|
964
966
|
// Se sacan de la línea antes de partirla, para que puedan ir en cualquier posición y no
|
|
965
967
|
// se cuelen dentro del valor (que es lo último y puede llevar espacios).
|
|
966
968
|
let isPublic
|
|
@@ -1054,8 +1056,8 @@ async function cmdSecret (rest) {
|
|
|
1054
1056
|
'la bóveda la aplica entera y avisa UNA sola vez. De una en una, cada variable es un',
|
|
1055
1057
|
'cambio de configuración y el servicio se reinicia a media carga.',
|
|
1056
1058
|
'',
|
|
1057
|
-
'Pública o privada dice UNA cosa: si
|
|
1058
|
-
'
|
|
1059
|
+
'Pública o privada dice UNA cosa: si al entregarla se te pide APROBACIÓN. Las dos se',
|
|
1060
|
+
'guardan cifradas igual y ninguna enseña su valor en la consola. Se nace privada.'
|
|
1059
1061
|
].join('\n')
|
|
1060
1062
|
|
|
1061
1063
|
/**
|
|
@@ -1108,11 +1110,14 @@ async function cmdSecret (rest) {
|
|
|
1108
1110
|
}
|
|
1109
1111
|
|
|
1110
1112
|
/**
|
|
1111
|
-
* Una variable en la lista: su nombre y
|
|
1112
|
-
*
|
|
1113
|
-
*
|
|
1113
|
+
* Una variable en la lista: su nombre, y que TIENE valor. Ninguna lo enseña.
|
|
1114
|
+
*
|
|
1115
|
+
* La pública lo enseñaba, porque se guardaba en claro. Desde 2026-09-02 las dos van
|
|
1116
|
+
* selladas igual y `pública` solo dice que se entrega sin pedir aprobación (dueño: «la
|
|
1117
|
+
* única diferencia es si se despachan o no, son políticas»). Enseñar el hueco vacío de
|
|
1118
|
+
* una pública era peor que no enseñar nada: parecía que se había perdido.
|
|
1114
1119
|
*/
|
|
1115
|
-
const printVar = (k) => console.log(' · %s
|
|
1120
|
+
const printVar = (k) => console.log(' · %s ••••••%s', k.key, k.public ? ' (pública)' : '')
|
|
1116
1121
|
/**
|
|
1117
1122
|
* Por qué no se aplicó. El daemon deja el motivo en el volcado siguiente; sin él lo
|
|
1118
1123
|
* único que se podía decir era «revisa los logs», que no ayuda a quien acaba de
|
|
@@ -1179,7 +1184,10 @@ async function cmdSecret (rest) {
|
|
|
1179
1184
|
console.log('%s%s%s%s', B, x.id, Z, who ? ' ' + who : '')
|
|
1180
1185
|
for (const k of x.keys) printVar(k)
|
|
1181
1186
|
}
|
|
1182
|
-
|
|
1187
|
+
// QUÉ SIGNIFICA HOY (dueño, 2026-09-02): todas se guardan selladas igual, y `pública`
|
|
1188
|
+
// solo dice a quién se le entrega SIN APROBACIÓN. Decir «su valor se puede ver» era
|
|
1189
|
+
// verdad cuando se guardaba en claro; dejarlo ahí sería prometer algo que ya no pasa.
|
|
1190
|
+
console.log('\n(pública) = se entrega sin pedirte aprobación. Todas se guardan cifradas igual.\n')
|
|
1183
1191
|
printPending(d)
|
|
1184
1192
|
await printNoPassword()
|
|
1185
1193
|
return
|
|
@@ -1534,9 +1542,9 @@ function help () {
|
|
|
1534
1542
|
vuelve a una versión anterior
|
|
1535
1543
|
secret settle salda lo pendiente: hereda lo ya guardado a un
|
|
1536
1544
|
aparato nuevo y rota de verdad el cajón del que se fue
|
|
1537
|
-
--public | --private (al hacer un set) si
|
|
1538
|
-
|
|
1539
|
-
y una privada NO se vuelve pública
|
|
1545
|
+
--public | --private (al hacer un set) si al entregarla se te pide
|
|
1546
|
+
APROBACIÓN. Las dos se guardan cifradas igual. Se nace
|
|
1547
|
+
privada, y una privada NO se vuelve pública.
|
|
1540
1548
|
secret visibility <ns> <CLAVE> private tapa una pública sin tocar el valor
|
|
1541
1549
|
secret device visibility <ID> <CLAVE> private
|
|
1542
1550
|
join <invitación> [--name <n>] [--kms <config.json>]
|
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
|
}
|
|
@@ -332,9 +338,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
332
338
|
/**
|
|
333
339
|
* @param {string} ns
|
|
334
340
|
* @param {string|null} devicePub
|
|
335
|
-
* @param {{publicOnly?: boolean}} [opts] `publicOnly`: SOLO las públicas
|
|
336
|
-
* envolturas
|
|
337
|
-
*
|
|
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.
|
|
338
345
|
*/
|
|
339
346
|
bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
|
|
340
347
|
assertNs(ns)
|
|
@@ -346,8 +353,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
346
353
|
? Object.fromEntries(Object.entries(todo).filter(([, e]) => e.pub))
|
|
347
354
|
: todo
|
|
348
355
|
if (isLegacy()) return { legacy: true, entries }
|
|
349
|
-
|
|
350
|
-
if (publicOnly) return { entries, ns: null, dev: null, wraps: { ns: [], dev: [] } }
|
|
356
|
+
|
|
351
357
|
const misWraps = (bag, k) => (bag[k]?.keyring || [])
|
|
352
358
|
.filter((g) => g.wraps?.[devicePub])
|
|
353
359
|
.map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
|
|
@@ -372,7 +378,9 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
372
378
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
373
379
|
const e = varsOf(bag, k)[key]
|
|
374
380
|
if (!e) return null
|
|
375
|
-
|
|
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.
|
|
376
384
|
if (isLegacy()) return e.v
|
|
377
385
|
const priv = await openRecovery(adminKey)
|
|
378
386
|
const cek = await this._cekOf(bag, k, e.gen, priv)
|
|
@@ -401,7 +409,8 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
401
409
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
402
410
|
const out = []
|
|
403
411
|
for (const [key, e] of Object.entries(varsOf(bag, k))) {
|
|
404
|
-
|
|
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.
|
|
405
414
|
const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
|
|
406
415
|
if (!g?.wraps?.[memberPub]) out.push(key)
|
|
407
416
|
}
|
|
@@ -423,7 +432,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
423
432
|
if (isLegacy()) return []
|
|
424
433
|
const [kind, k] = splitOwner(owner)
|
|
425
434
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
426
|
-
const inUse = new Set(Object.values(varsOf(bag, k)).
|
|
435
|
+
const inUse = new Set(Object.values(varsOf(bag, k)).map((e) => e.gen))
|
|
427
436
|
const out = []
|
|
428
437
|
for (const g of bag[k]?.keyring || []) {
|
|
429
438
|
if (!inUse.has(g.gen) || g.wraps?.[memberPub]) continue
|
|
@@ -514,10 +523,15 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
514
523
|
if (isLegacy()) { vars[key] = { v: value, pub }; save(); return }
|
|
515
524
|
if (needsMigration()) throw new NeedsMigration()
|
|
516
525
|
|
|
517
|
-
//
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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')
|
|
521
535
|
await ensureRecovery(null)
|
|
522
536
|
|
|
523
537
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
@@ -530,13 +544,102 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
530
544
|
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
531
545
|
|
|
532
546
|
pushHistory(owner, key, before, by)
|
|
533
|
-
vars[key] = { pub
|
|
547
|
+
vars[key] = { pub, owner, gen, e, seal, at: Date.now(), by: by || null }
|
|
534
548
|
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
|
|
535
549
|
gcKeyring(bag, k, owner)
|
|
536
550
|
save()
|
|
537
551
|
return { gen, sinLlave }
|
|
538
552
|
},
|
|
539
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
|
+
|
|
540
643
|
async delete (ns, key) {
|
|
541
644
|
assertNs(ns)
|
|
542
645
|
return this._drop(data.ns, ns, `ns:${ns}`, key)
|
|
@@ -588,13 +691,21 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
588
691
|
if (!e) return false
|
|
589
692
|
const want = !!isPublic
|
|
590
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.
|
|
591
697
|
if (!e.pub) throw new PrivateStaysPrivate(key)
|
|
592
698
|
if (isLegacy()) { e.pub = false; save(); return true }
|
|
593
699
|
if (needsMigration()) throw new NeedsMigration()
|
|
594
700
|
|
|
595
|
-
//
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
598
709
|
save()
|
|
599
710
|
return true
|
|
600
711
|
},
|
|
@@ -624,12 +735,12 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
624
735
|
return Object.keys(topGen(bag, k)?.wraps || {})
|
|
625
736
|
},
|
|
626
737
|
|
|
627
|
-
/**
|
|
738
|
+
/** Los NOMBRES de las públicas de un scope. Ya no hay valor que devolver: van en sobre. */
|
|
628
739
|
publicOf (ns) {
|
|
629
740
|
assertNs(ns)
|
|
630
741
|
return publics(data.ns, ns)
|
|
631
742
|
},
|
|
632
|
-
/**
|
|
743
|
+
/** Los NOMBRES de las públicas de un aparato. */
|
|
633
744
|
publicOfDevice (pub) {
|
|
634
745
|
assertPub(pub)
|
|
635
746
|
return publics(data.dev, pub)
|
|
@@ -894,7 +1005,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
894
1005
|
for (const [key, e] of Object.entries(entradas)) {
|
|
895
1006
|
const value = await claro(owner, e)
|
|
896
1007
|
antes[`${owner}\u0000${key}`] = value
|
|
897
|
-
|
|
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.
|
|
898
1012
|
if (!cek) {
|
|
899
1013
|
cek = await sealer.newKey()
|
|
900
1014
|
const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, (await membersOf(owner)) || [])
|
|
@@ -904,7 +1018,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
904
1018
|
}
|
|
905
1019
|
const sobre = await sealer.encrypt(cek, value, gen)
|
|
906
1020
|
const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
|
|
907
|
-
vars[key] = { pub:
|
|
1021
|
+
vars[key] = { pub: !!e.pub, owner, gen, e: sobre, seal, at: Date.now(), by: null }
|
|
908
1022
|
}
|
|
909
1023
|
dst[k] = { vars, keyring }
|
|
910
1024
|
}
|
|
@@ -916,12 +1030,10 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
916
1030
|
for (const [k, bag] of Object.entries(dst)) {
|
|
917
1031
|
const owner = `${kind}:${k}`
|
|
918
1032
|
for (const [key, e] of Object.entries(bag.vars)) {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
abierto = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
|
|
924
|
-
}
|
|
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)
|
|
925
1037
|
if (abierto !== antes[`${owner}\u0000${key}`]) {
|
|
926
1038
|
throw new Error(`secrets: the migration check failed on ${owner}/${key}; nothing was touched`)
|
|
927
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
|
|
@@ -1381,15 +1381,59 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1381
1381
|
}))
|
|
1382
1382
|
}
|
|
1383
1383
|
},
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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' })
|
|
1393
1437
|
},
|
|
1394
1438
|
/**
|
|
1395
1439
|
* VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
|
|
@@ -1401,22 +1445,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1401
1445
|
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
1402
1446
|
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
1403
1447
|
*/
|
|
1404
|
-
async setMany ({ ns, pub,
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
//
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const
|
|
1412
|
-
|
|
1413
|
-
key
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
}
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
|
|
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)
|
|
1420
1469
|
return { ok: true, keys }
|
|
1421
1470
|
}
|
|
1422
1471
|
}
|
|
@@ -1937,6 +1986,88 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1937
1986
|
* `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
|
|
1938
1987
|
* normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
|
|
1939
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
|
+
|
|
1940
2071
|
async function refreshWraps (motivo) {
|
|
1941
2072
|
// HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
|
|
1942
2073
|
//
|
|
@@ -2171,12 +2302,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2171
2302
|
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
2172
2303
|
*/
|
|
2173
2304
|
function listSecrets () {
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
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()
|
|
2180
2310
|
}
|
|
2181
2311
|
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
2182
2312
|
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
@@ -2253,13 +2383,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2253
2383
|
const out = []
|
|
2254
2384
|
for (const [pub, keys] of Object.entries(secrets.listDevices())) {
|
|
2255
2385
|
const m = members.find((x) => x.pub === pub) || null
|
|
2256
|
-
const values = secrets.publicOfDevice(pub)
|
|
2257
2386
|
out.push({
|
|
2258
2387
|
pub,
|
|
2259
2388
|
id: m?.id || await deviceIdOf(pub).catch(() => null),
|
|
2260
2389
|
label: m?.label || '',
|
|
2261
2390
|
cn: m?.cn || null,
|
|
2262
|
-
|
|
2391
|
+
// Sin valores: desde 2026-09-02 una pública también va en sobre (ver `listSecrets`).
|
|
2392
|
+
keys,
|
|
2263
2393
|
orphan: !!members.length && !m
|
|
2264
2394
|
})
|
|
2265
2395
|
}
|