@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/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` firmado por la maestra del vault — liga el token efímero de
5
- * la conexión a la pubkey estable, habilitando el direccionamiento por pubkey
6
- * (`sendByPubkey`) y la cola offline de 24 h del proxy.
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 || !client.token) return
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
- // Con el acta, el proxy bindea también el `profileId`: escribirle a la PERSONA llega
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()