@dotrino/vaultd 0.65.0 → 0.73.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/admin.js +63 -46
- package/lib/src/index.js +34 -13
- package/lib/src/service.js +51 -9
- package/package.json +3 -3
- package/src/client.js +16 -5
- package/src/commKey.js +71 -0
- package/src/daemon.js +4 -2
- package/src/manager.js +37 -2
- package/src/profiles.js +42 -1
- package/src/sealers.js +159 -0
- package/src/transport.js +32 -8
- package/src/vault.js +152 -31
package/src/profiles.js
CHANGED
|
@@ -162,12 +162,40 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
162
162
|
// hora a la que se cierra solo si nadie lo usa (ver AUTO_LOCK_MS).
|
|
163
163
|
const unlocked = new Map() // id -> vence (ms epoch)
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* LA LLAVE DERIVADA DE LA FRASE, mientras el perfil está abierto.
|
|
167
|
+
*
|
|
168
|
+
* Hasta ahora «abierto» era SOLO una bandera: la llave se derivaba en el `unlock`, se
|
|
169
|
+
* usaba para rehacer el llavero y se borraba en el `finally` de la misma línea. La
|
|
170
|
+
* consecuencia se veía media hora después y no se parecía a su causa — enrolar un
|
|
171
|
+
* servicio con la bóveda recién abierta contestaba «wrong password», porque envolverle
|
|
172
|
+
* la llave de su cajón exige abrir la copia de recuperación y para eso hace falta la
|
|
173
|
+
* frase, que ya no estaba. El aparato entraba y descubría al PRIMER ARRANQUE que no
|
|
174
|
+
* podía leer su cajón (dueño, 2026-08-31: «el rato de enrolar la bóveda está abierta,
|
|
175
|
+
* así que debía crear los sobres sin preguntar»).
|
|
176
|
+
*
|
|
177
|
+
* Lo que se guarda NO es la contraseña: es la llave que sale de ella por scrypt, y solo
|
|
178
|
+
* mientras el candado esté abierto. Se borra —con `wipe`, no con un `delete`— al
|
|
179
|
+
* cerrarse, al cerrarse solo por inactividad, al borrar el perfil y al reiniciar el
|
|
180
|
+
* daemon. Ese es el precio, y es el mismo que ya se paga por poder sellar actas
|
|
181
|
+
* estando abierta: lo que acota la exposición es el auto-candado, no el olvido.
|
|
182
|
+
*/
|
|
183
|
+
const llaves = new Map() // id -> Uint8Array (la llave derivada, solo si está abierto)
|
|
184
|
+
|
|
185
|
+
/** Borra el material de verdad antes de soltar la referencia. */
|
|
186
|
+
const olvidar = (id) => {
|
|
187
|
+
const k = llaves.get(id)
|
|
188
|
+
if (k) { try { k.fill(0) } catch (_) {} }
|
|
189
|
+
llaves.delete(id)
|
|
190
|
+
}
|
|
191
|
+
|
|
165
192
|
/** ¿Sigue abierto? Vence al MIRARLO, así que no hace falta ningún temporizador. */
|
|
166
193
|
const isOpen = (id) => {
|
|
167
194
|
const until = unlocked.get(id)
|
|
168
195
|
if (until == null) return false
|
|
169
196
|
if (autoLockMs > 0 && Date.now() >= until) {
|
|
170
197
|
unlocked.delete(id)
|
|
198
|
+
olvidar(id)
|
|
171
199
|
// El aviso va después de borrarlo: quien lo escuche verá el perfil ya cerrado.
|
|
172
200
|
try { onAutoLock?.(id) } catch (_) {}
|
|
173
201
|
return false
|
|
@@ -395,6 +423,7 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
395
423
|
}
|
|
396
424
|
if (data.current === id) data.current = quiere
|
|
397
425
|
if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
|
|
426
|
+
if (llaves.has(id)) { llaves.set(quiere, llaves.get(id)); llaves.delete(id) }
|
|
398
427
|
save()
|
|
399
428
|
return quiere
|
|
400
429
|
},
|
|
@@ -425,6 +454,7 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
425
454
|
if (data.current === id) data.current = data.profiles[0].id
|
|
426
455
|
save()
|
|
427
456
|
unlocked.delete(id)
|
|
457
|
+
olvidar(id)
|
|
428
458
|
try { fs.rmSync(dirOf(id), { recursive: true, force: true }) } catch (_) {}
|
|
429
459
|
return { id, name: p.name || '' }
|
|
430
460
|
},
|
|
@@ -511,10 +541,21 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
511
541
|
delete p.tries
|
|
512
542
|
save()
|
|
513
543
|
open(id)
|
|
544
|
+
// La llave se queda mientras el candado esté abierto: es lo que permite envolverle
|
|
545
|
+
// su cajón a un servicio que se enrola AHORA, sin volver a pedir la frase.
|
|
546
|
+
llaves.set(id, await api.adminKey(id, password))
|
|
514
547
|
return { ok: true, locked: false }
|
|
515
548
|
},
|
|
516
549
|
|
|
517
|
-
lock (id) { assertExists(id); unlocked.delete(id); return { ok: true, locked: api.isLocked(id) } },
|
|
550
|
+
lock (id) { assertExists(id); unlocked.delete(id); olvidar(id); return { ok: true, locked: api.isLocked(id) } },
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* La llave del perfil ABIERTO, o `null`. Es lo que hace que «la bóveda está abierta»
|
|
554
|
+
* signifique algo más que una bandera: con ella se envuelve la llave de un cajón sin
|
|
555
|
+
* volver a pedir la frase. Cerrado —o nunca abierto— devuelve `null` y quien llame
|
|
556
|
+
* tendrá que pedirla, que es exactamente el comportamiento de antes.
|
|
557
|
+
*/
|
|
558
|
+
openKey (id) { return isOpen(id) ? (llaves.get(id) || null) : null },
|
|
518
559
|
|
|
519
560
|
/** Pone o cambia la contraseña. Cambiarla exige haber desbloqueado antes. */
|
|
520
561
|
async setPassword (id, password) {
|
package/src/sealers.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sealers.js — depositar la cadena de selladores en el registro público.
|
|
3
|
+
*
|
|
4
|
+
* POR QUÉ ESTO EXISTE. Un acta que alguien guardó el año pasado no puede responder la única
|
|
5
|
+
* pregunta que importa al leer una firma vieja: *la llave que firmó esto, ¿sigue pudiendo
|
|
6
|
+
* sellar?* Si a una bóveda le quitaron el permiso, quien tenga el acta vieja no se entera
|
|
7
|
+
* nunca — y seguiría creyéndole. El registro es el sitio donde mirar.
|
|
8
|
+
*
|
|
9
|
+
* QUÉ SE MANDA: los ESLABONES (`sealerLinkOf`), nunca las actas. Y un eslabón no es un acta
|
|
10
|
+
* recortada: es un documento propio de ocho campos, firmado aparte y metido DENTRO del acta
|
|
11
|
+
* antes de firmarla (identity >= 0.72). Costó una vuelta descubrirlo — la primera cadena que
|
|
12
|
+
* llegó al registro se fue con los `label` y los `cn` de cada aparato, porque «un eslabón»
|
|
13
|
+
* era un acta entera. Un acta lleva el inventario de tu casa: no se publica, ni recortada.
|
|
14
|
+
*
|
|
15
|
+
* POR QUÉ SIN SELLAR, y no es un descuido (§4.1 pide sellar los mensajes dirigidos): lo que
|
|
16
|
+
* viaja aquí va a acabar en un repo PÚBLICO, que es su propósito. Es el mismo caso que los
|
|
17
|
+
* canales `publish`/`list` del proxio, exentos por la misma razón: no hay nada que ocultar.
|
|
18
|
+
* Y no filtra de más — quién lo manda ya lo sabe el proxio por el `identify`. Sellarlo,
|
|
19
|
+
* además, obligaría a conocer la llave de cifrado del testigo, y depositar tiene que poder
|
|
20
|
+
* hacerlo cualquiera sin pedirle nada a nadie.
|
|
21
|
+
*
|
|
22
|
+
* Va por `sendByPubkey` y no por `send`: al testigo se le conoce por su pubkey, y así lo
|
|
23
|
+
* depositado entra en la cola de 24 h del proxio si estuviera caído.
|
|
24
|
+
*
|
|
25
|
+
* CUÁNDO: al arrancar y después de cada acta. Publicar de más no cuesta nada (el registro
|
|
26
|
+
* contesta «ya estaba») y así no hay que llevar la cuenta de qué se depositó — un estado
|
|
27
|
+
* que se desincroniza en cuanto se restaura un disco.
|
|
28
|
+
*/
|
|
29
|
+
import * as Acta from '@dotrino/identity/acta'
|
|
30
|
+
import { verifyDelegation } from '@dotrino/identity/capabilities'
|
|
31
|
+
|
|
32
|
+
export const OP = 'sealers.publish'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* LA IDENTIDAD DEL TESTIGO DE DOTRINO — no su aparato (dueño, 2026-08-31: «¿no me puedes
|
|
36
|
+
* quemar un device en el código?»).
|
|
37
|
+
*
|
|
38
|
+
* Tenía razón y era el mismo fallo que ya costó caro con el `nodeId` del proxio: la pubkey
|
|
39
|
+
* de un APARATO muere en cuanto lo revocas o lo re-enrolas, y todo lo que la tuviera
|
|
40
|
+
* apuntada se queda hablando con una dirección muerta. Lo que no cambia nunca es la
|
|
41
|
+
* IDENTIDAD: el `profileId` es el nombre de la cuenta y es para siempre.
|
|
42
|
+
*
|
|
43
|
+
* Así que aquí va la identidad, y el aparato se DESCUBRE: el testigo se anuncia en el canal
|
|
44
|
+
* con su cert, y se acepta al que traiga uno emitido por esta identidad. Re-enrolarlo da un
|
|
45
|
+
* cert nuevo de la misma identidad y todo sigue solo; revocarlo lo saca del acta y su
|
|
46
|
+
* anuncio deja de valer.
|
|
47
|
+
*
|
|
48
|
+
* Y nadie configura nada: esto es una dirección pública, como `wss://proxy.dotrino.com`.
|
|
49
|
+
*/
|
|
50
|
+
export const DOTRINO_REGISTRY_ID = "{\"key_ops\":[\"verify\"],\"ext\":true,\"kty\":\"EC\",\"x\":\"McyhKieRw_Jcl3TY9EvLovL-ALzGoWqMOrfnFjT7XpA\",\"y\":\"V7tfpDWN37tPnhXnIFUlMaTepcTzzxPWTj_72PkpKPw\",\"crv\":\"P-256\"}"
|
|
51
|
+
|
|
52
|
+
/** El canal donde el testigo se anuncia (ver `@dotrino/sealers`). */
|
|
53
|
+
export const CANAL = 'dotrino.sealers'
|
|
54
|
+
|
|
55
|
+
/** Se le pregunta a cada anunciado quién es; contesta con su cert (ver `@dotrino/sealers`). */
|
|
56
|
+
export const WHOIS = 'sealers.whois'
|
|
57
|
+
|
|
58
|
+
export function startSealersPublisher ({ identity, client, log = console.log, registryId = DOTRINO_REGISTRY_ID, canal = CANAL } = {}) {
|
|
59
|
+
if (!registryId) return () => {}
|
|
60
|
+
|
|
61
|
+
/** Una respuesta a `sealers.whois`, o null si no contesta a tiempo. */
|
|
62
|
+
function preguntarQuienEs (token, timeoutMs = 8000) {
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const fin = (v) => { try { off() } catch (_) {} ; clearTimeout(t); resolve(v) }
|
|
65
|
+
const off = client.on('message', (_f, p) => { if (p?.op === WHOIS + '.result') fin(p) })
|
|
66
|
+
const t = setTimeout(() => fin(null), timeoutMs)
|
|
67
|
+
t.unref?.()
|
|
68
|
+
try { client.send(token, { op: WHOIS }) } catch (_) { fin(null) }
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* ¿A QUIÉN LE MANDO ESTO? Al que esté anunciado en el canal y demuestre ser de la
|
|
74
|
+
* IDENTIDAD que llevamos en el código.
|
|
75
|
+
*
|
|
76
|
+
* El canal dice DÓNDE mirar y nada más: `list` del proxio devuelve tokens sueltos — el
|
|
77
|
+
* `extraData` del `publish` no vuelve, comprobado contra el servidor. Así que a cada uno
|
|
78
|
+
* se le pregunta quién es (`sealers.whois`) y contesta con su cert. Que el cert venga del
|
|
79
|
+
* propio testigo y no del anuncio es además mejor: llega fresco.
|
|
80
|
+
*
|
|
81
|
+
* Sin comprobar el cert, cualquiera se anuncia ahí y se queda con los depósitos. No
|
|
82
|
+
* filtraría nada —el eslabón es público— pero se los tragaría EN SILENCIO, que es peor
|
|
83
|
+
* que un error.
|
|
84
|
+
*/
|
|
85
|
+
async function testigo () {
|
|
86
|
+
let tokens
|
|
87
|
+
try { tokens = await client.list(canal) } catch (_) { return null }
|
|
88
|
+
for (const token of tokens || []) {
|
|
89
|
+
const r = await preguntarQuienEs(token)
|
|
90
|
+
const cert = r?.cert
|
|
91
|
+
if (!cert?.sub || cert.iss !== registryId) continue
|
|
92
|
+
// AQUÍ SOMOS EXTRAÑOS: no tenemos el acta del registro, así que no podemos preguntarle
|
|
93
|
+
// quién sella. Lo que sí tenemos es su `profileId` quemado en el código —inmutable, a
|
|
94
|
+
// diferencia de la dirección de un aparato— y eso es lo que se ancla: aceptamos un
|
|
95
|
+
// papel firmado POR ESA LLAVE, para el acta que él diga.
|
|
96
|
+
//
|
|
97
|
+
// Es exactamente la misma fuerza que antes (`cert.iss === registryId` + firma válida),
|
|
98
|
+
// dicho con la forma nueva. Y tiene un límite que conviene decir: si el registro
|
|
99
|
+
// llegara a tener una SEGUNDA selladora, sus papeles no entrarían por aquí hasta que
|
|
100
|
+
// presente su cadena de eslabones — que es, con gracia, justo lo que este registro
|
|
101
|
+
// publica.
|
|
102
|
+
const v = await verifyDelegation({
|
|
103
|
+
cert, expectedSub: cert.sub, actaSeq: cert.seq, sealers: [registryId]
|
|
104
|
+
}).catch(() => ({ ok: false }))
|
|
105
|
+
if (v?.ok) return cert.sub
|
|
106
|
+
}
|
|
107
|
+
return null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let ultima = null
|
|
111
|
+
|
|
112
|
+
let avisadoDeOtroTestigo = false
|
|
113
|
+
|
|
114
|
+
async function publicar (motivo) {
|
|
115
|
+
let actas
|
|
116
|
+
try { actas = await identity.sealerChain?.() } catch (_) { return }
|
|
117
|
+
if (!Array.isArray(actas)) return
|
|
118
|
+
|
|
119
|
+
// SI LA CUENTA DECLARÓ SU PROPIO TESTIGO, no se deposita en el de Dotrino. No es que
|
|
120
|
+
// hacerlo filtre algo —el eslabón es público— sino que el `chainUrl` del génesis es la
|
|
121
|
+
// forma que tiene una cuenta de decir dónde vive su cadena, y decidir por ella sería
|
|
122
|
+
// exactamente lo que ese campo existe para impedir. Se dice una vez y se calla.
|
|
123
|
+
const genesis = actas[0]
|
|
124
|
+
if (genesis?.chainUrl && registryId === DOTRINO_REGISTRY_ID) {
|
|
125
|
+
if (!avisadoDeOtroTestigo) {
|
|
126
|
+
avisadoDeOtroTestigo = true
|
|
127
|
+
log(`[sealers] this account names its own registry (${genesis.chainUrl}): not depositing into Dotrino's`)
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
// De cada acta sale su eslabón, y las que no cambiaron el sellador no dan ninguno.
|
|
132
|
+
const chain = actas.map((a) => Acta.sealerLinkOf(a)).filter(Boolean)
|
|
133
|
+
// Una cuenta de una sola bóveda no tiene nada que refrescar: su conjunto de selladores
|
|
134
|
+
// no puede cambiar. El registro la rechazaría, así que ni se molesta en mandarla.
|
|
135
|
+
// Una cuenta anterior a identity 0.72 tampoco tiene eslabones, y es el mismo caso: no
|
|
136
|
+
// aparece en el registro hasta que selle un acta nueva.
|
|
137
|
+
if (chain.length < 2) return
|
|
138
|
+
|
|
139
|
+
const cabeza = chain[chain.length - 1]?.seq
|
|
140
|
+
if (cabeza === ultima) return
|
|
141
|
+
try {
|
|
142
|
+
const a = await testigo()
|
|
143
|
+
if (!a) { log('[sealers] no witness announced in ' + canal + ': the chain stays for the next time'); return }
|
|
144
|
+
await client.sendByPubkey(a, { op: OP, chain })
|
|
145
|
+
ultima = cabeza
|
|
146
|
+
log(`[sealers] chain up to #${cabeza} deposited (${motivo})`)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
// Que no se pueda depositar no rompe nada de lo que la bóveda hace: el registro es
|
|
149
|
+
// una comodidad para terceros, no una dependencia. Se reintenta en la próxima acta.
|
|
150
|
+
log('[sealers] could not deposit the chain:', e.message)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
publicar('startup').catch(() => {})
|
|
155
|
+
const off = identity.onVault?.((e) => { if (e?.phase === 'acta') publicar(`record #${e.seq}`).catch(() => {}) })
|
|
156
|
+
return () => { try { off?.() } catch (_) {} }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default { startSealersPublisher, OP, DOTRINO_REGISTRY_ID, CANAL }
|
package/src/transport.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Transporte headless del vault: el cliente OFICIAL `@dotrino/proxy-client`
|
|
3
3
|
* corriendo en Node (mismo patrón que `dotrino-bots/src/core/transport.js`).
|
|
4
|
-
* Hace el `identify`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Hace el `identify` — liga el token efímero de la conexión a una pubkey estable,
|
|
5
|
+
* habilitando el direccionamiento por pubkey (`sendByPubkey`) y la cola offline
|
|
6
|
+
* de 24 h del proxy.
|
|
7
|
+
*
|
|
8
|
+
* QUIÉN FIRMA ESE `identify`, que es lo que cambió: **la llave de comunicación**
|
|
9
|
+
* (`commKey.js`), no la maestra. La maestra sella el acta y reenvuelve sobres, y
|
|
10
|
+
* nada más; si además tuviera que firmar aquí no podría vivir bajo llave, porque
|
|
11
|
+
* esto se firma en cada conexión Y en cada reconexión — una bóveda cerrada no
|
|
12
|
+
* existiría en la red.
|
|
13
|
+
*
|
|
14
|
+
* Lo que ata el token al PERFIL entonces ya no es un certificado sino el ACTA: el
|
|
15
|
+
* proxio comprueba que quien habla sea miembro (`verifyActaMembership`) y ata el
|
|
16
|
+
* token también al `profileId`. Por eso se manda el acta siempre.
|
|
7
17
|
*
|
|
8
18
|
* IMPORTANTE: `me.publickey` puede ser null en el primer arranque (solo se puebla
|
|
9
19
|
* al fijar un nickname), así que la pubkey maestra se obtiene de forma robusta
|
|
@@ -26,7 +36,7 @@ export async function masterPubkeyOf (identity) {
|
|
|
26
36
|
* @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
|
|
27
37
|
* @returns {Promise<{ client, token:string, identify():Promise<void> }>}
|
|
28
38
|
*/
|
|
29
|
-
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY }) {
|
|
39
|
+
export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {} }) {
|
|
30
40
|
installNodeGlobals(dir)
|
|
31
41
|
// Import dinámico DESPUÉS de instalar los globals que el paquete usa.
|
|
32
42
|
// `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
|
|
@@ -47,13 +57,27 @@ export async function createTransport ({ identity, dir, url = DEFAULT_PROXY }) {
|
|
|
47
57
|
await client.connect()
|
|
48
58
|
|
|
49
59
|
const identify = async () => {
|
|
60
|
+
if (!client.token) return
|
|
61
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
62
|
+
const comm = commKey?.pub?.() || null
|
|
63
|
+
// ¿Nos nombra el acta? Con `cn` o sin él, lo que el proxio mira es la pertenencia.
|
|
64
|
+
const esMiembro = comm && (record?.members || []).some((m) => m?.pub === comm)
|
|
65
|
+
|
|
66
|
+
if (comm && esMiembro) {
|
|
67
|
+
const data = { op: 'identify', publickey: comm, token: client.token, ts: Date.now() }
|
|
68
|
+
const signature = await commKey.sign(data)
|
|
69
|
+
if (signature) return client.identify({ data, signature, acta: record })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// REPLIEGUE, y solo para migrar: una bóveda que todavía no ha metido su llave de
|
|
73
|
+
// comunicación en el acta se identifica con la maestra, como siempre. Exige el perfil
|
|
74
|
+
// ABIERTO —la maestra bajo llave no firma— y por eso se dice en voz alta: mientras se
|
|
75
|
+
// esté aquí, cerrar el perfil deja la bóveda sin voz.
|
|
50
76
|
const publickey = await masterPubkeyOf(identity)
|
|
51
|
-
if (!publickey
|
|
77
|
+
if (!publickey) return
|
|
52
78
|
const data = { op: 'identify', publickey, token: client.token, ts: Date.now() }
|
|
53
79
|
const { signature } = await identity.signData(data)
|
|
54
|
-
|
|
55
|
-
// a cualquiera de sus dispositivos, no solo a esta bóveda.
|
|
56
|
-
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
80
|
+
log('[vault] identifying with the master key: this vault is not in its own record yet (open the profile once to fix it)')
|
|
57
81
|
await client.identify({ data, signature, acta: record })
|
|
58
82
|
}
|
|
59
83
|
await identify()
|