@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/lib/src/service.js +113 -0
- package/package.json +1 -1
- package/src/ctl.js +66 -4
- package/src/daemon.js +139 -38
- package/src/notices.js +68 -0
- package/src/notifiable.js +9 -8
- package/src/settings.js +37 -0
- package/src/tui/app.js +69 -5
- package/src/tui/i18n.js +36 -4
- package/src/updateApproval.js +110 -0
- package/src/vault.js +190 -12
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`)
|
|
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
|
-
|
|
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
|
|
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:
|
|
4185
|
+
askUpdateApproval: ({ version }) => {
|
|
4025
4186
|
const yo = identity.me?.publickey || null
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
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ó).
|
|
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
|
}
|