@dotrino/vaultd 0.123.1 → 0.124.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 +3 -2
- package/src/approvals.js +25 -3
- package/src/ctl.js +80 -0
- package/src/daemon.js +51 -0
- package/src/passwords.js +10 -40
- package/src/secretsStore.js +27 -5
- package/src/update.js +39 -0
- package/src/vault.js +36 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.124.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": {
|
|
@@ -24,10 +24,11 @@
|
|
|
24
24
|
"@dotrino/compat": "0.4.0",
|
|
25
25
|
"@dotrino/identity": "0.92.0",
|
|
26
26
|
"@dotrino/opaque": "0.1.0",
|
|
27
|
-
"@dotrino/passmanager": "0.
|
|
27
|
+
"@dotrino/passmanager": "0.16.0",
|
|
28
28
|
"@dotrino/proxy-client": "0.23.1",
|
|
29
29
|
"@dotrino/roadmap": "0.3.3",
|
|
30
30
|
"@dotrino/store": "0.11.0",
|
|
31
|
+
"@dotrino/update": "0.1.1",
|
|
31
32
|
"ws": "^8.18.0"
|
|
32
33
|
},
|
|
33
34
|
"license": "MIT",
|
package/src/approvals.js
CHANGED
|
@@ -38,7 +38,27 @@ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.to
|
|
|
38
38
|
/** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
|
|
39
39
|
const SEP = String.fromCharCode(0)
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
|
|
43
|
+
*
|
|
44
|
+
* CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
|
|
45
|
+
* aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
|
|
46
|
+
* carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
|
|
47
|
+
*
|
|
48
|
+
* UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
|
|
49
|
+
* caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
|
|
50
|
+
* perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
|
|
51
|
+
* es una función que no existe.
|
|
52
|
+
*
|
|
53
|
+
* Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
|
|
54
|
+
*/
|
|
41
55
|
export const PENDING_TTL_MS = 5 * 60 * 1000
|
|
56
|
+
/**
|
|
57
|
+
* Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
|
|
58
|
+
* otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
|
|
59
|
+
* exactamente uno vivo, en vez de una cola de avisos caducados.
|
|
60
|
+
*/
|
|
61
|
+
export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
|
|
42
62
|
/** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
|
|
43
63
|
export const GRANT_TTL_MS = 60 * 60 * 1000
|
|
44
64
|
|
|
@@ -62,10 +82,12 @@ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS
|
|
|
62
82
|
* apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
|
|
63
83
|
* por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
|
|
64
84
|
*/
|
|
65
|
-
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null }) {
|
|
85
|
+
request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null }) {
|
|
66
86
|
for (const [id, p] of pending) if (p.ns === ns && p.device === device) pending.delete(id)
|
|
67
87
|
const ts = now()
|
|
68
|
-
|
|
88
|
+
// `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
|
|
89
|
+
// 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 }
|
|
69
91
|
pending.set(p.id, p)
|
|
70
92
|
return publicOf(p)
|
|
71
93
|
},
|
|
@@ -158,4 +180,4 @@ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
|
|
|
158
180
|
return api
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
export default { createApprovals, createGrants, PENDING_TTL_MS, GRANT_TTL_MS }
|
|
183
|
+
export default { createApprovals, createGrants, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
|
package/src/ctl.js
CHANGED
|
@@ -37,6 +37,7 @@ import { qrToString } from './qr.js'
|
|
|
37
37
|
import { encodeInvite, inviteUrl, parseInvite } from '../lib/src/invite.js'
|
|
38
38
|
import { atRestFor, readConfig as readKekConfig, probe as probeKek, rekeyDir, encryptedFilesIn, CONFIG_FILE as KEK_CONFIG_FILE } from '../lib/src/atrest.js'
|
|
39
39
|
import { VERSION } from './version.js'
|
|
40
|
+
import { isNewer } from './update.js'
|
|
40
41
|
|
|
41
42
|
const dir = dataDir()
|
|
42
43
|
const stateFile = path.join(dir, 'state.json')
|
|
@@ -144,6 +145,13 @@ function cmdStatus () {
|
|
|
144
145
|
console.log(' proxy : %s', s.proxy)
|
|
145
146
|
console.log(' pid : %s%s', s.pid, up ? '' : ' (no responde)')
|
|
146
147
|
console.log(' datos : %s', dir)
|
|
148
|
+
// HAY VERSIÓN NUEVA. Lo mira el daemon una vez al día y lo deja en `state.json`; aquí
|
|
149
|
+
// solo se lee, para que `status` conteste al instante y también sin conexión. Es lo que
|
|
150
|
+
// faltaba el 2026-09-19: el replicador llevaba quince días atrás y nada lo decía.
|
|
151
|
+
if (s.latest?.version && isNewer(s.latest.version, VERSION)) {
|
|
152
|
+
console.log(' %s⬆ hay %s publicada%s (esta es la %s) · tráela con: dotrino-vault update',
|
|
153
|
+
B, s.latest.version, Z, VERSION)
|
|
154
|
+
}
|
|
147
155
|
const profiles = s.profiles || []
|
|
148
156
|
if (profiles.length) {
|
|
149
157
|
console.log(' perfiles : %d', profiles.length)
|
|
@@ -1689,6 +1697,77 @@ async function cmdLogins (rest) {
|
|
|
1689
1697
|
}
|
|
1690
1698
|
}
|
|
1691
1699
|
|
|
1700
|
+
/**
|
|
1701
|
+
* `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
|
|
1702
|
+
*
|
|
1703
|
+
* La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
|
|
1704
|
+
* explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
|
|
1705
|
+
* máquina que guarda la maestra sin que nadie diga que sí).
|
|
1706
|
+
*
|
|
1707
|
+
* Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
|
|
1708
|
+
* si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
|
|
1709
|
+
*
|
|
1710
|
+
* Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
|
|
1711
|
+
* va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
|
|
1712
|
+
*/
|
|
1713
|
+
async function cmdUpdate (args = []) {
|
|
1714
|
+
const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
|
|
1715
|
+
const soloMirar = args.includes('--check')
|
|
1716
|
+
|
|
1717
|
+
process.stdout.write('Mirando qué hay publicado… ')
|
|
1718
|
+
const r = await latestRelease()
|
|
1719
|
+
if (!r.ok) {
|
|
1720
|
+
console.log('')
|
|
1721
|
+
console.error('No se pudo mirar (%s). No es que estés al día: es que no se sabe.', r.reason)
|
|
1722
|
+
process.exit(1)
|
|
1723
|
+
}
|
|
1724
|
+
console.log('%s', r.version)
|
|
1725
|
+
|
|
1726
|
+
if (!isNewer(r.version, VERSION)) {
|
|
1727
|
+
console.log('Estás al día (%s).', VERSION)
|
|
1728
|
+
return
|
|
1729
|
+
}
|
|
1730
|
+
console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
|
|
1731
|
+
if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
|
|
1732
|
+
|
|
1733
|
+
const pick = assetFor(r.assets)
|
|
1734
|
+
if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
|
|
1735
|
+
|
|
1736
|
+
process.stdout.write(`Bajando ${pick.asset.name}… `)
|
|
1737
|
+
let file
|
|
1738
|
+
try { file = await download(pick.asset) } catch (e) { console.log(''); console.error('%s', e.message); process.exit(1) }
|
|
1739
|
+
console.log('listo')
|
|
1740
|
+
|
|
1741
|
+
process.stdout.write('Comprobando la firma… ')
|
|
1742
|
+
const v = verifyArtifact(file)
|
|
1743
|
+
if (!v.ok) {
|
|
1744
|
+
console.log('')
|
|
1745
|
+
console.error('%sNO se instala nada.%s %s', R, Z, v.reason)
|
|
1746
|
+
console.error('El archivo quedó en %s, sin tocar el sistema.', file)
|
|
1747
|
+
process.exit(1)
|
|
1748
|
+
}
|
|
1749
|
+
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1750
|
+
|
|
1751
|
+
if (pick.kind === 'deb') {
|
|
1752
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
1753
|
+
execFileSync('dpkg', ['-i', file], { stdio: 'inherit' })
|
|
1754
|
+
} else {
|
|
1755
|
+
// No se pide sudo por dentro: que la contraseña la escriba quien la escribe, en su
|
|
1756
|
+
// terminal y viendo qué comando la pide.
|
|
1757
|
+
console.log('\nYa verificada. Instálala con:')
|
|
1758
|
+
console.log(' %ssudo dpkg -i %s%s', B, file, Z)
|
|
1759
|
+
console.log('\nEl servicio se reinicia solo al cambiar el binario.')
|
|
1760
|
+
return
|
|
1761
|
+
}
|
|
1762
|
+
} else {
|
|
1763
|
+
console.log('\nYa verificada. Descomprímela y reemplaza el binario:')
|
|
1764
|
+
console.log(' %star xzf %s -C /tmp && sudo install -m0755 /tmp/dotrino-vault-%s-linux-x64/dotrino-vault{,d} /usr/bin/%s',
|
|
1765
|
+
B, file, r.version, Z)
|
|
1766
|
+
return
|
|
1767
|
+
}
|
|
1768
|
+
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1692
1771
|
function reportProfiles (d) {
|
|
1693
1772
|
if (d.error) {
|
|
1694
1773
|
// Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
|
|
@@ -2085,6 +2164,7 @@ export async function runCtl (argv) {
|
|
|
2085
2164
|
case 'caps': return cmdCaps(rest)
|
|
2086
2165
|
case 'revoke': return cmdRevoke(rest[0])
|
|
2087
2166
|
case 'logins': return cmdLogins(rest)
|
|
2167
|
+
case 'update': return cmdUpdate(rest)
|
|
2088
2168
|
case 'secret': return cmdSecret(rest)
|
|
2089
2169
|
// `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
|
|
2090
2170
|
// quedó apuntando a una función que ya no existía: reventaba con un ReferenceError. Se
|
package/src/daemon.js
CHANGED
|
@@ -23,6 +23,7 @@ import { dataDir } from './paths.js'
|
|
|
23
23
|
import { ipcRead, ipcWrite, migrateIpcDir } from './ipc.js'
|
|
24
24
|
import { parseInvite } from '../lib/src/invite.js'
|
|
25
25
|
import { watchBinary } from './selfupdate.js'
|
|
26
|
+
import { latestRelease, isNewer, CHECK_EVERY_MS } from './update.js'
|
|
26
27
|
import { VERSION } from './version.js'
|
|
27
28
|
import crypto from 'node:crypto'
|
|
28
29
|
|
|
@@ -87,6 +88,8 @@ export async function runDaemon () {
|
|
|
87
88
|
// --- state.json ---
|
|
88
89
|
const stateFile = path.join(dir, 'state.json')
|
|
89
90
|
const daemonVersion = VERSION
|
|
91
|
+
/** `{ version, checkedAt }` de la última consulta que salió bien, o null si aún ninguna. */
|
|
92
|
+
let ultimaPublicada = null
|
|
90
93
|
// Los campos de la raíz (fingerprint/iss) son los del perfil ACTIVO: los leen el
|
|
91
94
|
// instalador y la web, que son anteriores al multi-perfil. La lista completa va
|
|
92
95
|
// en `profiles`.
|
|
@@ -98,6 +101,10 @@ export async function runDaemon () {
|
|
|
98
101
|
// Cuánto aguanta abierto el candado sin usarse. Va en la foto para que la consola
|
|
99
102
|
// pueda DECIRLO en vez de llevar su propio número (que se desincronizaría).
|
|
100
103
|
autoLockMs: mgr.profiles.autoLockMs,
|
|
104
|
+
// LO QUE HAY PUBLICADO, si se pudo mirar. Va en la foto para que `status` lo enseñe
|
|
105
|
+
// sin salir a la red: una pantalla de estado tiene que contestar al instante y
|
|
106
|
+
// también sin conexión.
|
|
107
|
+
...(ultimaPublicada ? { latest: ultimaPublicada } : {}),
|
|
101
108
|
current: mgr.currentId(), profiles: mgr.summary()
|
|
102
109
|
})
|
|
103
110
|
}
|
|
@@ -983,6 +990,50 @@ export async function runDaemon () {
|
|
|
983
990
|
})
|
|
984
991
|
if (vigia) console.log('[vault] watching for updates: a new binary restarts the service on its own')
|
|
985
992
|
|
|
993
|
+
/**
|
|
994
|
+
* ¿SALIÓ UNA VERSIÓN NUEVA? Una lectura al día, y nada más.
|
|
995
|
+
*
|
|
996
|
+
* Esto NO descarga ni instala: sería meter código en la máquina que guarda la maestra sin
|
|
997
|
+
* que nadie diga que sí, y eso es justo el interruptor remoto que el proyecto no tiene
|
|
998
|
+
* (`src/update.js` lo explica entero). Traerla la escribe una persona: `dotrino-vault
|
|
999
|
+
* update`.
|
|
1000
|
+
*
|
|
1001
|
+
* Lo que arregla es lo que de verdad dolió el 2026-09-19: el replicador llevaba quince
|
|
1002
|
+
* días atrás y las bóvedas dos versiones, y NADA lo decía en ninguna pantalla.
|
|
1003
|
+
*
|
|
1004
|
+
* Si no se puede mirar, no se dice nada y ya está: no saber si hay versión nueva no es un
|
|
1005
|
+
* problema del usuario, y llenarle el log de fallos de red tampoco ayuda.
|
|
1006
|
+
*/
|
|
1007
|
+
const mirarRelease = async () => {
|
|
1008
|
+
const r = await latestRelease()
|
|
1009
|
+
if (!r.ok) return
|
|
1010
|
+
ultimaPublicada = { version: r.version, checkedAt: Date.now() }
|
|
1011
|
+
writeState()
|
|
1012
|
+
if (!isNewer(r.version, daemonVersion)) return
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* PIDE PERMISO SOLO SI HAY A QUIÉN PEDÍRSELO (dueño, 2026-09-20).
|
|
1016
|
+
*
|
|
1017
|
+
* La bóveda es la pieza sensible del ecosistema: quien le cuele una actualización se
|
|
1018
|
+
* lleva la maestra, y eso no se rota — se pierde la cuenta. Por eso no se actualiza
|
|
1019
|
+
* sola mientras haya un aparato con `aprueba` que pueda decidirlo, igual que ya pasa
|
|
1020
|
+
* con las claves privadas.
|
|
1021
|
+
*
|
|
1022
|
+
* Y si NO hay aprobadores, esperar un sí que nadie puede dar sería condenarla a no
|
|
1023
|
+
* actualizarse nunca. Entonces se actualiza sola, que es lo que hace el resto del
|
|
1024
|
+
* ecosistema (§15).
|
|
1025
|
+
*/
|
|
1026
|
+
let quienAprueba = []
|
|
1027
|
+
try { quienAprueba = (await mgr.current()?.approvers?.()) || [] } catch (_) {}
|
|
1028
|
+
const comoLoDice = quienAprueba.length
|
|
1029
|
+
? `waiting for one of ${quienAprueba.length} approver(s) to say yes`
|
|
1030
|
+
: 'no approver in the record, so this vault may update itself'
|
|
1031
|
+
console.log(`[vault] version ${r.version} is out (this one is ${daemonVersion}) · ${comoLoDice} · dotrino-vault update`)
|
|
1032
|
+
}
|
|
1033
|
+
mirarRelease()
|
|
1034
|
+
const relojRelease = setInterval(mirarRelease, CHECK_EVERY_MS)
|
|
1035
|
+
relojRelease.unref?.()
|
|
1036
|
+
|
|
986
1037
|
console.log('[vault] servicio listo.')
|
|
987
1038
|
return mgr
|
|
988
1039
|
}
|
package/src/passwords.js
CHANGED
|
@@ -109,47 +109,17 @@ export function createPasswordDesk (opts = {}) {
|
|
|
109
109
|
* hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
|
|
110
110
|
* todo.
|
|
111
111
|
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
112
|
+
* El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
|
|
113
|
+
* bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
|
|
114
|
+
* copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
|
|
115
|
+
* quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
|
|
116
116
|
*
|
|
117
|
-
*
|
|
118
|
-
* `store` el almacén con las entradas viejas y donde irán los sobres
|
|
119
|
-
* `sealed` la `SealedStore` de esta bóveda
|
|
120
|
-
* `cek` la llave vieja (`CryptoKey`), o `null` si ya no hay nada que convertir
|
|
121
|
-
* `recipients` `{ recoveryPub, main, passkeys }`
|
|
122
|
-
* `author` `{ publickey, sign(body) }` — la maestra, que firma lo que escribe
|
|
123
|
-
* `dropOldKey()` borra la llave vieja del disco. Se llama AL FINAL y solo si todo salió
|
|
117
|
+
* Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
|
|
124
118
|
*/
|
|
125
119
|
export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
|
|
126
|
-
const {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
// 1. La llave del perfil, de la que salen el índice de sitios y los resúmenes.
|
|
133
|
-
const pk = await buildProfileKey({ recipients })
|
|
134
|
-
await sealed.setProfile({ envelope: pk.envelope, wraps: pk.wraps })
|
|
135
|
-
const { profileKeys } = await import('@dotrino/passmanager/sealed')
|
|
136
|
-
const keys = await profileKeys(pk.base)
|
|
137
|
-
|
|
138
|
-
// 2. Cada entrada, reescrita: un sobre por campo, la vista sellada, sus huellas.
|
|
139
|
-
let hechas = 0
|
|
140
|
-
for (const vieja of viejas) {
|
|
141
|
-
if (!cek) break
|
|
142
|
-
const abierta = await openEntry(cek, vieja)
|
|
143
|
-
const sobre = await buildSealedEntry({ plain: abierta, keys, recipients, author })
|
|
144
|
-
await sealed.putSealed(sobre)
|
|
145
|
-
hechas++
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// 3. Y se va lo viejo: las entradas con la llave que las abría, y la llave misma.
|
|
149
|
-
if (hechas === viejas.length) {
|
|
150
|
-
await store.set('passmanager/entries/v1', [])
|
|
151
|
-
await dropOldKey?.()
|
|
152
|
-
}
|
|
153
|
-
log(`[vault] passwords: ${hechas} entries converted to the sealed format`)
|
|
154
|
-
return { already: false, entries: hechas }
|
|
120
|
+
const { convertToSealed } = await import('@dotrino/passmanager/sealed')
|
|
121
|
+
return convertToSealed({
|
|
122
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
123
|
+
log: (m) => log('[vault] ' + m)
|
|
124
|
+
})
|
|
155
125
|
}
|
package/src/secretsStore.js
CHANGED
|
@@ -253,6 +253,20 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
253
253
|
return kr.reduce((best, g) => (!best || (g.gen || 0) > (best.gen || 0) ? g : best), null)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
+
/**
|
|
257
|
+
* LA GENERACIÓN QUE TOCA ESTRENAR. Se mira el llavero Y las entradas, no solo el llavero:
|
|
258
|
+
* un dato PÚBLICO del perfil lleva generación pero no sobre, así que no deja nada en el
|
|
259
|
+
* llavero. Contando solo el llavero, dos escrituras públicas seguidas salían con la MISMA
|
|
260
|
+
* generación — y la generación es lo que ordena un dato entre réplicas (`mergeBundle`):
|
|
261
|
+
* con empate gana el de hash menor, que puede ser el valor viejo.
|
|
262
|
+
*/
|
|
263
|
+
const nextGen = (bag, k, owner) => {
|
|
264
|
+
let top = topGen(bag, k)?.gen || 0
|
|
265
|
+
for (const e of Object.values(bag[k]?.vars || {})) if ((e.gen || 0) > top) top = e.gen
|
|
266
|
+
for (const h of data.history) if (h.owner === owner && (h.gen || 0) > top) top = h.gen
|
|
267
|
+
return top + 1
|
|
268
|
+
}
|
|
269
|
+
|
|
256
270
|
/**
|
|
257
271
|
* UN CAMBIO A LA VEZ.
|
|
258
272
|
*
|
|
@@ -445,14 +459,22 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
445
459
|
*
|
|
446
460
|
* @param {string} owner `ns:<scope>` o `dev:<pub>`
|
|
447
461
|
* @param {string} memberPub la llave de FIRMA del miembro (así se indexan las envolturas)
|
|
462
|
+
* @param {{ only?: (isPublic: boolean) => boolean }} [opts] qué variables le TOCAN, según
|
|
463
|
+
* su visibilidad: a quien administra se le envuelven las públicas de un cajón con dueño
|
|
464
|
+
* y no las privadas, así que la falta de una privada no es deuda suya.
|
|
448
465
|
* @returns {string[]} nombres de variables, vacío si puede con todas
|
|
449
466
|
*/
|
|
450
|
-
missingFor (owner, memberPub) {
|
|
467
|
+
missingFor (owner, memberPub, { only = null } = {}) {
|
|
451
468
|
if (isLegacy()) return []
|
|
452
469
|
const [kind, k] = splitOwner(owner)
|
|
453
470
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
454
471
|
const out = []
|
|
455
472
|
for (const [key, e] of Object.entries(varsOf(bag, k))) {
|
|
473
|
+
// Lo PÚBLICO DEL PERFIL no lleva sobre: va en claro y firmado, porque quien lo lee
|
|
474
|
+
// desde fuera no tiene ninguna llave tuya. No hay envoltura que pueda faltarle a
|
|
475
|
+
// nadie, y contarla como deuda era un aviso que ningún reparto podía apagar.
|
|
476
|
+
if (e.cls === 'public') continue
|
|
477
|
+
if (only && !only(!!e.pub)) continue
|
|
456
478
|
// También las públicas: desde 2026-09-02 van en sobre, así que a quien no tenga su
|
|
457
479
|
// envoltura le falta igual que con una privada.
|
|
458
480
|
const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
|
|
@@ -580,7 +602,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
580
602
|
|
|
581
603
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
582
604
|
const cek = await sealer.newKey()
|
|
583
|
-
const gen = (
|
|
605
|
+
const gen = nextGen(bag, k, owner)
|
|
584
606
|
const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
|
|
585
607
|
const e = await sealer.encrypt(cek, value, gen)
|
|
586
608
|
// La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
|
|
@@ -644,7 +666,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
644
666
|
|
|
645
667
|
// LA GENERACIÓN LA PONE EL ALMACÉN, no quien escribe: es la que ordena el llavero, y
|
|
646
668
|
// dejar que la eligiera el que llega permitiría pisar una anterior.
|
|
647
|
-
const gen = (
|
|
669
|
+
const gen = nextGen(bag, k, owner)
|
|
648
670
|
// La FIRMA sigue siendo de la bóveda —dice que este sobre salió de aquí y con qué
|
|
649
671
|
// acta (§8.8)— y se hace con la llave de SELLADO, que funciona con el perfil cerrado.
|
|
650
672
|
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
@@ -917,7 +939,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
917
939
|
}
|
|
918
940
|
const vars = ensureBag(data.ns, PROFILE_NS)
|
|
919
941
|
const before = vars[key]
|
|
920
|
-
const gen = (
|
|
942
|
+
const gen = nextGen(data.ns, PROFILE_NS, PROFILE_OWNER)
|
|
921
943
|
const seal = signer ? await signer({ owner: PROFILE_OWNER, key, gen, iv: '', ct: value }) : null
|
|
922
944
|
pushHistory(PROFILE_OWNER, key, before, by)
|
|
923
945
|
vars[key] = { cls: 'public', pub: true, owner: PROFILE_OWNER, gen, pubv: value, seal, at: Date.now(), by: by || null }
|
|
@@ -963,7 +985,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
|
|
|
963
985
|
for (const [key, e] of Object.entries(vars)) {
|
|
964
986
|
if (typeof e.v !== 'string' || e.e) continue
|
|
965
987
|
const cek = await sealer.newKey()
|
|
966
|
-
const gen = (
|
|
988
|
+
const gen = nextGen(bag, k, owner)
|
|
967
989
|
const r = await sealer.wrapFor(cek, await paraVisibilidad(!!e.pub))
|
|
968
990
|
r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
969
991
|
const sobre = await sealer.encrypt(cek, e.v, gen)
|
package/src/update.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* update.js — lo que es DEL VAULT al actualizarse, y nada más.
|
|
3
|
+
*
|
|
4
|
+
* La mecánica —comparar versiones, preguntar a GitHub, bajar y verificar contra sigstore—
|
|
5
|
+
* vive en `@dotrino/update`, porque es la misma para el proxio, geo, los agentes y los
|
|
6
|
+
* comandos (CONVENCIONES §15). Aquí solo queda lo que sabe el vault de sí mismo: en qué
|
|
7
|
+
* repo sale y cómo se llaman sus archivos.
|
|
8
|
+
*
|
|
9
|
+
* Antes de existir el paquete esto era una copia entera de esa mecánica. Dos copias de lo
|
|
10
|
+
* mismo son dos sitios donde arreglar el mismo fallo, y el segundo siempre se olvida.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs'
|
|
13
|
+
import { latestVersion, isNewer, CHECK_EVERY_MS } from '@dotrino/update'
|
|
14
|
+
import { pickAsset, download, verifyArtifact as verify } from '@dotrino/update/fetch'
|
|
15
|
+
import { VERSION } from './version.js'
|
|
16
|
+
|
|
17
|
+
export const REPO = 'imdotrino/dotrino-vault'
|
|
18
|
+
export { isNewer, CHECK_EVERY_MS, download }
|
|
19
|
+
|
|
20
|
+
/** La última release del vault. `{ ok:false, reason }` si no se pudo mirar, que NO es «al día». */
|
|
21
|
+
export const latestRelease = (opts = {}) =>
|
|
22
|
+
latestVersion({ source: 'github', repo: REPO, product: 'dotrino-vault', version: VERSION, ...opts })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Qué archivo le toca a esta máquina. El `.deb` donde hay `dpkg`; en cualquier otro Linux,
|
|
26
|
+
* el tarball. Fuera de Linux no se emite binario, y se dice en vez de bajar algo inútil.
|
|
27
|
+
*/
|
|
28
|
+
export function assetFor (assets, { platform = process.platform, arch = process.arch, hasDpkg = null } = {}) {
|
|
29
|
+
if (platform !== 'linux') return { ok: false, reason: `there is no binary for ${platform}: update with npm (npx @dotrino/vaultd)` }
|
|
30
|
+
if (arch !== 'x64') return { ok: false, reason: `there is no binary for ${arch}` }
|
|
31
|
+
const deb = hasDpkg === null ? fs.existsSync('/usr/bin/dpkg') : hasDpkg
|
|
32
|
+
return pickAsset(assets, [
|
|
33
|
+
{ kind: 'deb', re: /_amd64\.deb$/, when: () => deb },
|
|
34
|
+
{ kind: 'tar', re: /-linux-x64\.tar\.gz$/ }
|
|
35
|
+
])
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Verifica contra la atestación del release del vault. Sin `gh`, no se instala. */
|
|
39
|
+
export const verifyArtifact = (file, opts = {}) => verify(file, { repo: REPO, ...opts })
|
package/src/vault.js
CHANGED
|
@@ -2745,28 +2745,28 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2745
2745
|
*/
|
|
2746
2746
|
async function incompleteMembers () {
|
|
2747
2747
|
if (secrets.isLegacy?.()) return []
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
//
|
|
2758
|
-
//
|
|
2759
|
-
const
|
|
2760
|
-
const
|
|
2761
|
-
|
|
2762
|
-
|
|
2763
|
-
|
|
2764
|
-
const
|
|
2765
|
-
|
|
2748
|
+
// A QUIÉN LE FALTA ALGO LO DICE `recipientsOf`, Y SOLO ÉL — la misma lista con la que
|
|
2749
|
+
// se envuelve al escribir y se rehace el llavero al abrir. Aquí había otra: «un aparato
|
|
2750
|
+
// sin `cn` puede destapar cualquier cajón sin dueño». Para `@me` no es cierto —el
|
|
2751
|
+
// perfil se envuelve a quien puede FIRMAR por ti—, así que un aparato sin `firma` salía
|
|
2752
|
+
// con todos los datos del perfil «sin llave todavía», y abrir la bóveda, que es lo que
|
|
2753
|
+
// el aviso pedía, no podía apagarlo: el llavero ya estaba como decía el acta. Dos
|
|
2754
|
+
// listas para la misma pregunta acaban siempre así.
|
|
2755
|
+
const out = new Map()
|
|
2756
|
+
for (const owner of secrets.owners()) {
|
|
2757
|
+
// Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
|
|
2758
|
+
// y no la privada. La lista de las públicas contiene siempre a la de las privadas.
|
|
2759
|
+
const privados = new Set((await recipientsOf(owner)).map((m) => m.pub))
|
|
2760
|
+
for (const m of await recipientsOf(owner, { public: true })) {
|
|
2761
|
+
if (!m.encPub || m.pub === master) continue
|
|
2762
|
+
const missing = secrets.missingFor(owner, m.pub, { only: (esPublica) => esPublica || privados.has(m.pub) })
|
|
2763
|
+
if (!missing.length) continue
|
|
2764
|
+
const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
|
|
2765
|
+
row.owners[owner] = missing
|
|
2766
|
+
out.set(m.pub, row)
|
|
2766
2767
|
}
|
|
2767
|
-
if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
|
|
2768
2768
|
}
|
|
2769
|
-
return out
|
|
2769
|
+
return [...out.values()]
|
|
2770
2770
|
}
|
|
2771
2771
|
|
|
2772
2772
|
/**
|
|
@@ -3552,6 +3552,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3552
3552
|
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
3553
3553
|
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
3554
3554
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
3555
|
+
/**
|
|
3556
|
+
* QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
|
|
3557
|
+
* permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
|
|
3558
|
+
* `aprueba`, se actualiza sola.
|
|
3559
|
+
*
|
|
3560
|
+
* Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
|
|
3561
|
+
* aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
|
|
3562
|
+
* puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
|
|
3563
|
+
*/
|
|
3564
|
+
approvers: async () => {
|
|
3565
|
+
const record = await refreshActa()
|
|
3566
|
+
if (!record) return []
|
|
3567
|
+
return (record.members || [])
|
|
3568
|
+
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
3569
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
|
|
3570
|
+
},
|
|
3555
3571
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
3556
3572
|
// llave. Lo que no puede es listarla un aparato.
|
|
3557
3573
|
passwordDevices,
|