@dotrino/vault 0.85.0 → 0.86.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.
@@ -0,0 +1,172 @@
1
+ /**
2
+ * El SELLADOR: lo único de los secretos que toca criptografía.
3
+ *
4
+ * `secretsStore.js` guarda la forma y las reglas y no sabe cifrar; le inyecta este
5
+ * puerto y le pide sobres. Así el store se prueba con un sellador falso y este archivo
6
+ * se lee entero de una sentada.
7
+ *
8
+ * QUÉ HACE. Cada cajón (un namespace, o el cajón propio de un aparato) tiene una CEK
9
+ * AES-256-GCM. Las variables privadas se cifran con ella. La CEK viaja de dos formas,
10
+ * y esa dualidad es todo el diseño:
11
+ *
12
+ * · **hacia los miembros** — envuelta a la llave de cifrado (`encPub`) de cada
13
+ * aparato que deba leer ese cajón, con ECDH efímero: quien abre solo necesita su
14
+ * propia privada. Es lo que va en el llavero del archivo y lo que viaja al agente.
15
+ * · **hacia quien administra** — en la COPIA MAESTRA, cifrada con la llave derivada
16
+ * de la contraseña del perfil. Es lo que permite sellar una variable nueva o
17
+ * re-envolver la CEK a un aparato que entra.
18
+ *
19
+ * Y hay una tercera forma que NO existe a propósito: **la CEK nunca se envuelve a la
20
+ * llave de esta bóveda**. Si se hiciera, el daemon podría abrir todo por su cuenta y
21
+ * esto no valdría nada — que es exactamente la situación de la que venimos.
22
+ *
23
+ * La contraseña no está en el disco, así que una copia del disco no abre las privadas.
24
+ * Lo que sí abre es todo lo demás; los límites honestos están en
25
+ * `docs/secretos-sellados.md` §3 y conviene leerlos antes de confiar de más.
26
+ */
27
+ import {
28
+ makeContentKey, makeGeneration, encryptWithCek, decryptWithCek, wrapForMember, openWrap
29
+ } from '@dotrino/identity/content'
30
+
31
+ const b64 = (b) => { let s = ''; for (const x of new Uint8Array(b)) s += String.fromCharCode(x); return btoa(s) }
32
+ const un64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
33
+ /** AES-256-GCM con WebCrypto: el mismo en Node y en el navegador. */
34
+ const aesKey = (raw, uses) => crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, uses)
35
+
36
+ /** La copia maestra va cifrada con AES-256-GCM bajo la llave derivada de la contraseña. */
37
+ const MASTER_V = 1
38
+
39
+ /** Se pidió abrir la copia maestra sin llave, o con la equivocada. */
40
+ export class WrongPassword extends Error {
41
+ constructor (msg = 'wrong password') {
42
+ super(msg)
43
+ this.code = 'WRONG_PASSWORD'
44
+ }
45
+ }
46
+
47
+ function assertKey (adminKey) {
48
+ if (!(adminKey instanceof Uint8Array) || adminKey.length !== 32) {
49
+ throw new WrongPassword('the derived key is missing or malformed (32 bytes expected)')
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Crea el sellador. No guarda estado ni recuerda la contraseña: cada operación recibe
55
+ * la llave derivada y la suelta. Que no se cachee es la mitad del valor de todo esto —
56
+ * una llave que se queda en memoria para siempre deja el disco protegido y la máquina
57
+ * igual de expuesta que antes.
58
+ */
59
+ export function makeSealer () {
60
+ return {
61
+ /**
62
+ * Abre la copia maestra: `{ "ns:<ns>": cek, "dev:<pub>": cek }`. Un `blob` vacío
63
+ * devuelve un mapa vacío — es el primer arranque, no un error.
64
+ */
65
+ async openMaster (blob, adminKey) {
66
+ assertKey(adminKey)
67
+ if (!blob) return {}
68
+ if (blob.v !== MASTER_V) throw new Error(`master copy: unknown format v${blob.v}`)
69
+ try {
70
+ // WebCrypto quiere la etiqueta pegada al final del cifrado; en disco van aparte.
71
+ const ct = un64(blob.ct); const tag = un64(blob.tag)
72
+ const both = new Uint8Array(ct.length + tag.length); both.set(ct); both.set(tag, ct.length)
73
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: un64(blob.iv) }, await aesKey(adminKey, ['decrypt']), both)
74
+ return JSON.parse(new TextDecoder().decode(plain))
75
+ } catch (_) {
76
+ // El tag de AES-GCM ES el verificador: si no cuadra, la contraseña no era.
77
+ // Por eso no hace falta guardar aparte un verificador de la contraseña, que
78
+ // sería un camino más barato para atacarla desde una copia del disco.
79
+ throw new WrongPassword()
80
+ }
81
+ },
82
+
83
+ async sealMaster (obj, adminKey) {
84
+ assertKey(adminKey)
85
+ const iv = crypto.getRandomValues(new Uint8Array(12))
86
+ const both = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await aesKey(adminKey, ['encrypt']), new TextEncoder().encode(JSON.stringify(obj))))
87
+ return { v: MASTER_V, iv: b64(iv), ct: b64(both.subarray(0, both.length - 16)), tag: b64(both.subarray(both.length - 16)) }
88
+ },
89
+
90
+ /** La CEK de un cajón, creándola si es la primera variable que entra ahí. */
91
+ async cekFor (master, owner) {
92
+ if (!master[owner]) master[owner] = await makeContentKey()
93
+ return master[owner]
94
+ },
95
+
96
+ /** Una CEK NUEVA para el cajón, tirando la anterior. Rotar es esto. */
97
+ async newCek (master, owner) {
98
+ master[owner] = await makeContentKey()
99
+ return master[owner]
100
+ },
101
+
102
+ /** Cifra un valor con la CEK del cajón. */
103
+ async encrypt (cek, plaintext, gen = 0) {
104
+ const { iv, ct } = await encryptWithCek({ cek, gen, plaintext })
105
+ return { iv, ct }
106
+ },
107
+
108
+ /** Descifra con la CEK que corresponda al cajón. Solo lo usa quien administra. */
109
+ async decrypt (master, envelope, owner) {
110
+ const cek = master[owner]
111
+ if (!cek) throw new Error(`master copy: no key for drawer ${owner}`)
112
+ return decryptWithCek({ cek, envelope })
113
+ },
114
+
115
+ /**
116
+ * Una CEK NUEVA, sin llavero de por medio. Es lo que usa cada escritura desde v5: la
117
+ * bóveda no puede reutilizar la de antes porque **no puede abrir ninguna envoltura
118
+ * para recuperarla** — sellar es una capacidad pública, abrir no. Una generación por
119
+ * escritura es la consecuencia, no un capricho (§8.7).
120
+ */
121
+ async newKey () {
122
+ return makeContentKey()
123
+ },
124
+
125
+ /**
126
+ * El par de RECUPERACIÓN: su pública se guarda en claro —para poder envolverle sin
127
+ * saber nada— y su privada va sellada bajo la frase. Es la copia que se abre el día
128
+ * que no queda ningún aparato, y la que abre la TUI cuando el dueño pide ver un valor.
129
+ */
130
+ async makeRecoveryPair () {
131
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
132
+ return {
133
+ pub: JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey)),
134
+ priv: await crypto.subtle.exportKey('jwk', pair.privateKey)
135
+ }
136
+ },
137
+
138
+ /** Envuelve una CEK a UNA pública suelta (la de recuperación, que no es un miembro). */
139
+ async wrapForKey (cek, encPub) {
140
+ return wrapForMember({ cek, memberEncPub: encPub })
141
+ },
142
+
143
+ /**
144
+ * Abre una envoltura con una privada ECDH **en JWK**. Lo hace quien PUEDE leer.
145
+ *
146
+ * La privada llega como JWK porque así se guarda (sellada bajo la frase, en un JSON),
147
+ * y `openWrap` quiere una `CryptoKey`: importarla es el puente, y va aquí porque este
148
+ * es el único archivo que toca criptografía.
149
+ */
150
+ async openWrapWith (privJwk, wrap) {
151
+ const key = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
152
+ return openWrap({ wrap, myEncPrivateKey: key })
153
+ },
154
+
155
+ /** Descifra un sobre con la CEK ya abierta. */
156
+ async openValue (cek, envelope) {
157
+ return decryptWithCek({ cek, envelope })
158
+ },
159
+
160
+ /**
161
+ * Envuelve la CEK a cada miembro. Los que no tengan llave de cifrado salen en
162
+ * `sinLlave` en vez de reventar: quien administra tiene que poder VERLO, porque el
163
+ * síntoma sería un servicio arrancando sin configuración y sin decir por qué.
164
+ */
165
+ async wrapFor (cek, members) {
166
+ const { generation, sinLlave } = await makeGeneration({ members, cek })
167
+ return { wraps: generation.wraps, sinLlave }
168
+ }
169
+ }
170
+ }
171
+
172
+ export default { makeSealer, WrongPassword }
@@ -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 }