@dotrino/vault 0.49.1 → 0.50.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.50.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,
@@ -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/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