@dotrino/vaultd 0.126.0 → 0.127.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
@@ -284,6 +284,12 @@ export function createAdminDesk ({
284
284
  by
285
285
  })
286
286
  const keys = result?.keys || []
287
+ // EN ESPERA DE APROBACIÓN: todavía no cambió nada, así que no hay de qué avisar. El
288
+ // aviso sale cuando se aprueba y se escribe de verdad.
289
+ if (result?.pending) {
290
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: data.items.length })
291
+ return { ok: true, result }
292
+ }
287
293
  audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
288
294
  await notify('vars', { by, keys, ns: toScope ? data.ns : null })
289
295
  return { ok: true, result: result || { ok: true } }
@@ -308,6 +314,10 @@ export function createAdminDesk ({
308
314
  caller: chk.device,
309
315
  by
310
316
  })
317
+ if (result?.pending) {
318
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: 1 })
319
+ return { ok: true, result }
320
+ }
311
321
  audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
312
322
  // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
313
323
  // la contrapartida de delegar (F3 de docs/consola-remota.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.126.0",
3
+ "version": "0.127.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": {
@@ -25,7 +25,7 @@
25
25
  "@dotrino/identity": "0.101.0",
26
26
  "@dotrino/opaque": "0.1.0",
27
27
  "@dotrino/passmanager": "0.17.0",
28
- "@dotrino/proxy-client": "0.23.1",
28
+ "@dotrino/proxy-client": "0.24.0",
29
29
  "@dotrino/roadmap": "0.3.7",
30
30
  "@dotrino/store": "0.11.0",
31
31
  "@dotrino/update": "0.1.1",
package/src/approvals.js CHANGED
@@ -73,7 +73,9 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
73
73
  * de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
74
74
  * esto es lo que hay que recordar.
75
75
  */
76
- const publicOf = (p) => ({ id: p.id, ns: p.ns, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
76
+ // `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
77
+ // variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
78
+ const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
77
79
 
78
80
  return {
79
81
  /** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
@@ -82,12 +84,15 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
82
84
  * apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
83
85
  * por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
84
86
  */
85
- request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
86
- for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
87
+ request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
88
+ // Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
89
+ // ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
90
+ // sin que nadie dijera que no.
91
+ if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
87
92
  const ts = now()
88
93
  // `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
89
94
  // minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
90
- const p = { id: rnd(), ns, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
95
+ const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
91
96
  pending.set(p.id, p)
92
97
  return publicOf(p)
93
98
  },
package/src/vault.js CHANGED
@@ -155,6 +155,31 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
155
155
  return !Acta.memberCan(record, pub, 'unattended')
156
156
  }
157
157
 
158
+ /**
159
+ * APUNTA UN PEDIDO QUE NO ENTREGA UN CAJÓN, avisa a quien aprueba y deja la respuesta en
160
+ * `onAnswer(sí|no)`. Lo usan la bóveda de contraseñas (un aparato espera su sí) y guardar
161
+ * variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
162
+ * nada se queda colgado para siempre.
163
+ */
164
+ async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer }) {
165
+ const deviceId = await deviceIdOf(device).catch(() => null)
166
+ const record = await refreshActa()
167
+ const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
168
+ const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind })
169
+ audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
170
+ log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
171
+ waiters.set(pend.id, onAnswer)
172
+ const t = setTimeout(() => {
173
+ if (waiters.delete(pend.id)) {
174
+ audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
175
+ onAnswer(false)
176
+ }
177
+ }, PENDING_TTL_MS)
178
+ t.unref?.()
179
+ await notifyApprovers(pend, record)
180
+ return pend
181
+ }
182
+
158
183
  /**
159
184
  * QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
160
185
  *
@@ -1595,16 +1620,23 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1595
1620
  const id = typeof p.data?.id === 'string' ? p.data.id : ''
1596
1621
  const pend = approvals.take(id)
1597
1622
  if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
1598
- // PEDIDOS QUE NO SON DE UN CAJÓN (hoy: la bóveda de contraseñas). Se resuelve ANTES
1599
- // de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada que sellar,
1600
- // solo una promesa esperando un sí o un no.
1623
+ // PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
1624
+ // resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
1625
+ // que sellar, solo una promesa esperando un sí o un no.
1601
1626
  if (waiters.has(id)) {
1602
1627
  const resolver = waiters.get(id)
1603
1628
  waiters.delete(id)
1604
1629
  const ok = op === 'approve'
1605
- audit(ok ? 'passwords.approved' : 'passwords.denied', { device: pend.deviceId, id, by })
1606
- log(`[vault] passwords: request of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1607
- resolver(ok)
1630
+ const what = pend.kind === 'write' ? 'vars' : 'passwords'
1631
+ audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
1632
+ log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
1633
+ // Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
1634
+ // tiene que enterarse: decirle «hecho» a algo que no se guardó es el peor resultado.
1635
+ try { await resolver(ok) } catch (e) {
1636
+ log(`[vault] ${what}: ${pend.id} was approved but could not be applied: ${e.message}`)
1637
+ audit(`${what}.apply-failed`, { id, reason: e.message })
1638
+ return reply(from, { type: MSG.ERROR, error: `approval: approved, but it could not be applied: ${e.message}` })
1639
+ }
1608
1640
  return answer({ op: `${op}.result`, id, ok: true })
1609
1641
  }
1610
1642
  if (op === 'deny') {
@@ -1917,24 +1949,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1917
1949
  // El teléfono: se apunta el pedido, se le avisa y esta promesa espera su firma.
1918
1950
  // Es el mismo camino que ya recorren los cajones de secretos.
1919
1951
  approve: async ({ pubkey, op }) => {
1920
- const deviceId = await deviceIdOf(pubkey).catch(() => null)
1921
- const label = (actaCache?.members || []).find((m) => m.pub === pubkey)?.label || ''
1922
- const pend = approvals.request({ ns: 'passwords', device: pubkey, deviceId, label, ek: '' })
1923
- audit('passwords.pending', { device: deviceId, id: pend.id, op })
1924
- log(`[vault] passwords: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
1925
- const espera = new Promise((resolve) => {
1926
- waiters.set(pend.id, resolve)
1927
- // Si nadie contesta, vence solo: la promesa no se queda colgada para siempre
1928
- // y el aparato recibe un no en vez de esperar sin fin.
1929
- const t = setTimeout(() => {
1930
- if (waiters.delete(pend.id)) {
1931
- audit('passwords.expired', { device: deviceId, id: pend.id })
1932
- resolve(false)
1933
- }
1934
- }, PENDING_TTL_MS)
1935
- t.unref?.()
1936
- })
1937
- await notifyApprovers(pend, actaCache)
1952
+ let resolve
1953
+ const espera = new Promise((r) => { resolve = r })
1954
+ await awaitApproval({ device: pubkey, ns: 'passwords', kind: 'read', what: 'passwords', op, onAnswer: resolve })
1938
1955
  return espera
1939
1956
  },
1940
1957
  audit,
@@ -2111,7 +2128,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2111
2128
  // El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
2112
2129
  // bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
2113
2130
  // de log cuesta días (la última vez, tres).
2114
- try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }) }
2131
+ // CALLADO (`quiet`): se encola igual, pero no toca el timbre push. Un aviso de que
2132
+ // el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
2133
+ // sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
2134
+ // El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
2135
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }, { quiet: true }) }
2115
2136
  catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
2116
2137
  }
2117
2138
  for (const m of miembros) avisar(m.pub)
@@ -2300,11 +2321,20 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2300
2321
  // PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
2301
2322
  // despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
2302
2323
  // marca solo decide si se entrega sin aprobación.
2324
+ const apply = async () => {
2325
+ await checkAuthor(owner, key, sealed, caller)
2326
+ await checkRecipients(owner, sealed.wraps, !!isPublic)
2327
+ await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2328
+ audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
2329
+ await settleDebts(owner)
2330
+ }
2331
+ // Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
2332
+ // minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
2303
2333
  await checkAuthor(owner, key, sealed, caller)
2304
2334
  await checkRecipients(owner, sealed.wraps, !!isPublic)
2305
- await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
2306
- audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
2307
- await settleDebts(owner)
2335
+ const pending = await holdWrite({ caller, ns, pub, keys: [key], apply })
2336
+ if (pending) return { ok: true, key, pending }
2337
+ await apply()
2308
2338
  return { ok: true, key }
2309
2339
  }
2310
2340
  // NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
@@ -2345,16 +2375,58 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
2345
2375
  }
2346
2376
  // Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
2347
2377
  // configuración que nadie quiso, y el servicio se reinicia con ella.
2348
- for (const it of items) {
2349
- await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
2350
- keys.push(it.key)
2378
+ const apply = async () => {
2379
+ for (const it of items) {
2380
+ await checkAuthor(owner, it.key, it.sealed, caller)
2381
+ await checkRecipients(owner, it.sealed.wraps, !!it.public)
2382
+ }
2383
+ for (const it of items) {
2384
+ await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
2385
+ keys.push(it.key)
2386
+ }
2387
+ audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2388
+ await settleDebts(owner)
2351
2389
  }
2352
- audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
2353
- await settleDebts(owner)
2390
+ const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply })
2391
+ if (pending) return { ok: true, keys: [], pending }
2392
+ await apply()
2354
2393
  return { ok: true, keys }
2355
2394
  }
2356
2395
  }
2357
2396
 
2397
+ /**
2398
+ * GUARDAR VARIABLES TAMBIÉN PIDE PERMISO (dueño, 2026-09-23: «si el admin no es
2399
+ * unattended, sí debería pedir permiso para almacenar variables»).
2400
+ *
2401
+ * Es el mismo permiso que ya decide si un aparato se lleva las claves solo: quien tiene
2402
+ * `unattended` escribe sin preguntar; quien no, deja la escritura en espera y el teléfono
2403
+ * decide. Un permiso, una regla, para leer y para escribir.
2404
+ *
2405
+ * No se espera DENTRO de la llamada: la consola corta a los 20 s y aprobar puede tardar
2406
+ * cinco minutos. Se contesta «pendiente» en el acto, el sobre se queda en memoria (va
2407
+ * sellado, la bóveda no lo abre) y se escribe al aprobar. Denegado o vencido, se tira.
2408
+ *
2409
+ * Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
2410
+ */
2411
+ async function holdWrite ({ caller, ns, pub, keys, apply }) {
2412
+ if (!needsApproval(caller, await refreshActa())) return null
2413
+ // Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
2414
+ // acta pudo cambiar en medio.
2415
+ const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
2416
+ const pend = await awaitApproval({
2417
+ device: caller, ns: target, kind: 'write', what: 'vars',
2418
+ // LOS NOMBRES van en el contexto, que viaja SELLADO al que aprueba: el proxio no
2419
+ // tiene por qué aprender cómo se llama la configuración de un servicio.
2420
+ ctx: { keys },
2421
+ onAnswer: async (ok) => {
2422
+ if (!ok) return
2423
+ await apply()
2424
+ await notifyMembers('vars', { by: await deviceIdOf(caller).catch(() => null), keys, ns: ns || null })
2425
+ }
2426
+ })
2427
+ return pend.id
2428
+ }
2429
+
2358
2430
  const admin = createAdminDesk({
2359
2431
  desk,
2360
2432
  deviceIdOf,