@dotrino/vault 0.34.0 → 0.36.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vault",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "description": "Usa ESTE dispositivo (navegador) como bóveda/CA del ecosistema Dotrino: atiende enrolamientos por el proxy y firma certificados de delegación a tus máquinas. Incluye el cliente de SERVICIO (Node): un proyecto se enrola una vez y jala sus credenciales del vault en vez del .env (`import '@dotrino/vault/config'`).",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -47,8 +47,8 @@
47
47
  "pairing"
48
48
  ],
49
49
  "peerDependencies": {
50
- "@dotrino/identity": ">=0.58.0",
51
- "@dotrino/proxy-client": ">=0.9.0"
50
+ "@dotrino/identity": ">=0.63.0",
51
+ "@dotrino/proxy-client": ">=0.13.0"
52
52
  },
53
53
  "license": "MIT",
54
54
  "repository": {
package/src/atrest.js CHANGED
@@ -32,6 +32,13 @@
32
32
  * · `/etc/machine-id` — identifica la instalación del sistema
33
33
  * · un SALT aleatorio local (`atrest.salt`, 0600) — se genera la primera vez
34
34
  *
35
+ * **Y desde 0.55: de dónde sale la clave es CONFIGURABLE** (`kek.js`). Todo lo anterior
36
+ * describe el proveedor `machine`, que sigue siendo el de por defecto y el que se usa si
37
+ * no hay `atrest.json`. Con `provider: "command"` la clave la envuelve un KMS —el que
38
+ * sea, incluido el del cliente— y entonces **sí** deja de estar en este disco, que es la
39
+ * única forma de cerrar el párrafo de arriba. Cambiar de proveedor es `atrest rekey`;
40
+ * editar el JSON a mano está expresamente bloqueado porque dejaría los datos ilegibles.
41
+ *
35
42
  * `machineKey` **acepta** además una contraseña, pero **ningún llamante se la pasa** y no es
36
43
  * un descuido: los cinco (`src/store.js`, `src/secretsStore.js`, `src/vault.js`,
37
44
  * `src/threadStore.js`, `lib/src/service.js`) usan `atRestFor(dir)` a secas, porque el
@@ -47,6 +54,7 @@ import path from 'node:path'
47
54
  import os from 'node:os'
48
55
  import crypto from 'node:crypto'
49
56
  import { execFileSync } from 'node:child_process'
57
+ import { resolveKey, mintKey, readConfig, writeConfig, probe, clearCache, configFromEnv, KekError, CONFIG_FILE, WRAPPED_FILE, MACHINE_FILE } from './kek.js'
50
58
 
51
59
  const MAGIC = 'DOTRINO-ATREST-v1'
52
60
  const SALT_FILE = 'atrest.salt'
@@ -124,13 +132,28 @@ export function decryptText (blob, key) {
124
132
  return Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8')
125
133
  }
126
134
 
135
+ /**
136
+ * La clave de ESTE directorio, por el proveedor que diga su `atrest.json`.
137
+ *
138
+ * Sin ese archivo el proveedor es `machine` y esto devuelve exactamente lo mismo que
139
+ * `machineKey(dir, password)` — o sea, una instalación existente no nota el cambio.
140
+ * Con `provider: "command"` la clave viene envuelta por un KMS (ver `kek.js`).
141
+ *
142
+ * **Usa esto, no `machineKey`, en cualquier código nuevo.** `machineKey` es ahora la
143
+ * implementación de UN proveedor; llamarla directamente se salta la costura y deja de
144
+ * funcionar en cuanto alguien configure otro.
145
+ */
146
+ export function kekFor (dir, { password = '' } = {}) {
147
+ return resolveKey(dir, { password, machineKeyFn: machineKey, materialFn: machineMaterial, magic: MAGIC })
148
+ }
149
+
127
150
  /**
128
151
  * Adaptador para `@dotrino/identity/node`: cifra/descifra el archivo entero de la identidad.
129
152
  * Si el archivo está en claro (instalación anterior), lo lee igual y lo deja cifrado en la
130
153
  * primera escritura — sin pedirle nada al usuario.
131
154
  */
132
155
  export function atRestFor (dir, { password = '' } = {}) {
133
- const key = machineKey(dir, password)
156
+ const key = kekFor(dir, { password })
134
157
  return {
135
158
  encrypt: (text) => encryptText(text, key),
136
159
  decrypt: (text) => (isEncrypted(text) ? decryptText(text, key) : text)
@@ -154,4 +177,95 @@ export function migrateFile (file, key) {
154
177
  return 'migrado'
155
178
  }
156
179
 
157
- export default { machineKey, atRestFor, migrateFile, isEncrypted, encryptText, decryptText }
180
+ /** Los archivos de este directorio que están cifrados por nosotros. */
181
+ export function encryptedFilesIn (dir) {
182
+ let names = []
183
+ try { names = fs.readdirSync(dir) } catch (_) { return [] }
184
+ return names.filter((n) => {
185
+ if (n === CONFIG_FILE || n === WRAPPED_FILE || n === SALT_FILE || n === MACHINE_FILE) return false
186
+ try { return isEncrypted(fs.readFileSync(path.join(dir, n), 'utf8')) } catch (_) { return false }
187
+ })
188
+ }
189
+
190
+ /**
191
+ * CAMBIAR DE PROVEEDOR: descifra todo con la clave vieja y lo vuelve a cifrar con la nueva.
192
+ *
193
+ * Sin esto, cambiar `atrest.json` a mano deja el perfil ilegible — la maestra incluida — y
194
+ * no hay vuelta atrás. Por eso `kek.js` se niega a estrenar una DEK sobre datos que ya
195
+ * están cifrados: el único camino para cambiar de proveedor es este.
196
+ *
197
+ * Cómo se protege, por orden:
198
+ * 1. Se descifra TODO en memoria antes de escribir un solo byte. Si un archivo no abre,
199
+ * no se ha tocado nada.
200
+ * 2. Se comprueba que lo recifrado se vuelve a leer igual, archivo por archivo.
201
+ * 3. Se deja una copia `.bak-rekey` de cada original.
202
+ * 4. La config nueva se escribe LA ÚLTIMA, cuando los datos ya están convertidos.
203
+ *
204
+ * Lo que NO se puede prometer, y se dice: renombrar varios archivos no es atómico. Si el
205
+ * proceso muere a mitad, queda una mezcla — y se sale de ella restaurando los
206
+ * `.bak-rekey`, que es justo lo que devuelve `backups`.
207
+ */
208
+ export function rekeyDir (dir, newConfig, { password = '' } = {}) {
209
+ const oldCfg = readConfig(dir)
210
+ const nextCfg = { provider: 'machine', ...(newConfig || {}) }
211
+ const oldKey = kekFor(dir, { password })
212
+
213
+ // 1. Descifrar todo primero. Un fallo aquí no ha tocado nada.
214
+ const files = encryptedFilesIn(dir)
215
+ const plain = new Map()
216
+ for (const n of files) {
217
+ const p = path.join(dir, n)
218
+ try { plain.set(n, decryptText(fs.readFileSync(p, 'utf8'), oldKey)) } catch (e) {
219
+ throw new KekError('kek-rekey', `cannot decrypt ${n} with the current provider (${oldCfg.provider}): ${e.message}`)
220
+ }
221
+ }
222
+
223
+ // 2. La clave nueva, todavía sin escribir su envoltorio.
224
+ let newKey; let newBlob = null
225
+ if (nextCfg.provider === 'machine') {
226
+ newKey = machineKey(dir, password)
227
+ } else {
228
+ const minted = mintKey(nextCfg)
229
+ newKey = minted.dek; newBlob = minted.blob
230
+ }
231
+
232
+ // 3. Recifrar y comprobar, todo en memoria.
233
+ const out = new Map()
234
+ for (const [n, text] of plain) {
235
+ const blob = encryptText(text, newKey)
236
+ if (decryptText(blob, newKey) !== text) throw new KekError('kek-verify', `re-encryption check failed for ${n}: nothing was written`)
237
+ out.set(n, blob)
238
+ }
239
+
240
+ // 4. A disco. Copia de seguridad, temporal, renombrar.
241
+ const backups = []
242
+ for (const [n, blob] of out) {
243
+ const p = path.join(dir, n)
244
+ const bak = p + '.bak-rekey'
245
+ fs.copyFileSync(p, bak); backups.push(bak)
246
+ const tmp = p + '.rekey.tmp'
247
+ fs.writeFileSync(tmp, blob, { mode: 0o600 })
248
+ fs.renameSync(tmp, p)
249
+ }
250
+
251
+ // 5. El envoltorio nuevo y la config, al final: hasta aquí `atrest.json` seguía
252
+ // describiendo la clave con la que YA no están cifrados los datos.
253
+ const wrappedPath = path.join(dir, WRAPPED_FILE)
254
+ if (newBlob) {
255
+ const tmp = wrappedPath + '.tmp'
256
+ fs.writeFileSync(tmp, newBlob.toString('base64') + '\n', { mode: 0o600 })
257
+ fs.renameSync(tmp, wrappedPath)
258
+ } else {
259
+ try { fs.unlinkSync(wrappedPath) } catch (_) { /* no lo había */ }
260
+ }
261
+ writeConfig(dir, nextCfg)
262
+ clearCache()
263
+
264
+ return { from: oldCfg.provider, to: nextCfg.provider, files: [...out.keys()], backups }
265
+ }
266
+
267
+ export default {
268
+ machineKey, kekFor, atRestFor, migrateFile, isEncrypted, encryptText, decryptText,
269
+ rekeyDir, encryptedFilesIn, readConfig, writeConfig, probe, KekError
270
+ }
271
+ export { readConfig, writeConfig, probe, configFromEnv, KekError, CONFIG_FILE, WRAPPED_FILE }
package/src/enroll.js CHANGED
@@ -52,7 +52,7 @@ export const MSG_REVOKED = 'vault.revoked'
52
52
  export const MSG_ERROR = 'vault.error'
53
53
 
54
54
  /** Los scopes del cert se corresponden 1:1 con las capacidades del acta (§D7). */
55
- const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin' }
55
+ const SCOPE_TO_CAP = { 'vault:sign': 'sign', 'vault:store': 'store', 'vault:read': 'read', 'vault:admin': 'admin', 'vault:passwords': 'passwords' }
56
56
  export const scopeToCaps = (scope) =>
57
57
  (Array.isArray(scope) ? scope : [scope]).map((s) => SCOPE_TO_CAP[s]).filter(Boolean)
58
58
 
package/src/kek.js ADDED
@@ -0,0 +1,293 @@
1
+ /**
2
+ * kek.js — DE DÓNDE SALE la clave que cifra el disco.
3
+ *
4
+ * Antes había UNA forma cableada dentro de `atrest.js`: derivarla del material de la
5
+ * máquina (`/etc/machine-id` + salt). Eso sigue siendo el proveedor por defecto y no
6
+ * cambia nada — pero ahora es *un* proveedor, no *el* proveedor.
7
+ *
8
+ * Por qué importa: el hueco grande frente a un KMS es que no hay raíz de confianza en
9
+ * hardware, y `machine-id` y el salt viven **en el mismo disco que los datos**, así que
10
+ * una instantánea del disco se lo lleva todo. Cerrarlo pide que la clave salga de otro
11
+ * sitio — una llave FIDO2, el TPM, un KMS — y cada uno de esos es un módulo pequeño
12
+ * SIEMPRE QUE exista esta costura. Sin ella, cada opción es una cirugía.
13
+ * Plan completo: `docs/llaves-de-hardware.md`.
14
+ *
15
+ * El contrato es deliberadamente mínimo: un proveedor devuelve 32 bytes para este
16
+ * directorio. Nada más. Quien los guarda, los envuelve o los pide por red es asunto suyo.
17
+ *
18
+ * Proveedores:
19
+ * - `machine` (por defecto) lo de siempre: scrypt(material de la máquina + salt).
20
+ * - `command` envuelve una DEK aleatoria llamando a un programa de
21
+ * fuera. Con eso vale CUALQUIER KMS (AWS, OpenBao, gcloud,
22
+ * un script propio) sin meter un SDK aquí dentro.
23
+ *
24
+ * Por qué `command` y no un cliente de KMS de verdad, que es la pregunta obvia:
25
+ * 1. **Todo esto es síncrono.** `atRestFor()` devuelve `{ encrypt, decrypt }` que usan
26
+ * cinco módulos sin `await`. Un SDK sería asíncrono y habría que tocarlos todos;
27
+ * `execFileSync` entra sin mover una línea de los llamantes.
28
+ * 2. **Cero dependencias nuevas** y ningún módulo nativo: el binario único sigue siendo
29
+ * único.
30
+ * 3. Sirve para el KMS que ya tenga el cliente, que es justo lo que pide una empresa.
31
+ *
32
+ * REGLA QUE NO SE TOCA: si el proveedor configurado falla, esto **revienta**. No se cae
33
+ * al `machine` de reserva. Un repliegue silencioso convertiría «tumbo la red del vault»
34
+ * en «el vault se cifra con la clave débil», que es un agujero, no una comodidad.
35
+ */
36
+ import fs from 'node:fs'
37
+ import path from 'node:path'
38
+ import crypto from 'node:crypto'
39
+ import { execFileSync } from 'node:child_process'
40
+
41
+ /** Config del proveedor. EN CLARO a propósito: hay que leerla para poder descifrar. */
42
+ export const CONFIG_FILE = 'atrest.json'
43
+ /** La DEK envuelta por el proveedor externo. Sin él, estos bytes no valen nada. */
44
+ export const WRAPPED_FILE = 'atrest.kek'
45
+ /** Huella de la máquina que escribió estos datos. Ver `assertSameMachine`. */
46
+ export const MACHINE_FILE = 'atrest.machine'
47
+
48
+ /**
49
+ * Los errores cruzan procesos (el daemon los enseña, la CLI los distingue), así que
50
+ * llevan `code` y se comprueban por ahí. Traducir la frase no debe romper a nadie.
51
+ */
52
+ export class KekError extends Error {
53
+ constructor (code, message) { super(message); this.name = 'KekError'; this.code = code }
54
+ }
55
+
56
+ /** Caché por proceso: sin ella, cinco `atRestFor()` = cinco viajes al KMS en cada arranque. */
57
+ const cache = new Map()
58
+ export const clearCache = () => cache.clear()
59
+
60
+ /**
61
+ * Config efectiva del directorio. Sin archivo, `machine` — que es lo que hacía siempre,
62
+ * así que una instalación existente no nota nada.
63
+ */
64
+ export function readConfig (dir) {
65
+ let raw
66
+ try { raw = fs.readFileSync(path.join(dir, CONFIG_FILE), 'utf8') } catch (_) { return { provider: 'machine' } }
67
+ let cfg
68
+ try { cfg = JSON.parse(raw) } catch (e) {
69
+ throw new KekError('kek-config', `${CONFIG_FILE} is not valid JSON: ${e.message}`)
70
+ }
71
+ const provider = cfg?.provider || 'machine'
72
+ if (provider !== 'machine' && provider !== 'command') {
73
+ throw new KekError('kek-config', `unknown kek provider: ${provider}`)
74
+ }
75
+ return { ...cfg, provider }
76
+ }
77
+
78
+ export function writeConfig (dir, cfg) {
79
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
80
+ fs.writeFileSync(path.join(dir, CONFIG_FILE), JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
81
+ }
82
+
83
+ /**
84
+ * Llama al programa de fuera: base64 por la entrada, base64 por la salida. Ese contrato
85
+ * es a propósito el más tonto posible — cada CLI de KMS tiene sus manías con los binarios
86
+ * y sus flags, y se resuelven en un envoltorio de tres líneas en vez de aquí dentro.
87
+ */
88
+ function runStep (step, input, what) {
89
+ if (!step || typeof step.cmd !== 'string' || !step.cmd) {
90
+ throw new KekError('kek-config', `kek provider "command": missing ${what}.cmd`)
91
+ }
92
+ let out
93
+ try {
94
+ out = execFileSync(step.cmd, Array.isArray(step.args) ? step.args : [], {
95
+ input: input.toString('base64'),
96
+ encoding: 'utf8',
97
+ timeout: Number(step.timeoutMs) || 15000,
98
+ maxBuffer: 1024 * 1024,
99
+ stdio: ['pipe', 'pipe', 'pipe']
100
+ })
101
+ } catch (e) {
102
+ // `stderr` es lo único que dice POR QUÉ (credenciales caducadas, sin permiso, sin red).
103
+ const detail = (e.stderr ? String(e.stderr).trim().split('\n').slice(-3).join(' / ') : '') || e.message
104
+ throw new KekError('kek-unavailable', `kek ${what} failed (${step.cmd}): ${detail}`)
105
+ }
106
+ const buf = Buffer.from(String(out).trim(), 'base64')
107
+ if (!buf.length) throw new KekError('kek-unavailable', `kek ${what} returned nothing (${step.cmd})`)
108
+ return buf
109
+ }
110
+
111
+ /** ¿Hay ya datos cifrados aquí? Si los hay, estrenar una DEK nueva los dejaría ilegibles. */
112
+ function hasEncryptedData (dir, magic) {
113
+ let names = []
114
+ try { names = fs.readdirSync(dir) } catch (_) { return false }
115
+ for (const n of names) {
116
+ if (n === CONFIG_FILE || n === WRAPPED_FILE) continue
117
+ try {
118
+ const fd = fs.openSync(path.join(dir, n), 'r')
119
+ const head = Buffer.alloc(magic.length + 1)
120
+ const read = fs.readSync(fd, head, 0, head.length, 0)
121
+ fs.closeSync(fd)
122
+ if (read === head.length && head.toString('utf8') === magic + '.') return true
123
+ } catch (_) { /* directorios y archivos ilegibles: no cuentan */ }
124
+ }
125
+ return false
126
+ }
127
+
128
+ /**
129
+ * La DEK del proveedor `command`. Si ya está envuelta en el disco, se desenvuelve; si no,
130
+ * se estrena una — **pero solo si no hay nada cifrado todavía**.
131
+ *
132
+ * Ese portazo es el que evita el desastre silencioso: cambiar `atrest.json` a mano en un
133
+ * perfil que ya tiene datos generaría una DEK nueva y **dejaría todo lo anterior
134
+ * ilegible**, sin un solo aviso. Cambiar de proveedor es `atrest rekey`, no editar un JSON.
135
+ */
136
+ function commandKey (dir, cfg, magic) {
137
+ const wrappedPath = path.join(dir, WRAPPED_FILE)
138
+ let wrapped = null
139
+ try { wrapped = Buffer.from(fs.readFileSync(wrappedPath, 'utf8').trim(), 'base64') } catch (_) {}
140
+
141
+ if (wrapped && wrapped.length) {
142
+ const dek = runStep(cfg.unwrap, wrapped, 'unwrap')
143
+ if (dek.length !== 32) throw new KekError('kek-unavailable', `kek unwrap returned ${dek.length} bytes, expected 32`)
144
+ return dek
145
+ }
146
+
147
+ if (hasEncryptedData(dir, magic)) {
148
+ throw new KekError('kek-needs-rekey',
149
+ `${CONFIG_FILE} says provider "command" but ${WRAPPED_FILE} is missing and this profile already holds encrypted data. ` +
150
+ 'Switching providers requires re-encrypting: run `dotrino-vault atrest rekey`.')
151
+ }
152
+
153
+ // Estreno: envolver, COMPROBAR que se vuelve a abrir, y solo entonces escribir.
154
+ const dek = crypto.randomBytes(32)
155
+ const blob = runStep(cfg.wrap, dek, 'wrap')
156
+ const back = runStep(cfg.unwrap, blob, 'unwrap')
157
+ if (back.length !== dek.length || !crypto.timingSafeEqual(dek, back)) {
158
+ throw new KekError('kek-verify', 'kek wrap/unwrap round-trip mismatch: refusing to write a key that will not open')
159
+ }
160
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
161
+ const tmp = wrappedPath + '.tmp'
162
+ fs.writeFileSync(tmp, blob.toString('base64') + '\n', { mode: 0o600 })
163
+ fs.renameSync(tmp, wrappedPath)
164
+ return dek
165
+ }
166
+
167
+ /**
168
+ * ¿LOS ESCRIBIÓ ESTA MÁQUINA? Con el proveedor `machine` la clave sale del material de la
169
+ * máquina, así que en otra los datos no abren. Eso es lo que se quiere — copiar el archivo
170
+ * no basta— pero **cuando pasa por accidente, hay que decirlo**.
171
+ *
172
+ * El caso que lo hizo urgente: **un contenedor**. En una imagen Alpine no hay
173
+ * `/etc/machine-id`, así que el material se cae al `hostname`, que en Docker es el ID DEL
174
+ * CONTENEDOR y cambia en cada `docker run`. Con los datos en un volumen —o en un EBS— el
175
+ * ciclo normal (`docker rm` y volver a levantar, actualizar la imagen, mover el disco a
176
+ * otra instancia) dejaba la cuenta **ilegible para siempre**, y el único síntoma era un
177
+ * «unable to authenticate data» de la librería de cripto. Comprobado, no supuesto.
178
+ *
179
+ * Se guarda una HUELLA (no el material) de quién escribió aquí. Si no coincide y ya hay
180
+ * datos cifrados, se para y se explica, en vez de dejar al usuario delante de un error de
181
+ * AES preguntándose qué hizo mal.
182
+ */
183
+ export function assertSameMachine (dir, material, hasData) {
184
+ const f = path.join(dir, MACHINE_FILE)
185
+ const huella = crypto.createHash('sha256').update('dotrino-atrest-machine\u0000' + material).digest('hex').slice(0, 32)
186
+ let previa = null
187
+ try { previa = fs.readFileSync(f, 'utf8').trim() } catch (_) {}
188
+
189
+ if (previa && previa !== huella && hasData) {
190
+ throw new KekError('kek-machine-changed',
191
+ 'this data was written by a DIFFERENT machine and the key is derived from the machine itself, so it cannot be opened here. ' +
192
+ 'In a container this is usually the container id changing on every run: the fix is to stop deriving the key from the machine — ' +
193
+ 'give the profile a KMS provider (see docs/llaves-de-hardware.md) and re-create it, or restore this volume on the original host. ' +
194
+ 'Nothing was modified.')
195
+ }
196
+ if (!previa) {
197
+ try {
198
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
199
+ fs.writeFileSync(f, huella + '\n', { mode: 0o600 })
200
+ } catch (_) { /* si no se puede escribir la huella, no se bloquea el arranque */ }
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Los 32 bytes de este directorio, por el proveedor que diga su config.
206
+ * `machineKeyFn` la inyecta `atrest.js` para no importarnos en círculo.
207
+ */
208
+ export function resolveKey (dir, { password = '', machineKeyFn, materialFn = null, magic, config = null } = {}) {
209
+ const cfg = config || readConfig(dir)
210
+ if (cfg.provider === 'machine') {
211
+ // La comprobación va ANTES de derivar: si la máquina cambió, el error tiene que
212
+ // explicar por qué, no salir de las tripas de AES tres llamadas más abajo.
213
+ if (typeof materialFn === 'function') {
214
+ try { assertSameMachine(dir, materialFn(), hasEncryptedData(dir, magic)) } catch (e) {
215
+ if (e instanceof KekError) throw e
216
+ }
217
+ }
218
+ return machineKeyFn(dir, password)
219
+ }
220
+
221
+ const ck = 'command ' + path.resolve(dir)
222
+ const hit = cache.get(ck)
223
+ if (hit) return hit
224
+ const dek = commandKey(dir, cfg, magic)
225
+ cache.set(ck, dek)
226
+ return dek
227
+ }
228
+
229
+ /**
230
+ * Estrena una DEK y la envuelve, SIN escribir nada. La usa `rekey`, que necesita la
231
+ * clave nueva en la mano mientras todavía está descifrando con la vieja — y que no puede
232
+ * permitirse pisar el `atrest.kek` de la vieja hasta el final.
233
+ */
234
+ export function mintKey (cfg) {
235
+ if (cfg.provider !== 'command') throw new KekError('kek-config', 'mintKey only applies to the "command" provider')
236
+ const dek = crypto.randomBytes(32)
237
+ const blob = runStep(cfg.wrap, dek, 'wrap')
238
+ const back = runStep(cfg.unwrap, blob, 'unwrap')
239
+ if (back.length !== dek.length || !crypto.timingSafeEqual(dek, back)) {
240
+ throw new KekError('kek-verify', 'kek wrap/unwrap round-trip mismatch: refusing to use a key that will not open')
241
+ }
242
+ return { dek, blob }
243
+ }
244
+
245
+ /**
246
+ * EL PROVEEDOR QUE PIDE EL ENTORNO. Es lo que hace que un contenedor se pueda levantar con
247
+ * KMS sin entrar a escribir un JSON dentro: se pasan variables y ya.
248
+ *
249
+ * · `DOTRINO_KEK_CMD` un programa cualquiera que cumpla el contrato (base64 ⇄ base64)
250
+ * · `DOTRINO_KMS_KEY_ID` atajo para AWS: usa el `kms-aws.mjs` que viaja en la imagen
251
+ *
252
+ * Devuelve `null` si no hay nada configurado, que es el caso normal de un PC: ahí el
253
+ * proveedor sigue siendo `machine` y nada cambia.
254
+ *
255
+ * Solo se consulta al CREAR un perfil. Un perfil que ya existe manda con su `atrest.json`,
256
+ * porque cambiarle el proveedor por una variable de entorno lo dejaría ilegible — y eso se
257
+ * hace con `atrest rekey`, mirando, no con un `docker run` distinto por accidente.
258
+ */
259
+ export function configFromEnv (env = process.env) {
260
+ if (env.DOTRINO_KEK_CMD) {
261
+ return {
262
+ provider: 'command',
263
+ label: env.DOTRINO_KEK_LABEL || env.DOTRINO_KEK_CMD,
264
+ wrap: { cmd: env.DOTRINO_KEK_CMD, args: ['wrap'] },
265
+ unwrap: { cmd: env.DOTRINO_KEK_CMD, args: ['unwrap'] }
266
+ }
267
+ }
268
+ if (env.DOTRINO_KMS_KEY_ID) {
269
+ const script = env.DOTRINO_KMS_SCRIPT || '/app/packaging/kms-aws.mjs'
270
+ return {
271
+ provider: 'command',
272
+ label: 'AWS KMS ' + env.DOTRINO_KMS_KEY_ID,
273
+ wrap: { cmd: process.execPath, args: [script, 'wrap'] },
274
+ unwrap: { cmd: process.execPath, args: [script, 'unwrap'] }
275
+ }
276
+ }
277
+ return null
278
+ }
279
+
280
+ /** Prueba el ida y vuelta del proveedor SIN tocar los datos. Para `atrest test`. */
281
+ export function probe (dir, cfg = null) {
282
+ const c = cfg || readConfig(dir)
283
+ if (c.provider === 'machine') return { provider: 'machine', ok: true }
284
+ const sample = crypto.randomBytes(32)
285
+ const blob = runStep(c.wrap, sample, 'wrap')
286
+ const back = runStep(c.unwrap, blob, 'unwrap')
287
+ if (back.length !== sample.length || !crypto.timingSafeEqual(sample, back)) {
288
+ throw new KekError('kek-verify', 'kek wrap/unwrap round-trip mismatch')
289
+ }
290
+ return { provider: 'command', ok: true, label: c.label || c.wrap?.cmd || null, wrappedBytes: blob.length }
291
+ }
292
+
293
+ export default { readConfig, writeConfig, resolveKey, mintKey, probe, clearCache, assertSameMachine, configFromEnv, KekError, CONFIG_FILE, WRAPPED_FILE, MACHINE_FILE }
package/src/lock.js ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * lock.js — UN SOLO PROCESO POR DIRECTORIO, también entre máquinas.
3
+ *
4
+ * Es la otra mitad de la invariante que pidió el dueño (la primera está en
5
+ * `src/keyowner.js`): cada proceso con su directorio, y **uno solo** dentro de cada uno.
6
+ *
7
+ * El candado que había miraba un pid: si el proceso seguía vivo, no arrancábamos. Sirve
8
+ * cuando las dos bóvedas son procesos del mismo sistema, y **no sirve para nada** en
9
+ * cuanto dejan de serlo:
10
+ *
11
+ * · **Contenedores.** Cada uno tiene su espacio de pids. El segundo pregunta por el pid
12
+ * del primero en el suyo, no lo encuentra y concluye que está muerto. Comprobado con
13
+ * Docker: dos contenedores sobre el mismo volumen arrancaban los dos, sanos, con la
14
+ * misma cuenta.
15
+ * · **Un disco de red (EFS, NFS).** Peor todavía: son dos máquinas distintas. El pid del
16
+ * otro host o no existe aquí, o existe y es un proceso que no tiene nada que ver.
17
+ *
18
+ * Y dos daemons sobre el mismo directorio no son dos bóvedas: son la misma corriendo dos
19
+ * veces. Cargan la misma maestra, se identifican igual en el proxio y sellan actas las dos
20
+ * como el mismo sellador — el caso que `acta-de-perfil.md` §2.4.1 llama «el master
21
+ * mintiendo» y del que dice que no hay defensa.
22
+ *
23
+ * LA IDEA: un archivo que se crea con `O_EXCL` (crear-o-fallar, atómico también en NFS)
24
+ * más un LATIDO. Quien lo tiene lo va tocando; quien llega lo mira:
25
+ *
26
+ * · latido fresco → hay alguien vivo, no arrancamos.
27
+ * · latido viejo → se cortó la luz, se le quita el candado y se sigue.
28
+ *
29
+ * El plazo no es un reloj compartido —eso no existe entre máquinas— sino la EDAD del
30
+ * archivo medida por quien mira. Un reloj desajustado alarga o acorta la espera, pero no
31
+ * rompe la exclusión: la exclusión la da `O_EXCL`, el latido solo decide cuándo se
32
+ * considera abandonado.
33
+ */
34
+ import fs from 'node:fs'
35
+ import path from 'node:path'
36
+ import os from 'node:os'
37
+
38
+ export const LOCK_FILE = 'vault.lock'
39
+ /** Cada cuánto se toca el archivo. */
40
+ export const HEARTBEAT_MS = 10_000
41
+ /** Sin latido durante esto, se da por abandonado. Holgado a propósito: una máquina con
42
+ * carga o un disco de red lento no pueden costar que otro le quite el candado a uno vivo. */
43
+ export const STALE_MS = 60_000
44
+
45
+ const quienSoy = () => ({ pid: process.pid, host: os.hostname(), desde: Date.now() })
46
+
47
+ /**
48
+ * Toma el candado del directorio, o explica quién lo tiene y se rinde.
49
+ * @returns {{release:()=>void}}
50
+ */
51
+ export function takeLock (dir, { staleMs = STALE_MS, heartbeatMs = HEARTBEAT_MS } = {}) {
52
+ const f = path.join(dir, LOCK_FILE)
53
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
54
+
55
+ const intentar = () => {
56
+ try {
57
+ // 'wx' = O_CREAT|O_EXCL: o lo creo yo, o alguien lo tiene. Esa atomicidad es lo
58
+ // único que aquí hace de exclusión.
59
+ const fd = fs.openSync(f, 'wx', 0o600)
60
+ fs.writeSync(fd, JSON.stringify(quienSoy()))
61
+ fs.closeSync(fd)
62
+ return true
63
+ } catch (e) {
64
+ if (e.code !== 'EEXIST') throw e
65
+ return false
66
+ }
67
+ }
68
+
69
+ if (!intentar()) {
70
+ const edad = (() => {
71
+ try { return Date.now() - fs.statSync(f).mtimeMs } catch (_) { return Infinity }
72
+ })()
73
+ let dueño = null
74
+ try { dueño = JSON.parse(fs.readFileSync(f, 'utf8')) } catch (_) {}
75
+
76
+ if (edad <= staleMs) {
77
+ const e = new Error(
78
+ `another vault is running on this data (host ${dueño?.host || '?'}, process ${dueño?.pid ?? '?'}, ` +
79
+ `last heartbeat ${Math.round(edad / 1000)}s ago)`
80
+ )
81
+ e.code = 'vault-locked'
82
+ e.owner = dueño
83
+ throw e
84
+ }
85
+ // Abandonado: se quita y se vuelve a intentar UNA vez. Si en ese hueco entró otro,
86
+ // pierde este — que es lo correcto: dos que ven el candado viejo a la vez, solo uno
87
+ // gana el `O_EXCL`.
88
+ try { fs.unlinkSync(f) } catch (_) {}
89
+ if (!intentar()) {
90
+ const e = new Error('another vault took this data first')
91
+ e.code = 'vault-locked'
92
+ throw e
93
+ }
94
+ }
95
+
96
+ const latido = setInterval(() => {
97
+ // `utimes` y no reescribir: tocar la fecha basta y no puede dejar el archivo a medias.
98
+ try { fs.utimesSync(f, new Date(), new Date()) } catch (_) {}
99
+ }, heartbeatMs)
100
+ latido.unref?.()
101
+
102
+ let suelto = false
103
+ const release = () => {
104
+ if (suelto) return
105
+ suelto = true
106
+ clearInterval(latido)
107
+ // Solo se borra SI SIGUE SIENDO MÍO. Si otro me lo quitó por viejo, borrarlo aquí le
108
+ // quitaría a él un candado que sí es suyo.
109
+ try {
110
+ const d = JSON.parse(fs.readFileSync(f, 'utf8'))
111
+ if (d?.pid === process.pid && d?.host === os.hostname()) fs.unlinkSync(f)
112
+ } catch (_) {}
113
+ }
114
+ return { release }
115
+ }
116
+
117
+ export default { takeLock, LOCK_FILE, STALE_MS, HEARTBEAT_MS }
package/src/protocol.js CHANGED
@@ -86,6 +86,10 @@ export const MSG = Object.freeze({
86
86
  // contrapartida de administrar a distancia: sin esto, un enrolamiento remoto sería
87
87
  // invisible para el resto de tus dispositivos.
88
88
  ADMIN_EVENT: 'vault.admin.event', // vault → todos: { body:{ev,deviceId,by,ts}, signature }
89
+ // RÉPLICAS: la principal empuja lo que hay que servir (el acta y los sobres, que van
90
+ // firmados de antes y no se pueden falsificar) y la réplica acusa hasta qué `seq` tiene.
91
+ REPLICA_PUSH: 'vault.replica.push', // master → réplica: { body:{seq,acta,secrets,ts}, signature }
92
+ REPLICA_ACK: 'vault.replica.ack', // réplica → master: { body:{seq,ts}, signature }
89
93
  ERROR: 'vault.error' // vault → dispositivo: { error }
90
94
  })
91
95
 
@@ -98,7 +102,15 @@ export const SCOPE = Object.freeze({
98
102
  // NO incluye cambiar permisos, traspasar el mando ni conceder `admin`: eso es el rol
99
103
  // de master y sigue siendo local. No se empareja — se concede desde el PC.
100
104
  ADMIN: 'vault:admin',
101
- APPROVE: 'vault:approve' // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
105
+ APPROVE: 'vault:approve', // aprobar pedidos de secretos (cajones con `approval`); se concede a mano, como admin
106
+ // El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
107
+ // Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
108
+ // lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
109
+ PASSWORDS: 'vault:passwords',
110
+ // SELLAR EL ACTA: la OTRA bóveda de esta cuenta. Con esto puede admitir aparatos y
111
+ // cambiar permisos si la principal se pierde — que es todo el punto del multivault. Como
112
+ // `admin`, no se empareja: se concede a mano (`caps <ID> +sella`).
113
+ SEALER: 'vault:sealer'
102
114
  })
103
115
 
104
116
  /**
package/src/service.js CHANGED
@@ -458,8 +458,7 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
458
458
  // verdad llega cuando el aparato que aprueba firme — por esta misma conexión, que
459
459
  // sigue identificada. Se espera lo que dura el pedido; denegado o vencido es error.
460
460
  if (res.body?.op === 'secrets.pending' && res.body.ns === ns) {
461
- const okPending = await verifyDeviceSig({ publickey: masterPubkey, data: res.body, signature: res.signature })
462
- if (!okPending) throw new Error('invalid master signature on the pending reply')
461
+ await verifyResponder({ body: res.body, seal: res.seal, acta: res.acta, masterPubkey })
463
462
  try { onPending?.({ id: res.body.id, ns, exp: res.body.exp }) } catch (_) {}
464
463
  const until = typeof res.body.exp === 'number' ? Math.max(5000, res.body.exp - Date.now() + 5000) : approvalTimeoutMs
465
464
  res = await waitForMsg(client, (p) => (p.type === MSG.SECRETS_RESULT && p.body?.op === 'secrets.result') || p.type === MSG.ERROR, Math.min(until, approvalTimeoutMs))
@@ -467,14 +466,20 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
467
466
  if (res.type === MSG.ERROR) throw new Error(res.error)
468
467
  }
469
468
 
470
- // Autenticidad: el cuerpo viene firmado por la MAESTRA pineada.
469
+ // AUTENTICIDAD: la firma NO es de la maestra. El acta dice qué llave vale para esto
470
+ // (`sealPub`, buscada por `seq`), que es la misma regla que ya gobierna los sobres.
471
+ // La maestra solo sella el acta y reenvuelve; servir no es cosa suya, y por eso puede
472
+ // quedarse cerrada.
471
473
  const body = res.body
472
474
  if (!body || body.op !== 'secrets.result' || body.ns !== ns) throw new Error('malformed secrets reply')
473
475
  if (typeof body.ts !== 'number' || Math.abs(Date.now() - body.ts) > FRESH_WINDOW_MS) throw new Error('stale secrets reply')
474
- const ok = await verifyDeviceSig({ publickey: masterPubkey, data: body, signature: res.signature })
475
- if (!ok) throw new Error('invalid master signature on the secrets reply')
476
476
 
477
+ // Se abre ANTES de comprobar la firma, y no es un descuido: el sobre de fuera va
478
+ // sellado a la efímera que acabamos de estrenar, así que abrirlo no prueba nada ni
479
+ // confía en nadie — solo saca el acta con la que SÍ se comprueba. Nada de lo que hay
480
+ // dentro se usa hasta después de verificar.
477
481
  const payload = await openSealed({ privateKey: eph.privateKey, enc: body.enc })
482
+ await verifyResponder({ body, seal: res.seal, acta: payload?.acta, masterPubkey })
478
483
 
479
484
  // DOS CAPAS DE SOBRE, y hacen cosas distintas:
480
485
  // · la de fuera (`ek` efímera, recién abierta) tapa el TRAMO — el proxio no ve
@@ -499,6 +504,40 @@ export async function fetchSecrets ({ dir, ns, proxyUrl, masterPubkey, device, c
499
504
  * omitido: silenciarlo convertiría una rotación mal sellada en «el servicio sigue
500
505
  * con el valor viejo y nadie se entera», que es el peor modo de fallo de todo esto.
501
506
  */
507
+ /**
508
+ * ¿QUIÉN CONTESTÓ, Y PUEDE? La misma regla que gobierna los sobres, aplicada al
509
+ * transporte: **el acta dice qué llave firma qué**.
510
+ *
511
+ * La maestra no entra aquí. Sus dos trabajos son sellar el acta y reenvolver los sobres
512
+ * de todos los aparatos; servir una petición no es ninguno de los dos, así que exigir su
513
+ * firma la obligaba a estar despierta todo el tiempo — y de paso hacía imposible que
514
+ * sirviera nadie más.
515
+ *
516
+ * Lo que se comprueba, en orden:
517
+ * 1. el acta que llega va firmada por la maestra que este agente lleva pineada desde
518
+ * que se enroló (o por la que aquella nombró al traspasar);
519
+ * 2. la firma del cuerpo cuadra con la llave de sellado que ESA acta nombra para el
520
+ * `seq` con el que se firmó.
521
+ *
522
+ * Sin el paso 1 el paso 2 no vale nada: un acta cualquiera nombraría la llave que
523
+ * quisiera.
524
+ */
525
+ export async function verifyResponder ({ body, seal, acta, masterPubkey }) {
526
+ if (!seal?.sig || typeof seal.seq !== 'number') {
527
+ throw new Error('the reply carries no sealing signature: this vault is too old to serve (update it)')
528
+ }
529
+ if (!acta) throw new Error('the reply carries no record: cannot tell which key was allowed to sign it')
530
+ if (acta.sealedBy !== masterPubkey) {
531
+ throw new Error('the record was not sealed by the master this agent knows: refusing the reply')
532
+ }
533
+ if (!(await verifyActa({ acta })).ok) throw new Error('the record does not verify')
534
+ const pub = sealKeyAt(acta, seal.seq)
535
+ if (!pub) throw new Error(`the record names no sealing key for #${seal.seq}`)
536
+ if (!(await verifyDeviceSig({ publickey: pub, data: body, signature: seal.sig }))) {
537
+ throw new Error('the reply signature does not check out against the key the record names')
538
+ }
539
+ }
540
+
502
541
  /**
503
542
  * ¿SALIÓ ESTE SOBRE DE MI BÓVEDA? (§8.8 de `dotrino-vault/docs/secretos-sellados.md`)
504
543
  *