@dotrino/vaultd 0.144.0 → 0.145.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 +2 -2
- package/src/ctl.js +14 -0
- package/src/daemon.js +35 -3
- package/src/notifiable.js +75 -0
- package/src/tui/app.js +5 -0
- package/src/tui/i18n.js +2 -0
- package/src/vault.js +11 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.145.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.107.1",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
27
|
"@dotrino/passmanager": "0.17.0",
|
|
28
|
-
"@dotrino/proxy-client": "0.30.
|
|
28
|
+
"@dotrino/proxy-client": "0.30.1",
|
|
29
29
|
"@dotrino/roadmap": "0.3.19",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
|
31
31
|
"@dotrino/update": "0.2.2",
|
package/src/ctl.js
CHANGED
|
@@ -153,6 +153,12 @@ function cmdStatus () {
|
|
|
153
153
|
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
154
154
|
B, s.latest.version, Z, VERSION)
|
|
155
155
|
}
|
|
156
|
+
// …Y LLEVA DÍAS PIDIENDO PERMISO SIN RESPUESTA. Es un bloqueo que desde fuera no se ve:
|
|
157
|
+
// la bóveda parece normal y simplemente no se actualiza.
|
|
158
|
+
if (s.updateAsk?.version && isNewer(s.updateAsk.version, VERSION)) {
|
|
159
|
+
console.log(' pidió permiso para instalarla %s (%d %s) y nadie lo ha aprobado · mira Pedidos en tu teléfono',
|
|
160
|
+
hace(s.updateAsk.since), s.updateAsk.asks, s.updateAsk.asks === 1 ? 'vez' : 'veces')
|
|
161
|
+
}
|
|
156
162
|
const profiles = s.profiles || []
|
|
157
163
|
if (profiles.length) {
|
|
158
164
|
console.log(' perfiles : %d', profiles.length)
|
|
@@ -2380,6 +2386,14 @@ async function cmdReplica (args) {
|
|
|
2380
2386
|
process.exit(2)
|
|
2381
2387
|
}
|
|
2382
2388
|
|
|
2389
|
+
/** «hace 3 días», «hace 5 horas»: para decir desde cuándo espera algo. */
|
|
2390
|
+
function hace (ms) {
|
|
2391
|
+
const h = Math.max(0, Math.floor((Date.now() - ms) / 3600000))
|
|
2392
|
+
if (h < 1) return 'hace menos de una hora'
|
|
2393
|
+
if (h < 48) return `hace ${h} ${h === 1 ? 'hora' : 'horas'}`
|
|
2394
|
+
return `hace ${Math.floor(h / 24)} días`
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2383
2397
|
export async function runCtl (argv) {
|
|
2384
2398
|
const [cmd, ...rest] = takeProfileFlag(argv)
|
|
2385
2399
|
// `--help` EN CUALQUIER COMANDO ENSEÑA LA AYUDA Y NO HACE NADA. Ningún subcomando lo miraba,
|
package/src/daemon.js
CHANGED
|
@@ -92,6 +92,12 @@ export async function runDaemon () {
|
|
|
92
92
|
const daemonVersion = VERSION
|
|
93
93
|
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
94
94
|
let ultimaPublicada = null
|
|
95
|
+
/**
|
|
96
|
+
* EL PEDIDO DE ACTUALIZACIÓN QUE NADIE HA CONTESTADO: `{ version, since, asks }`, o null.
|
|
97
|
+
* Va en la foto para que `status` y la TUI lo DIGAN. Sin esto, una bóveda que lleva días
|
|
98
|
+
* pidiendo permiso y sin respuesta se veía igual que una al día.
|
|
99
|
+
*/
|
|
100
|
+
let pedidoSinRespuesta = null
|
|
95
101
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
96
102
|
// instalador y la web, que son anteriores al multi-perfil. La lista completa va
|
|
97
103
|
// en `profiles`.
|
|
@@ -107,6 +113,7 @@ export async function runDaemon () {
|
|
|
107
113
|
// sin salir a la red: una pantalla de estado tiene que contestar al instante y
|
|
108
114
|
// también sin conexión.
|
|
109
115
|
...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
|
|
116
|
+
...(pedidoSinRespuesta ? { updateAsk: pedidoSinRespuesta } : {}),
|
|
110
117
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
111
118
|
})
|
|
112
119
|
}
|
|
@@ -1107,8 +1114,24 @@ export async function runDaemon () {
|
|
|
1107
1114
|
* actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
|
|
1108
1115
|
* ecosistema (§15).
|
|
1109
1116
|
*/
|
|
1110
|
-
|
|
1111
|
-
|
|
1117
|
+
// SI NO SE PUEDE SABER QUIÉN APRUEBA, NO SE ACTUALIZA. Antes un fallo aquí dejaba la
|
|
1118
|
+
// lista vacía, y lista vacía es «nadie aprueba, adelante»: el repliegue que abre la
|
|
1119
|
+
// puerta justo cuando algo se rompió. Se dice y se vuelve a mirar en la próxima pasada.
|
|
1120
|
+
let enElActa
|
|
1121
|
+
try { enElActa = await mgr.current().approvers() } catch (e) {
|
|
1122
|
+
console.log(`[vault] version ${r.version} is out, but the approvers could not be read (${e?.message || e}) · not updating`)
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
/**
|
|
1126
|
+
* Y SOLO CUENTA EL APROBADOR QUE PUEDE ENTERARSE (dueño, 2026-10-07). Uno que no recibe
|
|
1127
|
+
* avisos no ve el pedido si no abre la app por su cuenta: la bóveda pedía cada día y se
|
|
1128
|
+
* quedaba atrás para siempre, que es el mismo bloqueo de «sin aprobadores» un paso
|
|
1129
|
+
* después. Lo dice el propio aparato, firmado, y caduca (`src/notifiable.js`).
|
|
1130
|
+
*
|
|
1131
|
+
* Esto vale SOLO para actualizarse. Lo que se instala se verifica igual contra la
|
|
1132
|
+
* atestación del release, se haya pedido permiso o no.
|
|
1133
|
+
*/
|
|
1134
|
+
const quienAprueba = enElActa.filter((a) => a.notifiedAt)
|
|
1112
1135
|
|
|
1113
1136
|
// INSTALADA COMO PAQUETE DEL SISTEMA (.deb en /usr/bin): el binario es de root y este
|
|
1114
1137
|
// proceso no puede tocarlo. Se dice, con las dos salidas, y no se intenta nada.
|
|
@@ -1124,10 +1147,19 @@ export async function runDaemon () {
|
|
|
1124
1147
|
if (quienAprueba.length) {
|
|
1125
1148
|
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · asking one of ${quienAprueba.length} approver(s)`)
|
|
1126
1149
|
const si = await mgr.current().askUpdateApproval({ version: r.version })
|
|
1127
|
-
if (!si) {
|
|
1150
|
+
if (!si) {
|
|
1151
|
+
const antes = pedidoSinRespuesta?.version === r.version ? pedidoSinRespuesta : null
|
|
1152
|
+
pedidoSinRespuesta = { version: r.version, since: antes?.since || Date.now(), asks: (antes?.asks || 0) + 1 }
|
|
1153
|
+
writeState()
|
|
1154
|
+
console.log(`[vault] update to ${r.version}: not approved (denied or expired) · asked ${pedidoSinRespuesta.asks} time(s) since ${new Date(pedidoSinRespuesta.since).toISOString()} · it will ask again`)
|
|
1155
|
+
return
|
|
1156
|
+
}
|
|
1157
|
+
} else if (enElActa.length) {
|
|
1158
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${enElActa.length} approver(s) in the record but none receives notifications: updating on its own`)
|
|
1128
1159
|
} else {
|
|
1129
1160
|
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · no approver in the record: updating on its own`)
|
|
1130
1161
|
}
|
|
1162
|
+
if (pedidoSinRespuesta) { pedidoSinRespuesta = null; writeState() }
|
|
1131
1163
|
const res = await installUserRelease(r, { log: (m) => console.log(m) })
|
|
1132
1164
|
if (!res.ok) {
|
|
1133
1165
|
// Lo que NO cuadra no se instala, y se dice por qué: el archivo queda a mano.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
|
|
3
|
+
*
|
|
4
|
+
* Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
|
|
5
|
+
* no le suena y solo lo ve si abre la app por su cuenta. Para la ACTUALIZACIÓN eso era un
|
|
6
|
+
* bloqueo mudo — la bóveda pedía cada día, nadie se enteraba y se quedaba atrás para siempre
|
|
7
|
+
* (dueño, 2026-10-07).
|
|
8
|
+
*
|
|
9
|
+
* Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
|
|
10
|
+
* manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
|
|
11
|
+
* Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
|
|
12
|
+
* desinstala deja de renovarlo y sale sola de la cuenta.
|
|
13
|
+
*
|
|
14
|
+
* SOLO DECIDE LA ACTUALIZACIÓN. Las claves, las contraseñas y guardar variables siguen
|
|
15
|
+
* pidiendo aprobación mientras haya un aparato con `approve` en el acta, pueda o no recibir
|
|
16
|
+
* avisos: ahí «nadie se entera» se resuelve esperando, no entregando.
|
|
17
|
+
*
|
|
18
|
+
* Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
|
|
19
|
+
* que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
|
|
20
|
+
*
|
|
21
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
|
|
22
|
+
* puede recibir avisos» (eso saltaría la aprobación): se lanza.
|
|
23
|
+
*/
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
export const NOTIFIABLE_FILE = 'notifiable.json'
|
|
28
|
+
/** Cuánto vale un «puedo recibir avisos» sin renovarse. */
|
|
29
|
+
export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
30
|
+
|
|
31
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
|
|
35
|
+
*/
|
|
36
|
+
export function openNotifiable ({ dir, atRest, now = Date.now }) {
|
|
37
|
+
const file = path.join(dir, NOTIFIABLE_FILE)
|
|
38
|
+
/** pub → ms de la última vez que dijo que sí */
|
|
39
|
+
const items = load()
|
|
40
|
+
|
|
41
|
+
function load () {
|
|
42
|
+
let raw
|
|
43
|
+
try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
|
|
44
|
+
if (e.code === 'ENOENT') return new Map()
|
|
45
|
+
throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
|
|
46
|
+
}
|
|
47
|
+
let d
|
|
48
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
|
|
49
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
|
|
50
|
+
return new Map(Object.entries(d.items))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function save () {
|
|
54
|
+
const tmp = file + '.tmp'
|
|
55
|
+
fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
|
|
56
|
+
fs.renameSync(tmp, file)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
/**
|
|
61
|
+
* Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
|
|
62
|
+
* ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
|
|
63
|
+
* cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
|
|
64
|
+
*/
|
|
65
|
+
declare (pub, canNotify) {
|
|
66
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
|
|
67
|
+
if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
|
|
68
|
+
},
|
|
69
|
+
/** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
|
|
70
|
+
since (pub) {
|
|
71
|
+
const t = items.get(pub)
|
|
72
|
+
return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
package/src/tui/app.js
CHANGED
|
@@ -2438,6 +2438,11 @@ function render (term, st) {
|
|
|
2438
2438
|
// instalar, peor: el daemon se queda con el binario viejo, ya borrado, y hay dos copias
|
|
2439
2439
|
// en RAM). `status` ya lo avisa; aquí también, que es donde uno se queda mirando.
|
|
2440
2440
|
lines[2] = (up && s?.version && VERSION !== 'dev' && s.version !== VERSION) ? ' ' + t.warn(i.daemonStale(s.version, VERSION)) : ''
|
|
2441
|
+
// Una actualización que lleva tiempo pidiendo permiso sin respuesta: la bóveda parece
|
|
2442
|
+
// normal y simplemente no se actualiza, así que se dice aquí.
|
|
2443
|
+
if (!lines[2] && up && s?.updateAsk?.version) {
|
|
2444
|
+
lines[2] = ' ' + t.warn(i.updateUnanswered(s.updateAsk.version, Math.floor((Date.now() - s.updateAsk.since) / 86400000)))
|
|
2445
|
+
}
|
|
2441
2446
|
// Dispositivos/Scopes son pestañas de la bóveda activa (se entra desde Bóvedas);
|
|
2442
2447
|
// el resto muestra su título simple.
|
|
2443
2448
|
lines[3] = INNER_TABS.includes(st.screen) ? ' ' + renderTabs(st, t) : ' ' + t.title('» ' + title(i, st.screen))
|
package/src/tui/i18n.js
CHANGED
|
@@ -26,6 +26,7 @@ const es = {
|
|
|
26
26
|
// encabezado / estado
|
|
27
27
|
daemonRunning: 'corriendo',
|
|
28
28
|
daemonStopped: 'DETENIDO',
|
|
29
|
+
updateUnanswered: (version, days) => `⬆ la ${version} espera tu aprobación desde hace ${days < 1 ? 'menos de un día' : days === 1 ? '1 día' : days + ' días'}: mira Pedidos en tu teléfono`,
|
|
29
30
|
daemonStale: (running, installed) => `⚠ el servicio corre ${running} y el binario instalado es ${installed}: reinícialo (systemctl --user restart dotrino-vault)`,
|
|
30
31
|
activeVault: 'Bóveda activa: ',
|
|
31
32
|
noName: '(sin nombre)',
|
|
@@ -498,6 +499,7 @@ const en = {
|
|
|
498
499
|
|
|
499
500
|
daemonRunning: 'running',
|
|
500
501
|
daemonStopped: 'STOPPED',
|
|
502
|
+
updateUnanswered: (version, days) => `⬆ ${version} has been waiting for your approval for ${days < 1 ? 'less than a day' : days === 1 ? '1 day' : days + ' days'}: check Requests on your phone`,
|
|
501
503
|
daemonStale: (running, installed) => `⚠ the service runs ${running} but the installed binary is ${installed}: restart it (systemctl --user restart dotrino-vault)`,
|
|
502
504
|
activeVault: 'Active vault: ',
|
|
503
505
|
tooSmall: 'Terminal too small',
|
package/src/vault.js
CHANGED
|
@@ -30,6 +30,7 @@ import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './thr
|
|
|
30
30
|
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
31
31
|
import { openSubacta } from './subacta.js'
|
|
32
32
|
import { openBlocked } from './blocked.js'
|
|
33
|
+
import { openNotifiable } from './notifiable.js'
|
|
33
34
|
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
34
35
|
import { VERSION } from './version.js'
|
|
35
36
|
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
@@ -262,6 +263,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
262
263
|
const passwordsAtRest = atRestFor(dir)
|
|
263
264
|
// Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
|
|
264
265
|
const blocked = openBlocked({ dir, atRest: passwordsAtRest })
|
|
266
|
+
// Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`): solo lo mira la actualización.
|
|
267
|
+
const notifiable = openNotifiable({ dir, atRest: passwordsAtRest })
|
|
265
268
|
const readPasswordsFile = () => {
|
|
266
269
|
try { return JSON.parse(passwordsAtRest.decrypt(fs.readFileSync(passwordsFile, 'utf8'))) } catch (_) { return null }
|
|
267
270
|
}
|
|
@@ -1763,6 +1766,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1763
1766
|
body = { ...body, ts: Date.now() }
|
|
1764
1767
|
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
1765
1768
|
}
|
|
1769
|
+
// AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
|
|
1770
|
+
// que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
|
|
1771
|
+
if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
|
|
1772
|
+
try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
|
|
1773
|
+
}
|
|
1766
1774
|
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record) })
|
|
1767
1775
|
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1768
1776
|
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
@@ -4027,7 +4035,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
4027
4035
|
if (!record) return []
|
|
4028
4036
|
return (record.members || [])
|
|
4029
4037
|
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
4030
|
-
|
|
4038
|
+
// `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.
|
|
4040
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
|
|
4031
4041
|
},
|
|
4032
4042
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
4033
4043
|
// llave. Lo que no puede es listarla un aparato.
|