@dotrino/vaultd 0.52.0 → 0.56.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/README.md CHANGED
@@ -456,18 +456,25 @@ y así un reinicio del PC nunca deja tus apps muertas esperando a que alguien te
456
456
  ```sh
457
457
  dotrino-vault profile password # pone o cambia la contraseña (te la pregunta)
458
458
  dotrino-vault profile password rm # la quita
459
- dotrino-vault unlock # abre la bóveda en esta consola
460
- dotrino-vault lock # vuelve a cerrarla
459
+ dotrino-vault unlock # abre la bóveda en esta consola (5 min sin usarse y se cierra sola)
460
+ dotrino-vault lock # vuelve a cerrarla ya
461
461
  ```
462
462
 
463
463
  Lo único que se sigue viendo con el candado puesto es que **existe** y que está cerrada
464
464
  (`status`, `profile ls`): si no, no habría forma de saber qué abrir.
465
465
 
466
- El perfil se vuelve a bloquear al reiniciar el servicio. La contraseña **no se
467
- guarda**: solo un verificador con sal (PBKDF2), igual que el candado del navegador.
468
- Tiene un mínimo de 4 caracteres y, tras 5 intentos fallidos, cada intento nuevo
469
- espera cada vez más (hasta 5 minutos); la cuenta de fallos se guarda, así que
470
- reiniciar no la borra.
466
+ **Se vuelve a cerrar solo a los 5 minutos sin usarse**, además de al reiniciar el
467
+ servicio o con `dotrino-vault lock`. El plazo se cuenta desde la última cosa que hiciste
468
+ en la consola, no desde que la abriste: mientras trabajas no te echa, y en cuanto te
469
+ levantas de la silla se cierra. Lo que piden tus aparatos por el proxy **no** cuenta como
470
+ uso —el candado no es suyo—, así que siguen funcionando igual mientras la consola se
471
+ cierra. La TUI, además, **olvida la contraseña** en ese momento: si no, la reabriría sola
472
+ a la siguiente tecla.
473
+
474
+ La contraseña **no se guarda**: solo un verificador con sal (scrypt), igual que el
475
+ candado del navegador. Tiene un mínimo de 12 caracteres —varias palabras al azar— y,
476
+ tras 5 intentos fallidos, cada intento nuevo espera cada vez más (hasta 5 minutos); la
477
+ cuenta de fallos se guarda, así que reiniciar no la borra.
471
478
 
472
479
  Con el perfil bloqueado, la CLI **no** te pide la contraseña sobre la marcha: cualquier
473
480
  comando que mire o toque esa bóveda falla con «perfil bloqueado» y hay que correr
@@ -31,7 +31,33 @@ if (process.argv.includes('--tui') && daemonAlive()) {
31
31
  process.exit(0)
32
32
  }
33
33
 
34
- const mgr = await runDaemon()
34
+ /**
35
+ * Un fallo de la CLAVE DEL DISCO no es un error cualquiera: casi siempre significa que el
36
+ * dueño está a punto de creer que perdió su cuenta. Se muestra como un MENSAJE, con lo
37
+ * que hay que hacer, en vez de un volcado de Node del que nadie saca nada.
38
+ *
39
+ * (Se distingue por `code`, no por la frase: los mensajes se pueden traducir, los códigos
40
+ * son el contrato.)
41
+ */
42
+ let mgr
43
+ try {
44
+ mgr = await runDaemon()
45
+ } catch (e) {
46
+ if (e?.code && String(e.code).startsWith('kek-')) {
47
+ console.error('\n No se pudo abrir la clave con la que están cifrados estos datos.\n')
48
+ console.error(' ' + e.message + '\n')
49
+ if (e.code === 'kek-machine-changed') {
50
+ console.error(' Esto pasa casi siempre en un contenedor: la clave por defecto sale de la')
51
+ console.error(' máquina, y en Docker «la máquina» es el id del contenedor, que cambia cada vez.')
52
+ console.error(' Levántalo con una llave que NO dependa de la máquina:\n')
53
+ console.error(' -e AWS_REGION=… -e DOTRINO_KMS_KEY_ID=alias/tu-llave')
54
+ console.error(' (o -e DOTRINO_KEK_CMD=/ruta/a/tu-script para OpenBao u otro)\n')
55
+ }
56
+ console.error(' No se modificó nada.\n')
57
+ process.exit(3)
58
+ }
59
+ throw e
60
+ }
35
61
 
36
62
  // Atajo de dev: --pair imprime el QR directo en stdout (en producción se usa el CLI).
37
63
  // Empareja contra el perfil ACTIVO.
package/lib/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/lib/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 }
@@ -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 }
@@ -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
 
@@ -102,7 +106,11 @@ export const SCOPE = Object.freeze({
102
106
  // El gestor de contraseñas: pedir credenciales de la bóveda, de a una y por dominio.
103
107
  // Nunca lista la bóveda entera. Este SÍ se empareja (`pair --scope contrasenas`): es
104
108
  // lo primero que hace la extensión, y no tendría sentido obligar a un segundo paso.
105
- PASSWORDS: 'vault:passwords'
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'
106
114
  })
107
115
 
108
116
  /**