@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 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
+ }
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 ETIQUETA DE BÓVEDA es obligatoria salvo que se diga el directorio a pelo: es lo que
150
- // hace que dos bóvedas puedan tener un cajón con el mismo nombre sin pisarse.
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) {
@@ -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
- const data = { op: 'secrets', ns, ek: eph.ek, publickey: device.publickey, ts: Date.now() }
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
- 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
+ }
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.83.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
  }
@@ -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
- bundleFor (ns, devicePub = null) {
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 entries = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
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
- 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.
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
- 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.
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)).filter((e) => !e.pub).map((e) => e.gen))
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
- // Una PÚBLICA se guarda en claro a propósito: eso es lo que significa marcarla.
503
- if (pub) { vars[key] = { v: value, pub: true }; save(); return }
504
-
505
- 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')
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: 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 }
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
- // De pública a privada: es una escritura normal, con su generación y su firma.
581
- held++
582
- 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
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
- /** 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. */
613
739
  publicOf (ns) {
614
740
  assertNs(ns)
615
741
  return publics(data.ns, ns)
616
742
  },
617
- /** Las PÚBLICAS de un aparato, con valor. */
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
- 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.
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: 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 }
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
- let abierto
905
- if (e.pub) abierto = e.v
906
- else {
907
- const g = bag.keyring.find((x) => x.gen === e.gen)
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
- if (needsApproval(chk.device, record)) {
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 maestra. */
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
- async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
1374
- const payload = JSON.parse(await identity.openContent(enc))
1375
- const value = payload?.value
1376
- if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
1377
- // NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
1378
- // leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
1379
- if (ns) await setSecret(ns, key, value, isPublic, { by: who })
1380
- else await setDeviceSecret(pub, key, value, isPublic, { by: who })
1381
- 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' })
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, enc, public: isPublic, by: who = null }) {
1394
- const payload = JSON.parse(await identity.openContent(enc))
1395
- const items = payload?.items
1396
- if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
1397
- // Borrar no se delega (`docs/consola-remota.md` §2): un aparato robado no puede
1398
- // dejar sin configuración a un servicio. Así que aquí solo entran valores nuevos.
1399
- /** @type {Array<{op:'set', key:string, value:string, public?:boolean}>} */
1400
- const list = items.map((it) => ({
1401
- op: /** @type {'set'} */ ('set'),
1402
- key: it?.key,
1403
- value: it?.value,
1404
- ...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
1405
- }))
1406
- // NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
1407
- // escritura quedaba al aire y la respuesta salía antes de guardar nada.
1408
- 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)
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
- const out = {}
2164
- for (const [ns, keys] of Object.entries(secrets.list())) {
2165
- const values = secrets.publicOf(ns)
2166
- out[ns] = keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k))
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
- 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,
2252
2393
  orphan: !!members.length && !m
2253
2394
  })
2254
2395
  }