@dotrino/vaultd 0.56.0 → 0.58.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/enroll.js +1 -1
- package/lib/src/service.js +3 -4
- package/package.json +2 -2
- package/src/client.js +3 -3
- package/src/ctl.js +29 -2
- package/src/daemon.js +12 -1
- package/src/profiles.js +13 -1
- package/src/selfupdate.js +86 -0
package/lib/src/enroll.js
CHANGED
|
@@ -359,7 +359,7 @@ export function createEnrollDesk ({
|
|
|
359
359
|
pend.state = 'DONE'
|
|
360
360
|
pending.delete(pend.token)
|
|
361
361
|
fire(onPendingChange)
|
|
362
|
-
log(
|
|
362
|
+
log(`[vault] device approved: ${pend.deviceId}`)
|
|
363
363
|
return { ok: true, deviceId: pend.deviceId, cert }
|
|
364
364
|
}
|
|
365
365
|
|
package/lib/src/service.js
CHANGED
|
@@ -23,8 +23,7 @@ import path from 'node:path'
|
|
|
23
23
|
import { createHash } from 'node:crypto'
|
|
24
24
|
import {
|
|
25
25
|
makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation,
|
|
26
|
-
verifyDeviceSig, makePairingCode, commitCode, pubkeyId
|
|
27
|
-
} from '@dotrino/identity/capabilities'
|
|
26
|
+
verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
28
27
|
import { openWrap, wrapForMember, decryptWithCek } from '@dotrino/identity/content'
|
|
29
28
|
import { verifyActa, sealKeyAt } from '@dotrino/identity/acta'
|
|
30
29
|
import { MSG, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
@@ -298,7 +297,7 @@ export async function enrollWithVault ({ qr, label = 'agent', expectedScope = nu
|
|
|
298
297
|
const res = await enrolled
|
|
299
298
|
|
|
300
299
|
if (res.code !== code) throw new Error('the vault echoed a code other than the one shown (possible malicious relay)')
|
|
301
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, ...(expectedScope ? { expectedScope } : {}) })
|
|
300
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, ...(expectedScope ? { expectedScope } : {}) })
|
|
302
301
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
303
302
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one in the QR')
|
|
304
303
|
|
|
@@ -440,7 +439,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
|
|
|
440
439
|
client.sendByPubkey(masterPubkey, { type: MSG.RENEW, data, signature, cert })
|
|
441
440
|
const res = await pending
|
|
442
441
|
if (res.type === MSG.RENEWED && res.cert?.sub === device.publickey) {
|
|
443
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, expectedScope: secretsScope(ns) })
|
|
442
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS, expectedScope: secretsScope(ns) })
|
|
444
443
|
if (v.ok) { cert = res.cert; if (dir && saved) writeServiceIdentity(dir, { ...saved, cert }) }
|
|
445
444
|
}
|
|
446
445
|
} catch (_) { /* la renovación no bloquea el fetch */ }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"node": ">=20"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@dotrino/identity": "^0.
|
|
23
|
+
"@dotrino/identity": "^0.64.0",
|
|
24
24
|
"@dotrino/passmanager": "^0.9.0",
|
|
25
25
|
"@dotrino/proxy-client": "^0.13.1",
|
|
26
26
|
"ws": "^8.18.0"
|
package/src/client.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
|
|
12
12
|
* guardarlo. La maestra nunca sale del vault.
|
|
13
13
|
*/
|
|
14
|
-
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
|
|
14
|
+
import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId, PEER_SKEW_MS } from '@dotrino/identity/capabilities'
|
|
15
15
|
import { installNodeGlobals } from './node-globals.js'
|
|
16
16
|
import { MSG } from './protocol.js'
|
|
17
17
|
|
|
@@ -84,7 +84,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
|
|
|
84
84
|
const res = await enrolled
|
|
85
85
|
|
|
86
86
|
// VALIDACIÓN ESTRICTA antes de persistir (cierra inyección de cert / sustitución de maestra).
|
|
87
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
87
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
88
88
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
89
89
|
if (res.cert.iss !== qr.iss) throw new Error('cert signed by a master other than the one you saw (possible malicious proxy)')
|
|
90
90
|
if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
|
|
@@ -151,7 +151,7 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
|
|
|
151
151
|
if (res.type === MSG.ERROR) throw new Error(res.error)
|
|
152
152
|
// Se valida antes de devolverlo, igual que en el enrolamiento: un cert que no está
|
|
153
153
|
// firmado por la maestra que ya conocemos, o que no es para esta llave, no se guarda.
|
|
154
|
-
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
154
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey, skewMs: PEER_SKEW_MS })
|
|
155
155
|
if (!v.ok) throw new Error('invalid cert: ' + v.reason)
|
|
156
156
|
if (res.cert.iss !== masterPubkey) throw new Error('cert signed by a master other than the pinned one')
|
|
157
157
|
return { cert: res.cert }
|
package/src/ctl.js
CHANGED
|
@@ -68,6 +68,20 @@ const writeReq = (name, obj) => {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
const R = '\x1b[31m', B = '\x1b[1m', Z = '\x1b[0m' // rojo / negrita / reset
|
|
71
|
+
const D = '\x1b[2m' // apagado: para el dato que acompaña sin competir con el nombre
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* La fecha en que entró un aparato, corta y en local. Sin hora: lo que se busca al mirar
|
|
75
|
+
* esta lista es «¿de cuándo es este?», no el minuto exacto — y una fecha larga en cada
|
|
76
|
+
* línea convierte la lista en un muro.
|
|
77
|
+
*
|
|
78
|
+
* En español, como el resto de esta CLI: con el idioma del sistema salía «Jul 29, 2026»
|
|
79
|
+
* en medio de una frase en español, que es peor que cualquiera de los dos por separado.
|
|
80
|
+
*/
|
|
81
|
+
function fechaCorta (ms) {
|
|
82
|
+
try { return new Date(ms).toLocaleDateString('es', { year: 'numeric', month: 'short', day: 'numeric' }) }
|
|
83
|
+
catch (_) { return '' }
|
|
84
|
+
}
|
|
71
85
|
// La versión se inyecta en build (esbuild --define); en dev cae a 'dev'.
|
|
72
86
|
|
|
73
87
|
/**
|
|
@@ -505,7 +519,14 @@ async function cmdMembers () {
|
|
|
505
519
|
assertOpen(record)
|
|
506
520
|
if (!record.members?.length) { console.log('Este perfil todavía no tiene acta.'); return }
|
|
507
521
|
|
|
508
|
-
|
|
522
|
+
// `sealer` y `passwords` faltaban y salían crudos, en inglés, entre los demás en
|
|
523
|
+
// español. Se resaltan como `admin` y `approve`: los cuatro cambian lo que ese aparato
|
|
524
|
+
// puede hacerle a la cuenta, y eso se lee de un vistazo o no se lee.
|
|
525
|
+
const CAP = {
|
|
526
|
+
sign: 'firma', store: 'guarda', read: 'lee', secrets: 'lee sus claves',
|
|
527
|
+
admin: `${B}administra el perfil${Z}`, approve: `${B}aprueba pedidos${Z}`,
|
|
528
|
+
sealer: `${B}sella el acta${Z}`, passwords: `${B}pide contraseñas${Z}`
|
|
529
|
+
}
|
|
509
530
|
// El nombre del perfil es una pubkey JWK. Recortarla no la hace legible: la deja
|
|
510
531
|
// pareciendo un error (`{"key_ops":["verify"],"e…`). Se muestra su huella corta, la
|
|
511
532
|
// misma que se enseña al emparejar y en la lista de miembros.
|
|
@@ -519,7 +540,13 @@ async function cmdMembers () {
|
|
|
519
540
|
m.cn ? `servicio «${m.cn}»` : null
|
|
520
541
|
].filter(Boolean)
|
|
521
542
|
const caps = m.caps.length ? m.caps.map((c) => CAP[c] || c).join(', ') : '(sin permisos)'
|
|
522
|
-
|
|
543
|
+
// CUÁNDO ENTRÓ. El nombre lo pone el propio aparato al emparejarse y muchas veces no
|
|
544
|
+
// distingue nada —dos teléfonos con el mismo apodo, o «cli» a secas—, así que la fecha
|
|
545
|
+
// es lo que deja reconocer cuál es cuál y ver si hay uno que no recuerdas haber
|
|
546
|
+
// conectado. El acta ya la guardaba (`addedAt`); solo no se enseñaba.
|
|
547
|
+
console.log(' %s %s%s%s\n %s', m.id, who,
|
|
548
|
+
m.addedAt ? ` ${D}· conectado el ${fechaCorta(m.addedAt)}${Z}` : '',
|
|
549
|
+
marks.length ? ' [' + marks.join(' · ') + ']' : '', caps)
|
|
523
550
|
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
524
551
|
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
525
552
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
package/src/daemon.js
CHANGED
|
@@ -20,6 +20,7 @@ import { takeLock } from '../lib/src/lock.js'
|
|
|
20
20
|
import { startVaultManager } from './manager.js'
|
|
21
21
|
import { dataDir, writeJson, readJson } from './paths.js'
|
|
22
22
|
import { parseInvite } from '../lib/src/invite.js'
|
|
23
|
+
import { watchBinary } from './selfupdate.js'
|
|
23
24
|
import { VERSION } from './version.js'
|
|
24
25
|
|
|
25
26
|
const readJsonSafe = (f) => readJson(f, null)
|
|
@@ -742,7 +743,7 @@ export async function runDaemon () {
|
|
|
742
743
|
|
|
743
744
|
// --- apagado limpio ---
|
|
744
745
|
const shutdown = (sig) => {
|
|
745
|
-
console.log(`\n[vault] ${sig} →
|
|
746
|
+
console.log(`\n[vault] ${sig} → stopping…`)
|
|
746
747
|
rm(pairFile); rm(pendingEnrollFile)
|
|
747
748
|
try { mgr.close() } catch (_) {}
|
|
748
749
|
process.exit(0)
|
|
@@ -803,6 +804,16 @@ export async function runDaemon () {
|
|
|
803
804
|
}
|
|
804
805
|
bootstrapJoin()
|
|
805
806
|
|
|
807
|
+
// ACTUALIZAR EL PAQUETE = ACTUALIZAR EL SERVICIO. El instalador no puede reiniciarnos
|
|
808
|
+
// (somos un servicio de USUARIO y él corre como root), así que lo hacemos nosotros: si
|
|
809
|
+
// el binario cambia en el disco, nos vamos y systemd nos levanta con el nuevo. Solo bajo
|
|
810
|
+
// systemd; ver `src/selfupdate.js` para por qué eso no es negociable.
|
|
811
|
+
const vigia = watchBinary({
|
|
812
|
+
log: console.log,
|
|
813
|
+
exit: () => shutdown('new binary installed')
|
|
814
|
+
})
|
|
815
|
+
if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
|
|
816
|
+
|
|
806
817
|
console.log('[vault] servicio listo.')
|
|
807
818
|
return mgr
|
|
808
819
|
}
|
package/src/profiles.js
CHANGED
|
@@ -222,6 +222,10 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
222
222
|
// la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
|
|
223
223
|
// trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
|
|
224
224
|
// misma que sale en `members`. Ambiguo se rechaza, no se adivina.
|
|
225
|
+
// El id que tenía antes de que la carpeta se llamara como su llave (ver
|
|
226
|
+
// `ensureNamedByKey`): un cliente abierto durante la actualización sigue usándolo.
|
|
227
|
+
const viejo = data.profiles.find((p) => p.wasId && p.wasId.toLowerCase() === needle)
|
|
228
|
+
if (viejo) return viejo.id
|
|
225
229
|
const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
|
|
226
230
|
if (porPrefijo.length === 1) return porPrefijo[0].id
|
|
227
231
|
if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
|
|
@@ -380,7 +384,15 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
380
384
|
}
|
|
381
385
|
fs.renameSync(dir, dirOf(quiere))
|
|
382
386
|
const p = find(id)
|
|
383
|
-
if (p)
|
|
387
|
+
if (p) {
|
|
388
|
+
p.id = quiere
|
|
389
|
+
// EL NOMBRE VIEJO SE RECUERDA. Quien tenía una consola o una TUI abierta durante
|
|
390
|
+
// la actualización sigue pidiendo por el id de antes, y sin esto cada petición
|
|
391
|
+
// suya contestaba «ese perfil no existe» —que es verdad y no ayuda nada—. Se
|
|
392
|
+
// traduce en `resolve`, así que el cliente viejo sigue funcionando hasta que se
|
|
393
|
+
// reinicie, que es cuando dejará de preguntarlo.
|
|
394
|
+
p.wasId = id
|
|
395
|
+
}
|
|
384
396
|
if (data.current === id) data.current = quiere
|
|
385
397
|
if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
|
|
386
398
|
save()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selfupdate.js — ACTUALIZAR EL PAQUETE TENÍA QUE SIGNIFICAR ACTUALIZAR EL SERVICIO.
|
|
3
|
+
*
|
|
4
|
+
* No lo significaba, y el resultado era una bóveda que decía correr una versión y corría
|
|
5
|
+
* otra. Pasó de verdad (dueño, 2026-08-31): el `.deb` puso 0.56.0 en el disco y el daemon
|
|
6
|
+
* siguió ocho versiones atrás durante días, sin que nada fallara de forma visible —
|
|
7
|
+
* `status` lo avisaba en una línea que nadie mira, y el aviso del instalador se pierde
|
|
8
|
+
* entre el resto de la salida.
|
|
9
|
+
*
|
|
10
|
+
* POR QUÉ EL INSTALADOR NO PUEDE HACERLO. La unidad es de USUARIO (`systemctl --user`) y
|
|
11
|
+
* el `postinst` del paquete corre como root. Root no tiene la sesión del usuario: para
|
|
12
|
+
* reiniciarle el servicio tendría que averiguar qué usuarios lo tienen puesto, buscar su
|
|
13
|
+
* `XDG_RUNTIME_DIR` y entrar en su bus. Es frágil, y con varios usuarios en la máquina es
|
|
14
|
+
* además decidir por ellos. Por eso el instalador solo lo dice.
|
|
15
|
+
*
|
|
16
|
+
* ASÍ QUE LO HACE EL DAEMON. Mira su propio binario y, cuando cambia, se va limpiamente;
|
|
17
|
+
* systemd lo levanta otra vez (`Restart=always`) y esa vez arranca el nuevo. Es la única
|
|
18
|
+
* vía que no necesita root, funciona con cualquier número de usuarios y no depende de que
|
|
19
|
+
* nadie se acuerde de nada.
|
|
20
|
+
*
|
|
21
|
+
* LAS DOS CONDICIONES, y ninguna es opcional:
|
|
22
|
+
*
|
|
23
|
+
* 1. **Que haya quien nos levante.** Si nadie nos reinicia, irse es apagar la bóveda del
|
|
24
|
+
* usuario para siempre — el peor final posible para una mejora de comodidad. Se exige
|
|
25
|
+
* `INVOCATION_ID`, que systemd pone en el entorno del servicio: sin eso, no se toca
|
|
26
|
+
* nada. Un `npx`, un contenedor o un arranque a mano quedan fuera a propósito.
|
|
27
|
+
* 2. **Que el archivo esté quieto.** Un `.deb` a medio escribir cambia de tamaño varias
|
|
28
|
+
* veces; irse en ese instante deja a systemd arrancando un binario truncado. Por eso
|
|
29
|
+
* la huella nueva tiene que verse en DOS pasadas seguidas: si aún se está copiando,
|
|
30
|
+
* la segunda no coincide y se vuelve a esperar.
|
|
31
|
+
*/
|
|
32
|
+
import fs from 'node:fs'
|
|
33
|
+
import path from 'node:path'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cada cuánto se mira. No hay prisa: un minuto de retraso en actualizar no le duele a
|
|
37
|
+
* nadie, y este mismo plazo hace de espera — la huella nueva tiene que verse DOS veces
|
|
38
|
+
* seguidas, así que un archivo a medio escribir no llega a contar.
|
|
39
|
+
*/
|
|
40
|
+
export const CHECK_MS = 60_000
|
|
41
|
+
|
|
42
|
+
/** Huella barata del archivo: tamaño + fecha. No hace falta leerlo entero (son 125 MB). */
|
|
43
|
+
function fingerprint (file) {
|
|
44
|
+
try { const s = fs.statSync(file); return `${s.size}:${Math.round(s.mtimeMs)}` } catch (_) { return null }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ¿Estamos corriendo el binario autosuficiente, bajo un systemd que nos va a levantar?
|
|
49
|
+
*
|
|
50
|
+
* `process.execPath` es el binario cuando es un SEA, y es `node` cuando se corre desde el
|
|
51
|
+
* repo o por npx. En ese segundo caso vigilar no tiene sentido: actualizar el paquete no
|
|
52
|
+
* cambia `node`, y lo que habría que mirar es otra cosa.
|
|
53
|
+
*/
|
|
54
|
+
export function shouldWatch (env = process.env, execPath = process.execPath) {
|
|
55
|
+
if (!env.INVOCATION_ID) return false // nadie garantiza el reinicio
|
|
56
|
+
return /dotrino-vaultd/.test(path.basename(execPath)) // el binario, no `node`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Vigila el binario propio y termina el proceso cuando cambie.
|
|
61
|
+
* @returns {{stop:()=>void}|null} `null` si no procede vigilar.
|
|
62
|
+
*/
|
|
63
|
+
export function watchBinary ({ log = console.log, exit = (c) => process.exit(c), env = process.env, execPath = process.execPath, checkMs = CHECK_MS } = {}) {
|
|
64
|
+
if (!shouldWatch(env, execPath)) return null
|
|
65
|
+
const inicial = fingerprint(execPath)
|
|
66
|
+
if (!inicial) return null
|
|
67
|
+
|
|
68
|
+
let quieto = null // huella del candidato, para exigir que se repita
|
|
69
|
+
|
|
70
|
+
const t = setInterval(() => {
|
|
71
|
+
const ahora = fingerprint(execPath)
|
|
72
|
+
if (!ahora || ahora === inicial) { quieto = null; return }
|
|
73
|
+
if (quieto !== ahora) { quieto = ahora; return } // primera vez que se ve: esperar
|
|
74
|
+
clearInterval(t)
|
|
75
|
+
log('[vault] the binary changed on disk: restarting to run the new version')
|
|
76
|
+
// Se sale con 0 y limpiamente: systemd lo trata como una parada normal y vuelve a
|
|
77
|
+
// arrancar por `Restart=always`. El apagado del daemon ya suelta el candado y cierra
|
|
78
|
+
// el transporte, así que la bóveda no queda ni con el candado puesto ni a medias.
|
|
79
|
+
exit(0)
|
|
80
|
+
}, Math.max(1000, checkMs))
|
|
81
|
+
t.unref?.()
|
|
82
|
+
|
|
83
|
+
return { stop: () => clearInterval(t) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export default { watchBinary, shouldWatch, CHECK_MS }
|