@dotrino/vaultd 0.123.1 → 0.124.1
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 +85 -1
- 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 +53 -24
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/vaultd",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.124.1",
|
|
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)
|
|
@@ -604,7 +612,11 @@ async function cmdMembers () {
|
|
|
604
612
|
// Un servicio SIN llave de cifrado no puede leer ninguna variable privada: van
|
|
605
613
|
// selladas a esa llave. Se dice aquí, junto a él, porque es el único sitio donde
|
|
606
614
|
// se mira quién es quién — y en la lista de variables ya seria tarde.
|
|
607
|
-
|
|
615
|
+
//
|
|
616
|
+
// Solo si LEE variables (`secrets`). La llave de comunicación de la bóveda entra con
|
|
617
|
+
// `cn: 'vault'` y solo `firma`, y no tiene llave de cifrado a propósito: salía marcada
|
|
618
|
+
// en rojo «NO puede leer sus variables» cuando no tiene ninguna que leer.
|
|
619
|
+
if (m.cn && !m.canSeal && m.caps.includes('secrets')) console.log(' %ssin llave de cifrado: NO puede leer sus variables%s', R, Z)
|
|
608
620
|
// QUÉ CORRE, y si cuadra con esta bóveda (CONVENCIONES §14). Sin esto una
|
|
609
621
|
// incompatibilidad de versiones se ve como que ese aparato «no responde», que es el
|
|
610
622
|
// fallo más caro que ha tenido el ecosistema. No bloquea nada: se enseña.
|
|
@@ -1689,6 +1701,77 @@ async function cmdLogins (rest) {
|
|
|
1689
1701
|
}
|
|
1690
1702
|
}
|
|
1691
1703
|
|
|
1704
|
+
/**
|
|
1705
|
+
* `dotrino-vault update` — TRAER LA VERSIÓN NUEVA, cuando lo escribe una persona.
|
|
1706
|
+
*
|
|
1707
|
+
* La mitad automática es MIRAR, y la hace el daemon una vez al día (`src/update.js`
|
|
1708
|
+
* explica por qué la otra mitad no se automatiza: sería un canal para meter código en la
|
|
1709
|
+
* máquina que guarda la maestra sin que nadie diga que sí).
|
|
1710
|
+
*
|
|
1711
|
+
* Lo que se baja SE VERIFICA contra la atestación de sigstore antes de tocar el disco, y
|
|
1712
|
+
* si no se puede verificar NO SE INSTALA. Que la URL sea la correcta no prueba nada.
|
|
1713
|
+
*
|
|
1714
|
+
* Instalar no reinicia nada a mano: el daemon vigila su propio binario y, al cambiar, se
|
|
1715
|
+
* va para que systemd lo levante con el nuevo (`src/selfupdate.js`).
|
|
1716
|
+
*/
|
|
1717
|
+
async function cmdUpdate (args = []) {
|
|
1718
|
+
const { latestRelease, assetFor, download, verifyArtifact, isNewer } = await import('./update.js')
|
|
1719
|
+
const soloMirar = args.includes('--check')
|
|
1720
|
+
|
|
1721
|
+
process.stdout.write('Mirando qué hay publicado… ')
|
|
1722
|
+
const r = await latestRelease()
|
|
1723
|
+
if (!r.ok) {
|
|
1724
|
+
console.log('')
|
|
1725
|
+
console.error('No se pudo mirar (%s). No es que estés al día: es que no se sabe.', r.reason)
|
|
1726
|
+
process.exit(1)
|
|
1727
|
+
}
|
|
1728
|
+
console.log('%s', r.version)
|
|
1729
|
+
|
|
1730
|
+
if (!isNewer(r.version, VERSION)) {
|
|
1731
|
+
console.log('Estás al día (%s).', VERSION)
|
|
1732
|
+
return
|
|
1733
|
+
}
|
|
1734
|
+
console.log('%sHay %s y esta es la %s.%s', B, r.version, VERSION, Z)
|
|
1735
|
+
if (soloMirar) { console.log('Tráela con: dotrino-vault update'); return }
|
|
1736
|
+
|
|
1737
|
+
const pick = assetFor(r.assets)
|
|
1738
|
+
if (!pick.ok) { console.error('%s', pick.reason); process.exit(1) }
|
|
1739
|
+
|
|
1740
|
+
process.stdout.write(`Bajando ${pick.asset.name}… `)
|
|
1741
|
+
let file
|
|
1742
|
+
try { file = await download(pick.asset) } catch (e) { console.log(''); console.error('%s', e.message); process.exit(1) }
|
|
1743
|
+
console.log('listo')
|
|
1744
|
+
|
|
1745
|
+
process.stdout.write('Comprobando la firma… ')
|
|
1746
|
+
const v = verifyArtifact(file)
|
|
1747
|
+
if (!v.ok) {
|
|
1748
|
+
console.log('')
|
|
1749
|
+
console.error('%sNO se instala nada.%s %s', R, Z, v.reason)
|
|
1750
|
+
console.error('El archivo quedó en %s, sin tocar el sistema.', file)
|
|
1751
|
+
process.exit(1)
|
|
1752
|
+
}
|
|
1753
|
+
console.log('firmada por el release de %s ✓', 'imdotrino/dotrino-vault')
|
|
1754
|
+
|
|
1755
|
+
if (pick.kind === 'deb') {
|
|
1756
|
+
if (typeof process.getuid === 'function' && process.getuid() === 0) {
|
|
1757
|
+
execFileSync('dpkg', ['-i', file], { stdio: 'inherit' })
|
|
1758
|
+
} else {
|
|
1759
|
+
// No se pide sudo por dentro: que la contraseña la escriba quien la escribe, en su
|
|
1760
|
+
// terminal y viendo qué comando la pide.
|
|
1761
|
+
console.log('\nYa verificada. Instálala con:')
|
|
1762
|
+
console.log(' %ssudo dpkg -i %s%s', B, file, Z)
|
|
1763
|
+
console.log('\nEl servicio se reinicia solo al cambiar el binario.')
|
|
1764
|
+
return
|
|
1765
|
+
}
|
|
1766
|
+
} else {
|
|
1767
|
+
console.log('\nYa verificada. Descomprímela y reemplaza el binario:')
|
|
1768
|
+
console.log(' %star xzf %s -C /tmp && sudo install -m0755 /tmp/dotrino-vault-%s-linux-x64/dotrino-vault{,d} /usr/bin/%s',
|
|
1769
|
+
B, file, r.version, Z)
|
|
1770
|
+
return
|
|
1771
|
+
}
|
|
1772
|
+
console.log('Listo. El servicio se reinicia solo; compruébalo con: dotrino-vault status')
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1692
1775
|
function reportProfiles (d) {
|
|
1693
1776
|
if (d.error) {
|
|
1694
1777
|
// Los dos rechazos del candado se dicen con palabras y con el dato que hace falta; el
|
|
@@ -2085,6 +2168,7 @@ export async function runCtl (argv) {
|
|
|
2085
2168
|
case 'caps': return cmdCaps(rest)
|
|
2086
2169
|
case 'revoke': return cmdRevoke(rest[0])
|
|
2087
2170
|
case 'logins': return cmdLogins(rest)
|
|
2171
|
+
case 'update': return cmdUpdate(rest)
|
|
2088
2172
|
case 'secret': return cmdSecret(rest)
|
|
2089
2173
|
// `approval` se quitó el 2026-09-01 (ahora es el permiso `unattended`), pero la ruta se
|
|
2090
2174
|
// 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
|
@@ -27,7 +27,7 @@ import { startSealersPublisher } from './sealers.js'
|
|
|
27
27
|
import { assertKeyOwnsDir } from './keyowner.js'
|
|
28
28
|
import { openStore } from './store.js'
|
|
29
29
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
30
|
-
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
30
|
+
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_NS, PROFILE_OWNER } from './secretsStore.js'
|
|
31
31
|
import { openSubacta } from './subacta.js'
|
|
32
32
|
import { makeEphemeralKey, openSealed } from '../lib/src/sealed.js'
|
|
33
33
|
import { VERSION } from './version.js'
|
|
@@ -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
|
/**
|
|
@@ -2785,8 +2785,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2785
2785
|
}
|
|
2786
2786
|
|
|
2787
2787
|
/**
|
|
2788
|
-
*
|
|
2789
|
-
* envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
2788
|
+
* Lo que deben las VARIABLES, por cajón: las rotaciones anotadas y, CALCULADO, cada
|
|
2789
|
+
* envoltura que falta. El perfil no entra (ver el bucle). La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
2790
2790
|
* se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
|
|
2791
2791
|
* se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
|
|
2792
2792
|
* entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
|
|
@@ -2796,6 +2796,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
2796
2796
|
const out = rotationsDue()
|
|
2797
2797
|
for (const m of await incompleteMembers()) {
|
|
2798
2798
|
for (const [owner, keys] of Object.entries(m.owners)) {
|
|
2799
|
+
// El perfil tampoco va aquí: esto es lo que se enseña como «variables sin
|
|
2800
|
+
// entregar». Lo que un aparato no puede abrir del perfil sigue a la vista en SU
|
|
2801
|
+
// fila (`incomplete`), y `secret settle` y abrir la bóveda lo saldan igual, porque
|
|
2802
|
+
// leen `incompleteMembers` y no esto.
|
|
2803
|
+
if (owner === PROFILE_OWNER) continue
|
|
2799
2804
|
if (out[owner]?.kind === 'rotate') continue
|
|
2800
2805
|
out[owner] = out[owner] || { kind: 'rewrap', members: [] }
|
|
2801
2806
|
out[owner].members.push({ pub: m.pub, keys })
|
|
@@ -3409,7 +3414,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3409
3414
|
// sobre, «pública» dejó de significar «en claro»: dice a quién se le despacha sin
|
|
3410
3415
|
// aprobación, nada más. Para ver un valor hay que poder abrirlo, igual que cualquiera —
|
|
3411
3416
|
// y en un cajón con dueño, quien administra deliberadamente no puede.
|
|
3412
|
-
|
|
3417
|
+
//
|
|
3418
|
+
// EL PERFIL NO ES UN GRUPO DE VARIABLES (dueño, 2026-09-21). `@me` vive en este
|
|
3419
|
+
// almacén porque se guarda igual —mismo sobre, mismo llavero—, pero no es configuración
|
|
3420
|
+
// de nadie: salía en `secret list` como «scope vault:secrets:@me», un scope que por
|
|
3421
|
+
// diseño no puede existir, y en la pantalla de variables como un grupo más. El perfil
|
|
3422
|
+
// tiene su sitio (`dotrino-vault me`, profile.dotrino.com).
|
|
3423
|
+
const variables = secrets.list()
|
|
3424
|
+
delete variables[PROFILE_NS]
|
|
3425
|
+
return variables
|
|
3413
3426
|
}
|
|
3414
3427
|
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
3415
3428
|
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
@@ -3552,6 +3565,22 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
3552
3565
|
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
3553
3566
|
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
3554
3567
|
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
3568
|
+
/**
|
|
3569
|
+
* QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
|
|
3570
|
+
* permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
|
|
3571
|
+
* `aprueba`, se actualiza sola.
|
|
3572
|
+
*
|
|
3573
|
+
* Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
|
|
3574
|
+
* aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
|
|
3575
|
+
* puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
|
|
3576
|
+
*/
|
|
3577
|
+
approvers: async () => {
|
|
3578
|
+
const record = await refreshActa()
|
|
3579
|
+
if (!record) return []
|
|
3580
|
+
return (record.members || [])
|
|
3581
|
+
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
3582
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub }))
|
|
3583
|
+
},
|
|
3555
3584
|
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
3556
3585
|
// llave. Lo que no puede es listarla un aparato.
|
|
3557
3586
|
passwordDevices,
|