@dotrino/vaultd 0.133.0 → 0.134.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.
@@ -512,6 +512,75 @@ export async function registerEncKey ({ dir, ns, proxyUrl, masterPubkey, device,
512
512
  } finally { client.close() }
513
513
  }
514
514
 
515
+ /**
516
+ * GUARDA VARIABLES EN EL CAJÓN DE ESTE SERVICIO (dueño, 2026-09-29: «para facilitar la
517
+ * migración de ENVs a Dotrino»). Es lo que hay detrás de `dotrino-env import`.
518
+ *
519
+ * Los valores se sellan AQUÍ, en la máquina del servicio, para quien diga el acta; la
520
+ * bóveda recibe sobres que no abre. La lista de destinatarios la firma la bóveda y se
521
+ * comprueba (`verifyResponder`): si el proxio pudiera cambiarla por sus llaves, abriría las
522
+ * privadas al pasar.
523
+ *
524
+ * Lo que decide la bóveda, no este cliente:
525
+ * · si hay quien apruebe, se pide aprobación siempre, y al aprobarse puede EDITAR;
526
+ * · si nadie puede aprobar, solo AÑADE lo que falta (una que ya exista falla con `exists`);
527
+ * · BORRAR no existe por aquí.
528
+ *
529
+ * @param {object} o
530
+ * @param {Array<{ key:string, value:string, public?:boolean }>} o.vars
531
+ * @param {string} [o.dir] identidad de `dotrino-env` (`service-identity.json`); o, en su
532
+ * lugar, la identidad por parámetros (`ns`, `proxyUrl`, `masterPubkey`, `device`, `cert`),
533
+ * que es como la trae un agente de remote-agent.
534
+ * @returns {Promise<{ keys: string[], pending: string|null, approval: boolean }>}
535
+ * `pending`: el id del pedido de aprobación (se guarda al aprobar); `keys`: lo guardado ya.
536
+ */
537
+ export async function storeVars ({ dir, ns, proxyUrl, masterPubkey, device, cert, vars, timeoutMs = 30000 } = {}) {
538
+ const saved = dir ? readServiceIdentity(dir) : null
539
+ ns = ns || saved?.ns
540
+ proxyUrl = proxyUrl || saved?.proxy
541
+ masterPubkey = masterPubkey || saved?.iss
542
+ device = device || saved?.device
543
+ cert = cert || saved?.cert
544
+ if (!isValidSecretsNs(ns)) throw new Error('invalid ns')
545
+ if (!proxyUrl || !masterPubkey || !device || !cert) throw new Error('service not enrolled')
546
+ if (!Array.isArray(vars) || !vars.length) throw new Error('storeVars: no variables to store')
547
+ for (const v of vars) {
548
+ if (typeof v?.key !== 'string' || !v.key) throw new Error('storeVars: every variable needs a key')
549
+ if (typeof v.value !== 'string' || !v.value) throw Object.assign(new Error(`storeVars: "${v.key}" has no value`), { code: 'novalue' })
550
+ }
551
+
552
+ const client = await freshClient(proxyUrl, 20000, masterPubkey)
553
+ try {
554
+ await identifyAsService(client, device)
555
+ const ask = async (data) => {
556
+ const signed = { ...data, ns, publickey: device.publickey, ts: Date.now() }
557
+ const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data: signed })
558
+ const pending = waitForMsg(client, (p) => p.type === MSG.SECRETS_RESULT || p.type === MSG.ERROR, timeoutMs)
559
+ client.sendByPubkey(masterPubkey, { type: MSG.SECRETS, data: signed, signature, cert })
560
+ const res = await pending
561
+ if (res.type === MSG.ERROR) throw vaultError(res)
562
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey, knownSeq: saved?.actaSeq ?? null })
563
+ if (res.body?.op !== data.op.replace(/^store$/, 'store.result') || res.body?.ns !== ns) throw new Error('the vault answered something else')
564
+ return res.body
565
+ }
566
+
567
+ const { recipients, approval } = await ask({ op: 'store.recipients' })
568
+ const { buildSealedVar, authorFromDeviceKey } = await import('./admin.js')
569
+ const author = authorFromDeviceKey(device)
570
+ const items = []
571
+ for (const v of vars) {
572
+ const sealed = await buildSealedVar({
573
+ recipients: v.public ? recipients.public : recipients.private,
574
+ owner: `ns:${ns}`, key: v.key, value: v.value, author
575
+ })
576
+ items.push({ key: v.key, sealed, public: !!v.public })
577
+ }
578
+ const nonce = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
579
+ const r = await ask({ op: 'store', items, nonce })
580
+ return { keys: r.keys || [], pending: r.pending || null, approval: !!approval }
581
+ } finally { client.close() }
582
+ }
583
+
515
584
  /**
516
585
  * Pide los secretos del ns al vault (una petición puntual; lanza si falla).
517
586
  * Usa la identidad persistida por `enrollService` salvo que se pase explícita.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.133.0",
3
+ "version": "0.134.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
package/src/vault.js CHANGED
@@ -150,6 +150,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
150
150
  * permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
151
151
  * en una máquina.
152
152
  */
153
+ /** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
154
+ function hasApprover (record) {
155
+ return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
156
+ }
157
+
153
158
  function needsApproval (pub, record) {
154
159
  if (!record) return true // sin acta no se decide que sí: se pide permiso
155
160
  // SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
@@ -1423,6 +1428,80 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1423
1428
  }
1424
1429
  }
1425
1430
 
1431
+ /**
1432
+ * UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
1433
+ * migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
1434
+ * existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
1435
+ *
1436
+ * Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
1437
+ * con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
1438
+ * nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
1439
+ * firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
1440
+ *
1441
+ * Los límites (dueño, 2026-09-29):
1442
+ * · si hay alguien que aprueba, SIEMPRE se pide aprobación —`unattended` no exime— y con
1443
+ * el sí puede también EDITAR lo que ya existe;
1444
+ * · si nadie puede aprobar, no se pide y solo puede AÑADIR lo que falta;
1445
+ * · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
1446
+ *
1447
+ * Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
1448
+ * y `store` (los sobres ya hechos; la bóveda no los abre).
1449
+ */
1450
+ const storeNonces = new Map()
1451
+ async function handleVarStore (from, p) {
1452
+ const ns = p.data?.ns
1453
+ const op = p.data?.op
1454
+ if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
1455
+ const chk = await verifyChain({
1456
+ data: p.data, signature: p.signature, cert: p.cert,
1457
+ expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
1458
+ })
1459
+ if (!chk.ok) return denyChain(from, chk, p, op)
1460
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1461
+ if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
1462
+ audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
1463
+ return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
1464
+ }
1465
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1466
+ // FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
1467
+ // el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
1468
+ // proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
1469
+ const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1470
+ try {
1471
+ if (op === 'store.recipients') {
1472
+ return await answer({
1473
+ op: 'store.recipients', ns, ts: Date.now(),
1474
+ recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
1475
+ // Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
1476
+ approval: hasApprover(record)
1477
+ })
1478
+ }
1479
+ // CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
1480
+ // dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
1481
+ const nonce = p.data?.nonce
1482
+ if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
1483
+ const now = Date.now()
1484
+ for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
1485
+ if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
1486
+ storeNonces.set(nonce, now + 10 * 60 * 1000)
1487
+
1488
+ const items = p.data?.items
1489
+ if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
1490
+ const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
1491
+ if (result?.pending) {
1492
+ audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
1493
+ log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
1494
+ } else {
1495
+ audit('store', { device: deviceId, ns, keys: result?.keys || [] })
1496
+ log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} variable(s) (nobody can approve: add only)`)
1497
+ await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
1498
+ }
1499
+ return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
1500
+ } catch (e) {
1501
+ return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
1502
+ }
1503
+ }
1504
+
1426
1505
  async function handleSecrets (from, p) {
1427
1506
  if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
1428
1507
  // REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
@@ -1433,6 +1512,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1433
1512
  // da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
1434
1513
  // del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
1435
1514
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
1515
+ if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1436
1516
  if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke'].includes(p.data?.op)) return handleApproval(from, p)
1437
1517
  const ns = p.data?.ns
1438
1518
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
@@ -2323,8 +2403,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2323
2403
  // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
2324
2404
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
2325
2405
  // marca solo decide si se entrega sin aprobación.
2406
+ // Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
2407
+ const ask = needsApproval(caller, await refreshActa())
2326
2408
  const apply = async () => {
2327
- await checkAuthor(owner, key, sealed, caller)
2409
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2328
2410
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2329
2411
  await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2330
2412
  audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
@@ -2332,9 +2414,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2332
2414
  }
2333
2415
  // Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
2334
2416
  // minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
2335
- await checkAuthor(owner, key, sealed, caller)
2417
+ await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
2336
2418
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2337
- const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
2419
+ const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
2338
2420
  if (pending) return { ok: true, key, pending }
2339
2421
  await apply()
2340
2422
  return { ok: true, key }
@@ -2362,7 +2444,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2362
2444
  * Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
2363
2445
  * transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
2364
2446
  */
2365
- async setMany ({ ns, pub, items, caller = null, by: who = null }) {
2447
+ async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
2366
2448
  // CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
2367
2449
  // comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
2368
2450
  // `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
@@ -2370,16 +2452,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2370
2452
  if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
2371
2453
  const owner = ns ? `ns:${ns}` : `dev:${pub}`
2372
2454
  const keys = []
2455
+ // ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
2456
+ // tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, se
2457
+ // pide SIEMPRE que haya quien apruebe, tenga o no `unattended` (dueño, 2026-09-29:
2458
+ // «el unattended también pediría aprobación»): `unattended` es para llevarse claves al
2459
+ // arrancar, no para reescribir su propia configuración. Sin nadie que apruebe no se
2460
+ // pide —la regla de siempre— y entonces solo puede añadir.
2461
+ const record = await refreshActa()
2462
+ const ask = fromService ? hasApprover(record) : needsApproval(caller, record)
2373
2463
  for (const it of items) {
2374
2464
  if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
2375
- await checkAuthor(owner, it.key, it.sealed, caller)
2465
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2376
2466
  await checkRecipients(owner, it.sealed.wraps, !!it.public)
2377
2467
  }
2378
2468
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
2379
2469
  // configuración que nadie quiso, y el servicio se reinicia con ella.
2380
2470
  const apply = async () => {
2381
2471
  for (const it of items) {
2382
- await checkAuthor(owner, it.key, it.sealed, caller)
2472
+ await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
2383
2473
  await checkRecipients(owner, it.sealed.wraps, !!it.public)
2384
2474
  }
2385
2475
  for (const it of items) {
@@ -2389,7 +2479,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2389
2479
  audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2390
2480
  await settleDebts(owner)
2391
2481
  }
2392
- const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
2482
+ const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
2393
2483
  if (pending) return { ok: true, keys: [], pending }
2394
2484
  await apply()
2395
2485
  return { ok: true, keys }
@@ -2410,8 +2500,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2410
2500
  *
2411
2501
  * Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
2412
2502
  */
2413
- async function holdWrite ({ caller, ns, pub, keys, apply }) {
2414
- if (!needsApproval(caller, await refreshActa())) return null
2503
+ async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
2504
+ if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
2415
2505
  // Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
2416
2506
  // acta pudo cambiar en medio.
2417
2507
  const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
@@ -3062,7 +3152,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3062
3152
  * relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
3063
3153
  * las dos comprobaciones lo note.
3064
3154
  */
3065
- async function checkAuthor (owner, key, sealed, caller) {
3155
+ async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
3066
3156
  const a = sealed?.author
3067
3157
  if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
3068
3158
  throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
@@ -3088,12 +3178,17 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
3088
3178
  // un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
3089
3179
  // crear los que faltan nomás»).
3090
3180
  //
3181
+ // SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
3182
+ // pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
3183
+ // nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
3184
+ // faltaba para reemplazar; sin él, sigue siendo solo rellenar.
3185
+ //
3091
3186
  // Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
3092
3187
  // servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
3093
3188
  // basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
3094
3189
  // quita nada a nadie; reemplazar sí, y eso es de quien administra.
3095
- if (m.cn && secrets.has?.(owner, key)) {
3096
- throw new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — a service only fills in what is missing; replacing is for who administers`)
3190
+ if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
3191
+ throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
3097
3192
  }
3098
3193
  const cuerpo = authorBody(owner, key, sealed.e, a.ts)
3099
3194
  if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {