@dotrino/vaultd 0.145.0 → 0.147.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/src/vault.js CHANGED
@@ -31,6 +31,7 @@ import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER }
31
31
  import { openSubacta } from './subacta.js'
32
32
  import { openBlocked } from './blocked.js'
33
33
  import { openNotifiable } from './notifiable.js'
34
+ import { openNotices } from './notices.js'
34
35
  import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
35
36
  import { VERSION } from './version.js'
36
37
  import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
@@ -263,8 +264,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
263
264
  const passwordsAtRest = atRestFor(dir)
264
265
  // Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
265
266
  const blocked = openBlocked({ dir, atRest: passwordsAtRest })
266
- // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`): solo lo mira la actualización.
267
+ // Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
267
268
  const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
269
+ // Lo que hay que contarle a quien aprueba y no es un pedido (`src/notices.js`): hoy, «me actualicé».
270
+ const notices = openNotices({ dir, atRest: passwordsAtRest })
268
271
  const readPasswordsFile = () => {
269
272
  try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
270
273
  }
@@ -1320,7 +1323,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1320
1323
  // que arrancamos», que es distinto de «no declara» — y se dice distinto.
1321
1324
  const devices = await Promise.all(issued.map(async (x) => ({
1322
1325
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
1323
- running: x.sub ? versionDe(x.sub) : null
1326
+ running: x.sub ? versionDe(x.sub) : null,
1327
+ notifiedAt: x.sub ? notifiable.since(x.sub) : null
1324
1328
  })))
1325
1329
  // LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
1326
1330
  // dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
@@ -1525,6 +1529,122 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1525
1529
  reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1526
1530
  }
1527
1531
 
1532
+ /**
1533
+ * UN APARATO PREGUNTA SI PUEDE ACTUALIZARSE (`op: 'update-ask'`, dueño 2026-10-08).
1534
+ *
1535
+ * Pedir aprobación es un interruptor DE CADA AGENTE, independiente del de la bóveda
1536
+ * (`updateApproval` de `src/settings.js` vale solo para la propia bóveda): el agente solo
1537
+ * manda esto cuando lo tiene encendido. Así que aquí no se consulta ningún ajuste:
1538
+ *
1539
+ * · alguien aprueba en este perfil → un pedido `kind: 'update'` a nombre del aparato, que
1540
+ * dura un día; se contesta «pendiente» y el resultado sale cuando alguien decide, por
1541
+ * donde vino la pregunta. Vencido es no.
1542
+ * · nadie aprueba → sí, sin preguntar: esperar un sí que nadie puede dar sería no
1543
+ * actualizarse nunca.
1544
+ *
1545
+ * Lo puede preguntar cualquier miembro del acta: no pide nada que no sea suyo.
1546
+ */
1547
+ const updateAsks = new Map() // device|product|version → { id, dest } del pedido vivo
1548
+ const updateDecisions = new Map() // device|product|version → { ok: true, exp }: lo ya aprobado
1549
+ const cut = (v, n) => (typeof v === 'string' && v && v.length <= n ? v : null)
1550
+ async function handleUpdateAsk (from, p) {
1551
+ const chk = await verifyChain({
1552
+ data: p.data, signature: p.signature, cert: p.cert,
1553
+ ...(await contextoActa()), revoked: await revocationSet()
1554
+ })
1555
+ if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
1556
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1557
+ if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
1558
+ audit('rejected', { what: 'update-ask', reason: record ? 'acta' : 'sin-acta' })
1559
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1560
+ }
1561
+ const product = cut(p.data?.product, 128)
1562
+ const version = cut(p.data?.version, 64)
1563
+ const fromVersion = cut(p.data?.from, 64)
1564
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-ask: needs `product` and `version`' })
1565
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1566
+ const say = async (dest, extra) => {
1567
+ const body = { op: 'update-ask', product, version, ...extra, ts: Date.now() }
1568
+ reply(dest, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1569
+ }
1570
+
1571
+ const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
1572
+ if (!approvers) {
1573
+ audit('update-ask', { device: deviceId, product, version, asked: false })
1574
+ log(`[vault] update-ask: ${deviceId || '????-????'} may update ${product} to ${version} on its own (nobody approves in this profile)`)
1575
+ return say(from, { ok: true, asked: false })
1576
+ }
1577
+
1578
+ const key = `${chk.device}|${product}|${version}`
1579
+ const now = Date.now()
1580
+ for (const [k, d] of updateDecisions) if (d.exp <= now) updateDecisions.delete(k)
1581
+ // YA SE DECIDIÓ y quien preguntó no estaba para oírlo: se le dice lo decidido en vez de
1582
+ // volver a timbrar por lo mismo.
1583
+ const decided = updateDecisions.get(key)
1584
+ if (decided) return say(from, { ok: decided.ok, asked: true })
1585
+ // YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
1586
+ // timbra otra vez; la respuesta irá a quien pregunta ahora.
1587
+ const vivo = updateAsks.get(key)
1588
+ if (vivo && waiters.has(vivo.id)) {
1589
+ vivo.dest = from
1590
+ return say(from, { pending: true, id: vivo.id, exp: vivo.exp, asked: true })
1591
+ }
1592
+ const ask = { id: null, exp: null, dest: from }
1593
+ let pend
1594
+ try {
1595
+ pend = await awaitApproval({
1596
+ device: chk.device, ns: 'vault', kind: 'update', what: 'update',
1597
+ ctx: { product, version, from: fromVersion }, ttlMs: UPDATE_TTL_MS,
1598
+ onAnswer: async (ok) => {
1599
+ updateAsks.delete(key)
1600
+ // Solo se recuerda el SÍ. Un no puede ser un vencimiento (nadie lo vio), y
1601
+ // recordarlo dejaría al aparato un día sin poder volver a preguntar.
1602
+ if (ok === true) updateDecisions.set(key, { ok: true, exp: Date.now() + UPDATE_TTL_MS })
1603
+ await say(ask.dest, { ok: ok === true, asked: true, id: ask.id })
1604
+ }
1605
+ })
1606
+ } catch (e) {
1607
+ log(`[vault] update-ask: could not ask for approval: ${e.message}`)
1608
+ return reply(from, { type: MSG.ERROR, code: 'ask-failed', error: 'update-ask: could not ask for approval' })
1609
+ }
1610
+ ask.id = pend.id; ask.exp = pend.exp
1611
+ updateAsks.set(key, ask)
1612
+ return say(from, { pending: true, id: pend.id, exp: pend.exp, asked: true })
1613
+ }
1614
+
1615
+ /**
1616
+ * UN APARATO DICE QUE SE ACTUALIZÓ (`op: 'update-done'`): la bóveda se lo cuenta a quien
1617
+ * aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
1618
+ * avisa o no lo decide él (su interruptor); aquí solo se reparte.
1619
+ *
1620
+ * QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
1621
+ */
1622
+ async function handleUpdateDone (from, p) {
1623
+ const chk = await verifyChain({
1624
+ data: p.data, signature: p.signature, cert: p.cert,
1625
+ ...(await contextoActa()), revoked: await revocationSet()
1626
+ })
1627
+ if (!chk.ok) return denyChain(from, chk, p, 'update-done')
1628
+ const record = (await identity.profileActa?.().catch(() => null))?.acta
1629
+ const member = (record?.members || []).find((m) => m.pub === chk.device)
1630
+ if (!member) {
1631
+ audit('rejected', { what: 'update-done', reason: record ? 'acta' : 'sin-acta' })
1632
+ return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
1633
+ }
1634
+ const product = cut(p.data?.product, 128)
1635
+ const version = cut(p.data?.version, 64)
1636
+ if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-done: needs `product` and `version`' })
1637
+ const deviceId = await deviceIdOf(chk.device).catch(() => null)
1638
+ try {
1639
+ await announceUpdate({ version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
1640
+ } catch (e) {
1641
+ log(`[vault] update-done: ${deviceId || '????-????'} updated ${product} to ${version}, but the approvers could not be told: ${e.message}`)
1642
+ return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: 'update-done: the approvers could not be told' })
1643
+ }
1644
+ const body = { op: 'update-done', ok: true, product, version, ts: Date.now() }
1645
+ reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
1646
+ }
1647
+
1528
1648
  const storeNonces = new Map()
1529
1649
  async function handleVarStore (from, p) {
1530
1650
  const ns = p.data?.ns
@@ -1592,6 +1712,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1592
1712
  if (p.data?.op === 'enckey') return handleEncKey(from, p)
1593
1713
  if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
1594
1714
  if (p.data?.op === 'digest') return handleDigest(from, p)
1715
+ if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
1716
+ if (p.data?.op === 'update-done') return handleUpdateDone(from, p)
1595
1717
  if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
1596
1718
  const ns = p.data?.ns
1597
1719
  if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
@@ -1771,7 +1893,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1771
1893
  if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
1772
1894
  try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
1773
1895
  }
1774
- if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
1896
+ // `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
1897
+ // actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
1898
+ if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record), notices: notices.list() })
1775
1899
  // LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
1776
1900
  // renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
1777
1901
  // el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
@@ -1948,6 +2072,42 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1948
2072
  } catch (e) { log('[vault] could not notify approvers:', e.message) }
1949
2073
  }
1950
2074
 
2075
+ /**
2076
+ * «ME ACTUALICÉ», a todo el que aprueba (dueño, 2026-10-08). Vale con la aprobación
2077
+ * encendida o apagada: lo que se instala solo también se cuenta.
2078
+ *
2079
+ * Lo manda la versión NUEVA ya corriendo —el daemon llama aquí al arrancar, si dejó dicho
2080
+ * que instaló—, que es la prueba de que ocurrió. El aviso queda además en `notices`, de
2081
+ * donde lo lee una app nativa (su timbre llega vacío).
2082
+ *
2083
+ * También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
2084
+ * `product`, `deviceId` y `label`.
2085
+ *
2086
+ * A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
2087
+ * marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
2088
+ * @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
2089
+ */
2090
+ async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
2091
+ const record = await refreshActa()
2092
+ const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
2093
+ if (!who.length) return { approvers: 0, sent: 0, notice: null }
2094
+ // `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
2095
+ // aviso de la propia bóveda no los lleva.
2096
+ const notice = notices.updated({ version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
2097
+ const { id, ev, ts, ...rest } = notice
2098
+ const body = { ev, id, ...rest, ts }
2099
+ const seal = await sealOrFail(body)
2100
+ let sent = 0
2101
+ for (const pub of who) {
2102
+ try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
2103
+ catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
2104
+ }
2105
+ audit('update.announced', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
2106
+ log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} runs ${product}` : 'this vault now runs'} ${version}`)
2107
+ if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
2108
+ return { approvers: who.length, sent, notice }
2109
+ }
2110
+
1951
2111
  /**
1952
2112
  * UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
1953
2113
  * clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
@@ -4019,24 +4179,39 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4019
4179
  * PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
4020
4180
  * un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
4021
4181
  * un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
4022
- * @returns {Promise<boolean>} sí o no; vencido es no.
4182
+ * @returns {{ answer: Promise<boolean>, withdraw: () => void }} `answer`: sí o no (vencido
4183
+ * o retirado es no). `withdraw` quita el pedido si sigue vivo.
4023
4184
  */
4024
- askUpdateApproval: async ({ version }) => {
4185
+ askUpdateApproval: ({ version }) => {
4025
4186
  const yo = identity.me?.publickey || null
4026
- return new Promise((resolve) => {
4027
- awaitApproval({
4028
- device: yo, ns: 'vault', kind: 'update', what: 'update',
4029
- ctx: { version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: resolve
4030
- }).catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); resolve(false) })
4031
- })
4187
+ let pendId = null
4188
+ let withdrawn = false
4189
+ let settle
4190
+ const answer = new Promise((resolve) => { settle = resolve })
4191
+ // RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
4192
+ // de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
4193
+ const retirar = () => {
4194
+ if (!pendId || !waiters.delete(pendId)) return
4195
+ const pend = approvals.take(pendId)
4196
+ audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
4197
+ log(`[vault] update: request ${pendId} withdrawn`)
4198
+ settle(false)
4199
+ }
4200
+ awaitApproval({
4201
+ device: yo, ns: 'vault', kind: 'update', what: 'update',
4202
+ ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
4203
+ }).then((pend) => { pendId = pend.id; if (withdrawn) retirar() })
4204
+ .catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
4205
+ return { answer, withdraw: () => { withdrawn = true; retirar() } }
4032
4206
  },
4207
+ announceUpdate,
4033
4208
  approvers: async () => {
4034
4209
  const record = await refreshActa()
4035
4210
  if (!record) return []
4036
4211
  return (record.members || [])
4037
4212
  .filter((m) => Acta.memberCan(record, m.pub, 'approve'))
4038
4213
  // `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
4039
- // dicho, o caducó). Solo lo usa la actualización para saber si hay a quién pedirle.
4214
+ // dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
4040
4215
  .map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
4041
4216
  },
4042
4217
  // La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
@@ -4176,6 +4351,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
4176
4351
  members: (r?.members || []).map((m) => ({
4177
4352
  ...m,
4178
4353
  running: versionDe(m.pub),
4354
+ // Cuándo dijo que le llegan los avisos (null = no lo ha dicho, o caducó). Solo
4355
+ // tiene sentido en quien aprueba, y es un dato para enseñar: no decide nada.
4356
+ notifiedAt: notifiable.since(m.pub),
4179
4357
  renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
4180
4358
  }))
4181
4359
  }