@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/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 PBKDF2 —mismo modelo que el
15
- * candado del navegador (`@dotrino/identity` vault/core.js)— NO cifra nada en reposo.
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 // mismo coste que el candado del navegador
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
- /** PBKDF2-SHA256 → verificador base64 (byte-idéntico al del navegador). */
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
- let data = readJson(file, null)
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(`hay ${hits.length} perfiles llamados "${ref}"; usa su id (dotrino-vault profile ls)`)
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
- const tries = p.tries || { n: 0, at: 0 }
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
- if (left > 0) throw new Error(`demasiados intentos: espera ${Math.ceil(left / 1000)} s`)
198
- const proof = await derivePwd(password, p.pwd.salt, p.pwd.iter)
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 < 4) throw new Error('password must be at least 4 characters')
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: 1, salt, iter: PWD_ITER, verifier: await derivePwd(password, salt, PWD_ITER) }
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 }