@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/vault.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import fs from 'node:fs'
|
|
14
14
|
import path from 'node:path'
|
|
15
15
|
import { Identity } from '@dotrino/identity/node'
|
|
16
|
-
import { verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
|
|
16
|
+
import { verifyChain, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
17
17
|
import * as Acta from '@dotrino/identity/acta'
|
|
18
18
|
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS } from '../lib/src/enroll.js'
|
|
19
19
|
import { createAdminDesk } from '../lib/src/admin.js'
|
|
@@ -21,7 +21,9 @@ import { shouldNotifyRevoked } from '../lib/src/revocation.js'
|
|
|
21
21
|
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
22
22
|
import { openStore } from './store.js'
|
|
23
23
|
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
24
|
-
import { openSecretsStore } from './secretsStore.js'
|
|
24
|
+
import { openSecretsStore, assertVar } from './secretsStore.js'
|
|
25
|
+
import { makeSealer } from './sealer.js'
|
|
26
|
+
import { openSealKeys } from './sealKey.js'
|
|
25
27
|
import { seal } from '../lib/src/sealed.js'
|
|
26
28
|
import { dataDir, ensureDir } from './paths.js'
|
|
27
29
|
import { atRestFor, machineKey, migrateFile } from './atrest.js'
|
|
@@ -37,7 +39,7 @@ import { MSG, SCOPE, secretsScope, isValidSecretsNs } from './protocol.js'
|
|
|
37
39
|
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
38
40
|
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
39
41
|
*/
|
|
40
|
-
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, forAdoption = false, onAdopted } = {}) {
|
|
42
|
+
export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, forAdoption = false, onAdopted } = {}) {
|
|
41
43
|
ensureDir(dir)
|
|
42
44
|
// CIFRADO EN REPOSO ligado a esta máquina: ningún archivo del dir queda en claro, así
|
|
43
45
|
// que copiarlos a otro equipo no sirve de nada. La identidad se migra AQUÍ (verificando
|
|
@@ -59,12 +61,60 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
59
61
|
try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
// LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
|
|
65
|
+
// abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
|
|
66
|
+
// y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
|
|
67
|
+
const sealKeys = openSealKeys(dir)
|
|
68
|
+
identity.setSealKeyProvider?.(() => sealKeys.mint())
|
|
69
|
+
|
|
62
70
|
const store = openStore(dir)
|
|
63
71
|
const threads = openThreadStore(dir)
|
|
64
|
-
const secrets = openSecretsStore(dir
|
|
72
|
+
const secrets = openSecretsStore(dir, {
|
|
73
|
+
sealer: makeSealer(),
|
|
74
|
+
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
75
|
+
// propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
|
|
76
|
+
// acta, y por eso lo pone el vault: el store no conoce el acta.
|
|
77
|
+
recipients: (owner) => recipientsOf(owner),
|
|
78
|
+
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
79
|
+
signer: (body) => signSeal(body),
|
|
80
|
+
defaultKey: () => new Uint8Array(machineKey(dir))
|
|
81
|
+
})
|
|
82
|
+
// SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
|
|
83
|
+
// §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
|
|
84
|
+
// con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
|
|
85
|
+
// siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
|
|
86
|
+
// Se dice en voz alta: prometer una protección que no está puesta es peor que no
|
|
87
|
+
// tenerla (docs/secretos-sellados.md §2.3).
|
|
88
|
+
try {
|
|
89
|
+
if (!hasPassword()) {
|
|
90
|
+
log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
|
|
91
|
+
log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
|
|
92
|
+
}
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
|
|
65
95
|
const master = await masterPubkeyOf(identity)
|
|
66
96
|
const fp = (await pubkeyId(master)).slice(0, 16)
|
|
67
97
|
|
|
98
|
+
/**
|
|
99
|
+
* El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
|
|
100
|
+
* (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
|
|
101
|
+
* restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
|
|
102
|
+
* máquina y no puede quedar a medias.
|
|
103
|
+
*
|
|
104
|
+
* Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
|
|
105
|
+
* llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
|
|
106
|
+
*/
|
|
107
|
+
try {
|
|
108
|
+
const info = await identity.profileActa?.()
|
|
109
|
+
const acta = info?.acta
|
|
110
|
+
if (info?.isMaster && acta && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
|
|
111
|
+
const r = await identity.rotateSealKey()
|
|
112
|
+
// Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
|
|
113
|
+
// primero y `console.log` no lo sustituye (salía «record #%s 2»).
|
|
114
|
+
log(`[vault] new sealing key in record #${r.seq}`)
|
|
115
|
+
}
|
|
116
|
+
} catch (e) { log('[vault] could not set up the sealing key:', e.message) }
|
|
117
|
+
|
|
68
118
|
const { client } = await createTransport({ identity, dir, url: proxyUrl })
|
|
69
119
|
|
|
70
120
|
async function revocationSet () {
|
|
@@ -129,6 +179,25 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
129
179
|
catch (_) { return null }
|
|
130
180
|
},
|
|
131
181
|
onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
|
|
182
|
+
// Se va el aparato, se van SUS variables. Guardarlas sería configuración de una llave
|
|
183
|
+
// que ya no entra, y volvería a la vida sola el día que se enrole otro aparato con esa
|
|
184
|
+
// misma llave. Va aquí porque a quitar se entra por dos puertas (el PC y la consola
|
|
185
|
+
// remota) y las dos pasan por `desk.revokeDevice`.
|
|
186
|
+
onDeviceRemoved: (sub) => {
|
|
187
|
+
try {
|
|
188
|
+
const n = secrets.forgetDevice(sub)
|
|
189
|
+
if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
|
|
190
|
+
} catch (e) { log('[vault] could not drop the device variables:', e.message) }
|
|
191
|
+
// Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
|
|
192
|
+
// a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
|
|
193
|
+
// la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
|
|
194
|
+
//
|
|
195
|
+
// Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
|
|
196
|
+
// emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
|
|
197
|
+
// Un interruptor que pide una frase que quizá no tienes a mano no es un
|
|
198
|
+
// interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
|
|
199
|
+
markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
|
|
200
|
+
},
|
|
132
201
|
defaultScope: [SCOPE.READ],
|
|
133
202
|
onChallenge ({ deviceId, scope }) {
|
|
134
203
|
log(`\n[vault] Un dispositivo quiere conectarse:`)
|
|
@@ -273,6 +342,37 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
273
342
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: ' + chk.reason })
|
|
274
343
|
}
|
|
275
344
|
|
|
345
|
+
/**
|
|
346
|
+
* «¿SIGO SIENDO DE ESTA CASA?» — la única pregunta que se atiende SIN certificado.
|
|
347
|
+
*
|
|
348
|
+
* Existe por el aparato que se quedó sin papel: no puede firmar, ni leer, ni renovar, y
|
|
349
|
+
* —esto es lo grave— tampoco tenía forma de enterarse de que lo echaron, porque todo lo
|
|
350
|
+
* demás exige el certificado que ya no tiene. Se quedaba enseñando para siempre una
|
|
351
|
+
* cuenta que ya no era suya. Va firmada con la llave del propio aparato, que es
|
|
352
|
+
* exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
|
|
353
|
+
*
|
|
354
|
+
* La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
|
|
355
|
+
* pidió, y contarle el perfil entero a quien no trae papel es dar de más—. Al que ya no
|
|
356
|
+
* está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
|
|
357
|
+
* Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
|
|
358
|
+
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
359
|
+
* el aviso nombre al aparato que lo recibe).
|
|
360
|
+
*/
|
|
361
|
+
async function handleCheck (from, p) {
|
|
362
|
+
if (!isFresh(p?.data)) return staleReply(from)
|
|
363
|
+
const pub = p.data.publickey
|
|
364
|
+
if (typeof pub !== 'string') return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
365
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: p.data, signature: p.signature }))) {
|
|
366
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
367
|
+
}
|
|
368
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
369
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
370
|
+
audit('check', { device: await deviceIdOf(pub).catch(() => null), in: inside })
|
|
371
|
+
if (inside) return reply(from, { type: MSG.CHECKED, in: true })
|
|
372
|
+
await notifyIfRevoked(pub, null, null, 'revoked')
|
|
373
|
+
reply(from, { type: MSG.CHECKED, in: false })
|
|
374
|
+
}
|
|
375
|
+
|
|
276
376
|
async function handleDevices (from, p) {
|
|
277
377
|
if (!isFresh(p.data)) return staleReply(from)
|
|
278
378
|
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, trustedIssuer: master, revoked: await revocationSet() })
|
|
@@ -280,7 +380,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
280
380
|
const { issued, revoked } = await identity.listDelegations()
|
|
281
381
|
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
282
382
|
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
283
|
-
const
|
|
383
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
284
384
|
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
285
385
|
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
286
386
|
// de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
|
|
@@ -301,7 +401,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
301
401
|
const devices = await Promise.all(issued.map(async (x) => ({
|
|
302
402
|
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce
|
|
303
403
|
})))
|
|
304
|
-
reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta, chain })
|
|
404
|
+
reply(from, { type: MSG.DEVICES_RESULT, devices, revoked, acta: record, chain })
|
|
305
405
|
}
|
|
306
406
|
|
|
307
407
|
// RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
|
|
@@ -322,10 +422,10 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
322
422
|
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
323
423
|
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
324
424
|
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
325
|
-
const
|
|
425
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
326
426
|
let scope = p.cert.scope
|
|
327
|
-
if (
|
|
328
|
-
scope = Acta.memberScopes(
|
|
427
|
+
if (record) {
|
|
428
|
+
scope = Acta.memberScopes(record, p.cert.sub)
|
|
329
429
|
if (!scope.length) {
|
|
330
430
|
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
|
|
331
431
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
@@ -338,14 +438,53 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
338
438
|
}
|
|
339
439
|
|
|
340
440
|
// SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
|
|
341
|
-
// pide el bundle de su namespace
|
|
441
|
+
// pide el bundle de su namespace — el del SCOPE (que comparten todos los
|
|
442
|
+
// aparatos que sirven ese ns) con el SUYO PROPIO encima (`secretsStore.js`).
|
|
443
|
+
// Lo suyo se indexa por la llave que firma esta misma petición, así que no hay
|
|
444
|
+
// manera de pedir lo de otro aparato. La respuesta va SELLADA a la llave ECDH
|
|
342
445
|
// efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
|
|
343
446
|
// puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
|
|
344
447
|
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
345
448
|
// secretos falsos). Replay inerte: cada petición usa una ek nueva.
|
|
346
449
|
// data: { op:'secrets', ns, ek, publickey, ts }
|
|
450
|
+
/**
|
|
451
|
+
* Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
|
|
452
|
+
* suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
|
|
453
|
+
* de variables va indexado por ella — se quedaría sin configuración sin decirlo.
|
|
454
|
+
*/
|
|
455
|
+
async function handleEncKey (from, p) {
|
|
456
|
+
const ns = p.data?.ns
|
|
457
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
|
|
458
|
+
const chk = await verifyChain({
|
|
459
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
460
|
+
expectedScope: secretsScope(ns), trustedIssuer: master, revoked: await revocationSet()
|
|
461
|
+
})
|
|
462
|
+
if (!chk.ok) return denyChain(from, chk, p, 'enckey')
|
|
463
|
+
try {
|
|
464
|
+
await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
|
|
465
|
+
audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
466
|
+
log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
|
|
467
|
+
// Ya puede recibir sobres: se le envuelve la llave de su cajón en el acto, o
|
|
468
|
+
// seguiría sin poder abrir nada hasta la siguiente escritura.
|
|
469
|
+
await spreadKey(`ns:${ns}`, await nsMembers(ns)).catch((e) => log('[vault] could not hand it the key:', e.message))
|
|
470
|
+
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
471
|
+
const { signature } = await identity.signData(body)
|
|
472
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, signature })
|
|
473
|
+
} catch (e) {
|
|
474
|
+
reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
347
478
|
async function handleSecrets (from, p) {
|
|
348
479
|
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
480
|
+
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
481
|
+
// un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
|
|
482
|
+
// de identidad y obligaría a re-vendorizar.
|
|
483
|
+
//
|
|
484
|
+
// No exige la contraseña del perfil, y el argumento importa: registrar una llave no
|
|
485
|
+
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
486
|
+
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
487
|
+
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
349
488
|
const ns = p.data?.ns
|
|
350
489
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
351
490
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
@@ -357,14 +496,27 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
357
496
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
358
497
|
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
359
498
|
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
360
|
-
const
|
|
361
|
-
if (
|
|
499
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
500
|
+
if (record && !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
362
501
|
audit('rejected', { what: 'secrets', ns, reason: 'cn' })
|
|
363
502
|
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
364
503
|
}
|
|
365
504
|
let enc
|
|
366
505
|
try {
|
|
367
|
-
|
|
506
|
+
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
507
|
+
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
508
|
+
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
509
|
+
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
510
|
+
const b = secrets.bundleFor(ns, chk.device)
|
|
511
|
+
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
512
|
+
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
513
|
+
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
514
|
+
// del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
|
|
515
|
+
// de dónde salió lo que abre.
|
|
516
|
+
const payload = b.legacy
|
|
517
|
+
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])) }
|
|
518
|
+
: { sealed: b, acta: record || null }
|
|
519
|
+
enc = await seal({ ek: p.data.ek, payload })
|
|
368
520
|
} catch (e) {
|
|
369
521
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
370
522
|
}
|
|
@@ -383,9 +535,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
383
535
|
if (payload.type === MSG.SIGN) return await handleSign(from, payload)
|
|
384
536
|
if (payload.type === MSG.GET) return await handleGet(from, payload)
|
|
385
537
|
if (payload.type === MSG.STORE) return await handleStore(from, payload)
|
|
538
|
+
if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
|
|
386
539
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
387
540
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
388
541
|
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
542
|
+
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
389
543
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
390
544
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
391
545
|
} catch (e) {
|
|
@@ -408,14 +562,15 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
408
562
|
// AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
|
|
409
563
|
// escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
|
|
410
564
|
// reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
|
|
411
|
-
|
|
412
|
-
const
|
|
565
|
+
// La variable de entorno va en inglés (CONVENCIONES §8.1); nadie la tenía puesta.
|
|
566
|
+
const NOTICE_GROUP_MS = Number(process.env.DOTRINO_VAULT_NOTICE_MS) || 3000
|
|
567
|
+
const pendingNotices = new Map() // clave (ns | 'dev:'+pub) → timer
|
|
413
568
|
|
|
414
|
-
async function
|
|
415
|
-
let
|
|
569
|
+
async function notifyNsChange (ns) {
|
|
570
|
+
let targets = []
|
|
416
571
|
try {
|
|
417
572
|
const { issued } = await identity.listDelegations()
|
|
418
|
-
const
|
|
573
|
+
const revokedNonces = await revocationSet()
|
|
419
574
|
const scope = secretsScope(ns)
|
|
420
575
|
// Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
|
|
421
576
|
// que mandárselo a otro sería filtrarle que existe.
|
|
@@ -423,33 +578,62 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
423
578
|
// Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
|
|
424
579
|
// delegación nueva para la MISMA sub-clave, así que un agente que lleve
|
|
425
580
|
// tiempo enrolado aparece varias veces y recibiría el aviso repetido.
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
if (!x.sub ||
|
|
429
|
-
if (
|
|
430
|
-
|
|
581
|
+
const seen = new Set()
|
|
582
|
+
targets = (issued || []).filter((x) => {
|
|
583
|
+
if (!x.sub || revokedNonces.has(x.nonce) || !(x.scope || []).includes(scope)) return false
|
|
584
|
+
if (seen.has(x.sub)) return false
|
|
585
|
+
seen.add(x.sub)
|
|
431
586
|
return true
|
|
432
587
|
})
|
|
433
588
|
} catch (e) { return log('[vault] could not list who to notify:', e.message) }
|
|
434
|
-
if (!
|
|
589
|
+
if (!targets.length) return
|
|
435
590
|
|
|
436
591
|
const body = { op: 'secrets.changed', ns, ts: Date.now() }
|
|
437
592
|
const { signature } = await identity.signData(body)
|
|
438
|
-
for (const d of
|
|
593
|
+
for (const d of targets) {
|
|
439
594
|
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
|
|
440
595
|
}
|
|
441
|
-
audit('secrets.changed', { ns,
|
|
442
|
-
log(`[vault] config for "${ns}" changed: notified ${
|
|
596
|
+
audit('secrets.changed', { ns, notified: targets.length })
|
|
597
|
+
log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Cambió una variable de UN aparato: el aviso va solo a ese aparato. El mensaje dice
|
|
602
|
+
* qué NAMESPACE cambió (es lo que el agente sabe leer), así que hace falta su `cn`; un
|
|
603
|
+
* miembro sin `cn` no es un servicio y no lee variables, de modo que no hay a quién
|
|
604
|
+
* avisar y no se manda nada.
|
|
605
|
+
*/
|
|
606
|
+
async function notifyDeviceChange (pub) {
|
|
607
|
+
let cn = null
|
|
608
|
+
try {
|
|
609
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
610
|
+
cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
|
|
611
|
+
} catch (e) { return log('[vault] could not look up who to notify:', e.message) }
|
|
612
|
+
if (!cn) return
|
|
613
|
+
const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
|
|
614
|
+
const { signature } = await identity.signData(body)
|
|
615
|
+
try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, signature }) } catch (_) {}
|
|
616
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
617
|
+
audit('secrets.changed', { ns: cn, device, notified: 1 })
|
|
618
|
+
log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
|
|
443
619
|
}
|
|
444
620
|
|
|
445
|
-
|
|
446
|
-
|
|
621
|
+
/**
|
|
622
|
+
* Un cambio de configuración, un aviso: escrituras seguidas se agrupan en la misma
|
|
623
|
+
* ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
|
|
624
|
+
* lo de un aparato no cancele el aviso pendiente de todo su namespace.
|
|
625
|
+
*/
|
|
626
|
+
function scheduleNotice (ns) { schedule(ns, () => notifyNsChange(ns)) }
|
|
627
|
+
function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
|
|
628
|
+
|
|
629
|
+
function schedule (key, fn) {
|
|
630
|
+
clearTimeout(pendingNotices.get(key))
|
|
447
631
|
const t = setTimeout(() => {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
},
|
|
632
|
+
pendingNotices.delete(key)
|
|
633
|
+
fn().catch((e) => log('[vault] change notice failed:', e.message))
|
|
634
|
+
}, NOTICE_GROUP_MS)
|
|
451
635
|
t.unref?.()
|
|
452
|
-
|
|
636
|
+
pendingNotices.set(key, t)
|
|
453
637
|
}
|
|
454
638
|
|
|
455
639
|
// --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
|
|
@@ -480,16 +664,110 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
480
664
|
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
481
665
|
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
482
666
|
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
483
|
-
// cuidado que en `
|
|
484
|
-
const
|
|
667
|
+
// cuidado que en `notifyNsChange`.
|
|
668
|
+
const seen = new Set()
|
|
485
669
|
for (const d of issued || []) {
|
|
486
|
-
if (!d.sub ||
|
|
487
|
-
|
|
670
|
+
if (!d.sub || seen.has(d.sub)) continue
|
|
671
|
+
seen.add(d.sub)
|
|
488
672
|
try { client.sendByPubkey(d.sub, { type: MSG.ADMIN_EVENT, body, signature }) } catch (_) {}
|
|
489
673
|
}
|
|
490
674
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
491
675
|
}
|
|
492
676
|
|
|
677
|
+
/**
|
|
678
|
+
* MOSTRADOR DE VARIABLES para la consola remota (`lib/src/admin.js` enruta; la política
|
|
679
|
+
* y la cripto viven aquí, que es donde están la clave y el disco).
|
|
680
|
+
*
|
|
681
|
+
* Dos reglas, y son toda la frontera:
|
|
682
|
+
*
|
|
683
|
+
* 1. **El valor de una PRIVADA no sale de esta máquina.** Ni para un aparato tuyo con
|
|
684
|
+
* `admin`. Lo que viaja de una privada es su nombre y que es privada — con eso se
|
|
685
|
+
* le puede poner un valor nuevo a ciegas, que es lo que hace falta para rotarla.
|
|
686
|
+
* 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
|
|
687
|
+
* transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
|
|
688
|
+
*/
|
|
689
|
+
/**
|
|
690
|
+
* La llave de administración a partir de lo que venga en el sobre de la consola
|
|
691
|
+
* remota. `undefined` si no trae contraseña — entonces el store cae a la de la
|
|
692
|
+
* máquina, que es lo correcto para un perfil que no tiene ninguna.
|
|
693
|
+
*
|
|
694
|
+
* La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
|
|
695
|
+
*/
|
|
696
|
+
/**
|
|
697
|
+
* La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
|
|
698
|
+
* operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
|
|
699
|
+
*/
|
|
700
|
+
async function adminKeyFrom (payload) {
|
|
701
|
+
const pwd = payload?.password
|
|
702
|
+
if (typeof pwd !== 'string' || !pwd) return undefined
|
|
703
|
+
if (typeof deriveAdminKey !== 'function') return undefined
|
|
704
|
+
return deriveAdminKey(pwd)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const varsDesk = {
|
|
708
|
+
async list () {
|
|
709
|
+
// `listSecrets`/`listDeviceSecrets` ya traen el valor de las públicas y solo de esas:
|
|
710
|
+
// la frontera se decide en un sitio, y lo mismo ve el dueño en su terminal que aquí.
|
|
711
|
+
// Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
712
|
+
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
713
|
+
return {
|
|
714
|
+
enc: await identity.sealContent(JSON.stringify({
|
|
715
|
+
ns: listSecrets(), dev: await listDeviceSecrets(),
|
|
716
|
+
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
717
|
+
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
718
|
+
incomplete: await incompleteMembers(),
|
|
719
|
+
// Lo que quedó a deber un sellado: quien administra a distancia tiene que
|
|
720
|
+
// poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
|
|
721
|
+
// variable con la contraseña) y la bóveda no.
|
|
722
|
+
pending: await secretDebts(),
|
|
723
|
+
// Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
|
|
724
|
+
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
725
|
+
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
726
|
+
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
727
|
+
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
728
|
+
}))
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
async set ({ ns, pub, key, enc, public: isPublic, by: who = null }) {
|
|
732
|
+
const payload = JSON.parse(await identity.openContent(enc))
|
|
733
|
+
const value = payload?.value
|
|
734
|
+
if (typeof value !== 'string' || !value) throw new Error('var.set: the sealed envelope must carry a non-empty value')
|
|
735
|
+
// NO PIDE LA CONTRASEÑA (§8.1): sellar solo necesita las públicas de quien va a
|
|
736
|
+
// leer. Lo que se guarda es quién lo escribió, para que el histórico lo diga.
|
|
737
|
+
if (ns) await setSecret(ns, key, value, isPublic, { by: who })
|
|
738
|
+
else await setDeviceSecret(pub, key, value, isPublic, { by: who })
|
|
739
|
+
return { ok: true, key }
|
|
740
|
+
},
|
|
741
|
+
/**
|
|
742
|
+
* VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
|
|
743
|
+
* al servicio de que su configuración cambió, y el servicio SALE para releerla entera
|
|
744
|
+
* (`watchEnv`). Guardadas de una en una, quien administra a distancia reiniciaba el
|
|
745
|
+
* servicio una vez por variable, y las primeras veces arrancaba con la configuración a
|
|
746
|
+
* medio poner. Juntas: un guardado, un aviso, un reinicio.
|
|
747
|
+
*
|
|
748
|
+
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
749
|
+
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
750
|
+
*/
|
|
751
|
+
async setMany ({ ns, pub, enc, public: isPublic, by: who = null }) {
|
|
752
|
+
const payload = JSON.parse(await identity.openContent(enc))
|
|
753
|
+
const items = payload?.items
|
|
754
|
+
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: the sealed envelope must carry the variables')
|
|
755
|
+
// Borrar no se delega (`docs/consola-remota.md` §2): un aparato robado no puede
|
|
756
|
+
// dejar sin configuración a un servicio. Así que aquí solo entran valores nuevos.
|
|
757
|
+
/** @type {Array<{op:'set', key:string, value:string, public?:boolean}>} */
|
|
758
|
+
const list = items.map((it) => ({
|
|
759
|
+
op: /** @type {'set'} */ ('set'),
|
|
760
|
+
key: it?.key,
|
|
761
|
+
value: it?.value,
|
|
762
|
+
...(typeof it?.public === 'boolean' ? { public: it.public } : (isPublic === undefined ? {} : { public: isPublic }))
|
|
763
|
+
}))
|
|
764
|
+
// NO PIDE LA CONTRASEÑA (§8.1). Y sí, `applySecrets` va con `await` — sin él la
|
|
765
|
+
// escritura quedaba al aire y la respuesta salía antes de guardar nada.
|
|
766
|
+
const keys = ns ? await applySecrets(ns, list, { by: who }) : await applyDeviceSecrets(pub, list, { by: who })
|
|
767
|
+
return { ok: true, keys }
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
493
771
|
const admin = createAdminDesk({
|
|
494
772
|
desk,
|
|
495
773
|
deviceIdOf,
|
|
@@ -497,6 +775,7 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
497
775
|
audit,
|
|
498
776
|
notify: notifyMembers,
|
|
499
777
|
readActivity,
|
|
778
|
+
vars: varsDesk,
|
|
500
779
|
// CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
|
|
501
780
|
// qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
|
|
502
781
|
// hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
|
|
@@ -513,8 +792,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
513
792
|
await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
|
|
514
793
|
return chk
|
|
515
794
|
}
|
|
516
|
-
const
|
|
517
|
-
if (
|
|
795
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
796
|
+
if (record && !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: 'acta' }
|
|
518
797
|
return chk
|
|
519
798
|
}
|
|
520
799
|
})
|
|
@@ -556,29 +835,674 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
556
835
|
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
557
836
|
}
|
|
558
837
|
|
|
559
|
-
// API local de secretos (
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
838
|
+
// API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
|
|
839
|
+
// sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
|
|
840
|
+
/**
|
|
841
|
+
* La llave con la que se abre la copia maestra.
|
|
842
|
+
*
|
|
843
|
+
* Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
|
|
844
|
+
* contraseña se cae a la llave de la máquina**, que es exactamente la protección
|
|
845
|
+
* que había antes de todo esto: el disco sigue cifrado, pero su material vive en
|
|
846
|
+
* ese mismo disco, así que una copia del disco lo abre.
|
|
847
|
+
*
|
|
848
|
+
* Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
|
|
849
|
+
* pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
|
|
850
|
+
* diseño). Prometer una protección que no está puesta es peor que no tenerla.
|
|
851
|
+
*/
|
|
852
|
+
const adminKeyOr = (adminKey) => adminKey || new Uint8Array(machineKey(dir))
|
|
853
|
+
|
|
854
|
+
// `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
|
|
855
|
+
// usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
|
|
856
|
+
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
857
|
+
/**
|
|
858
|
+
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
859
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
|
|
860
|
+
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
861
|
+
* que este diseño quita.
|
|
862
|
+
*/
|
|
863
|
+
async function nsMembers (ns) {
|
|
864
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
865
|
+
return (record?.members || []).filter((m) => m.cn === ns)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
|
|
870
|
+
* cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
|
|
871
|
+
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
872
|
+
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
873
|
+
*
|
|
874
|
+
* La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
875
|
+
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
876
|
+
* diseño quita. Hay un test que lo afirma.
|
|
877
|
+
*/
|
|
878
|
+
async function adminDevices () {
|
|
879
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
880
|
+
return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
|
|
885
|
+
* importa (decidida por el dueño el 2026-08-22):
|
|
886
|
+
*
|
|
887
|
+
* **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
|
|
888
|
+
* hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
|
|
889
|
+
* token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
|
|
890
|
+
* alguien se lleve tu portátil con la sesión puesta.
|
|
891
|
+
*
|
|
892
|
+
* Dos cosas que hacen que esto se sostenga:
|
|
893
|
+
*
|
|
894
|
+
* · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
|
|
895
|
+
* que la bóveda se negara a entregarlo, pero entonces la protección sería una
|
|
896
|
+
* política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
|
|
897
|
+
* llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
|
|
898
|
+
* se crea no se puede saltar.
|
|
899
|
+
* · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
|
|
900
|
+
* dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
|
|
901
|
+
* se puede forzar esperando a que ese servicio esté caído.
|
|
902
|
+
*
|
|
903
|
+
* Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
|
|
904
|
+
* miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
|
|
905
|
+
* abre la envoltura de recuperación, que `wrapAll` añade siempre.
|
|
906
|
+
*/
|
|
907
|
+
/**
|
|
908
|
+
* Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
|
|
909
|
+
* el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
|
|
910
|
+
* quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
|
|
911
|
+
* qué pidió; aquí solo se valida el remitente.
|
|
912
|
+
*/
|
|
913
|
+
async function handleRewrapOk (payload) {
|
|
914
|
+
const d = payload?.data
|
|
915
|
+
if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
|
|
916
|
+
const signer = payload?.cert?.sub
|
|
917
|
+
if (!signer) return
|
|
918
|
+
if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
|
|
919
|
+
return log('[vault] a handed key arrived BADLY SIGNED: ignored')
|
|
920
|
+
}
|
|
921
|
+
// Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
|
|
922
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
923
|
+
const m = (record?.members || []).find((x) => x.pub === signer)
|
|
924
|
+
const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
|
|
925
|
+
if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
|
|
926
|
+
for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
|
|
930
|
+
const rewrapWaiters = new Set()
|
|
931
|
+
const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
|
|
932
|
+
|
|
933
|
+
async function recipientsOf (owner) {
|
|
934
|
+
if (owner.startsWith('ns:')) {
|
|
935
|
+
const owned = await nsMembers(owner.slice(3))
|
|
936
|
+
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) sí entra quien
|
|
937
|
+
// administra: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
938
|
+
return owned.length ? owned : await adminDevices()
|
|
939
|
+
}
|
|
940
|
+
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
941
|
+
const m = await memberOf(pub)
|
|
942
|
+
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
943
|
+
if (m?.cn) return [m]
|
|
944
|
+
return [...(m ? [m] : []), ...await adminDevices()]
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
|
|
949
|
+
* del acta junto a la firma: es lo que le dice a quien verifica con qué llave
|
|
950
|
+
* comprobarla, porque esa llave rota con el acta (§8.9).
|
|
951
|
+
*
|
|
952
|
+
* Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
|
|
953
|
+
* es más importante que poder demostrar después de dónde salió.
|
|
954
|
+
*/
|
|
955
|
+
async function signSeal (body) {
|
|
956
|
+
try {
|
|
957
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
958
|
+
if (!acta?.sealPub) return null
|
|
959
|
+
const sig = await sealKeys.sign(acta.sealPub, body)
|
|
960
|
+
return sig ? { seq: acta.seq, sig } : null
|
|
961
|
+
} catch (_) { return null }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
|
|
966
|
+
* del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
|
|
967
|
+
* abrir y se queda reintentando para siempre, sin decir por qué.
|
|
968
|
+
*
|
|
969
|
+
* No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
|
|
970
|
+
* reiniciaría a todos los nodos del ns para nada.
|
|
971
|
+
*/
|
|
972
|
+
/**
|
|
973
|
+
* Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
|
|
974
|
+
* el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
|
|
975
|
+
* consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
|
|
976
|
+
*
|
|
977
|
+
* Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
|
|
978
|
+
* que ya no está podría abrir lo que se escriba mañana.
|
|
979
|
+
*/
|
|
980
|
+
async function markRotationDue (sub) {
|
|
981
|
+
const m = await memberOf(sub)
|
|
982
|
+
const ns = m?.cn
|
|
983
|
+
if (!ns) return
|
|
984
|
+
try {
|
|
985
|
+
const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
|
|
986
|
+
if (r?.rotated != null) {
|
|
987
|
+
log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
|
|
988
|
+
audit('secret.rotate', { ns, keys: r.rotated })
|
|
989
|
+
scheduleNotice(ns)
|
|
990
|
+
return
|
|
991
|
+
}
|
|
992
|
+
} catch (e) {
|
|
993
|
+
store.setSetting(`rotate-due:${ns}`, String(Date.now()))
|
|
994
|
+
log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
|
|
995
|
+
audit('secret.rotate-due', { ns, reason: e.message })
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
|
|
1001
|
+
* deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
|
|
1002
|
+
* salen juntas:
|
|
1003
|
+
*
|
|
1004
|
+
* - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
|
|
1005
|
+
* - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
|
|
1006
|
+
*
|
|
1007
|
+
* Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
|
|
1008
|
+
* enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
|
|
1009
|
+
* que además nadie ve.
|
|
1010
|
+
*/
|
|
1011
|
+
/**
|
|
1012
|
+
* Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
|
|
1013
|
+
* las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
|
|
1014
|
+
* llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
|
|
1015
|
+
* hay quien la teclee.
|
|
1016
|
+
*
|
|
1017
|
+
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
1018
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
|
|
1019
|
+
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
1020
|
+
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
1021
|
+
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
1022
|
+
*
|
|
1023
|
+
* @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
|
|
1024
|
+
*/
|
|
1025
|
+
async function incompleteMembers () {
|
|
1026
|
+
if (secrets.isLegacy?.()) return []
|
|
1027
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1028
|
+
const out = []
|
|
1029
|
+
for (const m of record?.members || []) {
|
|
1030
|
+
if (!m.encPub || m.pub === master) continue
|
|
1031
|
+
const owners = {}
|
|
1032
|
+
// Un servicio lee su cajón de ns y el suyo propio; un aparato que administra
|
|
1033
|
+
// puede DESTAPAR cualquiera, así que se le miran todos: sin envoltura, el botón
|
|
1034
|
+
// «Ver» de la consola le fallaría sin explicar por qué.
|
|
1035
|
+
// Un aparato que administra NO debe tener envoltura de los cajones con dueño
|
|
1036
|
+
// (`recipientsOf`), así que no se le cuenta como falta: lo que ahí falta es a
|
|
1037
|
+
// propósito y ponerlo en la lista sería pedir que se «arregle» lo correcto.
|
|
1038
|
+
const ownedNs = new Set((record?.members || []).filter((x) => x.cn).map((x) => x.cn))
|
|
1039
|
+
const drawers = m.cn
|
|
1040
|
+
? [`ns:${m.cn}`, `dev:${m.pub}`]
|
|
1041
|
+
: [...Object.keys(secrets.list()).filter((ns) => !ownedNs.has(ns)).map((ns) => `ns:${ns}`), `dev:${m.pub}`]
|
|
1042
|
+
for (const owner of drawers) {
|
|
1043
|
+
const missing = secrets.missingFor(owner, m.pub)
|
|
1044
|
+
if (missing.length) owners[owner] = missing
|
|
1045
|
+
}
|
|
1046
|
+
if (Object.keys(owners).length) out.push({ pub: m.pub, cn: m.cn || null, owners })
|
|
1047
|
+
}
|
|
1048
|
+
return out
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
/**
|
|
1052
|
+
* Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
|
|
1053
|
+
* no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
|
|
1054
|
+
* estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
|
|
1055
|
+
*/
|
|
1056
|
+
function rotationsDue () {
|
|
1057
|
+
const out = {}
|
|
1058
|
+
for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
|
|
1059
|
+
// La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
|
|
1060
|
+
// nombre del namespace: una sola forma para quien lo lee.
|
|
1061
|
+
if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
|
|
1062
|
+
}
|
|
1063
|
+
return out
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
|
|
1068
|
+
* envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
1069
|
+
* se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
|
|
1070
|
+
* se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
|
|
1071
|
+
* entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
|
|
1072
|
+
* @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
|
|
1073
|
+
*/
|
|
1074
|
+
async function secretDebts () {
|
|
1075
|
+
const out = rotationsDue()
|
|
1076
|
+
for (const m of await incompleteMembers()) {
|
|
1077
|
+
for (const [owner, keys] of Object.entries(m.owners)) {
|
|
1078
|
+
if (out[owner]?.kind === 'rotate') continue
|
|
1079
|
+
out[owner] = out[owner] || { kind: 'rewrap', members: [] }
|
|
1080
|
+
out[owner].members.push({ pub: m.pub, keys })
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
return out
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function spreadKey (owner, members, adminKey) {
|
|
1087
|
+
if (secrets.isLegacy()) return null
|
|
1088
|
+
// Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
|
|
1089
|
+
// se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
|
|
1090
|
+
// sustituye al reparto en vez de sumarse.
|
|
1091
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1092
|
+
if (ns && store.getSetting(`rotate-due:${ns}`)) {
|
|
1093
|
+
const rot = await secrets.rotate(owner, members, adminKey)
|
|
1094
|
+
store.setSetting(`rotate-due:${ns}`, undefined)
|
|
1095
|
+
log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
|
|
1096
|
+
audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
|
|
1097
|
+
return rot
|
|
1098
|
+
}
|
|
1099
|
+
try {
|
|
1100
|
+
const r = await secrets.rewrap(owner, members, adminKey)
|
|
1101
|
+
if (r?.sinLlave?.length) {
|
|
1102
|
+
log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
|
|
1103
|
+
audit('secret.nokey', { owner, count: r.sinLlave.length })
|
|
1104
|
+
}
|
|
1105
|
+
return r
|
|
1106
|
+
} catch (e) {
|
|
1107
|
+
// SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
|
|
1108
|
+
// donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
|
|
1109
|
+
// cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
|
|
1110
|
+
// del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
|
|
1111
|
+
// — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
|
|
1112
|
+
// anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
|
|
1113
|
+
// salde un hermano o el abrir la bóveda.
|
|
1114
|
+
log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
|
|
1115
|
+
audit('secret.rewrap-due', { owner, reason: e.message })
|
|
1116
|
+
throw e
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
|
|
1122
|
+
* a leer. `by` es el aparato que lo escribió, y va al histórico.
|
|
1123
|
+
*/
|
|
1124
|
+
async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
|
|
1125
|
+
await secrets.set(ns, key, value, isPublic, { by })
|
|
1126
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1127
|
+
audit('secret.set', { ns, key }); scheduleNotice(ns)
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
|
|
1132
|
+
* desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
|
|
1133
|
+
* verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
|
|
1134
|
+
* único que la frase guarda (§8.3).
|
|
1135
|
+
*
|
|
1136
|
+
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
1137
|
+
* tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
|
|
1138
|
+
*/
|
|
1139
|
+
/**
|
|
1140
|
+
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
1141
|
+
*
|
|
1142
|
+
* Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
|
|
1143
|
+
* llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
|
|
1144
|
+
* tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
|
|
1145
|
+
*
|
|
1146
|
+
* La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
|
|
1147
|
+
* podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
|
|
1148
|
+
* destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
|
|
1149
|
+
* solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
|
|
1150
|
+
*
|
|
1151
|
+
* @returns {Promise<{ done: number, asked: number }>}
|
|
1152
|
+
*/
|
|
1153
|
+
async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
|
|
1154
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1155
|
+
if (!ns) return { done: 0, asked: 0 }
|
|
1156
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1157
|
+
if (!record) return { done: 0, asked: 0 }
|
|
1158
|
+
// Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
|
|
1159
|
+
const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
|
|
1160
|
+
const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
|
|
1161
|
+
if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
|
|
1162
|
+
|
|
1163
|
+
let done = 0
|
|
1164
|
+
for (const gen of pending) {
|
|
1165
|
+
const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
|
|
1166
|
+
const { signature } = await identity.signData(body)
|
|
1167
|
+
const answer = new Promise((resolve) => {
|
|
1168
|
+
const off = onRewrapOk((d) => {
|
|
1169
|
+
if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
|
|
1170
|
+
off(); resolve(d.wrap)
|
|
1171
|
+
})
|
|
1172
|
+
setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
|
|
1173
|
+
})
|
|
1174
|
+
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, signature }) } catch (e) {
|
|
1175
|
+
log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
|
|
1176
|
+
continue
|
|
1177
|
+
}
|
|
1178
|
+
const wrap = await answer
|
|
1179
|
+
if (!wrap) continue
|
|
1180
|
+
try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
|
|
1181
|
+
log(`[vault] ${owner}: the handed key was refused (${e.message})`)
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (done) {
|
|
1185
|
+
audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
|
|
1186
|
+
log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
|
|
1187
|
+
}
|
|
1188
|
+
return { done, asked: pending.length }
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
async function resealAll (adminKey = null) {
|
|
1192
|
+
const out = { drawers: 0, wrapped: 0, dropped: 0, sinLlave: [] }
|
|
1193
|
+
for (const owner of secrets.owners?.() || []) {
|
|
1194
|
+
const before = new Set(secrets.recipientsIn(owner))
|
|
1195
|
+
const members = await recipientsOf(owner)
|
|
1196
|
+
try {
|
|
1197
|
+
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
1198
|
+
const after = new Set(secrets.recipientsIn(owner))
|
|
1199
|
+
out.drawers++
|
|
1200
|
+
out.wrapped += r?.wrapped || 0
|
|
1201
|
+
out.dropped += [...before].filter((p) => !after.has(p)).length
|
|
1202
|
+
for (const p of r?.sinLlave || []) out.sinLlave.push(p)
|
|
1203
|
+
} catch (e) {
|
|
1204
|
+
log(`[vault] ${owner}: could not reseal (${e.message})`)
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
if (out.dropped) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped })
|
|
1208
|
+
return out
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
async function settleSecretDebts (adminKey = null) {
|
|
1212
|
+
const out = {}
|
|
1213
|
+
// Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
|
|
1214
|
+
// envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
|
|
1215
|
+
// por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
|
|
1216
|
+
// pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
|
|
1217
|
+
// que es barato y no depende de que alguien se acordara de apuntarlo.
|
|
1218
|
+
const debts = new Set(Object.keys(rotationsDue()))
|
|
1219
|
+
for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
|
|
1220
|
+
|
|
1221
|
+
for (const owner of debts) {
|
|
1222
|
+
const k = owner.slice(owner.indexOf(':') + 1)
|
|
1223
|
+
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
1224
|
+
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
1225
|
+
// sin poder destapar lo que ella misma acababa de saldar.
|
|
1226
|
+
const members = await recipientsOf(owner)
|
|
1227
|
+
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
1228
|
+
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
1229
|
+
let delegated = 0
|
|
1230
|
+
for (const m of await incompleteMembers()) {
|
|
1231
|
+
if (!m.owners[owner]) continue
|
|
1232
|
+
delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
|
|
1233
|
+
}
|
|
1234
|
+
out[owner] = delegated ? { delegated } : { error: e.message }
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return out
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
|
|
1242
|
+
* Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
|
|
1243
|
+
* recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
|
|
1244
|
+
* anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
|
|
1245
|
+
* guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
|
|
1246
|
+
*/
|
|
1247
|
+
async function settleDebts (owner, membersFn) {
|
|
1248
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
1249
|
+
const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
|
|
1250
|
+
(await incompleteMembers()).some((m) => m.owners[owner])
|
|
1251
|
+
if (!owed) return null
|
|
1252
|
+
try { return await spreadKey(owner, await membersFn(), null) } catch (_) { return null }
|
|
1253
|
+
}
|
|
1254
|
+
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
1258
|
+
*
|
|
1259
|
+
* De una en una, cada `set` es un cambio de configuración para la bóveda, y el agente
|
|
1260
|
+
* obedece el primero —sale, lo levanta el supervisor, lee lo que hubiera en ese
|
|
1261
|
+
* instante— mientras el dueño sigue tecleando. El resultado es un servicio corriendo
|
|
1262
|
+
* con media configuración, y encima con el arranque a medio hacer. La ventana de
|
|
1263
|
+
* agrupado (`NOTICE_GROUP_MS`) tapa el caso de un script, no el de una persona
|
|
1264
|
+
* escribiendo con quince segundos entre variable y variable.
|
|
1265
|
+
*
|
|
1266
|
+
* Así que la carga en grupo llega hasta aquí entera: se valida TODO primero, se
|
|
1267
|
+
* escribe en un solo guardado y sale UN aviso al final. Las visibilidades no entran:
|
|
1268
|
+
* no cambian lo que el servicio lee y por eso nunca avisaron.
|
|
1269
|
+
*
|
|
1270
|
+
* @param {string} ns
|
|
1271
|
+
* @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
|
|
1272
|
+
* @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
|
|
1273
|
+
* estaba no cambia nada, y no tiene por qué reiniciar a nadie).
|
|
1274
|
+
*/
|
|
1275
|
+
async function applySecrets (ns, items, { by = null } = {}) {
|
|
1276
|
+
const list = assertItems(items)
|
|
1277
|
+
const changed = []
|
|
1278
|
+
await secrets.batch(async () => {
|
|
1279
|
+
for (const it of list) {
|
|
1280
|
+
if (it.op === 'rm') {
|
|
1281
|
+
if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
1282
|
+
} else {
|
|
1283
|
+
await secrets.set(ns, it.key, it.value, it.public, { by })
|
|
1284
|
+
audit('secret.set', { ns, key: it.key })
|
|
1285
|
+
changed.push(it.key)
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
})
|
|
1289
|
+
if (changed.length) {
|
|
1290
|
+
await settleDebts(`ns:${ns}`, () => nsMembers(ns))
|
|
1291
|
+
scheduleNotice(ns)
|
|
1292
|
+
}
|
|
1293
|
+
return changed
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
|
|
1297
|
+
async function applyDeviceSecrets (pub, items, { by = null } = {}) {
|
|
1298
|
+
const list = assertItems(items)
|
|
1299
|
+
const m = await requireService(pub)
|
|
1300
|
+
const changed = []
|
|
1301
|
+
await secrets.batch(async () => {
|
|
1302
|
+
for (const it of list) {
|
|
1303
|
+
if (it.op === 'rm') {
|
|
1304
|
+
if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
1305
|
+
} else {
|
|
1306
|
+
await secrets.setDevice(pub, it.key, it.value, it.public, { by })
|
|
1307
|
+
changed.push(it.key)
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
})
|
|
1311
|
+
if (changed.length) {
|
|
1312
|
+
await settleDebts(`dev:${pub}`, async () => [m].filter(Boolean))
|
|
1313
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
1314
|
+
for (const it of list) {
|
|
1315
|
+
if (!changed.includes(it.key)) continue
|
|
1316
|
+
audit(it.op === 'rm' ? 'secret.rm' : 'secret.set', { device, ns: m?.cn || null, key: it.key, scope: 'device' })
|
|
1317
|
+
}
|
|
1318
|
+
scheduleDeviceNotice(pub)
|
|
1319
|
+
}
|
|
1320
|
+
return changed
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Todo o nada: si una variable del grupo no vale, no se escribe NINGUNA. Media
|
|
1325
|
+
* configuración cargada es peor que ninguna — el servicio arranca con ella.
|
|
1326
|
+
*/
|
|
1327
|
+
function assertItems (items) {
|
|
1328
|
+
if (!Array.isArray(items) || !items.length) throw new Error('batch: no items')
|
|
1329
|
+
for (const it of items) {
|
|
1330
|
+
if (!it || (it.op !== 'set' && it.op !== 'rm')) throw new Error('batch: each item must be a set or an rm')
|
|
1331
|
+
if (it.op === 'rm') { if (!it.key) throw new Error('batch: rm needs a key') } else assertVar(it.key, it.value)
|
|
1332
|
+
}
|
|
1333
|
+
return items
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* Los nombres, y el VALOR de las públicas. Pública quiere decir «este valor puede salir
|
|
1337
|
+
* de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
|
|
1338
|
+
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
1339
|
+
*/
|
|
1340
|
+
function listSecrets () {
|
|
1341
|
+
const out = {}
|
|
1342
|
+
for (const [ns, keys] of Object.entries(secrets.list())) {
|
|
1343
|
+
const values = secrets.publicOf(ns)
|
|
1344
|
+
out[ns] = keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k))
|
|
1345
|
+
}
|
|
1346
|
+
return out
|
|
1347
|
+
}
|
|
1348
|
+
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
1349
|
+
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
1350
|
+
const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
|
|
1351
|
+
if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
|
|
1352
|
+
return ok
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
|
|
1357
|
+
* camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
|
|
1358
|
+
* este sí pide poder abrir la copia maestra.
|
|
1359
|
+
*/
|
|
1360
|
+
async function openSecrets (ns, devicePub = null, adminKey) {
|
|
1361
|
+
return secrets.openBundle(ns, devicePub, adminKey)
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
1365
|
+
async function memberOf (pub) {
|
|
1366
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1367
|
+
if (!record) return null
|
|
1368
|
+
return (record.members || []).find((m) => m.pub === pub) || null
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/**
|
|
1372
|
+
* Variables de UN aparato. Se exige que sea un SERVICIO del acta (miembro con `cn`) porque
|
|
1373
|
+
* es el único que las lee: guardárselas a un teléfono sería configuración muerta, escrita
|
|
1374
|
+
* donde nadie la va a buscar el día que no funcione. Si la bóveda es anterior al acta no
|
|
1375
|
+
* hay contra qué comprobarlo y se acepta.
|
|
1376
|
+
*/
|
|
1377
|
+
async function requireService (pub) {
|
|
1378
|
+
const m = await memberOf(pub)
|
|
1379
|
+
if (!m) {
|
|
1380
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1381
|
+
if (record) throw new Error('device: it is not a member of this profile')
|
|
1382
|
+
return null
|
|
1383
|
+
}
|
|
1384
|
+
if (!m.cn) throw new Error('device: it is not a service (only services read variables); pair it with `pair --service <ns>`')
|
|
1385
|
+
return m
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
|
|
1389
|
+
const m = await requireService(pub)
|
|
1390
|
+
await secrets.setDevice(pub, key, value, isPublic, { by })
|
|
1391
|
+
await settleDebts(`dev:${pub}`, async () => [await memberOf(pub)].filter(Boolean))
|
|
1392
|
+
audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
1393
|
+
scheduleDeviceNotice(pub)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
async function deleteDeviceSecret (pub, key) {
|
|
1397
|
+
const m = await memberOf(pub)
|
|
1398
|
+
const ok = await secrets.deleteDevice(pub, key)
|
|
1399
|
+
if (ok) {
|
|
1400
|
+
audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
1401
|
+
scheduleDeviceNotice(pub)
|
|
1402
|
+
}
|
|
1403
|
+
return ok
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
|
|
1407
|
+
const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
|
|
1408
|
+
if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
|
|
1409
|
+
return ok
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* Las variables por aparato —nombres, y el valor de las PÚBLICAS, igual que `listSecrets`—
|
|
1414
|
+
* con quién es cada aparato pegado: una llave suelta no se puede administrar. `orphan`
|
|
1415
|
+
* marca las que quedaron de una llave que ya no está en el acta.
|
|
1416
|
+
*/
|
|
1417
|
+
async function listDeviceSecrets () {
|
|
1418
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1419
|
+
const members = record?.members || []
|
|
1420
|
+
const out = []
|
|
1421
|
+
for (const [pub, keys] of Object.entries(secrets.listDevices())) {
|
|
1422
|
+
const m = members.find((x) => x.pub === pub) || null
|
|
1423
|
+
const values = secrets.publicOfDevice(pub)
|
|
1424
|
+
out.push({
|
|
1425
|
+
pub,
|
|
1426
|
+
id: m?.id || await deviceIdOf(pub).catch(() => null),
|
|
1427
|
+
label: m?.label || '',
|
|
1428
|
+
cn: m?.cn || null,
|
|
1429
|
+
keys: keys.map((k) => (k.public ? { ...k, value: values[k.key] } : k)),
|
|
1430
|
+
orphan: !!members.length && !m
|
|
1431
|
+
})
|
|
1432
|
+
}
|
|
1433
|
+
return out
|
|
1434
|
+
}
|
|
563
1435
|
|
|
564
1436
|
return {
|
|
565
|
-
identity, client, store, threads, secrets, master, fingerprint: fp,
|
|
1437
|
+
identity, client, store, threads, secrets, master, fingerprint: fp, dir,
|
|
566
1438
|
startPairing: desk.startPairing,
|
|
567
1439
|
stopPairing: desk.stopPairing,
|
|
568
1440
|
listPending: desk.listPending,
|
|
569
1441
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
570
1442
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
571
|
-
approveDevice: async (code) => {
|
|
1443
|
+
approveDevice: async (code, adminKey) => {
|
|
572
1444
|
const r = await desk.approve(code)
|
|
1445
|
+
// Un servicio que ENTRA a un namespace que ya tiene variables necesita su
|
|
1446
|
+
// envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
|
|
1447
|
+
// reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
|
|
1448
|
+
// puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
|
|
1449
|
+
// el archivo vendorizado en el iframe de identidad.
|
|
1450
|
+
const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
|
|
1451
|
+
if (m?.cn) {
|
|
1452
|
+
await spreadKey(`ns:${m.cn}`, await nsMembers(m.cn), adminKey).catch(async (e) => {
|
|
1453
|
+
log('[vault] could not hand the key to the new service:', e.message)
|
|
1454
|
+
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
1455
|
+
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|
|
1456
|
+
// recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
|
|
1457
|
+
// a la vista y se salda al abrir la bóveda.
|
|
1458
|
+
const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
|
|
1459
|
+
if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
|
|
1460
|
+
})
|
|
1461
|
+
}
|
|
573
1462
|
await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
|
|
574
1463
|
return r
|
|
575
1464
|
},
|
|
576
1465
|
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
577
|
-
|
|
1466
|
+
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
1467
|
+
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
1468
|
+
vars: varsDesk,
|
|
1469
|
+
setSecret, deleteSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
1470
|
+
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
1471
|
+
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
1472
|
+
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
1473
|
+
/**
|
|
1474
|
+
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
1475
|
+
* normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
|
|
1476
|
+
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
1477
|
+
*
|
|
1478
|
+
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
1479
|
+
* entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
|
|
1480
|
+
* diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
|
|
1481
|
+
*/
|
|
1482
|
+
resealAll,
|
|
1483
|
+
delegateRewrap,
|
|
1484
|
+
incompleteMembers,
|
|
1485
|
+
migrateSecrets: (membersOf, adminKey) => secrets.migrate(
|
|
1486
|
+
membersOf || ((owner) => recipientsOf(owner)), adminKey
|
|
1487
|
+
),
|
|
1488
|
+
settleSecretDebts,
|
|
1489
|
+
revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
|
|
1490
|
+
secretHistory: (owner, key) => secrets.history(owner, key),
|
|
1491
|
+
revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
|
|
1492
|
+
revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
|
|
1493
|
+
// Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
|
|
1494
|
+
// la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
|
|
1495
|
+
rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
|
|
1496
|
+
setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
|
|
1497
|
+
applySecrets, applyDeviceSecrets,
|
|
578
1498
|
listDevices: () => identity.listDelegations(),
|
|
579
1499
|
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
580
1500
|
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
581
1501
|
profileMembers: () => identity.profileMembers(),
|
|
1502
|
+
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|
|
1503
|
+
// rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
|
|
1504
|
+
rotationsDue,
|
|
1505
|
+
secretDebts,
|
|
582
1506
|
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
583
1507
|
isMaster: () => identity.isMaster(),
|
|
584
1508
|
setCaps: async (pub, caps) => {
|
|
@@ -613,8 +1537,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
|
|
|
613
1537
|
return r
|
|
614
1538
|
},
|
|
615
1539
|
close () {
|
|
616
|
-
for (const t of
|
|
617
|
-
|
|
1540
|
+
for (const t of pendingNotices.values()) clearTimeout(t)
|
|
1541
|
+
pendingNotices.clear()
|
|
618
1542
|
try { client.close() } catch (_) {} identity.destroy()
|
|
619
1543
|
}
|
|
620
1544
|
}
|