@dotrino/vaultd 0.26.2 → 0.46.2
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 +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
package/src/profiles.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* <root>/transport.json keypair del proxy-client (a nivel PROCESO, no por perfil)
|
|
12
12
|
* <root>/p/<id>/… los datos de cada perfil (incluida su maestra)
|
|
13
13
|
*
|
|
14
|
-
* CONTRASEÑA (opcional, por perfil): es un VERIFICADOR
|
|
15
|
-
*
|
|
14
|
+
* CONTRASEÑA (opcional, por perfil): es un VERIFICADOR scrypt (v2; v1 era PBKDF2 y se
|
|
15
|
+
* asciende al desbloquear) que NO cifra nada en reposo.
|
|
16
16
|
* Y solo bloquea EDITAR el perfil: el daemon sigue firmando y sirviendo a los
|
|
17
17
|
* dispositivos ya enrolados aunque el perfil esté bloqueado, para que un reinicio
|
|
18
18
|
* del PC no deje las apps muertas hasta que alguien teclee la contraseña.
|
|
@@ -21,12 +21,36 @@
|
|
|
21
21
|
* cifrado en reposo, ver `paths.js`).
|
|
22
22
|
*/
|
|
23
23
|
import fs from 'node:fs'
|
|
24
|
+
import crypto2 from 'node:crypto'
|
|
24
25
|
import path from 'node:path'
|
|
25
26
|
import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
|
|
27
|
+
import { atRestFor, migrateFile, machineKey } from './atrest.js'
|
|
26
28
|
|
|
27
29
|
const REGISTRY = 'profiles.json'
|
|
28
|
-
const PWD_ITER = 300000 //
|
|
30
|
+
const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
|
|
29
31
|
const MAX_NAME = 40
|
|
32
|
+
/**
|
|
33
|
+
* Lo MÍNIMO que se acepta al poner una contraseña.
|
|
34
|
+
*
|
|
35
|
+
* Eran 4 caracteres, y eso se quedó corto el día que los secretos pasaron a sellarse:
|
|
36
|
+
* desde entonces la contraseña no bloquea una consola, **es la llave** que abre la
|
|
37
|
+
* copia maestra, y todo el cifrado vale lo que valga ella. Cuatro dígitos son 10.000
|
|
38
|
+
* combinaciones — se prueban enteras en un rato aunque la derivación sea cara.
|
|
39
|
+
*
|
|
40
|
+
* No se piden mayúsculas ni símbolos a propósito: hacen la frase difícil de recordar
|
|
41
|
+
* y fácil de adivinar. Lo que da fuerza es la LONGITUD y que no la elija un humano;
|
|
42
|
+
* por eso lo que se pide en pantalla son varias palabras al azar.
|
|
43
|
+
*/
|
|
44
|
+
const PWD_MIN = 12
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Cuánto tarda el freno en OLVIDAR los fallos. Sin esto la cuenta solo subía —solo la
|
|
48
|
+
* borraba un acierto—, así que teclear mal la contraseña cinco veces un martes te dejaba
|
|
49
|
+
* el vault con esperas de minutos el miércoles, y cada intento nuevo (aunque fuera el
|
|
50
|
+
* bueno) la alargaba sin llegar a comprobarse: la bóveda quedaba cerrada para su dueño.
|
|
51
|
+
* El freno tiene que estorbar a una RÁFAGA, no a quien vuelve al día siguiente.
|
|
52
|
+
*/
|
|
53
|
+
const TRIES_FORGET_MS = 15 * 60 * 1000
|
|
30
54
|
|
|
31
55
|
/**
|
|
32
56
|
* Archivos de un perfil que en la versión mono-perfil vivían sueltos en la raíz.
|
|
@@ -39,7 +63,24 @@ const LEGACY_FILES = ['identity.json', 'peers.json', 'vault.json', 'threads.json
|
|
|
39
63
|
|
|
40
64
|
const b64 = (buf) => Buffer.from(new Uint8Array(buf)).toString('base64')
|
|
41
65
|
|
|
42
|
-
/**
|
|
66
|
+
/**
|
|
67
|
+
* El verificador del candado.
|
|
68
|
+
*
|
|
69
|
+
* v2 es **scrypt, con el mismo coste que `adminKey`**, y no por gusto: este valor vive
|
|
70
|
+
* EN CLARO en `profiles.json`, así que quien tenga el disco lo ataca fuera de línea. Si
|
|
71
|
+
* es más barato que la llave de verdad, se convierte en el camino corto para llegar a
|
|
72
|
+
* ella — que es exactamente lo que pasaba con PBKDF2 al lado de un scrypt.
|
|
73
|
+
*
|
|
74
|
+
* v1 (PBKDF2) se sigue aceptando porque hay perfiles con él en el disco, y se ASCIENDE
|
|
75
|
+
* a v2 en el primer desbloqueo correcto: es el único momento en que se tiene la
|
|
76
|
+
* contraseña en la mano.
|
|
77
|
+
*/
|
|
78
|
+
function deriveScryptPwd (password, saltB64) {
|
|
79
|
+
const salt = Buffer.from(saltB64, 'base64')
|
|
80
|
+
return b64(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** PBKDF2-SHA256 → verificador base64 (v1, heredado). */
|
|
43
84
|
async function derivePwd (password, saltB64, iter) {
|
|
44
85
|
const salt = Buffer.from(saltB64, 'base64')
|
|
45
86
|
const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(String(password)), 'PBKDF2', false, ['deriveBits'])
|
|
@@ -52,9 +93,18 @@ const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
|
|
|
52
93
|
|
|
53
94
|
export function openProfiles (root = dataDir()) {
|
|
54
95
|
const file = path.join(root, REGISTRY)
|
|
55
|
-
|
|
96
|
+
// CIFRADO EN REPOSO, como todo lo demás. Era el único archivo del vault sin códec, y
|
|
97
|
+
// lleva dentro el verificador del candado. No protege de quien tenga el disco entero
|
|
98
|
+
// —el material de la llave vive en ese mismo disco, y eso está dicho en voz alta en
|
|
99
|
+
// `docs/secretos-sellados.md`— pero sí de que el registro viaje en claro en un
|
|
100
|
+
// respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
|
|
101
|
+
// para el resto. La migración verifica antes de reemplazar y es de una sola vez.
|
|
102
|
+
ensureDir(root)
|
|
103
|
+
try { migrateFile(file, machineKey(root)) } catch (_) {}
|
|
104
|
+
const atRest = atRestFor(root)
|
|
105
|
+
let data = readJson(file, null, atRest)
|
|
56
106
|
if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
|
|
57
|
-
const save = () => writeJson(file, data)
|
|
107
|
+
const save = () => writeJson(file, data, atRest)
|
|
58
108
|
|
|
59
109
|
// Perfiles DESBLOQUEADOS en esta ejecución del daemon (en memoria: un reinicio
|
|
60
110
|
// vuelve a bloquear, igual que cerrar la pestaña en el navegador).
|
|
@@ -97,7 +147,7 @@ export function openProfiles (root = dataDir()) {
|
|
|
97
147
|
const needle = String(ref).trim().toLowerCase()
|
|
98
148
|
const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
|
|
99
149
|
if (hits.length === 1) return hits[0].id
|
|
100
|
-
if (hits.length > 1) throw new Error(`
|
|
150
|
+
if (hits.length > 1) throw new Error(`there are ${hits.length} profiles named "${ref}"; use its id (dotrino-vault profile ls)`)
|
|
101
151
|
throw new Error('profile does not exist: ' + ref)
|
|
102
152
|
},
|
|
103
153
|
|
|
@@ -186,20 +236,63 @@ export function openProfiles (root = dataDir()) {
|
|
|
186
236
|
if (api.isLocked(id)) throw new Error('profile locked: unlock it with your password (dotrino-vault unlock)')
|
|
187
237
|
},
|
|
188
238
|
|
|
239
|
+
/**
|
|
240
|
+
* La llave con la que se abre la copia MAESTRA de los secretos, derivada de la
|
|
241
|
+
* contraseña del perfil. Va por operación: quien la pide la usa y la suelta.
|
|
242
|
+
*
|
|
243
|
+
* Es scrypt (mismo coste que `machineKey`) y NO reusa el verificador del candado:
|
|
244
|
+
* ese es PBKDF2 y vive en claro en este mismo archivo, así que sería el camino
|
|
245
|
+
* barato para atacarla. Aquí la prueba de que es correcta es el tag AES-GCM del
|
|
246
|
+
* propio sobre — si no cuadra, la contraseña no era.
|
|
247
|
+
*/
|
|
248
|
+
async adminKey (id, password) {
|
|
249
|
+
const p = find(id)
|
|
250
|
+
if (!p) throw new Error('profile not found')
|
|
251
|
+
if (!p.kdf) {
|
|
252
|
+
p.kdf = { v: 1, salt: b64(crypto.getRandomValues(new Uint8Array(32))) }
|
|
253
|
+
save()
|
|
254
|
+
}
|
|
255
|
+
const salt = Buffer.from(p.kdf.salt, 'base64')
|
|
256
|
+
return new Uint8Array(crypto2.scryptSync(String(password || ''), salt, 32, { N: 16384, r: 8, p: 1 }))
|
|
257
|
+
},
|
|
258
|
+
|
|
189
259
|
async unlock (id, password) {
|
|
190
260
|
const p = assertExists(id)
|
|
191
261
|
if (!p.pwd) { unlocked.add(id); return { ok: true, locked: false } }
|
|
192
262
|
// Freno de fuerza bruta (una contraseña corta se adivina probando): tras 5
|
|
193
263
|
// fallos, espera exponencial (2^n s, tope 5 min) persistida en el registro.
|
|
194
|
-
|
|
264
|
+
// Los fallos VIEJOS se olvidan (ver TRIES_FORGET_MS): si desde el último ha pasado
|
|
265
|
+
// el rato, se empieza de cero. Así el freno sigue frenando una ráfaga —los intentos
|
|
266
|
+
// seguidos se cuentan igual— pero no convierte un despiste de ayer en un vault que
|
|
267
|
+
// ya no se abre.
|
|
268
|
+
let tries = p.tries || { n: 0, at: 0 }
|
|
269
|
+
if (tries.at && Date.now() - tries.at > TRIES_FORGET_MS) {
|
|
270
|
+
tries = { n: 0, at: 0 }
|
|
271
|
+
if (p.tries) { delete p.tries; save() }
|
|
272
|
+
}
|
|
195
273
|
const waitMs = tries.n >= 5 ? Math.min(2 ** (tries.n - 4) * 1000, 5 * 60 * 1000) : 0
|
|
196
274
|
const left = tries.at + waitMs - Date.now()
|
|
197
|
-
|
|
198
|
-
|
|
275
|
+
// Con CÓDIGO: la TUI es bilingüe y lo traduce, y la CLI puede decirlo con sus
|
|
276
|
+
// palabras. Sin código, el rechazo llegaba como un texto suelto del daemon y era
|
|
277
|
+
// indistinguible de «se volvió a pedir la contraseña porque sí».
|
|
278
|
+
if (left > 0) {
|
|
279
|
+
throw Object.assign(new Error(`too many tries: wait ${Math.ceil(left / 1000)} s`),
|
|
280
|
+
{ code: 'TOO_MANY_TRIES', waitSec: Math.ceil(left / 1000) })
|
|
281
|
+
}
|
|
282
|
+
const proof = p.pwd.v === 2
|
|
283
|
+
? deriveScryptPwd(password, p.pwd.salt)
|
|
284
|
+
: await derivePwd(password, p.pwd.salt, p.pwd.iter)
|
|
199
285
|
if (proof !== p.pwd.verifier) {
|
|
200
286
|
p.tries = { n: tries.n + 1, at: Date.now() }
|
|
201
287
|
save()
|
|
202
|
-
throw new Error('wrong password')
|
|
288
|
+
throw Object.assign(new Error('wrong password'), { code: 'WRONG_PASSWORD', tries: p.tries.n })
|
|
289
|
+
}
|
|
290
|
+
// ASCENSO v1 → v2. Aquí, y solo aquí, se tiene la contraseña correcta en la mano:
|
|
291
|
+
// es el momento de dejar de guardar el verificador barato. No cambia la
|
|
292
|
+
// contraseña ni toca los secretos — el `adminKey` sale de `p.kdf`, que es otro.
|
|
293
|
+
if (p.pwd.v !== 2) {
|
|
294
|
+
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
295
|
+
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
203
296
|
}
|
|
204
297
|
delete p.tries
|
|
205
298
|
save()
|
|
@@ -213,9 +306,12 @@ export function openProfiles (root = dataDir()) {
|
|
|
213
306
|
async setPassword (id, password) {
|
|
214
307
|
const p = assertExists(id)
|
|
215
308
|
api.assertUnlocked(id)
|
|
216
|
-
if (!password || String(password).length <
|
|
309
|
+
if (!password || String(password).length < PWD_MIN) {
|
|
310
|
+
throw Object.assign(new Error(`password must be at least ${PWD_MIN} characters: use several random words`),
|
|
311
|
+
{ code: 'PASSWORD_TOO_SHORT', min: PWD_MIN })
|
|
312
|
+
}
|
|
217
313
|
const salt = b64(crypto.getRandomValues(new Uint8Array(16)))
|
|
218
|
-
p.pwd = { v:
|
|
314
|
+
p.pwd = { v: 2, salt, verifier: deriveScryptPwd(password, salt) }
|
|
219
315
|
delete p.tries
|
|
220
316
|
save()
|
|
221
317
|
unlocked.add(id)
|
package/src/sealKey.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
|
|
3
|
+
* que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
|
|
4
|
+
*
|
|
5
|
+
* Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
|
|
6
|
+
*
|
|
7
|
+
* · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
|
|
8
|
+
* cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
|
|
9
|
+
* justo lo que hace posible administrar sin contraseña.
|
|
10
|
+
* · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
|
|
11
|
+
* la maestra. Ella no se autoriza a sí misma.
|
|
12
|
+
* · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
|
|
13
|
+
* un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
|
|
14
|
+
* la que mandaba entonces y hay que poder seguir firmando… no, seguir
|
|
15
|
+
* **identificando** cuál era. Verificar se hace con la pública que dice el acta.
|
|
16
|
+
*
|
|
17
|
+
* No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
|
|
18
|
+
* (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
|
|
19
|
+
*/
|
|
20
|
+
import path from 'node:path'
|
|
21
|
+
import { readJson, writeJson } from './paths.js'
|
|
22
|
+
import { atRestFor } from './atrest.js'
|
|
23
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
|
|
27
|
+
* guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
|
|
28
|
+
* porque tirarlas no ahorra nada: son cuatro líneas de JSON.
|
|
29
|
+
*/
|
|
30
|
+
const MAX_KEYS = 8
|
|
31
|
+
|
|
32
|
+
/** Abre (o estrena) el llavero de sellado del perfil. */
|
|
33
|
+
export function openSealKeys (dir) {
|
|
34
|
+
const file = path.join(dir, 'sealkeys.json')
|
|
35
|
+
const atRest = atRestFor(dir)
|
|
36
|
+
const data = readJson(file, null, atRest) || { v: 1, keys: [] }
|
|
37
|
+
if (!Array.isArray(data.keys)) data.keys = []
|
|
38
|
+
|
|
39
|
+
const save = () => writeJson(file, data, atRest)
|
|
40
|
+
const find = (pub) => data.keys.find((k) => k.pub === pub) || null
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
/** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
|
|
44
|
+
current: () => data.keys[data.keys.length - 1]?.pub || null,
|
|
45
|
+
|
|
46
|
+
/** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
|
|
47
|
+
has: (pub) => !!find(pub),
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
|
|
51
|
+
* se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
|
|
52
|
+
*
|
|
53
|
+
* Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
|
|
54
|
+
* por acta sellada.
|
|
55
|
+
*/
|
|
56
|
+
async mint () {
|
|
57
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
58
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
59
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
60
|
+
data.keys.push({ pub, priv, createdAt: Date.now() })
|
|
61
|
+
if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
|
|
62
|
+
save()
|
|
63
|
+
return pub
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
|
|
68
|
+
* nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
|
|
69
|
+
* sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
|
|
70
|
+
*/
|
|
71
|
+
async sign (sealPub, body) {
|
|
72
|
+
const k = sealPub ? find(sealPub) : null
|
|
73
|
+
if (!k) return null
|
|
74
|
+
const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
|
|
75
|
+
return signature
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default { openSealKeys }
|
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 }
|