@dotrino/vault 0.49.1 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.49.1",
3
+ "version": "0.51.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -30,6 +30,9 @@
30
30
  },
31
31
  "./protocol": {
32
32
  "import": "./src/protocol.js"
33
+ },
34
+ "./admin": {
35
+ "import": "./src/admin.js"
33
36
  }
34
37
  },
35
38
  "files": [
package/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,
@@ -189,7 +204,9 @@ export function createAdminDesk ({
189
204
  const toScope = typeof data.ns === 'string' && !!data.ns
190
205
  const toDevice = typeof data.pub === 'string' && !!data.pub
191
206
  if (data.op === 'vars') {
192
- const result = await vars.list({ by })
207
+ // QUIÉN PREGUNTA, no solo su nombre: con su llave se le manda la envoltura de
208
+ // cada pública para que pueda abrirla en su casa.
209
+ const result = await vars.list({ by, caller: chk.device })
193
210
  audit('admin.vars', { by })
194
211
  return { ok: true, result }
195
212
  }
@@ -205,14 +222,14 @@ export function createAdminDesk ({
205
222
  // Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
206
223
  // mejor que reventar con un TypeError que no explica qué falta actualizar.
207
224
  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' }
225
+ if (!Array.isArray(data.items) || !data.items.length) {
226
+ return { ok: false, error: 'admin: var.setMany needs `items`, each with its key and its sealed envelope' }
210
227
  }
211
228
  const result = await vars.setMany({
212
229
  ns: toScope ? data.ns : null,
213
230
  pub: toDevice ? data.pub : null,
214
- enc: data.enc,
215
- public: typeof data.public === 'boolean' ? data.public : undefined,
231
+ items: data.items,
232
+ caller: chk.device,
216
233
  by
217
234
  })
218
235
  const keys = result?.keys || []
@@ -222,17 +239,22 @@ export function createAdminDesk ({
222
239
  }
223
240
 
224
241
  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' }
242
+ if (!data.sealed || typeof data.sealed !== 'object') {
243
+ // El valor NUNCA viaja en claro, y desde 2026-09-02 tampoco sellado al perfil: el
244
+ // sobre llega HECHO y la bóveda no lo abre. Si llega de otra forma es un error de
245
+ // quien llama, no algo que se pueda «arreglar» aceptándolo.
246
+ return { ok: false, error: 'admin: var.set needs the value already sealed (`sealed`); build it with buildSealedVar()' }
229
247
  }
230
248
  const result = await vars.set({
231
249
  ns: toScope ? data.ns : null,
232
250
  pub: toDevice ? data.pub : null,
233
251
  key: data.key,
234
- enc: data.enc,
252
+ sealed: data.sealed,
235
253
  public: typeof data.public === 'boolean' ? data.public : undefined,
254
+ // LA LLAVE DE QUIEN LLAMA, no solo su nombre corto. `by` es el `deviceId`, que
255
+ // vale para el registro pero no para comprobar una firma: el sobre trae la de su
256
+ // AUTOR y hay que verificarla contra la llave de quien lo manda.
257
+ caller: chk.device,
236
258
  by
237
259
  })
238
260
  audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
@@ -276,3 +298,64 @@ export function createAdminDesk ({
276
298
  }
277
299
 
278
300
  export default { createAdminDesk, ADMIN_OPS, ADMIN_OPS_WHILE_LOCKED, ADMIN_NONCE_TTL_MS }
301
+
302
+ /**
303
+ * FABRICAR EL SOBRE DE UNA VARIABLE — del lado de quien escribe.
304
+ *
305
+ * La bóveda ya no ve el valor (dueño, 2026-09-01): recibe el sobre hecho, comprueba lo que
306
+ * puede sin la llave —quién lo firma y a quién envuelve— y lo guarda. Esto es lo que hay que
307
+ * mandarle, y vive aquí para que no lo rearme cada consola por su cuenta.
308
+ *
309
+ * El orden importa y no es casual:
310
+ * 1. se pide a la BÓVEDA a quién hay que envolver (`var.recipients`). La lista sale del
311
+ * acta y la sabe ella; tener dos sitios respondiendo a eso es cómo se deja fuera a
312
+ * alguien sin que nadie se entere.
313
+ * 2. CEK nueva y el valor cifrado con ella;
314
+ * 3. esa CEK envuelta a la PÚBLICA de cada destinatario, más la de recuperación —sin la
315
+ * cual el cajón nacería ilegible para siempre;
316
+ * 4. y la firma del AUTOR sobre el cajón, la clave y el sobre.
317
+ *
318
+ * QUIÉN FIRMA SE PASA COMO FUNCIÓN, no como llave. En el navegador la privada del aparato
319
+ * vive dentro del iframe de identidad y NO SALE de ahí —esa es media garantía del diseño—,
320
+ * así que la consola no puede firmar aquí: pide la firma y ya. En Node, donde la llave sí
321
+ * está a mano, se envuelve `signWithDevice` en dos líneas. Un solo camino para los dos.
322
+ *
323
+ * @param {{recipients:{recoveryPub:string, members:Array<{pub:string,encPub:string}>},
324
+ * owner:string, key:string, value:string,
325
+ * author:{publickey:string, sign:(body:any)=>Promise<{signature:string}>}}} opts
326
+ */
327
+ export async function buildSealedVar ({ recipients, owner, key, value, author } = {}) {
328
+ const { makeContentKey, encryptWithCek, wrapForMember } = await import('@dotrino/identity/content')
329
+ if (!recipients?.recoveryPub) throw new Error('buildSealedVar: ask the vault for the recipients first (var.recipients)')
330
+ if (typeof value !== 'string' || !value) throw new Error('buildSealedVar: the value must be a non-empty string')
331
+ if (typeof author?.sign !== 'function' || typeof author?.publickey !== 'string') {
332
+ throw new Error('buildSealedVar: author needs { publickey, sign(body) }')
333
+ }
334
+
335
+ const cek = await makeContentKey()
336
+ const e = await encryptWithCek({ cek, gen: 0, plaintext: value })
337
+ // Envolver solo necesita PÚBLICAS: por eso esto se puede hacer en un navegador sin que
338
+ // ninguna llave privada ande suelta.
339
+ const wraps = { '#recovery': await wrapForMember({ cek, memberEncPub: recipients.recoveryPub }) }
340
+ for (const m of recipients.members || []) {
341
+ wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub })
342
+ }
343
+ const ts = Date.now()
344
+ const { signature } = await author.sign(authorBody(owner, key, e, ts))
345
+ return { e, wraps, author: { pub: author.publickey, sig: signature, ts } }
346
+ }
347
+
348
+ /**
349
+ * El `author` de `buildSealedVar` a partir de una llave de aparato de Node (la que trae
350
+ * `makeDeviceKey` o un `service-identity.json`). En el navegador no se usa: allí la firma
351
+ * la da el iframe.
352
+ */
353
+ export function authorFromDeviceKey (device) {
354
+ return {
355
+ publickey: device.publickey,
356
+ sign: async (body) => {
357
+ const { signWithDevice } = await import('@dotrino/identity/capabilities')
358
+ return signWithDevice({ ...device, data: body })
359
+ }
360
+ }
361
+ }
package/src/service.js CHANGED
@@ -662,7 +662,14 @@ async function openSealedBundle (sealed, ident, acta = null, masterPubkey = null
662
662
 
663
663
  const out = {}
664
664
  for (const [key, e] of Object.entries(sealed.entries || {})) {
665
- 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