@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/secretsStore.js
CHANGED
|
@@ -1,64 +1,916 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Store de SECRETOS de servicios (`secrets.json`, 0600, mismo dir 0700 que la
|
|
3
|
-
* maestra
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* maestra), **cifrado en reposo** con la clave ligada a la máquina (`atrest.js`) y,
|
|
4
|
+
* desde v4, con los valores privados **SELLADOS A SU DESTINATARIO**.
|
|
5
|
+
*
|
|
6
|
+
* QUÉ CAMBIA EN v5 Y POR QUÉ: **ESCRIBIR NO PIDE LA FRASE**. Cifrar es una capacidad
|
|
7
|
+
* pública —envolver una llave solo necesita la `encPub` del que va a leer— así que
|
|
8
|
+
* sellar una variable nunca necesitó la contraseña. Lo que la pedía era la COPIA
|
|
9
|
+
* MAESTRA de v4, que guardaba las CEK cifradas con ella; para escribir había que
|
|
10
|
+
* abrirla, y eso obligaba a teclear la frase del perfil en el navegador. Aquí se va la
|
|
11
|
+
* copia maestra y en su lugar entra un **par de recuperación**: su pública se guarda en
|
|
12
|
+
* claro (cualquiera puede envolverle) y su privada, sellada bajo la frase (solo el
|
|
13
|
+
* dueño abre). Diseño completo: `docs/secretos-sellados.md` §8.
|
|
14
|
+
*
|
|
15
|
+
* De ahí salen las tres reglas de este archivo:
|
|
16
|
+
*
|
|
17
|
+
* · **Escribir** = CEK nueva → envolverla a los destinatarios → cifrar → firmar.
|
|
18
|
+
* Ningún secreto de la bóveda interviene.
|
|
19
|
+
* · **UNA GENERACIÓN POR ESCRITURA**, y no es un capricho: la bóveda no puede
|
|
20
|
+
* reutilizar la CEK del cajón porque no puede abrir ninguna envoltura para
|
|
21
|
+
* recuperarla. Las generaciones que ya no referencia ningún valor ni el histórico
|
|
22
|
+
* se recogen solas.
|
|
23
|
+
* · **Leer** (ver un valor, cambiar su visibilidad, rotar re-cifrando) sigue pidiendo
|
|
24
|
+
* la frase, porque leer es exactamente lo que la frase guarda.
|
|
25
|
+
*
|
|
26
|
+
* DOS CAJONES, y esa es la razón de ser de este archivo:
|
|
27
|
+
*
|
|
28
|
+
* · **Por scope** (`ns`) — un NAMESPACE de servicio (`proxy`, `geo`, `bots`…).
|
|
29
|
+
* Lo comparten TODOS los aparatos del perfil que sirven ese namespace: la
|
|
30
|
+
* llave de la API es la misma la corra quien la corra.
|
|
31
|
+
* · **Por aparato** (`dev`) — la llave es la del miembro (su `pub`), y solo la
|
|
32
|
+
* lee ESE aparato. Es donde va lo que cambia de máquina a máquina (el puerto,
|
|
33
|
+
* la URL pública, el nombre del nodo) sin partir el ns en uno por servidor.
|
|
34
|
+
*
|
|
35
|
+
* Al pedir el bundle se entregan MEZCLADOS y **manda el aparato**: lo suyo pisa
|
|
36
|
+
* lo del scope. Es el orden que espera cualquiera que haya usado un `.env`
|
|
37
|
+
* general y otro de la máquina — lo específico gana. **La mezcla sigue aquí** y sigue
|
|
38
|
+
* siendo una línea: los NOMBRES no se sellan, solo los valores, así que juntarlos no
|
|
39
|
+
* exige poder leerlos. Quien descifra es el agente, después.
|
|
40
|
+
*
|
|
41
|
+
* CADA VARIABLE ES PÚBLICA O PRIVADA, y eso decide UNA cosa: si su VALOR puede
|
|
42
|
+
* salir de esta máquina hacia la consola remota (`docs/consola-remota.md`). Al
|
|
43
|
+
* servicio que la lee le da igual —recibe las dos—; lo que cambia es que el valor
|
|
44
|
+
* de una privada no se le enseña a nadie más. Se nace PRIVADA: enseñar un secreto
|
|
45
|
+
* tiene que ser una decisión, no un descuido.
|
|
46
|
+
*
|
|
47
|
+
* EL HISTÓRICO. Cada escritura guarda el sobre ANTERIOR, con quién y cuándo. La bóveda
|
|
48
|
+
* lo escribe sin poder leerlo; auditar y revertir los hace quien puede abrir. Tiene
|
|
49
|
+
* tope: un histórico de secretos también es un pasivo — mantiene vivas credenciales
|
|
50
|
+
* viejas — así que se poda.
|
|
51
|
+
*
|
|
52
|
+
* ESTE MÓDULO NO HACE CRIPTOGRAFÍA. Recibe un `sealer` (ver `src/sealer.js`) y le
|
|
53
|
+
* pide sobres. Así se prueba con un sellador falso y determinista, y la forma del
|
|
54
|
+
* archivo se razona sin mirar una sola llave.
|
|
8
55
|
*/
|
|
9
56
|
import path from 'node:path'
|
|
10
57
|
import { readJson, writeJson } from './paths.js'
|
|
11
58
|
import { atRestFor } from './atrest.js'
|
|
12
59
|
import { isValidSecretsNs } from './protocol.js'
|
|
13
60
|
|
|
14
|
-
const SCHEMA_VERSION =
|
|
61
|
+
const SCHEMA_VERSION = 5
|
|
62
|
+
/** v4: sellado, pero con la copia maestra bajo la frase. Se lee y se sirve; no se escribe. */
|
|
63
|
+
const SEALED_MASTER_VERSION = 4
|
|
64
|
+
const LEGACY_VERSION = 3
|
|
15
65
|
const MAX_VALUE_LEN = 8 * 1024
|
|
16
66
|
const KEY_RE = /^[A-Z0-9_]{1,64}$/
|
|
17
67
|
|
|
18
|
-
|
|
68
|
+
/**
|
|
69
|
+
* La envoltura de la copia de RECUPERACIÓN va en el llavero como una más, con este
|
|
70
|
+
* nombre en lugar de la pubkey de un miembro. No es un miembro y no debe parecerlo: el
|
|
71
|
+
* acta no lo conoce y nadie le sirve un bundle.
|
|
72
|
+
*/
|
|
73
|
+
export const RECOVERY = '#recovery'
|
|
74
|
+
|
|
75
|
+
/** Cuántas versiones anteriores se conservan, en total. Ver «EL HISTÓRICO» arriba. */
|
|
76
|
+
const MAX_HISTORY = 500
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Valida un par nombre/valor. Se exporta porque quien carga varias variables de golpe
|
|
80
|
+
* las comprueba TODAS antes de escribir ninguna: media configuración aplicada y un
|
|
81
|
+
* error a la mitad es justo lo que la carga en grupo viene a evitar.
|
|
82
|
+
*/
|
|
83
|
+
export function assertVar (key, value) {
|
|
84
|
+
if (!KEY_RE.test(String(key || ''))) throw new Error('invalid key (use UPPERCASE_WITH_UNDERSCORES, e.g. TURN_KEY_ID)')
|
|
85
|
+
if (typeof value !== 'string' || !value) throw new Error('value must be a non-empty string')
|
|
86
|
+
if (value.length > MAX_VALUE_LEN) throw new Error(`value too long (max ${MAX_VALUE_LEN})`)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Se pidió una operación que necesita LEER un valor, y no vino la llave que lo abre. */
|
|
90
|
+
export class NeedsPassword extends Error {
|
|
91
|
+
constructor (what) {
|
|
92
|
+
super(`this needs the profile password: ${what}`)
|
|
93
|
+
this.code = 'NEEDS_PASSWORD'
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* El archivo sigue en v4 (con copia maestra) y hay que convertirlo antes de escribir.
|
|
99
|
+
* Convertir exige la frase UNA vez —hay que abrir la maestra para poder re-sellar—, y a
|
|
100
|
+
* partir de ahí no se pide nunca más para escribir.
|
|
101
|
+
*/
|
|
102
|
+
export class NeedsMigration extends Error {
|
|
103
|
+
constructor () {
|
|
104
|
+
super('the secrets file is still v4: unlock the profile once to convert it (it needs the password only this time)')
|
|
105
|
+
this.code = 'NEEDS_MIGRATION'
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Privada es para siempre. Una variable puede dejar de mostrarse (pública → privada), pero
|
|
111
|
+
* nunca al revés: lo que se marcó como secreto no se destapa ni por `visibility` ni de
|
|
112
|
+
* refilón con un `set --public`. Si hace falta un valor público, es OTRA variable (o se
|
|
113
|
+
* borra esta y se crea de nuevo, y eso queda a la vista).
|
|
114
|
+
*/
|
|
115
|
+
export class PrivateStaysPrivate extends Error {
|
|
116
|
+
constructor (key) {
|
|
117
|
+
super(`${key} is private and a private variable cannot be made public: delete it and create it again as public`)
|
|
118
|
+
this.code = 'PRIVATE_STAYS_PRIVATE'
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Migra un cajón `{KEY: 'valor'}` (v1/v2) a `{KEY: {v, pub}}`: todo entra como PRIVADO. */
|
|
123
|
+
function migrateBag (bag) {
|
|
124
|
+
const out = {}
|
|
125
|
+
for (const [k, v] of Object.entries(bag || {})) out[k] = (v && typeof v === 'object') ? v : { v: v, pub: false }
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Abre el store.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} dir
|
|
133
|
+
* @param {{ sealer?: object, recipients?: (owner: string) => any, signer?: (body: any) => any,
|
|
134
|
+
* defaultKey?: () => Uint8Array }} [opts]
|
|
135
|
+
* `sealer`: el puerto de sobres (`src/sealer.js`). Sin él, el store solo sabe leer y
|
|
136
|
+
* servir lo que ya hay — que es exactamente lo que hace falta para arrancar sin nada.
|
|
137
|
+
* `recipients(owner)`: a quién hay que envolverle la llave de ese cajón — los servicios
|
|
138
|
+
* de ese namespace y los aparatos que administran. Sale del acta, y por eso lo pone
|
|
139
|
+
* quien llama: este módulo no conoce el acta.
|
|
140
|
+
* `signer(body)`: firma del sobre, `{ seq, sig }` o `null`. Es lo que dice que el sobre
|
|
141
|
+
* salió de esta bóveda (§8.8).
|
|
142
|
+
* `defaultKey`: con qué sellar la privada de recuperación cuando el perfil NO tiene
|
|
143
|
+
* contraseña. Vive aquí, en un solo sitio, para que dé igual por qué puerta se entre.
|
|
144
|
+
*/
|
|
145
|
+
export function openSecretsStore (dir, { sealer = null, recipients = null, signer = null, defaultKey = null } = {}) {
|
|
19
146
|
const file = path.join(dir, 'secrets.json')
|
|
20
147
|
const atRest = atRestFor(dir)
|
|
21
148
|
let data = readJson(file, null, atRest)
|
|
22
|
-
|
|
23
|
-
|
|
149
|
+
|
|
150
|
+
// v1 → v2: el cajón por scope se conserva y estrena el `dev`.
|
|
151
|
+
// v2 → v3: los valores dejan de ser strings sueltos y pasan a llevar su visibilidad.
|
|
152
|
+
// Lo que ya existía entra como PRIVADO: nadie marcó nunca que se pudiera ver.
|
|
153
|
+
if (data && data.schemaVersion > 0 && data.schemaVersion < LEGACY_VERSION) {
|
|
154
|
+
const ns = {}; const dev = {}
|
|
155
|
+
for (const [k, bag] of Object.entries(data.ns || {})) ns[k] = migrateBag(bag)
|
|
156
|
+
for (const [k, bag] of Object.entries(data.dev || {})) dev[k] = migrateBag(bag)
|
|
157
|
+
data = { schemaVersion: LEGACY_VERSION, ns, dev }
|
|
24
158
|
}
|
|
159
|
+
if (!data) data = { schemaVersion: SCHEMA_VERSION, ns: {}, dev: {}, recovery: null, history: [] }
|
|
160
|
+
if (!data.dev) data.dev = {}
|
|
161
|
+
if (!data.history) data.history = []
|
|
162
|
+
|
|
163
|
+
// v3 → v5 y v4 → v5 NO se hacen al abrir, y es deliberado: el daemon tiene que poder
|
|
164
|
+
// arrancar y SERVIR sin nada (un perfil bloqueado sigue atendiendo a sus agentes). Un
|
|
165
|
+
// archivo viejo se queda como está, sirviendo igual que siempre, hasta que alguien lo
|
|
166
|
+
// convierta. Hasta entonces el despliegue se deshace con un reinicio.
|
|
167
|
+
const isLegacy = () => data.schemaVersion === LEGACY_VERSION
|
|
168
|
+
const needsMigration = () => data.schemaVersion === SEALED_MASTER_VERSION
|
|
169
|
+
|
|
25
170
|
writeJson(file, data, atRest) // reescribe al abrir: cifra lo que venía en claro
|
|
26
|
-
|
|
171
|
+
|
|
172
|
+
// Un grupo de escrituras se guarda UNA vez (ver `batch`). Fuera de un grupo, `held`
|
|
173
|
+
// vale 0 y cada cambio va al disco en el acto, como siempre.
|
|
174
|
+
let held = 0
|
|
175
|
+
let pending = false
|
|
176
|
+
const flush = () => writeJson(file, data, atRest)
|
|
177
|
+
const save = () => { if (held) { pending = true; return } ; flush() }
|
|
27
178
|
|
|
28
179
|
const assertNs = (ns) => {
|
|
29
180
|
if (!isValidSecretsNs(ns)) throw new Error('invalid namespace (use [a-z0-9-]{1,32}, e.g. "proxy")')
|
|
30
181
|
}
|
|
182
|
+
const assertPub = (pub) => {
|
|
183
|
+
if (typeof pub !== 'string' || !pub) throw new Error('device required (the member public key)')
|
|
184
|
+
}
|
|
185
|
+
const assertKeyValue = assertVar
|
|
186
|
+
const needSealer = (what) => { if (!sealer) throw new NeedsPassword(what) }
|
|
187
|
+
/** La llave con la que se abre la privada de recuperación: la dada, o la de la máquina. */
|
|
188
|
+
const keyOr = (adminKey) => adminKey || defaultKey?.() || null
|
|
189
|
+
|
|
190
|
+
/** Borra la rama si se quedó vacía: un scope (o un aparato) sin variables no existe. */
|
|
191
|
+
const prune = (bag, k) => { if (bag[k] && Object.keys(bag[k]).length === 0) delete bag[k] }
|
|
192
|
+
|
|
193
|
+
// --- forma de un cajón -------------------------------------------------------
|
|
194
|
+
// v3: `bag[k]` ES el mapa de variables. v4/v5: `bag[k] = { vars, keyring }`.
|
|
195
|
+
// `varsOf` es el único sitio que conoce las dos, para que el resto del archivo
|
|
196
|
+
// hable de variables y no de versiones.
|
|
197
|
+
const varsOf = (bag, k) => (isLegacy() ? (bag[k] || {}) : (bag[k]?.vars || {}))
|
|
198
|
+
const ensureBag = (bag, k) => {
|
|
199
|
+
if (isLegacy()) { if (!bag[k]) bag[k] = {}; return bag[k] }
|
|
200
|
+
if (!bag[k]) bag[k] = { vars: {}, keyring: [] }
|
|
201
|
+
if (!bag[k].vars) bag[k].vars = {}
|
|
202
|
+
if (!bag[k].keyring) bag[k].keyring = []
|
|
203
|
+
return bag[k].vars
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Los NOMBRES con su visibilidad: es lo que ve cualquier lista. Nunca valores. */
|
|
207
|
+
const names = (bag, k) => Object.entries(varsOf(bag, k)).map(([key, e]) => ({ key, public: !!e.pub }))
|
|
208
|
+
/** Solo las PÚBLICAS, con valor: lo único que puede salir de esta máquina en claro. */
|
|
209
|
+
const publics = (bag, k) => Object.fromEntries(
|
|
210
|
+
Object.entries(varsOf(bag, k)).filter(([, e]) => e.pub).map(([key, e]) => [key, e.v])
|
|
211
|
+
)
|
|
212
|
+
/**
|
|
213
|
+
* Las entradas TAL CUAL van al bundle: la pública con su valor, la privada con su
|
|
214
|
+
* sobre y su firma. No se descifra nada aquí — de eso vive todo esto.
|
|
215
|
+
*/
|
|
216
|
+
const entriesOf = (bag, k) => ({ ...varsOf(bag, k) })
|
|
217
|
+
|
|
218
|
+
/** La generación vigente de un cajón (la de `gen` mayor). */
|
|
219
|
+
const topGen = (bag, k) => {
|
|
220
|
+
const kr = bag[k]?.keyring || []
|
|
221
|
+
return kr.reduce((best, g) => (!best || (g.gen || 0) > (best.gen || 0) ? g : best), null)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* UN CAMBIO A LA VEZ.
|
|
226
|
+
*
|
|
227
|
+
* Todo lo que toca el llavero es leer-modificar-escribir con un `await` en medio, así
|
|
228
|
+
* que dos operaciones en vuelo se pisan la una a la otra. No es teórico: en v4 un
|
|
229
|
+
* `rewrap` lanzado al aprobar un aparato volvía a sellar la copia maestra **con la
|
|
230
|
+
* llave vieja** justo después de cambiar la contraseña del perfil, y el vault quedaba
|
|
231
|
+
* abierto para cualquiera con una copia del disco, sin un solo mensaje de error.
|
|
232
|
+
*
|
|
233
|
+
* La cola es de este proceso, que es el único que escribe este archivo.
|
|
234
|
+
*/
|
|
235
|
+
let cola = Promise.resolve()
|
|
236
|
+
const enFila = (fn) => {
|
|
237
|
+
const r = cola.then(fn, fn)
|
|
238
|
+
cola = r.then(() => {}, () => {})
|
|
239
|
+
return r
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- histórico y recogida de generaciones ------------------------------------
|
|
243
|
+
|
|
244
|
+
/** Guarda la versión que se va a pisar. Sin valores en claro: el sobre tal cual. */
|
|
245
|
+
const pushHistory = (owner, key, before, by) => {
|
|
246
|
+
if (!before || before.pub) return // una pública no es un secreto que revertir a ciegas
|
|
247
|
+
data.history.push({ ts: Date.now(), owner, key, gen: before.gen, e: before.e, seal: before.seal || null, by: by || null })
|
|
248
|
+
if (data.history.length > MAX_HISTORY) data.history = data.history.slice(-MAX_HISTORY)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Tira las generaciones que ya no abre nada. Con una generación por escritura, el
|
|
253
|
+
* llavero crecería sin fin; lo que hay que conservar es lo que todavía referencia una
|
|
254
|
+
* variable viva o una entrada del histórico — ni una más, porque cada generación
|
|
255
|
+
* conservada es una llave que sigue por ahí.
|
|
256
|
+
*/
|
|
257
|
+
const gcKeyring = (bag, k, owner) => {
|
|
258
|
+
if (!bag[k]) return
|
|
259
|
+
const vivos = new Set()
|
|
260
|
+
for (const e of Object.values(bag[k].vars || {})) if (!e.pub && e.gen != null) vivos.add(e.gen)
|
|
261
|
+
for (const h of data.history) if (h.owner === owner && h.gen != null) vivos.add(h.gen)
|
|
262
|
+
bag[k].keyring = (bag[k].keyring || []).filter((g) => vivos.has(g.gen))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Abre la privada de RECUPERACIÓN. Es la única puerta de este módulo a un valor en
|
|
267
|
+
* claro, y por eso es la única que pide la frase.
|
|
268
|
+
*/
|
|
269
|
+
const openRecovery = async (adminKey) => {
|
|
270
|
+
needSealer('read a private variable')
|
|
271
|
+
if (!data.recovery?.priv) throw new NeedsPassword('this store has no recovery key yet')
|
|
272
|
+
return sealer.openMaster(data.recovery.priv, keyOr(adminKey))
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* El par de recuperación, creándolo si es la primera vez. Se sella con lo que haya:
|
|
277
|
+
* con la frase si quien llama la trajo, y si no con la llave de la máquina — que es
|
|
278
|
+
* la protección de siempre y no una nueva promesa. Cuando el perfil estrene
|
|
279
|
+
* contraseña, `rekeyRecovery` lo vuelve a cerrar con ella.
|
|
280
|
+
*/
|
|
281
|
+
const ensureRecovery = async (adminKey) => {
|
|
282
|
+
if (data.recovery?.pub) return data.recovery
|
|
283
|
+
needSealer('create the recovery key')
|
|
284
|
+
const pair = await sealer.makeRecoveryPair()
|
|
285
|
+
data.recovery = { pub: pair.pub, priv: await sealer.sealMaster(pair.priv, keyOr(adminKey)) }
|
|
286
|
+
return data.recovery
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Los destinatarios de un cajón: los que dice el acta, más la copia de recuperación. */
|
|
290
|
+
const wrapAll = async (cek, owner) => {
|
|
291
|
+
const members = (await recipients?.(owner)) || []
|
|
292
|
+
const { wraps, sinLlave } = await sealer.wrapFor(cek, members)
|
|
293
|
+
wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
294
|
+
return { wraps, sinLlave }
|
|
295
|
+
}
|
|
31
296
|
|
|
32
297
|
return {
|
|
33
|
-
/**
|
|
34
|
-
|
|
298
|
+
/** `true` mientras el archivo siga en v3 (sin sellar). */
|
|
299
|
+
isLegacy,
|
|
300
|
+
/** `true` si sigue en v4 (sellado, pero con copia maestra): hay que convertirlo. */
|
|
301
|
+
needsMigration,
|
|
302
|
+
schemaVersion: () => data.schemaVersion,
|
|
303
|
+
/** ¿La privada de recuperación está sellada solo con la llave de esta máquina? */
|
|
304
|
+
recoveryPub: () => data.recovery?.pub || null,
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* MUCHAS ESCRITURAS, UN GUARDADO. Cargar la configuración de un servicio son veinte
|
|
308
|
+
* variables pero un solo cambio: con un `save()` por variable, el archivo entero se
|
|
309
|
+
* reescribía y se volvía a cifrar veinte veces.
|
|
310
|
+
*/
|
|
311
|
+
async batch (fn) {
|
|
312
|
+
held++
|
|
313
|
+
try { return await fn() } finally {
|
|
314
|
+
held--
|
|
315
|
+
if (!held && pending) { pending = false; flush() }
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* El bundle que se le entrega a un servicio: lo del scope con lo del aparato
|
|
321
|
+
* ENCIMA. Públicas y privadas por igual — la visibilidad no es un permiso de
|
|
322
|
+
* lectura del servicio, es si el valor puede salir de esta máquina.
|
|
323
|
+
*
|
|
324
|
+
* Devuelve las entradas SIN abrir, más las envolturas **de este miembro y solo las
|
|
325
|
+
* suyas**: el resto del llavero son las llaves de sus compañeros y no le hacen falta.
|
|
326
|
+
* Van TODAS sus generaciones, no solo la última, porque desde v5 cada variable puede
|
|
327
|
+
* venir de una escritura distinta. En v3 devuelve los valores en claro, como siempre.
|
|
328
|
+
*/
|
|
329
|
+
bundleFor (ns, devicePub = null) {
|
|
35
330
|
assertNs(ns)
|
|
36
|
-
|
|
331
|
+
const entries = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
|
|
332
|
+
if (isLegacy()) return { legacy: true, entries }
|
|
333
|
+
const misWraps = (bag, k) => (bag[k]?.keyring || [])
|
|
334
|
+
.filter((g) => g.wraps?.[devicePub])
|
|
335
|
+
.map((g) => ({ gen: g.gen, wrap: g.wraps[devicePub] }))
|
|
336
|
+
const ns1 = misWraps(data.ns, ns)
|
|
337
|
+
const dev1 = devicePub ? misWraps(data.dev, devicePub) : []
|
|
338
|
+
return {
|
|
339
|
+
entries,
|
|
340
|
+
// Compat con el bundle de v4, que llevaba UNA envoltura por cajón: se manda la
|
|
341
|
+
// vigente ahí y la lista entera aparte. Quien sepa leer `wraps` usa la lista.
|
|
342
|
+
ns: ns1[ns1.length - 1] || null,
|
|
343
|
+
dev: dev1[dev1.length - 1] || null,
|
|
344
|
+
wraps: { ns: ns1, dev: dev1 }
|
|
345
|
+
}
|
|
37
346
|
},
|
|
38
|
-
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* VER UN VALOR. Es lo único que pide la frase, y es lo que la frase significa desde
|
|
350
|
+
* v5 (§8.3). Devuelve `null` si esa variable no existe.
|
|
351
|
+
*/
|
|
352
|
+
async reveal (owner, key, adminKey = null) {
|
|
353
|
+
const [kind, k] = splitOwner(owner)
|
|
354
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
355
|
+
const e = varsOf(bag, k)[key]
|
|
356
|
+
if (!e) return null
|
|
357
|
+
if (e.pub) return e.v
|
|
358
|
+
if (isLegacy()) return e.v
|
|
359
|
+
const priv = await openRecovery(adminKey)
|
|
360
|
+
const cek = await this._cekOf(bag, k, e.gen, priv)
|
|
361
|
+
return sealer.openValue(cek, e.e)
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Las variables privadas de un cajón que ESE miembro **no puede abrir**: las que no
|
|
367
|
+
* tienen envoltura para su llave en la generación vigente.
|
|
368
|
+
*
|
|
369
|
+
* Es la deuda del §8.7 vista desde el aparato en vez de desde el cajón. Un aparato
|
|
370
|
+
* que entra después de escrita una variable no tiene envoltura de ella —y no la
|
|
371
|
+
* puede tener, porque envolver exige abrir la CEK y eso pide la frase—, así que se
|
|
372
|
+
* queda sin poder leerla. Eso es correcto y no se relaja; lo que no puede pasar es
|
|
373
|
+
* que no se vea, que era el modo de fallo de verdad: un servicio en el acta,
|
|
374
|
+
* aparentemente bien, que arranca sin configuración.
|
|
375
|
+
*
|
|
376
|
+
* @param {string} owner `ns:<scope>` o `dev:<pub>`
|
|
377
|
+
* @param {string} memberPub la llave de FIRMA del miembro (así se indexan las envolturas)
|
|
378
|
+
* @returns {string[]} nombres de variables, vacío si puede con todas
|
|
379
|
+
*/
|
|
380
|
+
missingFor (owner, memberPub) {
|
|
381
|
+
if (isLegacy()) return []
|
|
382
|
+
const [kind, k] = splitOwner(owner)
|
|
383
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
384
|
+
const out = []
|
|
385
|
+
for (const [key, e] of Object.entries(varsOf(bag, k))) {
|
|
386
|
+
if (e.pub) continue
|
|
387
|
+
const g = (bag[k]?.keyring || []).find((x) => x.gen === e.gen)
|
|
388
|
+
if (!g?.wraps?.[memberPub]) out.push(key)
|
|
389
|
+
}
|
|
390
|
+
return out
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Las generaciones de un cajón que a `memberPub` le faltan, con LA ENVOLTURA DE
|
|
395
|
+
* QUIEN PREGUNTA para cada una. Es lo que necesita un aparato que administra para
|
|
396
|
+
* completar a otro sin la frase: abre cada una con su llave y la vuelve a envolver
|
|
397
|
+
* (`rewrapFor` de `@dotrino/identity`).
|
|
398
|
+
*
|
|
399
|
+
* Devuelve solo las generaciones que están EN USO (alguna variable las apunta): las
|
|
400
|
+
* viejas no hacen falta y re-envolverlas sería repartir llaves que ya no abren nada.
|
|
401
|
+
*
|
|
402
|
+
* @returns {Array<{ gen: number, mine: any }>} vacío si quien pregunta tampoco puede
|
|
403
|
+
*/
|
|
404
|
+
wrapsToShare (owner, memberPub, myPub) {
|
|
405
|
+
if (isLegacy()) return []
|
|
406
|
+
const [kind, k] = splitOwner(owner)
|
|
407
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
408
|
+
const inUse = new Set(Object.values(varsOf(bag, k)).filter((e) => !e.pub).map((e) => e.gen))
|
|
409
|
+
const out = []
|
|
410
|
+
for (const g of bag[k]?.keyring || []) {
|
|
411
|
+
if (!inUse.has(g.gen) || g.wraps?.[memberPub]) continue
|
|
412
|
+
if (g.wraps?.[myPub]) out.push({ gen: g.gen, mine: g.wraps[myPub] })
|
|
413
|
+
}
|
|
414
|
+
return out
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Guarda una envoltura que hizo otro. La bóveda NO la puede comprobar —abrirla
|
|
419
|
+
* exigiría la frase, que es justo lo que este camino evita—, así que lo que sí
|
|
420
|
+
* comprueba es lo que puede: que la generación exista y que ese miembro no tuviera
|
|
421
|
+
* ya una. Una envoltura mala deja al servicio sin leer, que se ve; no da acceso a
|
|
422
|
+
* nadie.
|
|
423
|
+
*/
|
|
424
|
+
putWrap (owner, gen, memberPub, wrap) {
|
|
425
|
+
const [kind, k] = splitOwner(owner)
|
|
426
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
427
|
+
const g = (bag[k]?.keyring || []).find((x) => x.gen === gen)
|
|
428
|
+
if (!g) throw new Error(`putWrap: no existe la generación ${gen} de ${owner}`)
|
|
429
|
+
if (!wrap?.epk || !wrap?.iv || !wrap?.ct) throw new Error('putWrap: envoltura mal formada')
|
|
430
|
+
// SOLO AÑADE, NUNCA PISA. Quien re-envuelve es un servicio, y un servicio no
|
|
431
|
+
// administra: si pudiera reemplazar una envoltura existente podría dejar sin leer
|
|
432
|
+
// a otro miembro —o a quien administra— con una envoltura basura, y eso es
|
|
433
|
+
// denegación de servicio disfrazada de reparto. Reemplazar es cosa de la bóveda,
|
|
434
|
+
// por el camino de escribir (que estrena generación entera).
|
|
435
|
+
if (g.wraps?.[memberPub]) throw new Error(`putWrap: ${memberPub.slice(0, 12)}… ya tiene envoltura de la generación ${gen}`)
|
|
436
|
+
g.wraps = { ...(g.wraps || {}), [memberPub]: wrap }
|
|
437
|
+
save()
|
|
438
|
+
return true
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
/** @private La CEK de una generación, abierta con la privada de recuperación. */
|
|
443
|
+
async _cekOf (bag, k, gen, priv) {
|
|
444
|
+
const g = (bag[k]?.keyring || []).find((x) => x.gen === gen) || topGen(bag, k)
|
|
445
|
+
const w = g?.wraps?.[RECOVERY]
|
|
446
|
+
if (!w) throw new NeedsPassword(`there is no recovery copy of the key for generation ${gen}`)
|
|
447
|
+
return sealer.openWrapWith(priv, w)
|
|
448
|
+
},
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Igual que `bundleFor` pero YA ABIERTO. Solo existe para las pruebas y para
|
|
452
|
+
* diagnosticar: pide la frase. Nadie del camino de servir lo llama.
|
|
453
|
+
*/
|
|
454
|
+
async openBundle (ns, devicePub = null, adminKey = null) {
|
|
455
|
+
const b = this.bundleFor(ns, devicePub)
|
|
456
|
+
if (b.legacy) return Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v]))
|
|
457
|
+
const priv = await openRecovery(adminKey)
|
|
458
|
+
const out = {}
|
|
459
|
+
for (const [key, e] of Object.entries(b.entries)) {
|
|
460
|
+
if (e.pub) { out[key] = e.v; continue }
|
|
461
|
+
const [kind, k] = splitOwner(e.owner)
|
|
462
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
463
|
+
out[key] = await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e)
|
|
464
|
+
}
|
|
465
|
+
return out
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
// --- escritura: NO pide la frase (§8.1) --------------------------------------
|
|
469
|
+
/**
|
|
470
|
+
* Escribe en un cajón. `isPublic` es OPCIONAL a propósito: sin decir nada se
|
|
471
|
+
* conserva lo que la variable ya era (rotar un valor no debe cambiar quién puede
|
|
472
|
+
* verlo por olvido) y una variable nueva nace PRIVADA.
|
|
473
|
+
*
|
|
474
|
+
* `by` es quién la escribió (la pubkey del aparato), y va al histórico.
|
|
475
|
+
*/
|
|
476
|
+
async set (ns, key, value, isPublic, { by = null } = {}) {
|
|
39
477
|
assertNs(ns)
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
data.
|
|
478
|
+
return this._put(data.ns, ns, `ns:${ns}`, key, value, isPublic, by)
|
|
479
|
+
},
|
|
480
|
+
async setDevice (pub, key, value, isPublic, { by = null } = {}) {
|
|
481
|
+
assertPub(pub)
|
|
482
|
+
return this._put(data.dev, pub, `dev:${pub}`, key, value, isPublic, by)
|
|
483
|
+
},
|
|
484
|
+
/** @private */
|
|
485
|
+
async _put (...a) { return enFila(() => this._putRaw(...a)) },
|
|
486
|
+
/** @private El cuerpo, ya en fila (ver `enFila`). */
|
|
487
|
+
async _putRaw (bag, k, owner, key, value, isPublic, by) {
|
|
488
|
+
assertKeyValue(key, value)
|
|
489
|
+
const vars = ensureBag(bag, k)
|
|
490
|
+
const before = vars[key]
|
|
491
|
+
const pub = isPublic === undefined ? !!before?.pub : !!isPublic
|
|
492
|
+
if (before && !before.pub && pub) throw new PrivateStaysPrivate(key)
|
|
493
|
+
|
|
494
|
+
// v3: se guarda como siempre. Convertir a sobres es un gesto aparte y explícito
|
|
495
|
+
// (`migrate`), no algo que ocurra de refilón al escribir.
|
|
496
|
+
if (isLegacy()) { vars[key] = { v: value, pub }; save(); return }
|
|
497
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
498
|
+
|
|
499
|
+
// Una PÚBLICA se guarda en claro a propósito: eso es lo que significa marcarla.
|
|
500
|
+
if (pub) { vars[key] = { v: value, pub: true }; save(); return }
|
|
501
|
+
|
|
502
|
+
needSealer('write a private variable')
|
|
503
|
+
await ensureRecovery(null)
|
|
504
|
+
|
|
505
|
+
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
506
|
+
const cek = await sealer.newKey()
|
|
507
|
+
const gen = (topGen(bag, k)?.gen || 0) + 1
|
|
508
|
+
const { wraps, sinLlave } = await wrapAll(cek, owner)
|
|
509
|
+
const e = await sealer.encrypt(cek, value, gen)
|
|
510
|
+
// La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
|
|
511
|
+
// hay con qué firmar, el sobre sale sin firma: guardar es más importante.
|
|
512
|
+
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
513
|
+
|
|
514
|
+
pushHistory(owner, key, before, by)
|
|
515
|
+
vars[key] = { pub: false, owner, gen, e, seal, at: Date.now(), by: by || null }
|
|
516
|
+
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
|
|
517
|
+
gcKeyring(bag, k, owner)
|
|
45
518
|
save()
|
|
519
|
+
return { gen, sinLlave }
|
|
46
520
|
},
|
|
47
|
-
|
|
521
|
+
|
|
522
|
+
async delete (ns, key) {
|
|
48
523
|
assertNs(ns)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
524
|
+
return this._drop(data.ns, ns, `ns:${ns}`, key)
|
|
525
|
+
},
|
|
526
|
+
async deleteDevice (pub, key) {
|
|
527
|
+
assertPub(pub)
|
|
528
|
+
return this._drop(data.dev, pub, `dev:${pub}`, key)
|
|
529
|
+
},
|
|
530
|
+
/**
|
|
531
|
+
* @private Borrar NO exige la frase: quitar algo no pide poder leerlo.
|
|
532
|
+
*
|
|
533
|
+
* Y borrar SE LLEVA SU HISTÓRICO. Si no, borrar una variable dejaría sus versiones
|
|
534
|
+
* anteriores guardadas —cifradas, pero recuperables— y «borré esa credencial» sería
|
|
535
|
+
* mentira. El histórico existe para revertir mientras la variable vive; cuando se va,
|
|
536
|
+
* se va entera.
|
|
537
|
+
*/
|
|
538
|
+
_drop (bag, k, owner, key) {
|
|
539
|
+
const vars = varsOf(bag, k)
|
|
540
|
+
const existed = key in vars
|
|
541
|
+
if (!existed) return false
|
|
542
|
+
delete vars[key]
|
|
543
|
+
data.history = data.history.filter((h) => !(h.owner === owner && h.key === key))
|
|
544
|
+
if (!isLegacy()) gcKeyring(bag, k, owner)
|
|
545
|
+
if (Object.keys(vars).length === 0) prune(bag, k)
|
|
546
|
+
save()
|
|
547
|
+
return true
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Cambiar la visibilidad solo va en UNA dirección: de pública a privada (sellar el
|
|
552
|
+
* valor, que es escribir y no pide nada). De privada a pública NO existe
|
|
553
|
+
* (`PrivateStaysPrivate`): destapar un secreto no es una casilla, es borrarlo y
|
|
554
|
+
* crear otro.
|
|
555
|
+
*/
|
|
556
|
+
async setVisibility (ns, key, isPublic, adminKey = null) {
|
|
557
|
+
assertNs(ns)
|
|
558
|
+
return this._setVis(data.ns, ns, `ns:${ns}`, key, isPublic, adminKey)
|
|
559
|
+
},
|
|
560
|
+
async setDeviceVisibility (pub, key, isPublic, adminKey = null) {
|
|
561
|
+
assertPub(pub)
|
|
562
|
+
return this._setVis(data.dev, pub, `dev:${pub}`, key, isPublic, adminKey)
|
|
56
563
|
},
|
|
57
|
-
/**
|
|
564
|
+
/** @private */
|
|
565
|
+
async _setVis (...a) { return enFila(() => this._setVisRaw(...a)) },
|
|
566
|
+
/** @private El cuerpo, ya en fila. */
|
|
567
|
+
async _setVisRaw (bag, k, owner, key, isPublic, adminKey) {
|
|
568
|
+
const vars = varsOf(bag, k)
|
|
569
|
+
const e = vars[key]
|
|
570
|
+
if (!e) return false
|
|
571
|
+
const want = !!isPublic
|
|
572
|
+
if (!!e.pub === want) return true
|
|
573
|
+
if (!e.pub) throw new PrivateStaysPrivate(key)
|
|
574
|
+
if (isLegacy()) { e.pub = false; save(); return true }
|
|
575
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
576
|
+
|
|
577
|
+
// De pública a privada: es una escritura normal, con su generación y su firma.
|
|
578
|
+
held++
|
|
579
|
+
try { await this._putRaw(bag, k, owner, key, e.v, false, null) } finally { held-- }
|
|
580
|
+
save()
|
|
581
|
+
return true
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
// --- lecturas que NO abren nada ---------------------------------------------
|
|
585
|
+
/** Nombres y visibilidad (ns → [{key, public}]), sin valores: para `secret list`. */
|
|
58
586
|
list () {
|
|
59
587
|
const out = {}
|
|
60
|
-
for (const ns of Object.keys(data.ns)) out[ns] =
|
|
588
|
+
for (const ns of Object.keys(data.ns)) out[ns] = names(data.ns, ns)
|
|
589
|
+
return out
|
|
590
|
+
},
|
|
591
|
+
/** Nombres y visibilidad (pub → [{key, public}]), sin valores. */
|
|
592
|
+
listDevices () {
|
|
593
|
+
const out = {}
|
|
594
|
+
for (const pub of Object.keys(data.dev)) out[pub] = names(data.dev, pub)
|
|
61
595
|
return out
|
|
596
|
+
},
|
|
597
|
+
/**
|
|
598
|
+
* QUIÉN puede abrir la generación vigente de un cajón (sus llaves de firma, más
|
|
599
|
+
* `#recovery`). Es diagnóstico, no un secreto: saber a cuántos se les envolvió no
|
|
600
|
+
* ayuda a abrir nada, y en cambio es lo único que responde de verdad a «¿quién
|
|
601
|
+
* puede leer esto?» — que es la pregunta que uno se hace mirando un cajón.
|
|
602
|
+
*/
|
|
603
|
+
recipientsIn (owner) {
|
|
604
|
+
const [kind, k] = splitOwner(owner)
|
|
605
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
606
|
+
return Object.keys(topGen(bag, k)?.wraps || {})
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
/** Las PÚBLICAS de un scope, con valor. Lo que la consola remota puede ver. */
|
|
610
|
+
publicOf (ns) {
|
|
611
|
+
assertNs(ns)
|
|
612
|
+
return publics(data.ns, ns)
|
|
613
|
+
},
|
|
614
|
+
/** Las PÚBLICAS de un aparato, con valor. */
|
|
615
|
+
publicOfDevice (pub) {
|
|
616
|
+
assertPub(pub)
|
|
617
|
+
return publics(data.dev, pub)
|
|
618
|
+
},
|
|
619
|
+
|
|
620
|
+
// --- histórico: qué había antes, y cómo se vuelve ---------------------------
|
|
621
|
+
/**
|
|
622
|
+
* Las versiones anteriores, de la más nueva a la más vieja. SIN valores: son sobres.
|
|
623
|
+
* Quien pueda abrirlos los abre con `revealHistory`; quien no, ve que existieron.
|
|
624
|
+
*/
|
|
625
|
+
history (owner = null, key = null) {
|
|
626
|
+
return data.history
|
|
627
|
+
.filter((h) => (!owner || h.owner === owner) && (!key || h.key === key))
|
|
628
|
+
.map((h) => ({ ts: h.ts, owner: h.owner, key: h.key, gen: h.gen, by: h.by || null, signed: !!h.seal }))
|
|
629
|
+
.reverse()
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
/** El valor de una versión anterior. Pide la frase, como cualquier lectura. */
|
|
633
|
+
async revealHistory (owner, key, ts, adminKey = null) {
|
|
634
|
+
const h = data.history.find((x) => x.owner === owner && x.key === key && x.ts === ts)
|
|
635
|
+
if (!h) return null
|
|
636
|
+
const [kind, k] = splitOwner(owner)
|
|
637
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
638
|
+
const priv = await openRecovery(adminKey)
|
|
639
|
+
return sealer.openValue(await this._cekOf(bag, k, h.gen, priv), h.e)
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* REVERTIR: coge una versión anterior y la vuelve a guardar. No es un modo especial
|
|
644
|
+
* del store —es abrir y escribir—, así que hereda las dos reglas: abrir pide la
|
|
645
|
+
* frase (o la hace quien puede leer, desde su aparato) y escribir no pide nada.
|
|
646
|
+
*/
|
|
647
|
+
async revert (owner, key, ts, { adminKey = null, by = null } = {}) {
|
|
648
|
+
const value = await this.revealHistory(owner, key, ts, adminKey)
|
|
649
|
+
if (value == null) return false
|
|
650
|
+
const [kind, k] = splitOwner(owner)
|
|
651
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
652
|
+
await this._put(bag, k, owner, key, value, false, by)
|
|
653
|
+
return true
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
// --- llavero: quién puede abrir cada cajón ----------------------------------
|
|
657
|
+
/**
|
|
658
|
+
* Re-envuelve la llave de lo YA GUARDADO a los miembros dados. Hace falta cuando
|
|
659
|
+
* entra un aparato a un cajón que ya tiene variables: lo que se escriba desde ahora
|
|
660
|
+
* ya se le envuelve solo, pero lo de antes está cerrado con llaves que él no tiene.
|
|
661
|
+
*
|
|
662
|
+
* Y por eso ESTO sí pide la frase: heredar lo viejo obliga a abrirlo.
|
|
663
|
+
*/
|
|
664
|
+
async rewrap (...a) { return enFila(() => this._rewrap(...a)) },
|
|
665
|
+
/** Los cajones que existen, para poder recorrerlos todos (`ns:…` y `dev:…`). */
|
|
666
|
+
owners () {
|
|
667
|
+
return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
|
|
668
|
+
},
|
|
669
|
+
/** @private */
|
|
670
|
+
async _rewrap (owner, members, adminKey = null, { exact = false } = {}) {
|
|
671
|
+
needSealer('re-wrap the key of a drawer')
|
|
672
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
673
|
+
const [kind, k] = splitOwner(owner)
|
|
674
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
675
|
+
if (!bag[k]) return { wrapped: 0, sinLlave: [] }
|
|
676
|
+
const priv = await openRecovery(adminKey)
|
|
677
|
+
let wrapped = 0
|
|
678
|
+
const sinLlave = new Set()
|
|
679
|
+
for (const g of bag[k].keyring || []) {
|
|
680
|
+
const w = g.wraps?.[RECOVERY]
|
|
681
|
+
if (!w) continue
|
|
682
|
+
const cek = await sealer.openWrapWith(priv, w)
|
|
683
|
+
const r = await sealer.wrapFor(cek, members)
|
|
684
|
+
// `exact`: el llavero queda con lo que dice el acta y NADA más. Es lo que hace
|
|
685
|
+
// falta para rehacerlo al abrir la bóveda:
|
|
686
|
+
// · una envoltura basura que metió alguien se reemplaza por la buena;
|
|
687
|
+
// · una que sobra —de quien administraba antes de que este cajón tuviera
|
|
688
|
+
// dueño, o de un miembro inventado— se cae.
|
|
689
|
+
// Sin `exact` se fusiona, que es lo correcto cuando solo se está repartiendo a
|
|
690
|
+
// unos pocos y no se quiere tocar al resto.
|
|
691
|
+
//
|
|
692
|
+
// OJO con lo que esto NO es: quitarle la envoltura a alguien no le quita lo que
|
|
693
|
+
// ya leyó ni la llave que se haya guardado. Cortar de verdad es `rotate`.
|
|
694
|
+
g.wraps = exact ? { ...r.wraps, [RECOVERY]: w } : { ...g.wraps, ...r.wraps }
|
|
695
|
+
for (const s of r.sinLlave) sinLlave.add(s)
|
|
696
|
+
wrapped += Object.keys(r.wraps).length
|
|
697
|
+
}
|
|
698
|
+
save()
|
|
699
|
+
return { wrapped, sinLlave: [...sinLlave] }
|
|
700
|
+
},
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* ROTA de verdad: vuelve a cifrar las variables privadas del cajón con llaves nuevas
|
|
704
|
+
* y solo para los miembros dados. Es lo que corta el acceso de quien salió —
|
|
705
|
+
* quitarle la envoltura no basta, porque si guardó la llave sigue abriendo lo que ya
|
|
706
|
+
* estaba cifrado con ella.
|
|
707
|
+
*
|
|
708
|
+
* No devuelve lo que el expulsado ya leyó. Eso no se puede deshacer y no se promete.
|
|
709
|
+
*/
|
|
710
|
+
async rotate (...a) { return enFila(() => this._rotate(...a)) },
|
|
711
|
+
/** @private */
|
|
712
|
+
async _rotate (owner, members, adminKey = null) {
|
|
713
|
+
needSealer('rotate the key of a drawer')
|
|
714
|
+
if (needsMigration()) throw new NeedsMigration()
|
|
715
|
+
const [kind, k] = splitOwner(owner)
|
|
716
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
717
|
+
if (!bag[k]) return { rotated: 0, sinLlave: [] }
|
|
718
|
+
const priv = await openRecovery(adminKey)
|
|
719
|
+
|
|
720
|
+
const vars = bag[k].vars || {}
|
|
721
|
+
const claras = {}
|
|
722
|
+
for (const [key, e] of Object.entries(vars)) {
|
|
723
|
+
if (!e.pub) claras[key] = await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e)
|
|
724
|
+
}
|
|
725
|
+
// Se tira el llavero entero: las generaciones viejas son justamente lo que el que
|
|
726
|
+
// se fue podría abrir. Con ellas se va el histórico de este cajón, que estaba
|
|
727
|
+
// cifrado con ellas — rotar es renunciar a poder revertir lo de antes.
|
|
728
|
+
bag[k].keyring = []
|
|
729
|
+
data.history = data.history.filter((h) => h.owner !== owner)
|
|
730
|
+
const sinLlave = new Set()
|
|
731
|
+
let gen = 0
|
|
732
|
+
for (const [key, value] of Object.entries(claras)) {
|
|
733
|
+
const cek = await sealer.newKey()
|
|
734
|
+
gen += 1
|
|
735
|
+
const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, members)
|
|
736
|
+
wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
737
|
+
for (const s of faltan) sinLlave.add(s)
|
|
738
|
+
const e = await sealer.encrypt(cek, value, gen)
|
|
739
|
+
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
740
|
+
vars[key] = { pub: false, owner, gen, e, seal, at: Date.now(), by: null }
|
|
741
|
+
bag[k].keyring.push({ gen, createdAt: Date.now(), wraps })
|
|
742
|
+
}
|
|
743
|
+
save()
|
|
744
|
+
return { rotated: Object.keys(claras).length, sinLlave: [...sinLlave], gen, cambio: true }
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Se va un aparato, se van sus variables. Lo llama el vault al quitar un
|
|
749
|
+
* miembro: dejarlas sería guardar la configuración de una llave que ya no
|
|
750
|
+
* entra, y reaparecería sola si mañana se enrola otro aparato con esa llave.
|
|
751
|
+
*
|
|
752
|
+
* NO exige la frase: es la mitad del interruptor de emergencia. Su cajón `dev`
|
|
753
|
+
* estaba sellado solo a él, así que borrarlo es inmediato y completo. Lo que sí queda
|
|
754
|
+
* pendiente es rotar los `ns` que compartía (ver `rotate`).
|
|
755
|
+
*/
|
|
756
|
+
forgetDevice (pub) {
|
|
757
|
+
assertPub(pub)
|
|
758
|
+
const keys = Object.keys(varsOf(data.dev, pub))
|
|
759
|
+
if (keys.length || data.dev[pub]) { delete data.dev[pub]; save() }
|
|
760
|
+
data.history = data.history.filter((h) => h.owner !== `dev:${pub}`)
|
|
761
|
+
return keys.length
|
|
762
|
+
},
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Quita a un miembro del llavero de un cajón, sin abrir nada. Es lo que se puede
|
|
766
|
+
* hacer SIN la frase cuando alguien sale: deja de poder abrir lo que se guarde en
|
|
767
|
+
* adelante y lo que aún no había abierto. Lo que ya guardó no se arregla así — para
|
|
768
|
+
* eso está `rotate`, que sí pide la frase.
|
|
769
|
+
*/
|
|
770
|
+
unwrap (owner, pub) {
|
|
771
|
+
if (isLegacy() || needsMigration()) return 0
|
|
772
|
+
const [kind, k] = splitOwner(owner)
|
|
773
|
+
const bag = kind === 'ns' ? data.ns : data.dev
|
|
774
|
+
if (!bag[k]) return 0
|
|
775
|
+
let n = 0
|
|
776
|
+
for (const g of bag[k].keyring || []) {
|
|
777
|
+
if (g.wraps?.[pub]) { delete g.wraps[pub]; n++ }
|
|
778
|
+
}
|
|
779
|
+
if (n) save()
|
|
780
|
+
return n
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Vuelve a cerrar la privada de RECUPERACIÓN con otra llave. Es lo que hay que hacer
|
|
785
|
+
* al poner, cambiar o quitar la contraseña del perfil: los sobres de las variables no
|
|
786
|
+
* se tocan —siguen sellados a cada destinatario—, lo único que cambia es con qué se
|
|
787
|
+
* abre la copia del dueño.
|
|
788
|
+
*
|
|
789
|
+
* Sin esto, cambiar la contraseña dejaría los secretos ILEGIBLES para él: la copia
|
|
790
|
+
* seguiría sellada con la llave vieja y ya nadie tendría cómo abrirla. Es barato (un
|
|
791
|
+
* solo sobre) y es obligatorio.
|
|
792
|
+
*
|
|
793
|
+
* `null` en cualquiera de las dos significa «la del perfil sin contraseña».
|
|
794
|
+
*/
|
|
795
|
+
async rekeyRecovery (...a) { return enFila(() => this._rekeyRecovery(...a)) },
|
|
796
|
+
/** @private */
|
|
797
|
+
async _rekeyRecovery (oldKey, newKey) {
|
|
798
|
+
if (isLegacy()) return { rekeyed: false, reason: 'v3' }
|
|
799
|
+
if (!data.recovery?.priv) return { rekeyed: false, reason: 'no-recovery' }
|
|
800
|
+
needSealer('change the profile password')
|
|
801
|
+
const priv = await sealer.openMaster(data.recovery.priv, keyOr(oldKey))
|
|
802
|
+
data.recovery.priv = await sealer.sealMaster(priv, keyOr(newKey))
|
|
803
|
+
save()
|
|
804
|
+
return { rekeyed: true }
|
|
805
|
+
},
|
|
806
|
+
|
|
807
|
+
// --- conversión a v5 ---------------------------------------------------------
|
|
808
|
+
/**
|
|
809
|
+
* Lleva el archivo a v5, venga de donde venga:
|
|
810
|
+
*
|
|
811
|
+
* · **desde v3** (valores en claro): no hace falta la frase para nada. Se sella
|
|
812
|
+
* cada valor a sus destinatarios y se estrena el par de recuperación.
|
|
813
|
+
* · **desde v4** (sellado con copia maestra): la frase hace falta UNA vez, para
|
|
814
|
+
* abrir esa copia. A partir de ahí, escribir no la pide nunca más.
|
|
815
|
+
*
|
|
816
|
+
* Verificar antes de reemplazar, igual que `migrateFile()` de `atrest.js`: se
|
|
817
|
+
* construye la forma nueva, se vuelve a abrir **valor por valor** y se compara con
|
|
818
|
+
* el original, y solo entonces se escribe. Si algo no cuadra no se toca nada y se
|
|
819
|
+
* lanza: media migración es peor que ninguna.
|
|
820
|
+
*/
|
|
821
|
+
async migrate (...a) { return enFila(() => this._migrate(...a)) },
|
|
822
|
+
/** @private */
|
|
823
|
+
async _migrate (membersOf, adminKey = null) {
|
|
824
|
+
if (data.schemaVersion === SCHEMA_VERSION) return { migrated: false, reason: 'already-v5' }
|
|
825
|
+
needSealer('seal the store')
|
|
826
|
+
const desde = data.schemaVersion
|
|
827
|
+
|
|
828
|
+
// De v4: las CEK viejas viven en la copia maestra, y para abrirla hace falta la
|
|
829
|
+
// frase. Es la única vez que se pide.
|
|
830
|
+
const master = desde === SEALED_MASTER_VERSION ? await sealer.openMaster(data.master, keyOr(adminKey)) : null
|
|
831
|
+
const claro = async (owner, e) => {
|
|
832
|
+
if (e.pub) return e.v
|
|
833
|
+
if (desde === LEGACY_VERSION) return e.v
|
|
834
|
+
return sealer.decrypt(master, e.e, e.owner || owner)
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const pair = await sealer.makeRecoveryPair()
|
|
838
|
+
const next = {
|
|
839
|
+
schemaVersion: SCHEMA_VERSION,
|
|
840
|
+
ns: {},
|
|
841
|
+
dev: {},
|
|
842
|
+
recovery: { pub: pair.pub, priv: await sealer.sealMaster(pair.priv, keyOr(adminKey)) },
|
|
843
|
+
// El histórico empieza aquí: de lo de antes no se guardó ninguna versión previa.
|
|
844
|
+
history: []
|
|
845
|
+
}
|
|
846
|
+
const antes = {}
|
|
847
|
+
const sinLlave = {}
|
|
848
|
+
|
|
849
|
+
for (const [kind, src, dst] of [['ns', data.ns, next.ns], ['dev', data.dev, next.dev]]) {
|
|
850
|
+
for (const [k, bag] of Object.entries(src)) {
|
|
851
|
+
const owner = `${kind}:${k}`
|
|
852
|
+
const entradas = desde === LEGACY_VERSION ? (bag || {}) : (bag?.vars || {})
|
|
853
|
+
const vars = {}
|
|
854
|
+
const keyring = []
|
|
855
|
+
// UNA SOLA generación para todo el cajón, y no es una excepción a «una por
|
|
856
|
+
// escritura»: convertir es UN acto, y aquí la bóveda tiene delante todos los
|
|
857
|
+
// valores en claro, así que puede sellarlos con la misma llave sin tener que
|
|
858
|
+
// recuperar nada. Además deja el cajón como lo espera un agente que todavía no
|
|
859
|
+
// se ha actualizado —una envoltura por cajón—, y eso es lo que permite convertir
|
|
860
|
+
// sin apagar a nadie. Lo que se escriba DESPUÉS ya estrena generación.
|
|
861
|
+
const gen = 1
|
|
862
|
+
let cek = null
|
|
863
|
+
for (const [key, e] of Object.entries(entradas)) {
|
|
864
|
+
const value = await claro(owner, e)
|
|
865
|
+
antes[`${owner}\u0000${key}`] = value
|
|
866
|
+
if (e.pub) { vars[key] = { v: value, pub: true }; continue }
|
|
867
|
+
if (!cek) {
|
|
868
|
+
cek = await sealer.newKey()
|
|
869
|
+
const { wraps, sinLlave: faltan } = await sealer.wrapFor(cek, (await membersOf(owner)) || [])
|
|
870
|
+
wraps[RECOVERY] = await sealer.wrapForKey(cek, next.recovery.pub)
|
|
871
|
+
if (faltan.length) sinLlave[owner] = faltan
|
|
872
|
+
keyring.push({ gen, createdAt: Date.now(), wraps })
|
|
873
|
+
}
|
|
874
|
+
const sobre = await sealer.encrypt(cek, value, gen)
|
|
875
|
+
const seal = signer ? await signer({ owner, key, gen, iv: sobre.iv, ct: sobre.ct }) : null
|
|
876
|
+
vars[key] = { pub: false, owner, gen, e: sobre, seal, at: Date.now(), by: null }
|
|
877
|
+
}
|
|
878
|
+
dst[k] = { vars, keyring }
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Releer lo escrito y comparar contra el original, antes de reemplazar nada.
|
|
883
|
+
const priv = await sealer.openMaster(next.recovery.priv, keyOr(adminKey))
|
|
884
|
+
for (const [kind, dst] of [['ns', next.ns], ['dev', next.dev]]) {
|
|
885
|
+
for (const [k, bag] of Object.entries(dst)) {
|
|
886
|
+
const owner = `${kind}:${k}`
|
|
887
|
+
for (const [key, e] of Object.entries(bag.vars)) {
|
|
888
|
+
let abierto
|
|
889
|
+
if (e.pub) abierto = e.v
|
|
890
|
+
else {
|
|
891
|
+
const g = bag.keyring.find((x) => x.gen === e.gen)
|
|
892
|
+
abierto = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
|
|
893
|
+
}
|
|
894
|
+
if (abierto !== antes[`${owner}\u0000${key}`]) {
|
|
895
|
+
throw new Error(`secrets: the migration check failed on ${owner}/${key}; nothing was touched`)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// Copia de lo anterior antes de pisarlo. Con nodos en producción, deshacer tiene
|
|
902
|
+
// que ser un `mv`, no una restauración. La borra el operador a mano.
|
|
903
|
+
writeJson(`${file}.v${desde}.bak`, data, atRest)
|
|
904
|
+
data = next
|
|
905
|
+
flush()
|
|
906
|
+
return { migrated: true, from: desde, sinLlave }
|
|
62
907
|
}
|
|
63
908
|
}
|
|
64
909
|
}
|
|
910
|
+
|
|
911
|
+
/** `ns:proxy` → `['ns','proxy']`. El `dev:` lleva dentro un JWK con dos puntos. */
|
|
912
|
+
function splitOwner (owner) {
|
|
913
|
+
const i = String(owner).indexOf(':')
|
|
914
|
+
if (i < 0) throw new Error('invalid drawer: expected "ns:<name>" or "dev:<pubkey>"')
|
|
915
|
+
return [owner.slice(0, i), owner.slice(i + 1)]
|
|
916
|
+
}
|