@dotrino/vaultd 0.38.0 → 0.49.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/sealer.js ADDED
@@ -0,0 +1,170 @@
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 crypto from 'node:crypto'
28
+ import {
29
+ makeContentKey, makeGeneration, encryptWithCek, decryptWithCek, wrapForMember, openWrap
30
+ } from '@dotrino/identity/content'
31
+
32
+ const b64 = (b) => Buffer.from(b).toString('base64')
33
+ const un64 = (s) => Buffer.from(s, 'base64')
34
+
35
+ /** La copia maestra va cifrada con AES-256-GCM bajo la llave derivada de la contraseña. */
36
+ const MASTER_V = 1
37
+
38
+ /** Se pidió abrir la copia maestra sin llave, o con la equivocada. */
39
+ export class WrongPassword extends Error {
40
+ constructor (msg = 'wrong password') {
41
+ super(msg)
42
+ this.code = 'WRONG_PASSWORD'
43
+ }
44
+ }
45
+
46
+ function assertKey (adminKey) {
47
+ if (!(adminKey instanceof Uint8Array) || adminKey.length !== 32) {
48
+ throw new WrongPassword('the derived key is missing or malformed (32 bytes expected)')
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Crea el sellador. No guarda estado ni recuerda la contraseña: cada operación recibe
54
+ * la llave derivada y la suelta. Que no se cachee es la mitad del valor de todo esto —
55
+ * una llave que se queda en memoria para siempre deja el disco protegido y la máquina
56
+ * igual de expuesta que antes.
57
+ */
58
+ export function makeSealer () {
59
+ return {
60
+ /**
61
+ * Abre la copia maestra: `{ "ns:<ns>": cek, "dev:<pub>": cek }`. Un `blob` vacío
62
+ * devuelve un mapa vacío — es el primer arranque, no un error.
63
+ */
64
+ async openMaster (blob, adminKey) {
65
+ assertKey(adminKey)
66
+ if (!blob) return {}
67
+ if (blob.v !== MASTER_V) throw new Error(`master copy: unknown format v${blob.v}`)
68
+ try {
69
+ const d = crypto.createDecipheriv('aes-256-gcm', adminKey, un64(blob.iv))
70
+ d.setAuthTag(un64(blob.tag))
71
+ return JSON.parse(Buffer.concat([d.update(un64(blob.ct)), d.final()]).toString('utf8'))
72
+ } catch (_) {
73
+ // El tag de AES-GCM ES el verificador: si no cuadra, la contraseña no era.
74
+ // Por eso no hace falta guardar aparte un verificador de la contraseña, que
75
+ // sería un camino más barato para atacarla desde una copia del disco.
76
+ throw new WrongPassword()
77
+ }
78
+ },
79
+
80
+ async sealMaster (obj, adminKey) {
81
+ assertKey(adminKey)
82
+ const iv = crypto.randomBytes(12)
83
+ const c = crypto.createCipheriv('aes-256-gcm', adminKey, iv)
84
+ const ct = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()])
85
+ return { v: MASTER_V, iv: b64(iv), ct: b64(ct), tag: b64(c.getAuthTag()) }
86
+ },
87
+
88
+ /** La CEK de un cajón, creándola si es la primera variable que entra ahí. */
89
+ async cekFor (master, owner) {
90
+ if (!master[owner]) master[owner] = await makeContentKey()
91
+ return master[owner]
92
+ },
93
+
94
+ /** Una CEK NUEVA para el cajón, tirando la anterior. Rotar es esto. */
95
+ async newCek (master, owner) {
96
+ master[owner] = await makeContentKey()
97
+ return master[owner]
98
+ },
99
+
100
+ /** Cifra un valor con la CEK del cajón. */
101
+ async encrypt (cek, plaintext, gen = 0) {
102
+ const { iv, ct } = await encryptWithCek({ cek, gen, plaintext })
103
+ return { iv, ct }
104
+ },
105
+
106
+ /** Descifra con la CEK que corresponda al cajón. Solo lo usa quien administra. */
107
+ async decrypt (master, envelope, owner) {
108
+ const cek = master[owner]
109
+ if (!cek) throw new Error(`master copy: no key for drawer ${owner}`)
110
+ return decryptWithCek({ cek, envelope })
111
+ },
112
+
113
+ /**
114
+ * Una CEK NUEVA, sin llavero de por medio. Es lo que usa cada escritura desde v5: la
115
+ * bóveda no puede reutilizar la de antes porque **no puede abrir ninguna envoltura
116
+ * para recuperarla** — sellar es una capacidad pública, abrir no. Una generación por
117
+ * escritura es la consecuencia, no un capricho (§8.7).
118
+ */
119
+ async newKey () {
120
+ return makeContentKey()
121
+ },
122
+
123
+ /**
124
+ * El par de RECUPERACIÓN: su pública se guarda en claro —para poder envolverle sin
125
+ * saber nada— y su privada va sellada bajo la frase. Es la copia que se abre el día
126
+ * que no queda ningún aparato, y la que abre la TUI cuando el dueño pide ver un valor.
127
+ */
128
+ async makeRecoveryPair () {
129
+ const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey', 'deriveBits'])
130
+ return {
131
+ pub: JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey)),
132
+ priv: await crypto.subtle.exportKey('jwk', pair.privateKey)
133
+ }
134
+ },
135
+
136
+ /** Envuelve una CEK a UNA pública suelta (la de recuperación, que no es un miembro). */
137
+ async wrapForKey (cek, encPub) {
138
+ return wrapForMember({ cek, memberEncPub: encPub })
139
+ },
140
+
141
+ /**
142
+ * Abre una envoltura con una privada ECDH **en JWK**. Lo hace quien PUEDE leer.
143
+ *
144
+ * La privada llega como JWK porque así se guarda (sellada bajo la frase, en un JSON),
145
+ * y `openWrap` quiere una `CryptoKey`: importarla es el puente, y va aquí porque este
146
+ * es el único archivo que toca criptografía.
147
+ */
148
+ async openWrapWith (privJwk, wrap) {
149
+ const key = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
150
+ return openWrap({ wrap, myEncPrivateKey: key })
151
+ },
152
+
153
+ /** Descifra un sobre con la CEK ya abierta. */
154
+ async openValue (cek, envelope) {
155
+ return decryptWithCek({ cek, envelope })
156
+ },
157
+
158
+ /**
159
+ * Envuelve la CEK a cada miembro. Los que no tengan llave de cifrado salen en
160
+ * `sinLlave` en vez de reventar: quien administra tiene que poder VERLO, porque el
161
+ * síntoma sería un servicio arrancando sin configuración y sin decir por qué.
162
+ */
163
+ async wrapFor (cek, members) {
164
+ const { generation, sinLlave } = await makeGeneration({ members, cek })
165
+ return { wraps: generation.wraps, sinLlave }
166
+ }
167
+ }
168
+ }
169
+
170
+ export default { makeSealer, WrongPassword }