@dotrino/vault 0.92.0 → 0.93.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/dotrino-env.js +38 -38
- package/package.json +1 -1
- package/src/admin.js +2 -2
- package/src/atrest.js +4 -4
- package/src/browser.js +10 -10
- package/src/core/approvals.js +3 -2
- package/src/core/libVersion.js +2 -2
- package/src/core/sealer.js +2 -2
- package/src/core/sealers.js +15 -15
- package/src/core/secretsStore.js +66 -66
- package/src/core/subacta.js +8 -8
- package/src/core/threadStore.js +2 -2
- package/src/core/transport.js +31 -31
- package/src/core/vault.js +247 -214
- package/src/enroll.js +1 -1
- package/src/env.js +10 -10
- package/src/index.js +7 -7
- package/src/kek.js +6 -6
- package/src/localdesk.js +10 -10
- package/src/lock.js +18 -18
- package/src/loginClient.js +23 -23
- package/src/passwordLogins.js +8 -8
- package/src/proc.js +5 -5
- package/src/procContext.js +2 -2
- package/src/service.js +67 -43
- package/src/sshKeys.js +3 -3
- package/src/version.js +4 -4
package/src/core/secretsStore.js
CHANGED
|
@@ -285,10 +285,10 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
285
285
|
*
|
|
286
286
|
* La cola es de este proceso, que es el único que escribe este archivo.
|
|
287
287
|
*/
|
|
288
|
-
let
|
|
289
|
-
const
|
|
290
|
-
const r =
|
|
291
|
-
|
|
288
|
+
let queue = Promise.resolve()
|
|
289
|
+
const queued = (fn) => {
|
|
290
|
+
const r = queue.then(fn, fn)
|
|
291
|
+
queue = r.then(() => {}, () => {})
|
|
292
292
|
return r
|
|
293
293
|
}
|
|
294
294
|
|
|
@@ -309,13 +309,13 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
309
309
|
*/
|
|
310
310
|
const gcKeyring = (bag, k, owner) => {
|
|
311
311
|
if (!bag[k]) return
|
|
312
|
-
const
|
|
312
|
+
const aliveOnes = new Set()
|
|
313
313
|
// TAMBIÉN LAS PÚBLICAS. Se saltaban porque antes no tenían generación —se guardaban en
|
|
314
314
|
// claro—, así que su llave se recogía por «no la usa nadie» y la variable quedaba
|
|
315
315
|
// ilegible. Desde 2026-09-02 van en sobre como todas y cuentan como todas.
|
|
316
|
-
for (const e of Object.values(bag[k].vars || {})) if (e.gen != null)
|
|
317
|
-
for (const h of data.history) if (h.owner === owner && h.gen != null)
|
|
318
|
-
bag[k].keyring = (bag[k].keyring || []).filter((g) =>
|
|
316
|
+
for (const e of Object.values(bag[k].vars || {})) if (e.gen != null) aliveOnes.add(e.gen)
|
|
317
|
+
for (const h of data.history) if (h.owner === owner && h.gen != null) aliveOnes.add(h.gen)
|
|
318
|
+
bag[k].keyring = (bag[k].keyring || []).filter((g) => aliveOnes.has(g.gen))
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
/**
|
|
@@ -350,9 +350,9 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
350
350
|
*/
|
|
351
351
|
const wrapAll = async (cek, owner, isPublic = false) => {
|
|
352
352
|
const members = (await recipients?.(owner, { public: isPublic })) || []
|
|
353
|
-
const { wraps, sinLlave } = await sealer.wrapFor(cek, members)
|
|
353
|
+
const { wraps, sinLlave: noKey } = await sealer.wrapFor(cek, members)
|
|
354
354
|
wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
355
|
-
return { wraps, sinLlave }
|
|
355
|
+
return { wraps, sinLlave: noKey }
|
|
356
356
|
}
|
|
357
357
|
|
|
358
358
|
return {
|
|
@@ -375,7 +375,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
375
375
|
*
|
|
376
376
|
* Idempotente: si ya está, no toca nada.
|
|
377
377
|
*/
|
|
378
|
-
async ensureRecovery (adminKey = null) { return
|
|
378
|
+
async ensureRecovery (adminKey = null) { return queued(async () => (await ensureRecovery(adminKey)).pub) },
|
|
379
379
|
|
|
380
380
|
/**
|
|
381
381
|
* MUCHAS ESCRITURAS, UN GUARDADO. Cargar la configuración de un servicio son veinte
|
|
@@ -410,13 +410,13 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
410
410
|
*/
|
|
411
411
|
bundleFor (ns, devicePub = null, { publicOnly = false } = {}) {
|
|
412
412
|
assertNs(ns)
|
|
413
|
-
const
|
|
413
|
+
const everything = { ...entriesOf(data.ns, ns), ...(devicePub ? entriesOf(data.dev, devicePub) : {}) }
|
|
414
414
|
// EL FILTRO LO HACE LA BÓVEDA, no quien pide. Si mandara todo y el cliente eligiera,
|
|
415
415
|
// pedir «solo públicas» sería la forma de saltarse la aprobación y llevarse las
|
|
416
416
|
// privadas igual — o sea justo lo contrario de lo que se quiere.
|
|
417
417
|
const entries = publicOnly
|
|
418
|
-
? Object.fromEntries(Object.entries(
|
|
419
|
-
:
|
|
418
|
+
? Object.fromEntries(Object.entries(everything).filter(([, e]) => e.pub))
|
|
419
|
+
: everything
|
|
420
420
|
if (isLegacy()) return { legacy: true, entries }
|
|
421
421
|
|
|
422
422
|
const misWraps = (bag, k) => (bag[k]?.keyring || [])
|
|
@@ -628,7 +628,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
628
628
|
return this._put(data.dev, pub, `dev:${pub}`, key, value, isPublic, by)
|
|
629
629
|
},
|
|
630
630
|
/** @private */
|
|
631
|
-
async _put (...a) { return
|
|
631
|
+
async _put (...a) { return queued(() => this._putRaw(...a)) },
|
|
632
632
|
/** @private El cuerpo, ya en fila (ver `enFila`). */
|
|
633
633
|
async _putRaw (bag, k, owner, key, value, isPublic, by) {
|
|
634
634
|
// El cajón del perfil tiene su propio alfabeto (`nickname`, no `NICKNAME`). Por aquí
|
|
@@ -659,7 +659,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
659
659
|
// CEK NUEVA, siempre: no se puede reutilizar la de antes sin poder abrirla.
|
|
660
660
|
const cek = await sealer.newKey()
|
|
661
661
|
const gen = nextGen(bag, k, owner)
|
|
662
|
-
const { wraps, sinLlave } = await wrapAll(cek, owner, pub)
|
|
662
|
+
const { wraps, sinLlave: noKey } = await wrapAll(cek, owner, pub)
|
|
663
663
|
const e = await sealer.encrypt(cek, value, gen)
|
|
664
664
|
// La FIRMA dice que este sobre salió de esta bóveda, y con qué acta (§8.8). Si no
|
|
665
665
|
// hay con qué firmar, el sobre sale sin firma: guardar es más importante.
|
|
@@ -670,7 +670,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
670
670
|
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps }]
|
|
671
671
|
gcKeyring(bag, k, owner)
|
|
672
672
|
save()
|
|
673
|
-
return { gen, sinLlave }
|
|
673
|
+
return { gen, sinLlave: noKey }
|
|
674
674
|
},
|
|
675
675
|
|
|
676
676
|
/**
|
|
@@ -693,18 +693,18 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
693
693
|
* después ni abriendo la bóveda.
|
|
694
694
|
*
|
|
695
695
|
* @param {string} owner `ns:<scope>` o `dev:<pub>`
|
|
696
|
-
* @param {{e:{iv:string,ct:string,gen?:number}, wraps:Object}}
|
|
696
|
+
* @param {{e:{iv:string,ct:string,gen?:number}, wraps:Object}} envelope
|
|
697
697
|
*/
|
|
698
|
-
async putSealed (owner, key,
|
|
699
|
-
return
|
|
698
|
+
async putSealed (owner, key, envelope, { by = null, public: isPublic = false } = {}) {
|
|
699
|
+
return queued(() => this._putSealedRaw(owner, key, envelope, by, !!isPublic))
|
|
700
700
|
},
|
|
701
701
|
/** @private */
|
|
702
|
-
async _putSealedRaw (owner, key,
|
|
702
|
+
async _putSealedRaw (owner, key, envelope, by, isPublic) {
|
|
703
703
|
if (isLegacy()) throw new Error('putSealed: this store is still v3 (run `migrate` first)')
|
|
704
704
|
if (needsMigration()) throw new NeedsMigration()
|
|
705
705
|
const [kind, k] = splitOwner(owner)
|
|
706
706
|
const bag = kind === 'ns' ? data.ns : data.dev
|
|
707
|
-
const e =
|
|
707
|
+
const e = envelope?.e; const wraps = envelope?.wraps
|
|
708
708
|
if (!e || typeof e.iv !== 'string' || typeof e.ct !== 'string') {
|
|
709
709
|
throw new Error('putSealed: the envelope must carry `iv` and `ct`')
|
|
710
710
|
}
|
|
@@ -823,7 +823,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
823
823
|
return this._setVis(data.dev, pub, `dev:${pub}`, key, isPublic, adminKey)
|
|
824
824
|
},
|
|
825
825
|
/** @private */
|
|
826
|
-
async _setVis (...a) { return
|
|
826
|
+
async _setVis (...a) { return queued(() => this._setVisRaw(...a)) },
|
|
827
827
|
/** @private El cuerpo, ya en fila. */
|
|
828
828
|
async _setVisRaw (bag, k, owner, key, isPublic, adminKey) {
|
|
829
829
|
const vars = varsOf(bag, k)
|
|
@@ -930,7 +930,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
930
930
|
*
|
|
931
931
|
* Y por eso ESTO sí pide la frase: heredar lo viejo obliga a abrirlo.
|
|
932
932
|
*/
|
|
933
|
-
async rewrap (...a) { return
|
|
933
|
+
async rewrap (...a) { return queued(() => this._rewrap(...a)) },
|
|
934
934
|
/** Los cajones que existen, para poder recorrerlos todos (`ns:…` y `dev:…`). */
|
|
935
935
|
owners () {
|
|
936
936
|
return [...Object.keys(data.ns).map((k) => `ns:${k}`), ...Object.keys(data.dev).map((k) => `dev:${k}`)]
|
|
@@ -1004,7 +1004,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1004
1004
|
* de los secretos se queda intacta.
|
|
1005
1005
|
*/
|
|
1006
1006
|
async profilePutPublic (key, value, { by = null } = {}) {
|
|
1007
|
-
return
|
|
1007
|
+
return queued(async () => {
|
|
1008
1008
|
if (isLegacy()) throw new Error('profile: this store is still v3 (run `migrate` first)')
|
|
1009
1009
|
if (needsMigration()) throw new NeedsMigration()
|
|
1010
1010
|
assertProfileKey(key)
|
|
@@ -1039,9 +1039,9 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1039
1039
|
const priv = await openRecovery(adminKey)
|
|
1040
1040
|
let wrapped = 0
|
|
1041
1041
|
let sealed = 0
|
|
1042
|
-
const
|
|
1042
|
+
const noKey = new Set()
|
|
1043
1043
|
const vars = varsOf(bag, k)
|
|
1044
|
-
const
|
|
1044
|
+
const forVisibility = async (isPublic) => (typeof members === 'function' ? members(isPublic) : members)
|
|
1045
1045
|
|
|
1046
1046
|
// UN VALOR QUE SE QUEDÓ EN CLARO SE SELLA AQUÍ, que es el único sitio donde se puede.
|
|
1047
1047
|
//
|
|
@@ -1060,27 +1060,27 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1060
1060
|
if (typeof e.v !== 'string' || e.e) continue
|
|
1061
1061
|
const cek = await sealer.newKey()
|
|
1062
1062
|
const gen = nextGen(bag, k, owner)
|
|
1063
|
-
const r = await sealer.wrapFor(cek, await
|
|
1063
|
+
const r = await sealer.wrapFor(cek, await forVisibility(!!e.pub))
|
|
1064
1064
|
r.wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
1065
|
-
const
|
|
1066
|
-
const seal = signer ? await signer({ owner, key, gen, iv:
|
|
1065
|
+
const envelope = await sealer.encrypt(cek, e.v, gen)
|
|
1066
|
+
const seal = signer ? await signer({ owner, key, gen, iv: envelope.iv, ct: envelope.ct }) : null
|
|
1067
1067
|
// `pub` y `at` se conservan: no es un valor nuevo, es el mismo mejor guardado.
|
|
1068
|
-
vars[key] = { pub: !!e.pub, owner, gen, e:
|
|
1068
|
+
vars[key] = { pub: !!e.pub, owner, gen, e: envelope, seal, at: e.at || Date.now(), by: e.by || null }
|
|
1069
1069
|
bag[k].keyring = [...(bag[k].keyring || []), { gen, createdAt: Date.now(), wraps: r.wraps }]
|
|
1070
|
-
for (const x of r.sinLlave)
|
|
1070
|
+
for (const x of r.sinLlave) noKey.add(x)
|
|
1071
1071
|
sealed++
|
|
1072
1072
|
}
|
|
1073
1073
|
|
|
1074
1074
|
// De qué variable es cada generación: es lo que dice si esa CEK es de una pública.
|
|
1075
1075
|
// Se calcula DESPUÉS de sellar, para que las generaciones recién creadas entren.
|
|
1076
|
-
const
|
|
1077
|
-
for (const e of Object.values(vars)) if (e.gen != null)
|
|
1078
|
-
const
|
|
1076
|
+
const visibility = new Map()
|
|
1077
|
+
for (const e of Object.values(vars)) if (e.gen != null) visibility.set(e.gen, !!e.pub)
|
|
1078
|
+
const forGen = async (gen) => forVisibility(visibility.get(gen) === true)
|
|
1079
1079
|
for (const g of bag[k].keyring || []) {
|
|
1080
1080
|
const w = g.wraps?.[RECOVERY]
|
|
1081
1081
|
if (!w) continue
|
|
1082
1082
|
const cek = await sealer.openWrapWith(priv, w)
|
|
1083
|
-
const r = await sealer.wrapFor(cek, await
|
|
1083
|
+
const r = await sealer.wrapFor(cek, await forGen(g.gen))
|
|
1084
1084
|
// `exact`: el llavero queda con lo que dice el acta y NADA más. Es lo que hace
|
|
1085
1085
|
// falta para rehacerlo al abrir la bóveda:
|
|
1086
1086
|
// · una envoltura basura que metió alguien se reemplaza por la buena;
|
|
@@ -1092,11 +1092,11 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1092
1092
|
// OJO con lo que esto NO es: quitarle la envoltura a alguien no le quita lo que
|
|
1093
1093
|
// ya leyó ni la llave que se haya guardado. Cortar de verdad es `rotate`.
|
|
1094
1094
|
g.wraps = exact ? { ...r.wraps, [RECOVERY]: w } : { ...g.wraps, ...r.wraps }
|
|
1095
|
-
for (const s of r.sinLlave)
|
|
1095
|
+
for (const s of r.sinLlave) noKey.add(s)
|
|
1096
1096
|
wrapped += Object.keys(r.wraps).length
|
|
1097
1097
|
}
|
|
1098
1098
|
save()
|
|
1099
|
-
return { wrapped, sealed, sinLlave: [...
|
|
1099
|
+
return { wrapped, sealed, sinLlave: [...noKey] }
|
|
1100
1100
|
},
|
|
1101
1101
|
|
|
1102
1102
|
/**
|
|
@@ -1114,7 +1114,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1114
1114
|
* El HISTÓRICO se muda con ella: es la misma variable, y dejarlo bajo el nombre viejo
|
|
1115
1115
|
* lo volvería inalcanzable — se vería como un hueco, no como una decisión.
|
|
1116
1116
|
*/
|
|
1117
|
-
async rename (owner, from, to) { return
|
|
1117
|
+
async rename (owner, from, to) { return queued(() => this._renameRaw(owner, from, to)) },
|
|
1118
1118
|
/** @private */
|
|
1119
1119
|
async _renameRaw (owner, from, to) {
|
|
1120
1120
|
if (needsMigration()) throw new NeedsMigration()
|
|
@@ -1148,7 +1148,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1148
1148
|
*
|
|
1149
1149
|
* No devuelve lo que el expulsado ya leyó. Eso no se puede deshacer y no se promete.
|
|
1150
1150
|
*/
|
|
1151
|
-
async rotate (...a) { return
|
|
1151
|
+
async rotate (...a) { return queued(() => this._rotate(...a)) },
|
|
1152
1152
|
/** @private */
|
|
1153
1153
|
async _rotate (owner, members, adminKey = null) {
|
|
1154
1154
|
needSealer('rotate the key of a drawer')
|
|
@@ -1159,30 +1159,30 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1159
1159
|
const priv = await openRecovery(adminKey)
|
|
1160
1160
|
|
|
1161
1161
|
const vars = bag[k].vars || {}
|
|
1162
|
-
const
|
|
1162
|
+
const plainOnes = {}
|
|
1163
1163
|
for (const [key, e] of Object.entries(vars)) {
|
|
1164
|
-
if (!e.pub)
|
|
1164
|
+
if (!e.pub) plainOnes[key] = await sealer.openValue(await this._cekOf(bag, k, e.gen, priv), e.e)
|
|
1165
1165
|
}
|
|
1166
1166
|
// Se tira el llavero entero: las generaciones viejas son justamente lo que el que
|
|
1167
1167
|
// se fue podría abrir. Con ellas se va el histórico de este cajón, que estaba
|
|
1168
1168
|
// cifrado con ellas — rotar es renunciar a poder revertir lo de antes.
|
|
1169
1169
|
bag[k].keyring = []
|
|
1170
1170
|
data.history = data.history.filter((h) => h.owner !== owner)
|
|
1171
|
-
const
|
|
1171
|
+
const noKey = new Set()
|
|
1172
1172
|
let gen = 0
|
|
1173
|
-
for (const [key, value] of Object.entries(
|
|
1173
|
+
for (const [key, value] of Object.entries(plainOnes)) {
|
|
1174
1174
|
const cek = await sealer.newKey()
|
|
1175
1175
|
gen += 1
|
|
1176
|
-
const { wraps, sinLlave:
|
|
1176
|
+
const { wraps, sinLlave: missingOnes } = await sealer.wrapFor(cek, members)
|
|
1177
1177
|
wraps[RECOVERY] = await sealer.wrapForKey(cek, data.recovery.pub)
|
|
1178
|
-
for (const s of
|
|
1178
|
+
for (const s of missingOnes) noKey.add(s)
|
|
1179
1179
|
const e = await sealer.encrypt(cek, value, gen)
|
|
1180
1180
|
const seal = signer ? await signer({ owner, key, gen, iv: e.iv, ct: e.ct }) : null
|
|
1181
1181
|
vars[key] = { pub: false, owner, gen, e, seal, at: Date.now(), by: null }
|
|
1182
1182
|
bag[k].keyring.push({ gen, createdAt: Date.now(), wraps })
|
|
1183
1183
|
}
|
|
1184
1184
|
save()
|
|
1185
|
-
return { rotated: Object.keys(
|
|
1185
|
+
return { rotated: Object.keys(plainOnes).length, sinLlave: [...noKey], gen, cambio: true }
|
|
1186
1186
|
},
|
|
1187
1187
|
|
|
1188
1188
|
/**
|
|
@@ -1233,7 +1233,7 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1233
1233
|
*
|
|
1234
1234
|
* `null` en cualquiera de las dos significa «la del perfil sin contraseña».
|
|
1235
1235
|
*/
|
|
1236
|
-
async rekeyRecovery (...a) { return
|
|
1236
|
+
async rekeyRecovery (...a) { return queued(() => this._rekeyRecovery(...a)) },
|
|
1237
1237
|
/**
|
|
1238
1238
|
* ¿ESTA llave abre la copia de recuperación? Lanza si no.
|
|
1239
1239
|
*
|
|
@@ -1286,19 +1286,19 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1286
1286
|
* el original, y solo entonces se escribe. Si algo no cuadra no se toca nada y se
|
|
1287
1287
|
* lanza: media migración es peor que ninguna.
|
|
1288
1288
|
*/
|
|
1289
|
-
async migrate (...a) { return
|
|
1289
|
+
async migrate (...a) { return queued(() => this._migrate(...a)) },
|
|
1290
1290
|
/** @private */
|
|
1291
1291
|
async _migrate (membersOf, adminKey = null) {
|
|
1292
1292
|
if (data.schemaVersion === SCHEMA_VERSION) return { migrated: false, reason: 'already-v5' }
|
|
1293
1293
|
needSealer('seal the store')
|
|
1294
|
-
const
|
|
1294
|
+
const since = data.schemaVersion
|
|
1295
1295
|
|
|
1296
1296
|
// De v4: las CEK viejas viven en la copia maestra, y para abrirla hace falta la
|
|
1297
1297
|
// frase. Es la única vez que se pide.
|
|
1298
|
-
const master =
|
|
1299
|
-
const
|
|
1298
|
+
const master = since === SEALED_MASTER_VERSION ? await sealer.openMaster(data.master, keyOr(adminKey)) : null
|
|
1299
|
+
const plain = async (owner, e) => {
|
|
1300
1300
|
if (e.pub) return e.v
|
|
1301
|
-
if (
|
|
1301
|
+
if (since === LEGACY_VERSION) return e.v
|
|
1302
1302
|
return sealer.decrypt(master, e.e, e.owner || owner)
|
|
1303
1303
|
}
|
|
1304
1304
|
|
|
@@ -1312,12 +1312,12 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1312
1312
|
history: []
|
|
1313
1313
|
}
|
|
1314
1314
|
const antes = {}
|
|
1315
|
-
const
|
|
1315
|
+
const noKey = {}
|
|
1316
1316
|
|
|
1317
1317
|
for (const [kind, src, dst] of [['ns', data.ns, next.ns], ['dev', data.dev, next.dev]]) {
|
|
1318
1318
|
for (const [k, bag] of Object.entries(src)) {
|
|
1319
1319
|
const owner = `${kind}:${k}`
|
|
1320
|
-
const
|
|
1320
|
+
const entries = since === LEGACY_VERSION ? (bag || {}) : (bag?.vars || {})
|
|
1321
1321
|
const vars = {}
|
|
1322
1322
|
const keyring = []
|
|
1323
1323
|
// UNA SOLA generación para todo el cajón, y no es una excepción a «una por
|
|
@@ -1328,8 +1328,8 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1328
1328
|
// sin apagar a nadie. Lo que se escriba DESPUÉS ya estrena generación.
|
|
1329
1329
|
const gen = 1
|
|
1330
1330
|
let cek = null
|
|
1331
|
-
for (const [key, e] of Object.entries(
|
|
1332
|
-
const value = await
|
|
1331
|
+
for (const [key, e] of Object.entries(entries)) {
|
|
1332
|
+
const value = await plain(owner, e)
|
|
1333
1333
|
antes[`${owner}\u0000${key}`] = value
|
|
1334
1334
|
// LA PÚBLICA TAMBIÉN SE SELLA (dueño, 2026-09-02). Aquí se dejaba en claro,
|
|
1335
1335
|
// así que un archivo migrado se quedaba con valores legibles en el disco —
|
|
@@ -1337,14 +1337,14 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1337
1337
|
// a quién se le despacha sin aprobación, no cómo se guarda.
|
|
1338
1338
|
if (!cek) {
|
|
1339
1339
|
cek = await sealer.newKey()
|
|
1340
|
-
const { wraps, sinLlave:
|
|
1340
|
+
const { wraps, sinLlave: missingOnes } = await sealer.wrapFor(cek, (await membersOf(owner)) || [])
|
|
1341
1341
|
wraps[RECOVERY] = await sealer.wrapForKey(cek, next.recovery.pub)
|
|
1342
|
-
if (
|
|
1342
|
+
if (missingOnes.length) noKey[owner] = missingOnes
|
|
1343
1343
|
keyring.push({ gen, createdAt: Date.now(), wraps })
|
|
1344
1344
|
}
|
|
1345
|
-
const
|
|
1346
|
-
const seal = signer ? await signer({ owner, key, gen, iv:
|
|
1347
|
-
vars[key] = { pub: !!e.pub, owner, gen, e:
|
|
1345
|
+
const envelope = await sealer.encrypt(cek, value, gen)
|
|
1346
|
+
const seal = signer ? await signer({ owner, key, gen, iv: envelope.iv, ct: envelope.ct }) : null
|
|
1347
|
+
vars[key] = { pub: !!e.pub, owner, gen, e: envelope, seal, at: Date.now(), by: null }
|
|
1348
1348
|
}
|
|
1349
1349
|
dst[k] = { vars, keyring }
|
|
1350
1350
|
}
|
|
@@ -1359,8 +1359,8 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1359
1359
|
// Se abre TODO, también las públicas: ahora van selladas igual, así que la
|
|
1360
1360
|
// comprobación de que la migración no cambió ningún valor vale para las dos.
|
|
1361
1361
|
const g = bag.keyring.find((x) => x.gen === e.gen)
|
|
1362
|
-
const
|
|
1363
|
-
if (
|
|
1362
|
+
const isOpen = await sealer.openValue(await sealer.openWrapWith(priv, g.wraps[RECOVERY]), e.e)
|
|
1363
|
+
if (isOpen !== antes[`${owner}\u0000${key}`]) {
|
|
1364
1364
|
throw new Error(`secrets: the migration check failed on ${owner}/${key}; nothing was touched`)
|
|
1365
1365
|
}
|
|
1366
1366
|
}
|
|
@@ -1369,10 +1369,10 @@ export function openSecretsStore (disk, { sealer = null, recipients = null, sign
|
|
|
1369
1369
|
|
|
1370
1370
|
// Copia de lo anterior antes de pisarlo. Con nodos en producción, deshacer tiene
|
|
1371
1371
|
// que ser un `mv`, no una restauración. La borra el operador a mano.
|
|
1372
|
-
writeJson(disk, `${file}.v${
|
|
1372
|
+
writeJson(disk, `${file}.v${since}.bak`, data)
|
|
1373
1373
|
data = next
|
|
1374
1374
|
flush()
|
|
1375
|
-
return { migrated: true, from:
|
|
1375
|
+
return { migrated: true, from: since, sinLlave: noKey }
|
|
1376
1376
|
}
|
|
1377
1377
|
}
|
|
1378
1378
|
}
|
package/src/core/subacta.js
CHANGED
|
@@ -59,7 +59,7 @@ export async function isAcceptable (e) {
|
|
|
59
59
|
export function openSubacta (disk) {
|
|
60
60
|
const file = 'subacta.json'
|
|
61
61
|
let data = readJson(disk, file, null) || { v: 1, entries: [] }
|
|
62
|
-
const
|
|
62
|
+
const save = () => writeJson(disk, file, data)
|
|
63
63
|
|
|
64
64
|
return {
|
|
65
65
|
/** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
|
|
@@ -75,12 +75,12 @@ export function openSubacta (disk) {
|
|
|
75
75
|
* aparatos se cuentan lo mismo.
|
|
76
76
|
*/
|
|
77
77
|
async add (e) {
|
|
78
|
-
if (!(await isAcceptable(e))) return { ok: false, reason: '
|
|
78
|
+
if (!(await isAcceptable(e))) return { ok: false, reason: 'not-acceptable' }
|
|
79
79
|
const id = idOf(e)
|
|
80
|
-
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: '
|
|
80
|
+
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'already-present' }
|
|
81
81
|
data.entries = [...data.entries, e]
|
|
82
|
-
|
|
83
|
-
return { ok: true, reason: '
|
|
82
|
+
save()
|
|
83
|
+
return { ok: true, reason: 'new' }
|
|
84
84
|
},
|
|
85
85
|
|
|
86
86
|
/**
|
|
@@ -92,10 +92,10 @@ export function openSubacta (disk) {
|
|
|
92
92
|
|
|
93
93
|
/** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
|
|
94
94
|
* puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
|
|
95
|
-
clear (
|
|
96
|
-
const ids = new Set(
|
|
95
|
+
clear (absorbed) {
|
|
96
|
+
const ids = new Set(absorbed.map(idOf))
|
|
97
97
|
data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
|
|
98
|
-
|
|
98
|
+
save()
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
}
|
package/src/core/threadStore.js
CHANGED
|
@@ -235,7 +235,7 @@ export function openThreadStore (disk) {
|
|
|
235
235
|
// Solo sale lo que ya cumplió su año de respaldo.
|
|
236
236
|
const antes = data.resealed.length
|
|
237
237
|
data.resealed = data.resealed.filter((x) => now - (x.at || 0) < RESEALED_KEEP_MS)
|
|
238
|
-
const
|
|
238
|
+
const pruned = antes - data.resealed.length
|
|
239
239
|
for (const [key, arr] of Object.entries(data.threads)) {
|
|
240
240
|
for (let i = 0; i < arr.length; i++) {
|
|
241
241
|
const r = await resealStale(arr[i], { gen, reseal })
|
|
@@ -246,7 +246,7 @@ export function openThreadStore (disk) {
|
|
|
246
246
|
changed += r.changed; entries++
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
-
if (entries ||
|
|
249
|
+
if (entries || pruned) save()
|
|
250
250
|
return { changed, entries, skipped }
|
|
251
251
|
}
|
|
252
252
|
|
package/src/core/transport.js
CHANGED
|
@@ -67,38 +67,38 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
67
67
|
// La puerta tiene que decidir AL INSTANTE (la señal llega y o se atiende o no), y pedir
|
|
68
68
|
// el acta es asíncrono. Así que se guarda una copia y se refresca donde ya se refresca
|
|
69
69
|
// todo: en `identify`, que es lo que corre cada vez que el acta cambia o se reconecta.
|
|
70
|
-
let
|
|
70
|
+
let seenActa = null
|
|
71
71
|
// ¿Está esta bóveda ATADA al proxio ahora mismo? Conectado no es identificado: el socket
|
|
72
72
|
// puede estar perfectamente abierto y el sobre rechazado, y entonces nadie la alcanza por
|
|
73
73
|
// su pubkey. Se expone para que `status` lo enseñe en vez de dejarlo a un log del arranque.
|
|
74
|
-
let
|
|
75
|
-
const
|
|
74
|
+
let identified = false
|
|
75
|
+
const inActa = (token) => {
|
|
76
76
|
// SIN ACTA NO SE NEGOCIA. No se sabe quién es nadie, y en la duda no se arranca a
|
|
77
77
|
// parsear red no confiable. Se sigue por el proxio, que es el escalón que siempre está.
|
|
78
|
-
if (!
|
|
79
|
-
return (
|
|
78
|
+
if (!seenActa) return false
|
|
79
|
+
return (seenActa.members || []).some((m) => m?.pub === token)
|
|
80
80
|
}
|
|
81
|
-
const client = makeClient ? makeClient({ url, acceptDirectFrom:
|
|
82
|
-
url, enableWebRTC: true, acceptDirectFrom:
|
|
81
|
+
const client = makeClient ? makeClient({ url, acceptDirectFrom: inActa }) : new WebSocketProxyClient({
|
|
82
|
+
url, enableWebRTC: true, acceptDirectFrom: inActa, autoReconnect: true,
|
|
83
83
|
maxReconnectAttempts: 100000, reconnectDelay: 4000
|
|
84
84
|
})
|
|
85
85
|
|
|
86
86
|
await client.connect()
|
|
87
87
|
|
|
88
88
|
const identify = async () => {
|
|
89
|
-
|
|
89
|
+
identified = false
|
|
90
90
|
if (!client.token) return
|
|
91
91
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
92
|
-
|
|
92
|
+
seenActa = record // la copia que mira la puerta del canal directo
|
|
93
93
|
const comm = commKey?.pub?.() || null
|
|
94
94
|
// ¿Nos nombra el acta? Con `cn` o sin él, lo que el proxio mira es la pertenencia.
|
|
95
|
-
const
|
|
95
|
+
const isMember = comm && (record?.members || []).some((m) => m?.pub === comm)
|
|
96
96
|
|
|
97
|
-
if (comm &&
|
|
97
|
+
if (comm && isMember) {
|
|
98
98
|
// El sobre lo arma el pilar (`identifyAs`), que le pone el destinatario.
|
|
99
99
|
try {
|
|
100
100
|
await client.identifyAs({ publickey: comm, sign: (d) => commKey.sign(d), acta: record })
|
|
101
|
-
|
|
101
|
+
identified = true
|
|
102
102
|
// SE DICE QUE SE IDENTIFICÓ, y con qué llave. Este camino era MUDO: al pasar de la
|
|
103
103
|
// maestra a la llave de comunicación, el único rastro de «estoy alcanzable» que
|
|
104
104
|
// quedaba en el log era el del repliegue. Así que cuando la consola decía «no
|
|
@@ -133,7 +133,7 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
133
133
|
if (!publickey) return
|
|
134
134
|
log('[vault] identifying with the master key: this vault is not in its own record yet (open the profile once to fix it)')
|
|
135
135
|
await client.identifyAs({ publickey, sign: (d) => identity.signData(d), acta: record })
|
|
136
|
-
|
|
136
|
+
identified = true
|
|
137
137
|
} catch (e) {
|
|
138
138
|
log(`[vault] cannot identify on the proxy yet: ${e.message} — the profile still opens; unlock it once and it gets its own communication key`)
|
|
139
139
|
}
|
|
@@ -175,7 +175,7 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
175
175
|
*/
|
|
176
176
|
let announcedAt = 0
|
|
177
177
|
const announceAccount = async () => {
|
|
178
|
-
if (!announce || !
|
|
178
|
+
if (!announce || !identified) return
|
|
179
179
|
announcedAt = Date.now()
|
|
180
180
|
try { await client.publish(announce) } catch (e) {
|
|
181
181
|
log(`[vault] could not announce this account on the proxy (${e.message}) — a borrowed machine will not find this vault by its address`)
|
|
@@ -190,38 +190,38 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
190
190
|
* lo que se pierde es que la despierten, y eso no se puede callar.
|
|
191
191
|
*/
|
|
192
192
|
const registerWake = async () => {
|
|
193
|
-
if (!wakeUrl || !
|
|
193
|
+
if (!wakeUrl || !identified) return
|
|
194
194
|
const comm = commKey?.pub?.() || null
|
|
195
|
-
if (!comm || !
|
|
195
|
+
if (!comm || !seenActa) { log('[vault] cannot register the wake URL: no communication key in the record yet'); return }
|
|
196
196
|
try {
|
|
197
|
-
await client.registerWake({ publicKey: comm, sign: (d) => commKey.sign(d), url: wakeUrl, acta:
|
|
197
|
+
await client.registerWake({ publicKey: comm, sign: (d) => commKey.sign(d), url: wakeUrl, acta: seenActa, channel: announce || undefined })
|
|
198
198
|
log('[vault] wake URL registered on the proxy')
|
|
199
199
|
} catch (e) {
|
|
200
200
|
log(`[vault] could not register the wake URL (${e.code || ''} ${e.message}) — the proxy will not wake this vault`)
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
let
|
|
205
|
-
let
|
|
204
|
+
let retry = null
|
|
205
|
+
let failures = 0
|
|
206
206
|
const identifyWithRetry = async () => {
|
|
207
|
-
if (
|
|
207
|
+
if (retry) { clearTimeout(retry); retry = null }
|
|
208
208
|
try {
|
|
209
209
|
await identify()
|
|
210
210
|
await announceAccount()
|
|
211
211
|
await registerWake()
|
|
212
|
-
if (
|
|
213
|
-
|
|
212
|
+
if (failures) log(`[vault] identified on the proxy after ${failures + 1} attempt(s)`)
|
|
213
|
+
failures = 0
|
|
214
214
|
} catch (e) {
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
log(`[vault] could not identify on the proxy (${e.message}) — retrying in ${Math.round(
|
|
218
|
-
|
|
219
|
-
|
|
215
|
+
failures++
|
|
216
|
+
const wait = Math.min(60000, 5000 * failures)
|
|
217
|
+
log(`[vault] could not identify on the proxy (${e.message}) — retrying in ${Math.round(wait / 1000)}s; until then this vault is unreachable and cannot ring anyone`)
|
|
218
|
+
retry = setTimeout(() => { retry = null; identifyWithRetry() }, wait)
|
|
219
|
+
retry.unref?.()
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
await identifyWithRetry()
|
|
223
223
|
// Re-identificar al reconectar (el token cambia).
|
|
224
|
-
client.on('token', () => {
|
|
224
|
+
client.on('token', () => { failures = 0; identifyWithRetry() })
|
|
225
225
|
|
|
226
226
|
/**
|
|
227
227
|
* QUE NO SE PUEDA VOLVER A QUEDAR MUDA.
|
|
@@ -247,7 +247,7 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
247
247
|
let saidDown = false
|
|
248
248
|
client.on('disconnect', ({ code, reason } = {}) => {
|
|
249
249
|
// Sin socket no hay nadie atado al proxio: lo que se identificó era la conexión anterior.
|
|
250
|
-
|
|
250
|
+
identified = false
|
|
251
251
|
if (!downSince) downSince = Date.now()
|
|
252
252
|
if (saidDown) return
|
|
253
253
|
saidDown = true
|
|
@@ -267,7 +267,7 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
267
267
|
// borra al caerse, así que mirarlo daba la bóveda por vuelta con el socket cerrado: el
|
|
268
268
|
// 2026-10-15 dijo «back on the proxy after 50s», no volvió a marcar nunca y pasó hora y
|
|
269
269
|
// media fuera, avisando cada 10 minutos de que no podía anunciarse.
|
|
270
|
-
if (
|
|
270
|
+
if (identified && client.isConnected && client.token) {
|
|
271
271
|
if (downSince) log(`[vault] back on the proxy after ${Math.round((Date.now() - downSince) / 1000)}s`)
|
|
272
272
|
downSince = 0; saidDown = false
|
|
273
273
|
// EL ANUNCIO CADUCA: el proxio borra una entrada de canal a los 20 minutos de publicada.
|
|
@@ -286,5 +286,5 @@ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey
|
|
|
286
286
|
}, RECHECK_MS)
|
|
287
287
|
watchdog.unref?.()
|
|
288
288
|
|
|
289
|
-
return { client, token: client.token, identify: identifyWithRetry, isIdentified: () =>
|
|
289
|
+
return { client, token: client.token, identify: identifyWithRetry, isIdentified: () => identified }
|
|
290
290
|
}
|