@dotrino/vaultd 0.53.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dotrino-vaultd.js +27 -1
- package/lib/src/atrest.js +116 -2
- package/lib/src/kek.js +293 -0
- package/lib/src/lock.js +117 -0
- package/lib/src/protocol.js +9 -1
- package/lib/src/service.js +44 -5
- package/package.json +4 -4
- package/src/ctl.js +302 -11
- package/src/daemon.js +167 -14
- package/src/keyowner.js +83 -0
- package/src/manager.js +33 -4
- package/src/passwords.js +15 -4
- package/src/profiles.js +161 -16
- package/src/vault.js +137 -19
package/src/profiles.js
CHANGED
|
@@ -24,7 +24,9 @@ import fs from 'node:fs'
|
|
|
24
24
|
import crypto2 from 'node:crypto'
|
|
25
25
|
import path from 'node:path'
|
|
26
26
|
import { dataDir, ensureDir, readJson, writeJson } from './paths.js'
|
|
27
|
-
import { atRestFor, migrateFile,
|
|
27
|
+
import { atRestFor, migrateFile, kekFor } from './atrest.js'
|
|
28
|
+
import { probe as probeKek, writeConfig as writeKekConfig, configFromEnv } from './atrest.js'
|
|
29
|
+
import { keyDirName, keyOwnerOf } from './keyowner.js'
|
|
28
30
|
|
|
29
31
|
const REGISTRY = 'profiles.json'
|
|
30
32
|
const PWD_ITER = 300000 // PBKDF2 del verificador v1 (heredado); v2 usa scrypt
|
|
@@ -107,7 +109,16 @@ async function derivePwd (password, saltB64, iter) {
|
|
|
107
109
|
return b64(bits)
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
/**
|
|
113
|
+
* El nombre de la carpeta DE PASO, la que existe solo mientras se acuña la llave.
|
|
114
|
+
*
|
|
115
|
+
* La llave se genera antes de escribirse (`crypto.subtle.generateKey` y luego el kv), pero
|
|
116
|
+
* la única API que tenemos —`Identity.connect({ dir })`— recibe la carpeta por delante y
|
|
117
|
+
* hace las dos cosas de un tirón: no hay costura donde meterse a preguntar la huella. Así
|
|
118
|
+
* que se acuña aquí y en cuanto se sabe de quién es, la carpeta se MUEVE a su nombre.
|
|
119
|
+
* El punto delante la deja fuera de la lista de perfiles si algo se corta a medias.
|
|
120
|
+
*/
|
|
121
|
+
const stagingName = () => '.new-' + crypto.randomUUID().slice(0, 8)
|
|
111
122
|
const cleanName = (name) => String(name || '').slice(0, MAX_NAME)
|
|
112
123
|
|
|
113
124
|
/**
|
|
@@ -125,7 +136,22 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
125
136
|
// respaldo o en una carpeta compartida por descuido, que es lo que el códec cubre
|
|
126
137
|
// para el resto. La migración verifica antes de reemplazar y es de una sola vez.
|
|
127
138
|
ensureDir(root)
|
|
128
|
-
|
|
139
|
+
// LA RAÍZ TAMBIÉN NECESITA EL PROVEEDOR DEL ENTORNO, y esto lo destapó un contenedor:
|
|
140
|
+
// los perfiles nacían con la clave en el KMS, pero el REGISTRO de perfiles seguía con
|
|
141
|
+
// la de la máquina — así que al recrear el contenedor la bóveda no arrancaba igual,
|
|
142
|
+
// solo que fallando un paso antes. No guarda el contenido de ningún perfil (la lista y
|
|
143
|
+
// el verificador del candado), pero sin él no hay bóveda.
|
|
144
|
+
//
|
|
145
|
+
// Solo si está por estrenar: si el registro ya existe cifrado con la clave de antes,
|
|
146
|
+
// cambiarle el proveedor lo dejaría ilegible. Ahí se migra con `atrest rekey`.
|
|
147
|
+
if (!fs.existsSync(path.join(root, 'atrest.json'))) {
|
|
148
|
+
const kek = configFromEnv()
|
|
149
|
+
if (kek && !fs.existsSync(file)) {
|
|
150
|
+
probeKek(root, kek)
|
|
151
|
+
writeKekConfig(root, kek)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
try { migrateFile(file, kekFor(root)) } catch (_) {}
|
|
129
155
|
const atRest = atRestFor(root)
|
|
130
156
|
let data = readJson(file, null, atRest)
|
|
131
157
|
if (!data || !Array.isArray(data.profiles)) data = { v: 1, current: null, profiles: [] }
|
|
@@ -192,6 +218,13 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
192
218
|
const hits = data.profiles.filter((p) => (p.name || '').toLowerCase() === needle)
|
|
193
219
|
if (hits.length === 1) return hits[0].id
|
|
194
220
|
if (hits.length > 1) throw new Error(`there are ${hits.length} profiles named "${ref}"; use its id (dotrino-vault profile ls)`)
|
|
221
|
+
// POR PREFIJO. Desde que el id del perfil es el nombre de su carpeta —y ese sale de
|
|
222
|
+
// la llave— es largo, y nadie va a teclear 24 caracteres para decir `--profile`. El
|
|
223
|
+
// trozo que sí se lee y se reconoce es la huella de delante (`0571-465F`), que es la
|
|
224
|
+
// misma que sale en `members`. Ambiguo se rechaza, no se adivina.
|
|
225
|
+
const porPrefijo = data.profiles.filter((p) => p.id.toLowerCase().startsWith(needle))
|
|
226
|
+
if (porPrefijo.length === 1) return porPrefijo[0].id
|
|
227
|
+
if (porPrefijo.length > 1) throw new Error(`"${ref}" matches ${porPrefijo.length} profiles; give more of the id (dotrino-vault profile ls)`)
|
|
195
228
|
throw new Error('profile does not exist: ' + ref)
|
|
196
229
|
},
|
|
197
230
|
|
|
@@ -201,12 +234,18 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
201
234
|
* navegador, que adopta la identidad vieja como «Perfil 1»). `transport.json`
|
|
202
235
|
* se queda en la raíz: es del proceso, no de la identidad.
|
|
203
236
|
*/
|
|
204
|
-
migrate () {
|
|
237
|
+
async migrate (mintKey) {
|
|
205
238
|
if (data.profiles.length) return null
|
|
206
239
|
const legacy = fs.existsSync(path.join(root, 'identity.json'))
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
240
|
+
// EL PROVEEDOR DEL ENTORNO, y SOLO en una instalación nueva. Es lo que hace que un
|
|
241
|
+
// contenedor levantado con `DOTRINO_KMS_KEY_ID` tenga su primer perfil ya con la
|
|
242
|
+
// clave en el KMS, sin entrar a configurar nada.
|
|
243
|
+
//
|
|
244
|
+
// En una MIGRACIÓN no se toca, y el matiz importa: esos archivos ya están cifrados
|
|
245
|
+
// con la clave de la máquina, así que ponerles ahora un proveedor distinto los
|
|
246
|
+
// dejaría ilegibles —el guardia lo pararía, pero el usuario se quedaría con una
|
|
247
|
+
// bóveda que no arranca tras actualizar—. Ahí se migra con `atrest rekey`, a la vista.
|
|
248
|
+
const dir = api.stage({ fromEnv: !legacy })
|
|
210
249
|
if (legacy) {
|
|
211
250
|
for (const f of LEGACY_FILES) {
|
|
212
251
|
const from = path.join(root, f)
|
|
@@ -217,12 +256,23 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
217
256
|
if (/^peers\..+\.json$/.test(f)) { try { fs.renameSync(path.join(root, f), path.join(dir, f)) } catch (_) {} }
|
|
218
257
|
}
|
|
219
258
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
259
|
+
try {
|
|
260
|
+
// Al migrar, la llave ya existe y esto solo la lee; en una instalación nueva, la crea.
|
|
261
|
+
const pub = await mintKey(dir)
|
|
262
|
+
// «Perfil 1» tanto al migrar como en una instalación nueva: es un nombre que
|
|
263
|
+
// el dueño puede cambiar, y evita que la CLI salude con «(sin nombre)».
|
|
264
|
+
const p = await api.commit(dir, 'Perfil 1', { pub })
|
|
265
|
+
data.current = p.id
|
|
266
|
+
save()
|
|
267
|
+
return { id: p.id, migrated: legacy }
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// En una instalación NUEVA no hay nada que perder y la carpeta de paso se va. En
|
|
270
|
+
// una MIGRACIÓN sí lo hay —los archivos del dueño ya están dentro—, así que se
|
|
271
|
+
// queda donde está y se dice en voz alta: borrarla sería borrar su bóveda.
|
|
272
|
+
if (!legacy) { try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {} }
|
|
273
|
+
else e.message += ` (your data is safe in ${dir}; it could not be given its final name)`
|
|
274
|
+
throw e
|
|
275
|
+
}
|
|
226
276
|
},
|
|
227
277
|
|
|
228
278
|
/**
|
|
@@ -233,15 +283,110 @@ export function openProfiles (root = dataDir(), { autoLockMs = AUTO_LOCK_MS, onA
|
|
|
233
283
|
* leería como pisar una cuenta con datos y se rechazaría, que es lo que tiene que
|
|
234
284
|
* pasar cuando nadie lo pidió.
|
|
235
285
|
*/
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
286
|
+
/**
|
|
287
|
+
* `kek`: el proveedor de la clave del disco, si el perfil tiene que NACER con él.
|
|
288
|
+
*
|
|
289
|
+
* Se escribe aquí y no después porque ese es justo el momento que importa: el
|
|
290
|
+
* directorio existe y todavía no hay un solo byte dentro. La maestra que genere
|
|
291
|
+
* `open()` un instante más tarde ya nace bajo esa clave y NUNCA existe bajo otra.
|
|
292
|
+
*
|
|
293
|
+
* Migrar un perfil que ya tiene identidad NO da esto, y por eso no se ofrece como
|
|
294
|
+
* si lo diera: su maestra ya se escribió bajo la clave vieja, y una copia del disco
|
|
295
|
+
* anterior a la migración la sigue abriendo para siempre. Un perfil con raíz en el
|
|
296
|
+
* KMS **nace** así (dueño, 2026-08-30).
|
|
297
|
+
*/
|
|
298
|
+
stage ({ kek = null, fromEnv = true } = {}) {
|
|
299
|
+
const dir = path.join(root, 'p', stagingName())
|
|
300
|
+
ensureDir(dir)
|
|
301
|
+
// Sin `--kms` explícito, manda el entorno: es lo que permite levantar un contenedor
|
|
302
|
+
// con KMS pasando variables, sin entrar a escribir un JSON dentro. Solo al CREAR:
|
|
303
|
+
// un perfil que ya existe manda con su `atrest.json`.
|
|
304
|
+
kek = kek || (fromEnv ? configFromEnv() : null)
|
|
305
|
+
if (kek) {
|
|
306
|
+
// Comprobar ANTES de dejar rastro: un KMS que no responde no puede dejar a
|
|
307
|
+
// medio crear un perfil cuya maestra no se va a poder volver a abrir.
|
|
308
|
+
try {
|
|
309
|
+
probeKek(dir, kek)
|
|
310
|
+
writeKekConfig(dir, kek)
|
|
311
|
+
} catch (e) {
|
|
312
|
+
try { fs.rmSync(dir, { recursive: true, force: true }) } catch (_) {}
|
|
313
|
+
throw e
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return dir
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* La carpeta de paso ya tiene su llave: se le pone SU nombre y entra en el registro.
|
|
321
|
+
* El `id` del perfil ES el nombre de su carpeta, y sale de la llave (`keyDirName`).
|
|
322
|
+
*/
|
|
323
|
+
async commit (staging, name, { adopt = false, pub } = {}) {
|
|
324
|
+
if (!pub) throw new Error('cannot name the folder: the key is missing')
|
|
325
|
+
const id = await keyDirName(pub)
|
|
326
|
+
const dest = dirOf(id)
|
|
327
|
+
// Esa llave ya tiene carpeta. No es un choque de nombres: es la MISMA llave dos
|
|
328
|
+
// veces, que es justo lo que este esquema existe para que no pase.
|
|
329
|
+
if (fs.existsSync(dest)) {
|
|
330
|
+
try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
|
|
331
|
+
throw Object.assign(new Error('that key already has a folder here: ' + id), { code: 'key-exists' })
|
|
332
|
+
}
|
|
333
|
+
fs.renameSync(staging, dest)
|
|
239
334
|
data.profiles.push({ id, name: cleanName(name), createdAt: Date.now(), ...(adopt ? { adopt: true } : {}) })
|
|
240
335
|
if (!data.current) data.current = id
|
|
241
336
|
save()
|
|
242
337
|
return entry(find(id))
|
|
243
338
|
},
|
|
244
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Crea un perfil entero: hace el sitio, deja que `mintKey` acuñe la llave dentro y
|
|
342
|
+
* mueve la carpeta a su nombre. `mintKey(dir)` devuelve la pública, y es cosa de quien
|
|
343
|
+
* llama porque generar una identidad no es asunto del registro.
|
|
344
|
+
*/
|
|
345
|
+
async add (name, { adopt = false, kek = null, mintKey } = {}) {
|
|
346
|
+
if (typeof mintKey !== 'function') throw new Error('add() needs mintKey to know whose folder it is')
|
|
347
|
+
const staging = api.stage({ kek })
|
|
348
|
+
try {
|
|
349
|
+
const pub = await mintKey(staging)
|
|
350
|
+
return await api.commit(staging, name, { adopt, pub })
|
|
351
|
+
} catch (e) {
|
|
352
|
+
// Nada a medias: la carpeta de paso se va con el intento fallido.
|
|
353
|
+
try { fs.rmSync(staging, { recursive: true, force: true }) } catch (_) {}
|
|
354
|
+
throw e
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* PONERLE A UNA CARPETA VIEJA EL NOMBRE DE SU LLAVE.
|
|
360
|
+
*
|
|
361
|
+
* Es toda la migración: mover la data a la carpeta que le toca. Se hace con la
|
|
362
|
+
* identidad CERRADA —antes de abrirla—, porque la identidad se queda con la ruta como
|
|
363
|
+
* texto y renombrar por debajo la deja escribiendo en una carpeta que ya no existe.
|
|
364
|
+
*
|
|
365
|
+
* De quién es la carpeta se sabe por la marca (`key.json`); si no la tiene —una
|
|
366
|
+
* instalación anterior a que existiera— se abre un momento solo para preguntárselo.
|
|
367
|
+
*/
|
|
368
|
+
async ensureNamedByKey (id, mintKey) {
|
|
369
|
+
const dir = dirOf(id)
|
|
370
|
+
if (!fs.existsSync(dir)) return id
|
|
371
|
+
let pub = keyOwnerOf(dir)
|
|
372
|
+
if (!pub && typeof mintKey === 'function') {
|
|
373
|
+
try { pub = await mintKey(dir) } catch (_) { return id }
|
|
374
|
+
}
|
|
375
|
+
if (!pub) return id
|
|
376
|
+
const quiere = await keyDirName(pub)
|
|
377
|
+
if (quiere === id) return id
|
|
378
|
+
if (fs.existsSync(dirOf(quiere))) {
|
|
379
|
+
throw Object.assign(new Error(`cannot rename ${id}: ${quiere} already exists`), { code: 'key-exists' })
|
|
380
|
+
}
|
|
381
|
+
fs.renameSync(dir, dirOf(quiere))
|
|
382
|
+
const p = find(id)
|
|
383
|
+
if (p) p.id = quiere
|
|
384
|
+
if (data.current === id) data.current = quiere
|
|
385
|
+
if (unlocked.has(id)) { unlocked.set(quiere, unlocked.get(id)); unlocked.delete(id) }
|
|
386
|
+
save()
|
|
387
|
+
return quiere
|
|
388
|
+
},
|
|
389
|
+
|
|
245
390
|
/** Quita la marca de «nació para adoptar» (ya adoptó, o se canceló). */
|
|
246
391
|
clearAdopt (id) {
|
|
247
392
|
const p = find(id)
|
package/src/vault.js
CHANGED
|
@@ -19,15 +19,20 @@ import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.j
|
|
|
19
19
|
import { createAdminDesk } from '../lib/src/admin.js'
|
|
20
20
|
import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
21
21
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
22
|
+
import { assertKeyOwnsDir } from './keyowner.js'
|
|
22
23
|
import { openStore } from './store.js'
|
|
23
24
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
24
25
|
import { openSecretsStore, assertVar } from './secretsStore.js'
|
|
25
|
-
|
|
26
|
+
// `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
|
|
27
|
+
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
28
|
+
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
29
|
+
// que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
|
|
30
|
+
import { createApprovals, PENDING_TTL_MS } from './approvals.js'
|
|
26
31
|
import { makeSealer } from './sealer.js'
|
|
27
32
|
import { openSealKeys } from './sealKey.js'
|
|
28
33
|
import { seal } from '../lib/src/sealed.js'
|
|
29
34
|
import { dataDir, ensureDir } from './paths.js'
|
|
30
|
-
import { atRestFor,
|
|
35
|
+
import { atRestFor, kekFor, migrateFile } from './atrest.js'
|
|
31
36
|
import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
32
37
|
|
|
33
38
|
/**
|
|
@@ -56,10 +61,13 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
56
61
|
// máquina (puede leer el mismo material); es subir el listón, no una imposibilidad.
|
|
57
62
|
// La migración verifica antes de reemplazar: si algo falla, el original queda intacto.
|
|
58
63
|
try {
|
|
59
|
-
const r = migrateFile(path.join(dir, 'identity.json'),
|
|
64
|
+
const r = migrateFile(path.join(dir, 'identity.json'), kekFor(dir))
|
|
60
65
|
if (r === 'migrado') log('[vault] identity encrypted at rest (bound to this machine)')
|
|
61
66
|
} catch (e) { log('[vault] could not encrypt the identity at rest:', e.message) }
|
|
62
67
|
const identity = await Identity.connect({ dir, atRest: atRestFor(dir) })
|
|
68
|
+
// ESTE DIRECTORIO ES DE ESTA LLAVE. Es lo único que hay que proteger cuando varias
|
|
69
|
+
// bóvedas viven en un mismo disco: cada una con el suyo, para que nunca se mezclen.
|
|
70
|
+
assertKeyOwnsDir(dir, identity.me?.publickey || null)
|
|
63
71
|
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
64
72
|
// CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
|
|
65
73
|
// igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
|
|
@@ -168,7 +176,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
168
176
|
recipients: (owner) => recipientsOf(owner),
|
|
169
177
|
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
170
178
|
signer: (body) => signSeal(body),
|
|
171
|
-
defaultKey: () => new Uint8Array(
|
|
179
|
+
defaultKey: () => new Uint8Array(kekFor(dir))
|
|
172
180
|
})
|
|
173
181
|
// SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
|
|
174
182
|
// §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
|
|
@@ -629,8 +637,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
629
637
|
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
630
638
|
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
631
639
|
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
632
|
-
|
|
633
|
-
|
|
640
|
+
// El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
|
|
641
|
+
// esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
|
|
642
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record || null })
|
|
634
643
|
await notifyApprovers(pend, record)
|
|
635
644
|
return
|
|
636
645
|
}
|
|
@@ -654,13 +663,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
654
663
|
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
655
664
|
// del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
|
|
656
665
|
// de dónde salió lo que abre.
|
|
666
|
+
// El acta va en LOS DOS caminos, también en el v3 heredado: ya no sirve solo para
|
|
667
|
+
// decir de dónde salieron los sobres, sino para saber qué llave podía firmar esta
|
|
668
|
+
// respuesta. Sin ella el agente no puede comprobar quién le contestó.
|
|
657
669
|
const payload = b.legacy
|
|
658
|
-
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
|
|
670
|
+
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
|
|
659
671
|
: { sealed: b, acta: record || null }
|
|
660
672
|
const enc = await seal({ ek, payload })
|
|
661
673
|
const body = { op: 'secrets.result', ns, enc, ts: Date.now() }
|
|
662
|
-
|
|
663
|
-
|
|
674
|
+
// LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
|
|
675
|
+
// no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
|
|
676
|
+
// misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
|
|
677
|
+
// bloqueado, y algún día desde una réplica que no tiene la maestra ni debe tenerla.
|
|
678
|
+
return { body, seal: await sealOrFail(body) }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Firma con la llave de sellado, o revienta. No hay repliegue a la maestra: si el acta
|
|
683
|
+
* no nombra una llave de sellado que sea nuestra, esta bóveda no está en condiciones de
|
|
684
|
+
* servir, y decirlo es mejor que servir firmado por quien no toca.
|
|
685
|
+
*/
|
|
686
|
+
async function sealOrFail (body) {
|
|
687
|
+
const s = await signSeal(body)
|
|
688
|
+
if (!s) throw new Error('this vault has no sealing key named by the record: it cannot serve')
|
|
689
|
+
return s
|
|
664
690
|
}
|
|
665
691
|
|
|
666
692
|
/**
|
|
@@ -684,8 +710,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
684
710
|
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
685
711
|
const answer = async (body) => {
|
|
686
712
|
body = { ...body, ts: Date.now() }
|
|
687
|
-
|
|
688
|
-
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
713
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
689
714
|
}
|
|
690
715
|
if (op === 'approvals') return answer({ op: 'approvals', items: approvals.list() })
|
|
691
716
|
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
@@ -748,11 +773,34 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
748
773
|
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
749
774
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
750
775
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
776
|
+
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
751
777
|
} catch (e) {
|
|
752
778
|
reply(from, { type: MSG.ERROR, error: e.message })
|
|
753
779
|
}
|
|
754
780
|
})
|
|
755
781
|
|
|
782
|
+
/**
|
|
783
|
+
* AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
|
|
784
|
+
* ADOPTA, que es lo que hace que conceder un permiso surta efecto en la otra máquina en
|
|
785
|
+
* vez de esperar a que renueve su cert.
|
|
786
|
+
*
|
|
787
|
+
* No se comprueba quién lo manda y no hace falta: `adoptActa` aplica §2.4.1 —firma,
|
|
788
|
+
* encadenado, `seq` que no baja y desempate— así que un acta ajena o vieja se rechaza
|
|
789
|
+
* sola. Fiarse del remitente sería la comprobación débil; fiarse del acta es la fuerte.
|
|
790
|
+
*/
|
|
791
|
+
async function handleAdminEvent (payload) {
|
|
792
|
+
const acta = payload?.acta
|
|
793
|
+
if (!acta || typeof acta !== 'object') return
|
|
794
|
+
try {
|
|
795
|
+
const r = await identity.adoptActa?.(acta)
|
|
796
|
+
if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
|
|
797
|
+
// Y el RECHAZO también se dice. Callarlo dejaría el peor fallo de todos: se concede
|
|
798
|
+
// un permiso en una máquina, la otra no se entera, y no hay una sola línea que lo
|
|
799
|
+
// explique — que es exactamente lo que costó encontrar esto.
|
|
800
|
+
else log(`[vault] IGNORED the record #${acta.seq} announced by another vault: ${r?.reason || 'no reason given'}`)
|
|
801
|
+
} catch (e) { log('[vault] could not adopt the announced record:', e.message) }
|
|
802
|
+
}
|
|
803
|
+
|
|
756
804
|
// ----- LA BÓVEDA DE CONTRASEÑAS -----
|
|
757
805
|
//
|
|
758
806
|
// Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
|
|
@@ -961,17 +1009,45 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
961
1009
|
try {
|
|
962
1010
|
const body = { ev, ...info, ts: Date.now() }
|
|
963
1011
|
const { signature } = await identity.signData(body)
|
|
1012
|
+
// EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
|
|
1013
|
+
// pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
|
|
1014
|
+
// Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
|
|
1015
|
+
// no puede sellar, porque su copia del acta no lo dice todavía.
|
|
1016
|
+
// No es un dato secreto (es pública dentro del perfil y estos son sus miembros), y
|
|
1017
|
+
// quien la recibe la adopta por las reglas de §2.4.1, así que una vieja o ajena no
|
|
1018
|
+
// hace daño: se rechaza sola.
|
|
1019
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
964
1020
|
const { issued } = await identity.listDelegations()
|
|
1021
|
+
// A QUIÉN SE AVISA: a los MIEMBROS DEL ACTA, no solo a quien esta bóveda enroló.
|
|
1022
|
+
//
|
|
1023
|
+
// Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
|
|
1024
|
+
// eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
|
|
1025
|
+
// `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
|
|
1026
|
+
// acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
|
|
1027
|
+
// tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
|
|
1028
|
+
// quedaban con actas distintas y la del dueño sin enterarse de nada.
|
|
1029
|
+
//
|
|
1030
|
+
// El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
|
|
1031
|
+
// suma porque cubre a quien tiene cert y todavía no aparece ahí.
|
|
1032
|
+
const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
|
|
1033
|
+
const yo = identity.me?.publickey || null
|
|
965
1034
|
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
966
1035
|
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
967
1036
|
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
968
1037
|
// cuidado que en `notifyNsChange`.
|
|
969
1038
|
const seen = new Set()
|
|
970
|
-
|
|
971
|
-
if (!
|
|
972
|
-
seen.add(
|
|
973
|
-
|
|
1039
|
+
const avisar = (pub) => {
|
|
1040
|
+
if (!pub || pub === yo || seen.has(pub)) return
|
|
1041
|
+
seen.add(pub)
|
|
1042
|
+
// El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
|
|
1043
|
+
// bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
|
|
1044
|
+
// de log cuesta días (la última vez, tres).
|
|
1045
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, signature, acta }) }
|
|
1046
|
+
catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
|
|
974
1047
|
}
|
|
1048
|
+
for (const m of miembros) avisar(m.pub)
|
|
1049
|
+
for (const d of issued || []) avisar(d.sub)
|
|
1050
|
+
log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
|
|
975
1051
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
976
1052
|
}
|
|
977
1053
|
|
|
@@ -1150,7 +1226,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1150
1226
|
* pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
|
|
1151
1227
|
* diseño). Prometer una protección que no está puesta es peor que no tenerla.
|
|
1152
1228
|
*/
|
|
1153
|
-
const adminKeyOr = (adminKey) => adminKey || new Uint8Array(
|
|
1229
|
+
const adminKeyOr = (adminKey) => adminKey || new Uint8Array(kekFor(dir))
|
|
1154
1230
|
|
|
1155
1231
|
// `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
|
|
1156
1232
|
// usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
|
|
@@ -1231,18 +1307,48 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1231
1307
|
const rewrapWaiters = new Set()
|
|
1232
1308
|
const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
|
|
1233
1309
|
|
|
1310
|
+
/**
|
|
1311
|
+
* LAS OTRAS BÓVEDAS —los miembros con el permiso `sella`— entran en TODOS los cajones,
|
|
1312
|
+
* también en los que tienen dueño. Decidido por el dueño el 2026-08-30.
|
|
1313
|
+
*
|
|
1314
|
+
* Y no contradice la regla de agosto —«un cajón con dueño no se envuelve para quien
|
|
1315
|
+
* administra»—, porque un cosellador **no es quien administra**: es un master. Aquella
|
|
1316
|
+
* regla saca a los aparatos de consola (un navegador, un portátil con la sesión puesta),
|
|
1317
|
+
* cuyo compromiso es probable y cuya necesidad es comodidad. Una segunda bóveda existe
|
|
1318
|
+
* justamente para poder REGENERAR los sobres el día que la primera no esté, y regenerar
|
|
1319
|
+
* exige abrir: dejarla fuera la haría inútil para el único desastre que viene a cubrir.
|
|
1320
|
+
*
|
|
1321
|
+
* El precio, que se dice y no se esconde: a partir de aquí son DOS los discos cuya
|
|
1322
|
+
* captura abre ese cajón. Eso es exactamente lo que cuesta sobrevivir a perder uno.
|
|
1323
|
+
*/
|
|
1324
|
+
async function cosealerMembers () {
|
|
1325
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1326
|
+
if (!record) return []
|
|
1327
|
+
// Sin `encPub` no hay a dónde envolver: se queda en deuda y se ve como `sinLlave`,
|
|
1328
|
+
// igual que cualquier otro miembro incompleto.
|
|
1329
|
+
return (record.members || []).filter((m) =>
|
|
1330
|
+
m.pub !== master && m.encPub && Acta.memberCan(record, m.pub, 'sealer'))
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
|
|
1334
|
+
const conCoselladores = (base, co) => {
|
|
1335
|
+
const vistos = new Set(base.map((m) => m.pub))
|
|
1336
|
+
return [...base, ...co.filter((m) => !vistos.has(m.pub))]
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1234
1339
|
async function recipientsOf (owner) {
|
|
1340
|
+
const co = await cosealerMembers()
|
|
1235
1341
|
if (owner.startsWith('ns:')) {
|
|
1236
1342
|
const owned = await nsMembers(owner.slice(3))
|
|
1237
1343
|
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
|
|
1238
1344
|
// administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
1239
|
-
return owned.length ? owned : await adminDevices()
|
|
1345
|
+
return conCoselladores(owned.length ? owned : await adminDevices(), co)
|
|
1240
1346
|
}
|
|
1241
1347
|
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
1242
1348
|
const m = await memberOf(pub)
|
|
1243
1349
|
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
1244
|
-
if (m?.cn) return [m]
|
|
1245
|
-
return [...(m ? [m] : []), ...await adminDevices()]
|
|
1350
|
+
if (m?.cn) return conCoselladores([m], co)
|
|
1351
|
+
return conCoselladores([...(m ? [m] : []), ...await adminDevices()], co)
|
|
1246
1352
|
}
|
|
1247
1353
|
|
|
1248
1354
|
/**
|
|
@@ -1818,6 +1924,18 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
1818
1924
|
isMaster: () => identity.isMaster(),
|
|
1819
1925
|
setCaps: async (pub, caps) => {
|
|
1820
1926
|
const r = await identity.setCaps(pub, caps)
|
|
1927
|
+
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
|
|
1928
|
+
// `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
|
|
1929
|
+
// pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
|
|
1930
|
+
// devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
|
|
1931
|
+
// contenedor, porque la imagen traía la versión de npm y el árbol local otra.
|
|
1932
|
+
try {
|
|
1933
|
+
const quedaron = new Set((await identity.profileMembers()).members.find((m) => m.pub === pub)?.caps || [])
|
|
1934
|
+
const perdidos = caps.filter((c) => !quedaron.has(c))
|
|
1935
|
+
if (perdidos.length) {
|
|
1936
|
+
log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
|
|
1937
|
+
}
|
|
1938
|
+
} catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
|
|
1821
1939
|
audit('caps', { device: await deviceIdOf(pub).catch(() => null), caps })
|
|
1822
1940
|
await notifyMembers('caps', { deviceId: await deviceIdOf(pub).catch(() => null), caps })
|
|
1823
1941
|
return r
|