@dotrino/vaultd 0.52.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/README.md +14 -7
- 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 +311 -14
- package/src/daemon.js +204 -22
- package/src/keyowner.js +83 -0
- package/src/manager.js +41 -6
- package/src/passwords.js +15 -4
- package/src/profiles.js +227 -25
- package/src/tui/app.js +77 -2
- package/src/tui/i18n.js +2 -0
- package/src/vault.js +137 -19
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
|