@dotrino/vaultd 0.84.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 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.enc || typeof data.enc !== 'object') {
209
- return { ok: false, error: 'admin: var.setMany needs the variables sealed with the profile content key' }
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
- enc: data.enc,
215
- public: typeof data.public === 'boolean' ? data.public : undefined,
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.enc || typeof data.enc !== 'object') {
226
- // El valor NUNCA viaja en claro: si llega sin sobre, es un error de quien llama,
227
- // no algo que se pueda «arreglar» aceptándolo.
228
- return { ok: false, error: 'admin: var.set needs the value sealed with the profile content key' }
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
- enc: data.enc,
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
+ }
@@ -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
- if (e.pub) { out[key] = e.v; continue }
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.84.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": {
@@ -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
- const publics = (bag, k) => Object.fromEntries(
213
- Object.entries(varsOf(bag, k)).filter(([, e]) => e.pub).map(([key, e]) => [key, e.v])
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
- for (const e of Object.values(bag[k].vars || {})) if (!e.pub && e.gen != null) vivos.add(e.gen)
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, y sin
336
- * envolturas. Una pública está guardada en claro, así que no hay llave que repartir
337
- * ni sobre que abrir — y por eso pedirlas no pasa por la aprobación (ver `handleSecrets`).
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
- // Sin privadas no hay nada que envolver: el paquete va sin llavero.
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
- if (e.pub) return e.v
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
- if (e.pub) continue
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)).filter((e) => !e.pub).map((e) => e.gen))
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
- // Una PÚBLICA se guarda en claro a propósito: eso es lo que significa marcarla.
518
- if (pub) { vars[key] = { v: value, pub: true }; save(); return }
519
-
520
- needSealer('write a private variable')
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: false, owner, gen, e, seal, at: Date.now(), by: by || null }
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
- // De pública a privada: es una escritura normal, con su generación y su firma.
596
- held++
597
- try { await this._putRaw(bag, k, owner, key, e.v, false, null) } finally { held-- }
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
- /** Las PÚBLICAS de un scope, con valor. Lo que la consola remota puede ver. */
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
- /** Las PÚBLICAS de un aparato, con valor. */
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
- if (e.pub) { vars[key] = { v: value, pub: true }; continue }
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: false, owner, gen, e: sobre, seal, at: Date.now(), by: null }
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
- let abierto
920
- if (e.pub) abierto = e.v
921
- else {
922
- const g = bag.keyring.find((x) => x.gen === e.gen)
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
- async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
1385
- const payload = JSON.parse(await identity.openContent(enc))
1386
- const value = payload?.value
1387
- if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
1388
- // NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
1389
- // leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
1390
- if (ns) await setSecret(ns, key, value, isPublic, { by: who })
1391
- else await setDeviceSecret(pub, key, value, isPublic, { by: who })
1392
- return { ok: true, key }
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, enc, public: isPublic, by: who = null }) {
1405
- const payload = JSON.parse(await identity.openContent(enc))
1406
- const items = payload?.items
1407
- if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
1408
- // Borrar no se delega (`docs/consola-remota.md` §2): un aparato robado no puede
1409
- // dejar sin configuración a un servicio. Así que aquí solo entran valores nuevos.
1410
- /** @type {Array<{op:'set', key:string, value:string, public?:boolean}>} */
1411
- const list = items.map((it) => ({
1412
- op: /** @type {'set'} */ ('set'),
1413
- key: it?.key,
1414
- value: it?.value,
1415
- ...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
1416
- }))
1417
- // NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
1418
- // escritura quedaba al aire y la respuesta salía antes de guardar nada.
1419
- const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
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
- const out = {}
2175
- for (const [ns, keys] of Object.entries(secrets.list())) {
2176
- const values = secrets.publicOf(ns)
2177
- out[ns] = keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k))
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
- keys: keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k)),
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
  }