@dotrino/vault 0.93.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/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 +5 -5
- package/src/core/threadStore.js +2 -2
- package/src/core/transport.js +31 -31
- package/src/core/vault.js +222 -189
- package/src/env.js +10 -10
- package/src/index.js +5 -5
- 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 +7 -7
- 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/vault.js
CHANGED
|
@@ -226,7 +226,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
226
226
|
*
|
|
227
227
|
* SE MIRA EL PEDIDO, NUNCA EL CANAL. Sería más cómodo comprobar «vino por el socket local»,
|
|
228
228
|
* y está prohibido: el día que un handler decide algo por dónde entró el mensaje, el atajo
|
|
229
|
-
* local deja de ser un atajo y pasa a ser una puerta de atrás (`test/
|
|
229
|
+
* local deja de ser un atajo y pasa a ser una puerta de atrás (`test/local-desk.test.mjs`).
|
|
230
230
|
* El `host` es un dato del pedido como cualquier otro, y mentirlo no consigue nada: lo que
|
|
231
231
|
* se gana es quedar marcado como no comprobado, que es exactamente lo que se quería evitar.
|
|
232
232
|
*
|
|
@@ -241,19 +241,19 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
241
241
|
* llamarse como quiera. La frontera es el usuario, no el proceso: esto existe para que
|
|
242
242
|
* quien aprueba decida mirando lo que pasa, y para que quede rastro.
|
|
243
243
|
*/
|
|
244
|
-
function
|
|
245
|
-
const
|
|
246
|
-
if (!
|
|
244
|
+
function requestContext (declared) {
|
|
245
|
+
const said = sanitizeContext(declared)
|
|
246
|
+
if (!said) return null
|
|
247
247
|
// Otra máquina: aquí no hay ningún `/proc` que hable de ese proceso.
|
|
248
|
-
if (!
|
|
249
|
-
const
|
|
248
|
+
if (!said.host || said.host !== platform.hostname?.()) return { ...said, verified: 'declared' }
|
|
249
|
+
const measured = sanitizeContext(platform.readProcContext?.(said.pid) ?? null)
|
|
250
250
|
// El proceso ya no está, o no se puede mirar: no se sabe, y no saber se dice como no
|
|
251
251
|
// saber. Inventar un «comprobado» aquí sería el repliegue de siempre.
|
|
252
|
-
if (!
|
|
253
|
-
if (commandFingerprint(
|
|
254
|
-
throw new Error(`ctx — it says it is running "${commandLine(
|
|
252
|
+
if (!measured) return { ...said, verified: 'declared' }
|
|
253
|
+
if (commandFingerprint(measured) !== commandFingerprint(said)) {
|
|
254
|
+
throw new Error(`ctx — it says it is running "${commandLine(said, 60)}" but pid ${said.pid} is "${commandLine(measured, 60)}"`)
|
|
255
255
|
}
|
|
256
|
-
return { ...
|
|
256
|
+
return { ...measured, user: said.user, host: said.host, verified: 'proc' }
|
|
257
257
|
}
|
|
258
258
|
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
259
259
|
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
@@ -319,7 +319,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
319
319
|
* (dueño, 2026-10-16: «realmente deberían ser uno solo»); `kind` se queda por el formato. Un miembro sin llave de cifrado no puede recibir nada: se
|
|
320
320
|
* queda fuera y se ve en `incompleteMembers`.
|
|
321
321
|
*/
|
|
322
|
-
const
|
|
322
|
+
const notTheVault = (pub) => pub !== master && pub !== commKey.pub()
|
|
323
323
|
/**
|
|
324
324
|
* ¿PUEDE ESCRIBIR UNA CONTRASEÑA? Un aparato del acta con `passwords`… y la MAESTRA de esta
|
|
325
325
|
* bóveda, que es quien reescribe al convertir (`sealPasswords`) y al importar un respaldo.
|
|
@@ -329,7 +329,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
329
329
|
*/
|
|
330
330
|
const canWritePasswords = (pub) => !!pub && (pub === master || (!!actaCache && Acta.memberCan(actaCache, pub, 'passwords')))
|
|
331
331
|
const passwordRecipients = (_kind = 'main') => (actaCache?.members || [])
|
|
332
|
-
.filter((m) => m.encPub &&
|
|
332
|
+
.filter((m) => m.encPub && notTheVault(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
333
333
|
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
334
334
|
|
|
335
335
|
/**
|
|
@@ -355,13 +355,13 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
355
355
|
* y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
|
|
356
356
|
* `soltarLlaveVieja` la borra del archivo.
|
|
357
357
|
*/
|
|
358
|
-
async function
|
|
358
|
+
async function oldPasswordsKey () {
|
|
359
359
|
const d = readPasswordsFile()
|
|
360
360
|
if (!d?.cek) return null
|
|
361
361
|
const raw = b64ToBytes(d.cek)
|
|
362
362
|
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
|
|
363
363
|
}
|
|
364
|
-
const
|
|
364
|
+
const dropOldKey = () => {
|
|
365
365
|
const d = readPasswordsFile()
|
|
366
366
|
if (!d?.cek) return
|
|
367
367
|
delete d.cek
|
|
@@ -493,7 +493,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
493
493
|
// `announce`: el canal de ESTA cuenta, para que una máquina prestada —que no tiene
|
|
494
494
|
// ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
|
|
495
495
|
// la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
|
|
496
|
-
const { client, identify:
|
|
496
|
+
const { client, identify: reidentify, isIdentified } = await createTransport({
|
|
497
497
|
identity, url: proxyUrl, commKey, log, announce: vaultChannel(fp), makeClient: platform.makeClient || null, wakeUrl: platform.wakeUrl || null
|
|
498
498
|
})
|
|
499
499
|
|
|
@@ -525,7 +525,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
525
525
|
* del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
|
|
526
526
|
* nada — es la misma puerta, con el camino más corto.
|
|
527
527
|
*/
|
|
528
|
-
const
|
|
528
|
+
const channels = new Map()
|
|
529
529
|
|
|
530
530
|
/**
|
|
531
531
|
* QUÉ SOMOS, Y QUÉ CORRE CADA APARATO (CONVENCIONES §14, `@dotrino/compat`).
|
|
@@ -544,8 +544,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
544
544
|
const MI_VERSION = compatDeclare({
|
|
545
545
|
product: platform.product || 'vaultd', version: VERSION, protocol: VAULT_PROTOCOL, speaks: VAULT_SPEAKS
|
|
546
546
|
})
|
|
547
|
-
let
|
|
548
|
-
try {
|
|
547
|
+
let BROKEN = []
|
|
548
|
+
try { BROKEN = brokenOf(loadManifest()) } catch (e) { log('[vault] no compatibility registry: ' + e.message) }
|
|
549
549
|
|
|
550
550
|
/**
|
|
551
551
|
* Lo que dice correr cada aparato, por llave. Se apunta de lo que viene FIRMADO
|
|
@@ -553,12 +553,12 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
553
553
|
* escribir no vale para enseñarla como si fuera un dato.
|
|
554
554
|
*/
|
|
555
555
|
const versiones = new Map()
|
|
556
|
-
const
|
|
556
|
+
const recordVersion = (pub, v) => {
|
|
557
557
|
if (!pub || !v) return
|
|
558
|
-
const
|
|
559
|
-
versiones.set(pub, { ...v, at: Date.now(), compatible:
|
|
560
|
-
if (!
|
|
561
|
-
return
|
|
558
|
+
const verdict = compatCheck({ mine: MI_VERSION, theirs: v, broken: BROKEN })
|
|
559
|
+
versiones.set(pub, { ...v, at: Date.now(), compatible: verdict.compatible, reason: verdict.reason })
|
|
560
|
+
if (!verdict.compatible) log(`[vault] ${v.product} ${v.version}: ${verdict.reason}`)
|
|
561
|
+
return verdict
|
|
562
562
|
}
|
|
563
563
|
/** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
|
|
564
564
|
const versionDe = (pub) => versiones.get(pub) || null
|
|
@@ -599,19 +599,19 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
599
599
|
* sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
|
|
600
600
|
* y tirarla sería perder una renuncia sin aplicar.
|
|
601
601
|
*/
|
|
602
|
-
async function
|
|
603
|
-
const
|
|
604
|
-
if (!
|
|
605
|
-
const
|
|
606
|
-
for (const e of
|
|
607
|
-
try { await identity.absorbRenounce(e);
|
|
608
|
-
catch (err) { log(`[vault] ${
|
|
602
|
+
async function absorbSubacta (reason = 'unlock') {
|
|
603
|
+
const pendingOnes = subacta.drain()
|
|
604
|
+
if (!pendingOnes.length) return 0
|
|
605
|
+
const finished = []
|
|
606
|
+
for (const e of pendingOnes) {
|
|
607
|
+
try { await identity.absorbRenounce(e); finished.push(e) }
|
|
608
|
+
catch (err) { log(`[vault] ${reason}: could not absorb a pending renounce: ${err.message}`) }
|
|
609
609
|
}
|
|
610
|
-
if (
|
|
611
|
-
subacta.clear(
|
|
612
|
-
log(`[vault] ${
|
|
610
|
+
if (finished.length) {
|
|
611
|
+
subacta.clear(finished)
|
|
612
|
+
log(`[vault] ${reason}: ${finished.length} pending renounce(s) absorbed into the record`)
|
|
613
613
|
}
|
|
614
|
-
return
|
|
614
|
+
return finished.length
|
|
615
615
|
}
|
|
616
616
|
|
|
617
617
|
/**
|
|
@@ -626,7 +626,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
626
626
|
*/
|
|
627
627
|
const verifyChain = async (args) => {
|
|
628
628
|
const chk = await verifyChainRaw(args)
|
|
629
|
-
if (chk.ok && args?.data?.v)
|
|
629
|
+
if (chk.ok && args?.data?.v) recordVersion(chk.device, args.data.v)
|
|
630
630
|
// DÓNDE CORRE, por el mismo motivo: se apunta de lo firmado. Un fallo al guardarlo no
|
|
631
631
|
// puede tumbar el pedido —es un dato para la lista—, pero se dice.
|
|
632
632
|
if (chk.ok && args?.data?.machine) {
|
|
@@ -642,9 +642,35 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
642
642
|
}
|
|
643
643
|
|
|
644
644
|
|
|
645
|
+
/**
|
|
646
|
+
* LOS ERRORES DE UNA PETICIÓN DE SECRETOS VAN FIRMADOS, con la llave de sellado (la misma que
|
|
647
|
+
* firma lo que se sirve) y atados a esa petición (`re` = huella de su firma). Lo que va a la
|
|
648
|
+
* cuenta lo reciben todas sus bóvedas, y la que no puede servir (el teléfono, sin llave de
|
|
649
|
+
* sellado) contestaba con un error que el servicio tomaba por la respuesta: se quedaba sin
|
|
650
|
+
* secretos aunque esta bóveda sí se los daba (2026-10-18, accounts). Ahora el servicio solo
|
|
651
|
+
* obedece el error que verifica contra el acta; uno sin firma no corta nada.
|
|
652
|
+
*
|
|
653
|
+
* `errorRe`: a quién se le está contestando una petición de secretos, y con qué huella.
|
|
654
|
+
*/
|
|
655
|
+
const errorRe = new Map()
|
|
656
|
+
async function signError (obj, re) {
|
|
657
|
+
const signed = { op: 'secrets.error', error: String(obj?.error || ''), re, ts: Date.now() }
|
|
658
|
+
const seal = await signSeal(signed)
|
|
659
|
+
// Sin llave de sellado no hay firma que poner: el error sale tal cual, y quien pidió no
|
|
660
|
+
// le hará caso (es justo el caso de la bóveda que no puede servir).
|
|
661
|
+
if (!seal) return { ...obj, re, signed: null }
|
|
662
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
663
|
+
return { ...obj, re, signed, seal, acta }
|
|
664
|
+
}
|
|
665
|
+
|
|
645
666
|
const reply = (to, obj) => {
|
|
667
|
+
if (obj?.type === MSG.ERROR && !('signed' in obj) && errorRe.has(to)) {
|
|
668
|
+
const re = errorRe.get(to)
|
|
669
|
+
signError(obj, re).then((o) => reply(to, o), (e) => { log('[vault] could not sign an error:', e.message); reply(to, { ...obj, signed: null }) })
|
|
670
|
+
return
|
|
671
|
+
}
|
|
646
672
|
try {
|
|
647
|
-
const
|
|
673
|
+
const send = channels.get(to) || ((o) => client.send(to, o))
|
|
648
674
|
// ANUNCIAMOS EN CADA RESPUESTA: el otro lado decide con esto, y decide él porque es
|
|
649
675
|
// el que puede tener la lista al día (§14). Va en el envoltorio y no dentro de un
|
|
650
676
|
// cuerpo firmado a propósito — es un dato para enseñar y para explicar un fallo, no
|
|
@@ -654,9 +680,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
654
680
|
if (bytes > MAX_REPLY_BYTES) {
|
|
655
681
|
log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
|
|
656
682
|
audit('reply-too-big', { type: obj?.type || null, bytes })
|
|
657
|
-
return
|
|
683
|
+
return send({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
|
|
658
684
|
}
|
|
659
|
-
|
|
685
|
+
send(obj)
|
|
660
686
|
} catch (e) { log('[vault] could not reply:', e.message) }
|
|
661
687
|
}
|
|
662
688
|
|
|
@@ -859,7 +885,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
859
885
|
* mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
|
|
860
886
|
* se decide que sí.
|
|
861
887
|
*/
|
|
862
|
-
async function
|
|
888
|
+
async function actaContext () {
|
|
863
889
|
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
864
890
|
if (!acta) return { actaSeq: null, sealers: null }
|
|
865
891
|
return { actaSeq: acta.seq, sealers: Acta.sealersOf(acta) }
|
|
@@ -887,7 +913,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
887
913
|
if (!isFresh(p.data)) { audit('rejected', { what: 'sign', reason: 'stale' }); return staleReply(from) }
|
|
888
914
|
const chk = await verifyChain({
|
|
889
915
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
890
|
-
expectedScope: SCOPE.SIGN, ...(await
|
|
916
|
+
expectedScope: SCOPE.SIGN, ...(await actaContext()), revoked: await revocationSet()
|
|
891
917
|
})
|
|
892
918
|
if (!chk.ok) return denyChain(from, chk, p, 'sign')
|
|
893
919
|
|
|
@@ -923,7 +949,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
923
949
|
if (!isFresh(p.data)) return staleReply(from)
|
|
924
950
|
const chk = await verifyChain({
|
|
925
951
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
926
|
-
expectedScope: SCOPE.READ, ...(await
|
|
952
|
+
expectedScope: SCOPE.READ, ...(await actaContext()), revoked: await revocationSet()
|
|
927
953
|
})
|
|
928
954
|
if (!chk.ok) return denyChain(from, chk, p, 'get')
|
|
929
955
|
if (!await actaAllows(from, chk, SCOPE.READ, 'get')) return
|
|
@@ -982,22 +1008,22 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
982
1008
|
},
|
|
983
1009
|
|
|
984
1010
|
async open ({ nonce, enc, by }) {
|
|
985
|
-
const
|
|
1011
|
+
const saved = aperturas.get('pending')
|
|
986
1012
|
aperturas.delete('pending')
|
|
987
|
-
if (!
|
|
1013
|
+
if (!saved || saved.exp <= Date.now()) {
|
|
988
1014
|
return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
|
|
989
1015
|
}
|
|
990
1016
|
let payload = null
|
|
991
|
-
try { payload = await openSealed({ privateKey:
|
|
1017
|
+
try { payload = await openSealed({ privateKey: saved.privateKey, enc }) } catch (_) { payload = null }
|
|
992
1018
|
// EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
|
|
993
1019
|
// reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
|
|
994
1020
|
if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
|
|
995
1021
|
audit('rejected', { what: 'unlock', by, reason: 'envelope' })
|
|
996
1022
|
return { ok: false, error: 'unlock: the envelope does not check out' }
|
|
997
1023
|
}
|
|
998
|
-
const
|
|
1024
|
+
const derived = b64ToBytes(payload.key)
|
|
999
1025
|
try {
|
|
1000
|
-
const r = await openWithSecondary(
|
|
1026
|
+
const r = await openWithSecondary(derived)
|
|
1001
1027
|
audit('unlock', { by })
|
|
1002
1028
|
log(`[vault] opened from the admin by ${by || '????-????'}`)
|
|
1003
1029
|
return { ok: true, result: { ok: true, locked: false } }
|
|
@@ -1007,7 +1033,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1007
1033
|
// que ya no responde y no sabe por qué.
|
|
1008
1034
|
return { ok: false, code: e.code || null, error: e.message, tries: e.tries ?? null, waitSec: e.waitSec ?? null }
|
|
1009
1035
|
} finally {
|
|
1010
|
-
|
|
1036
|
+
derived.fill(0)
|
|
1011
1037
|
}
|
|
1012
1038
|
}
|
|
1013
1039
|
}
|
|
@@ -1024,7 +1050,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1024
1050
|
}
|
|
1025
1051
|
const revoked = await revocationSet()
|
|
1026
1052
|
// `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
|
|
1027
|
-
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await
|
|
1053
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await actaContext()), revoked })
|
|
1028
1054
|
if (!chk.ok) return denyChain(from, chk, p, 'profile')
|
|
1029
1055
|
if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
|
|
1030
1056
|
|
|
@@ -1055,17 +1081,17 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1055
1081
|
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
|
|
1056
1082
|
}
|
|
1057
1083
|
// profilePut
|
|
1058
|
-
const { key, cls, sobre, value } = d.args || {}
|
|
1059
|
-
const
|
|
1084
|
+
const { key, cls, sobre: envelope, value } = d.args || {}
|
|
1085
|
+
const who = await deviceIdOf(chk.device).catch(() => null)
|
|
1060
1086
|
let r
|
|
1061
1087
|
if (cls === 'public') {
|
|
1062
|
-
r = await secrets.profilePutPublic(key, value, { by:
|
|
1088
|
+
r = await secrets.profilePutPublic(key, value, { by: who })
|
|
1063
1089
|
} else {
|
|
1064
|
-
r = await secrets.putSealed(PROFILE_OWNER, key,
|
|
1090
|
+
r = await secrets.putSealed(PROFILE_OWNER, key, envelope, { by: who })
|
|
1065
1091
|
}
|
|
1066
|
-
audit('profile.put', { key, cls: cls || 'private', by:
|
|
1092
|
+
audit('profile.put', { key, cls: cls || 'private', by: who, gen: r?.gen ?? null })
|
|
1067
1093
|
// Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
|
|
1068
|
-
await notifyMembers('profile', { key, by:
|
|
1094
|
+
await notifyMembers('profile', { key, by: who })
|
|
1069
1095
|
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
|
|
1070
1096
|
} catch (e) {
|
|
1071
1097
|
return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
|
|
@@ -1111,9 +1137,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1111
1137
|
return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
|
|
1112
1138
|
}
|
|
1113
1139
|
const revoked = await revocationSet()
|
|
1114
|
-
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, ...(await
|
|
1140
|
+
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, ...(await actaContext()), revoked })
|
|
1115
1141
|
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
1116
|
-
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, ...(await
|
|
1142
|
+
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, ...(await actaContext()), revoked })
|
|
1117
1143
|
}
|
|
1118
1144
|
if (!chk.ok) return denyChain(from, chk, p, 'store')
|
|
1119
1145
|
// El scope que valió es el que hay que preguntarle al acta: un método de lectura pudo
|
|
@@ -1291,7 +1317,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1291
1317
|
|
|
1292
1318
|
async function handleDevices (from, p) {
|
|
1293
1319
|
if (!isFresh(p.data)) return staleReply(from)
|
|
1294
|
-
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await
|
|
1320
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await actaContext()), revoked: await revocationSet() })
|
|
1295
1321
|
if (!chk.ok) return denyChain(from, chk, p, null)
|
|
1296
1322
|
// DOS RESPUESTAS, PORQUE SON DOS PREGUNTAS DISTINTAS.
|
|
1297
1323
|
//
|
|
@@ -1310,9 +1336,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1310
1336
|
// génesis), y hasta ahora ningún agente headless podía: solo el navegador la tenía. Es
|
|
1311
1337
|
// corta a propósito (génesis, cambios de sellador, actual), así que no pasa por `fitChain`.
|
|
1312
1338
|
const sealerChainOf = async () => { try { return (await identity.sealerChain?.()) || null } catch (e) { log('[vault] could not build the sealer chain:', e.message); return null } }
|
|
1313
|
-
const
|
|
1339
|
+
const canSeeAll = !!record && Acta.memberCanScope(record, chk.device, SCOPE.READ)
|
|
1314
1340
|
const { issued, revoked } = await identity.listDelegations()
|
|
1315
|
-
if (!
|
|
1341
|
+
if (!canSeeAll) {
|
|
1316
1342
|
if (!record) {
|
|
1317
1343
|
audit('rejected', { what: 'devices', reason: 'no-record' })
|
|
1318
1344
|
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
@@ -1386,7 +1412,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1386
1412
|
// revocado NO puede renovarse (ahí sí toca re-emparejar con aprobación).
|
|
1387
1413
|
async function handleRenew (from, p) {
|
|
1388
1414
|
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
1389
|
-
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await
|
|
1415
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await actaContext()), revoked: await revocationSet() })
|
|
1390
1416
|
if (!chk.ok) return denyChain(from, chk, p, 'renew')
|
|
1391
1417
|
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
1392
1418
|
const { issued } = await identity.listDelegations()
|
|
@@ -1450,7 +1476,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1450
1476
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
|
|
1451
1477
|
const chk = await verifyChain({
|
|
1452
1478
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1453
|
-
expectedScope: secretsScope(ns), ...(await
|
|
1479
|
+
expectedScope: secretsScope(ns), ...(await actaContext()), revoked: await revocationSet()
|
|
1454
1480
|
})
|
|
1455
1481
|
if (!chk.ok) return denyChain(from, chk, p, 'enckey')
|
|
1456
1482
|
// MISMA FRONTERA QUE SERVIR EL CAJÓN, y por la misma razón: esto no solo apunta una
|
|
@@ -1477,7 +1503,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1477
1503
|
// Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
|
|
1478
1504
|
// abrirlas, que es el mismo agujero por otra puerta.
|
|
1479
1505
|
for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
|
|
1480
|
-
await spreadKey(owner, (
|
|
1506
|
+
await spreadKey(owner, (isPublic) => recipientsOf(owner, { public: isPublic }))
|
|
1481
1507
|
.catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
|
|
1482
1508
|
}
|
|
1483
1509
|
// Con la llave de sellado, como todo lo que sirve la bóveda: la maestra no firma con la
|
|
@@ -1518,10 +1544,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1518
1544
|
*/
|
|
1519
1545
|
function drawerDigest (ns, devicePub) {
|
|
1520
1546
|
const b = secrets.bundleFor(ns, devicePub)
|
|
1521
|
-
const
|
|
1547
|
+
const rows = Object.entries(b.entries || {})
|
|
1522
1548
|
.map(([k, e]) => [k, b.legacy ? sha256Hex(String(e.v)) : (e.gen ?? null)])
|
|
1523
1549
|
.sort((a, c) => (a[0] < c[0] ? -1 : 1))
|
|
1524
|
-
return sha256Hex(JSON.stringify(
|
|
1550
|
+
return sha256Hex(JSON.stringify(rows))
|
|
1525
1551
|
}
|
|
1526
1552
|
|
|
1527
1553
|
/**
|
|
@@ -1540,7 +1566,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1540
1566
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'digest: invalid namespace' })
|
|
1541
1567
|
const chk = await verifyChain({
|
|
1542
1568
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1543
|
-
expectedScope: secretsScope(ns), ...(await
|
|
1569
|
+
expectedScope: secretsScope(ns), ...(await actaContext()), revoked: await revocationSet()
|
|
1544
1570
|
})
|
|
1545
1571
|
if (!chk.ok) return denyChain(from, chk, p, 'digest')
|
|
1546
1572
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -1573,7 +1599,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1573
1599
|
async function handleUpdateAsk (from, p) {
|
|
1574
1600
|
const chk = await verifyChain({
|
|
1575
1601
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1576
|
-
...(await
|
|
1602
|
+
...(await actaContext()), revoked: await revocationSet()
|
|
1577
1603
|
})
|
|
1578
1604
|
if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
|
|
1579
1605
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -1607,10 +1633,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1607
1633
|
if (decided) return say(from, { ok: decided.ok, asked: true })
|
|
1608
1634
|
// YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
|
|
1609
1635
|
// timbra otra vez; la respuesta irá a quien pregunta ahora.
|
|
1610
|
-
const
|
|
1611
|
-
if (
|
|
1612
|
-
|
|
1613
|
-
return say(from, { pending: true, id:
|
|
1636
|
+
const alive = updateAsks.get(key)
|
|
1637
|
+
if (alive && waiters.has(alive.id)) {
|
|
1638
|
+
alive.dest = from
|
|
1639
|
+
return say(from, { pending: true, id: alive.id, exp: alive.exp, asked: true })
|
|
1614
1640
|
}
|
|
1615
1641
|
const ask = { id: null, exp: null, dest: from }
|
|
1616
1642
|
let pend
|
|
@@ -1650,7 +1676,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1650
1676
|
const ev = op === 'update-needs-root' ? 'update-needs-root' : 'updated'
|
|
1651
1677
|
const chk = await verifyChain({
|
|
1652
1678
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1653
|
-
...(await
|
|
1679
|
+
...(await actaContext()), revoked: await revocationSet()
|
|
1654
1680
|
})
|
|
1655
1681
|
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1656
1682
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -1680,7 +1706,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1680
1706
|
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
|
|
1681
1707
|
const chk = await verifyChain({
|
|
1682
1708
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1683
|
-
expectedScope: secretsScope(ns), ...(await
|
|
1709
|
+
expectedScope: secretsScope(ns), ...(await actaContext()), revoked: await revocationSet()
|
|
1684
1710
|
})
|
|
1685
1711
|
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1686
1712
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -1748,7 +1774,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1748
1774
|
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
1749
1775
|
const chk = await verifyChain({
|
|
1750
1776
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1751
|
-
expectedScope: secretsScope(ns), ...(await
|
|
1777
|
+
expectedScope: secretsScope(ns), ...(await actaContext()), revoked: await revocationSet()
|
|
1752
1778
|
})
|
|
1753
1779
|
if (!chk.ok) return denyChain(from, chk, p, 'secrets')
|
|
1754
1780
|
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
@@ -1788,7 +1814,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1788
1814
|
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1789
1815
|
// QUÉ COMANDO, DESDE QUÉ CARPETA — medido contra `/proc` si se puede.
|
|
1790
1816
|
let ctx = null
|
|
1791
|
-
try { ctx =
|
|
1817
|
+
try { ctx = requestContext(p.data?.ctx) } catch (e) {
|
|
1792
1818
|
audit('rejected', { what: 'secrets', ns, reason: 'ctx-mismatch' })
|
|
1793
1819
|
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — ${e.message}`)
|
|
1794
1820
|
return reply(from, { type: MSG.ERROR, code: 'ctx-mismatch', error: 'unauthorized: ' + e.message })
|
|
@@ -1796,12 +1822,12 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1796
1822
|
// ¿YA DIJISTE QUE SÍ A ESTE COMANDO? Entonces no se vuelve a timbrar: la concesión
|
|
1797
1823
|
// dura una hora DESDE EL ÚLTIMO USO, así que un servicio que sigue pidiendo la
|
|
1798
1824
|
// mantiene viva. Cambia el comando o la carpeta y esto ya no encaja: otro pedido.
|
|
1799
|
-
const
|
|
1800
|
-
if (
|
|
1825
|
+
const current = grants.allows({ ns, device: chk.device, ctx })
|
|
1826
|
+
if (current) {
|
|
1801
1827
|
try {
|
|
1802
1828
|
const res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly: false })
|
|
1803
|
-
audit('secrets', { device: deviceId, ns, grant:
|
|
1804
|
-
log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${
|
|
1829
|
+
audit('secrets', { device: deviceId, ns, grant: current.id })
|
|
1830
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${current.uses} use(s))`)
|
|
1805
1831
|
return reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
1806
1832
|
} catch (e) {
|
|
1807
1833
|
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
@@ -1813,7 +1839,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1813
1839
|
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1814
1840
|
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
1815
1841
|
// Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
|
|
1816
|
-
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from })
|
|
1842
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from, re: errorRe.get(from) || null })
|
|
1817
1843
|
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
1818
1844
|
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})` +
|
|
1819
1845
|
(ctx ? ` — ${commandLine(ctx, 80)} [${ctx.cwd || '?'}]` : ' — it does not say what it is running'))
|
|
@@ -1857,11 +1883,11 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1857
1883
|
// aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
|
|
1858
1884
|
// bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
|
|
1859
1885
|
if (!b.legacy && !publicOnly) {
|
|
1860
|
-
const
|
|
1861
|
-
if (
|
|
1862
|
-
const
|
|
1863
|
-
audit('secrets.no-wrapping', { device:
|
|
1864
|
-
log(`[vault] ${ns}: ${
|
|
1886
|
+
const missingOnes = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
|
|
1887
|
+
if (missingOnes.length) {
|
|
1888
|
+
const who = await deviceIdOf(devicePub).catch(() => '????-????')
|
|
1889
|
+
audit('secrets.no-wrapping', { device: who, ns, vars: missingOnes })
|
|
1890
|
+
log(`[vault] ${ns}: ${who} has no wrapping for ${missingOnes.join(', ')} — open the vault (dotrino-vault unlock) so it hands it the key`)
|
|
1865
1891
|
}
|
|
1866
1892
|
}
|
|
1867
1893
|
const enc = await seal({ ek, payload })
|
|
@@ -1903,7 +1929,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1903
1929
|
const op = p.data?.op
|
|
1904
1930
|
const chk = await verifyChain({
|
|
1905
1931
|
data: p.data, signature: p.signature, cert: p.cert,
|
|
1906
|
-
...(await
|
|
1932
|
+
...(await actaContext()), revoked: await revocationSet()
|
|
1907
1933
|
})
|
|
1908
1934
|
if (!chk.ok) return denyChain(from, chk, p, 'approval')
|
|
1909
1935
|
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
@@ -1923,11 +1949,11 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1923
1949
|
}
|
|
1924
1950
|
// `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
|
|
1925
1951
|
// actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
|
|
1926
|
-
if (op === 'approvals') return answer({ op: 'approvals', items: await
|
|
1952
|
+
if (op === 'approvals') return answer({ op: 'approvals', items: await withSealedContext(approvals.list(), chk.device, record), notices: notices.list() })
|
|
1927
1953
|
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1928
1954
|
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1929
1955
|
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
1930
|
-
if (op === 'grants') return answer({ op: 'grants', items: await
|
|
1956
|
+
if (op === 'grants') return answer({ op: 'grants', items: await withSealedContext(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
|
|
1931
1957
|
// LOS BLOQUEADOS SE VEN desde donde se bloquean. Desbloquear no: eso es de la bóveda
|
|
1932
1958
|
// (`dotrino-vault unblock <ID>`) o del administrador, que es lo que pidió el dueño.
|
|
1933
1959
|
if (op === 'blocked') return answer({ op: 'blocked', items: blocked.list() })
|
|
@@ -1991,9 +2017,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
1991
2017
|
audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
|
|
1992
2018
|
log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
|
|
1993
2019
|
try {
|
|
1994
|
-
const
|
|
1995
|
-
const
|
|
1996
|
-
|
|
2020
|
+
const plain = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
|
|
2021
|
+
const err = pend.re ? await signError(plain, pend.re) : plain
|
|
2022
|
+
const byLocal = pend.from && channels.get(pend.from)
|
|
2023
|
+
if (byLocal) byLocal(err)
|
|
1997
2024
|
else client.sendByPubkey(pend.device, err)
|
|
1998
2025
|
} catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
|
|
1999
2026
|
return answer({ op: 'deny.result', id, ok: true })
|
|
@@ -2020,8 +2047,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2020
2047
|
// Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
|
|
2021
2048
|
// volver aunque ahora esté apagado. Si fue el socket local, por el socket.
|
|
2022
2049
|
try {
|
|
2023
|
-
const
|
|
2024
|
-
if (
|
|
2050
|
+
const byLocal = pend.from && channels.get(pend.from)
|
|
2051
|
+
if (byLocal) byLocal({ type: MSG.SECRETS_RESULT, ...res })
|
|
2025
2052
|
else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
|
|
2026
2053
|
} catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
|
|
2027
2054
|
return answer({ op: 'approve.result', id, ok: true })
|
|
@@ -2042,26 +2069,26 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2042
2069
|
* en vez de mandárselo en claro «para que al menos lo vea»: eso sería cambiar la promesa
|
|
2043
2070
|
* por comodidad, y encima sin avisar a nadie.
|
|
2044
2071
|
*/
|
|
2045
|
-
async function
|
|
2046
|
-
const encPub = (record?.members || []).find((m) => m.pub ===
|
|
2047
|
-
const
|
|
2072
|
+
async function withSealedContext (items, approver, record) {
|
|
2073
|
+
const encPub = (record?.members || []).find((m) => m.pub === approver)?.encPub || null
|
|
2074
|
+
const output = []
|
|
2048
2075
|
for (const it of items) {
|
|
2049
|
-
const { ctx, ...
|
|
2050
|
-
if (!ctx) {
|
|
2051
|
-
if (!encPub) {
|
|
2076
|
+
const { ctx, ...rest } = it
|
|
2077
|
+
if (!ctx) { output.push({ ...rest, ctx: null }); continue }
|
|
2078
|
+
if (!encPub) { output.push({ ...rest, ctx: null, ctxSealed: false, ctxReason: 'no-enc-key' }); continue }
|
|
2052
2079
|
try {
|
|
2053
2080
|
const cek = await makeContentKey()
|
|
2054
2081
|
const envelope = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(ctx) })
|
|
2055
2082
|
const wrap = await wrapForMember({ cek, memberEncPub: encPub })
|
|
2056
|
-
|
|
2083
|
+
output.push({ ...rest, ctx: null, ctxSealed: true, ctxWrap: wrap, ctxEnvelope: envelope })
|
|
2057
2084
|
} catch (e) {
|
|
2058
2085
|
// NO SE TRAGA: sin esto, un fallo al sellar se vería en el teléfono igual que un
|
|
2059
2086
|
// pedido que no dice nada, y nadie sabría que hay algo que arreglar.
|
|
2060
|
-
log(`[vault] could not seal the command of ${it.id} for ${
|
|
2061
|
-
|
|
2087
|
+
log(`[vault] could not seal the command of ${it.id} for ${approver.slice(0, 24)}…: ${e.message}`)
|
|
2088
|
+
output.push({ ...rest, ctx: null, ctxSealed: false, ctxReason: 'seal-failed' })
|
|
2062
2089
|
}
|
|
2063
2090
|
}
|
|
2064
|
-
return
|
|
2091
|
+
return output
|
|
2065
2092
|
}
|
|
2066
2093
|
|
|
2067
2094
|
/**
|
|
@@ -2153,7 +2180,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2153
2180
|
*/
|
|
2154
2181
|
async function handleIncident (from, p) {
|
|
2155
2182
|
if (!isFresh(p.data)) return staleReply(from)
|
|
2156
|
-
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await
|
|
2183
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await actaContext()), revoked: await revocationSet() })
|
|
2157
2184
|
if (!chk.ok) return denyChain(from, chk, p, 'incident')
|
|
2158
2185
|
const record = await refreshActa()
|
|
2159
2186
|
if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
|
|
@@ -2208,7 +2235,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2208
2235
|
if (identity.me?.publickey && typeof platform.openLocalDesk === 'function') {
|
|
2209
2236
|
platform.openLocalDesk({
|
|
2210
2237
|
name: (await pubkeyId(identity.me.publickey)).slice(0, 16),
|
|
2211
|
-
onMessage, channels:
|
|
2238
|
+
onMessage, channels: channels, maxBytes: MAX_REPLY_BYTES, log
|
|
2212
2239
|
})
|
|
2213
2240
|
}
|
|
2214
2241
|
|
|
@@ -2224,7 +2251,13 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2224
2251
|
if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
|
|
2225
2252
|
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
2226
2253
|
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
2227
|
-
if (payload.type === MSG.SECRETS)
|
|
2254
|
+
if (payload.type === MSG.SECRETS) {
|
|
2255
|
+
const re = sha256Hex(String(payload.signature || ''))
|
|
2256
|
+
errorRe.set(from, re)
|
|
2257
|
+
try { return await handleSecrets(from, payload) } catch (e) {
|
|
2258
|
+
return reply(from, { type: MSG.ERROR, error: e.message })
|
|
2259
|
+
} finally { if (errorRe.get(from) === re) errorRe.delete(from) }
|
|
2260
|
+
}
|
|
2228
2261
|
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
2229
2262
|
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
2230
2263
|
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
@@ -2257,8 +2290,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2257
2290
|
// («sin-acta-previa»), y este es el único mostrador donde una llega de un desconocido.
|
|
2258
2291
|
// Anunciar no es cómo se entra en una cuenta —para eso está el enrolamiento—, así que
|
|
2259
2292
|
// sin acta propia no se adopta: se dice y se para.
|
|
2260
|
-
const
|
|
2261
|
-
if (!
|
|
2293
|
+
const own = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2294
|
+
if (!own) return log('[vault] a record was announced but this vault has none of its own to compare it with: ignored')
|
|
2262
2295
|
try {
|
|
2263
2296
|
const r = await identity.adoptActa?.(acta)
|
|
2264
2297
|
if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
|
|
@@ -2321,13 +2354,13 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2321
2354
|
*/
|
|
2322
2355
|
async function approvePasswordRequest ({ pubkey, op, payload }) {
|
|
2323
2356
|
let resolve
|
|
2324
|
-
const
|
|
2357
|
+
const wait = new Promise((r) => { resolve = r })
|
|
2325
2358
|
const fields = Array.isArray(payload?.keys) ? payload.keys.slice(0, 12).map((k) => String(k).slice(0, 40)) : null
|
|
2326
2359
|
await awaitApproval({
|
|
2327
2360
|
device: pubkey, ns: 'passwords', kind: 'passwords', what: 'passwords', op,
|
|
2328
2361
|
ctx: { op: String(op || '').replace(/^pm2\./, ''), fields }, onAnswer: resolve
|
|
2329
2362
|
})
|
|
2330
|
-
return
|
|
2363
|
+
return wait
|
|
2331
2364
|
}
|
|
2332
2365
|
|
|
2333
2366
|
const startPasswordDesk = async () => {
|
|
@@ -2529,14 +2562,14 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2529
2562
|
//
|
|
2530
2563
|
// El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
|
|
2531
2564
|
// suma porque cubre a quien tiene cert y todavía no aparece ahí.
|
|
2532
|
-
const
|
|
2565
|
+
const members = (await identity.profileMembers?.().catch(() => null))?.members || []
|
|
2533
2566
|
const yo = identity.me?.publickey || null
|
|
2534
2567
|
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
2535
2568
|
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
2536
2569
|
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
2537
2570
|
// cuidado que en `notifyNsChange`.
|
|
2538
2571
|
const seen = new Set()
|
|
2539
|
-
const
|
|
2572
|
+
const notify = (pub) => {
|
|
2540
2573
|
if (!pub || pub === yo || seen.has(pub)) return
|
|
2541
2574
|
seen.add(pub)
|
|
2542
2575
|
// El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
|
|
@@ -2549,8 +2582,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2549
2582
|
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true, app: 'vault' }) }
|
|
2550
2583
|
catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
|
|
2551
2584
|
}
|
|
2552
|
-
for (const m of
|
|
2553
|
-
for (const d of issued || [])
|
|
2585
|
+
for (const m of members) notify(m.pub)
|
|
2586
|
+
for (const d of issued || []) notify(d.sub)
|
|
2554
2587
|
log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
|
|
2555
2588
|
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
2556
2589
|
// Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
|
|
@@ -2578,11 +2611,11 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2578
2611
|
// LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
|
|
2579
2612
|
// identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
|
|
2580
2613
|
// el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
|
|
2581
|
-
const
|
|
2582
|
-
if (typeof
|
|
2614
|
+
const who = body.publickey
|
|
2615
|
+
if (typeof who !== 'string' || !Acta.memberCan(record, who, 'replica')) {
|
|
2583
2616
|
return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
|
|
2584
2617
|
}
|
|
2585
|
-
if (!(await verifyDeviceSig({ publickey:
|
|
2618
|
+
if (!(await verifyDeviceSig({ publickey: who, data: body, signature: p.signature }))) {
|
|
2586
2619
|
return log('[vault] a replica ack does not check out against its own key: ignored')
|
|
2587
2620
|
}
|
|
2588
2621
|
if (typeof body.seq === 'number' && body.seq >= record.seq) return
|
|
@@ -2603,7 +2636,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2603
2636
|
* peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
|
|
2604
2637
|
* disponibilidad, que es exactamente lo que un replicador arriesga.
|
|
2605
2638
|
*/
|
|
2606
|
-
async function pushToReplicas (
|
|
2639
|
+
async function pushToReplicas (reason = 'change') {
|
|
2607
2640
|
try {
|
|
2608
2641
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2609
2642
|
if (!record) return
|
|
@@ -2625,7 +2658,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2625
2658
|
try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
|
|
2626
2659
|
catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
|
|
2627
2660
|
}
|
|
2628
|
-
log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${
|
|
2661
|
+
log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${reason}`)
|
|
2629
2662
|
} catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
|
|
2630
2663
|
}
|
|
2631
2664
|
|
|
@@ -2903,7 +2936,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2903
2936
|
verify: async ({ data, signature, cert }) => {
|
|
2904
2937
|
const chk = await verifyChain({
|
|
2905
2938
|
data, signature, cert,
|
|
2906
|
-
expectedScope: SCOPE.ADMIN, ...(await
|
|
2939
|
+
expectedScope: SCOPE.ADMIN, ...(await actaContext()), revoked: await revocationSet()
|
|
2907
2940
|
})
|
|
2908
2941
|
// Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
|
|
2909
2942
|
// que mande el aparato ya llega con el certificado retirado. Que se entere con el
|
|
@@ -2952,8 +2985,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2952
2985
|
audit('rejected', { what: 'renounce', reason: 'no-record' })
|
|
2953
2986
|
return reply(from, { type: MSG.ERROR, error: 'renounce: this vault has no record to apply it to' })
|
|
2954
2987
|
}
|
|
2955
|
-
const
|
|
2956
|
-
if (!(record.caps || []).some((c) =>
|
|
2988
|
+
const has = new Set(Acta.effectiveCaps(actual, record.member) || [])
|
|
2989
|
+
if (!(record.caps || []).some((c) => has.has(c))) {
|
|
2957
2990
|
audit('rejected', { what: 'renounce', reason: 'nothing-to-remove' })
|
|
2958
2991
|
return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
|
|
2959
2992
|
}
|
|
@@ -2962,8 +2995,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
2962
2995
|
// esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
|
|
2963
2996
|
// de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
|
|
2964
2997
|
// único momento en que de verdad hace falta.
|
|
2965
|
-
const
|
|
2966
|
-
if (!
|
|
2998
|
+
const saved = await subacta.add(record)
|
|
2999
|
+
if (!saved.ok && saved.reason === 'not-acceptable') {
|
|
2967
3000
|
audit('rejected', { what: 'renounce', reason: 'not-acceptable' })
|
|
2968
3001
|
return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
|
|
2969
3002
|
}
|
|
@@ -3142,13 +3175,13 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3142
3175
|
|
|
3143
3176
|
/** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
|
|
3144
3177
|
/** Sin repetidos por llave: un aparato puede entrar por dos vías (dueño y admin). */
|
|
3145
|
-
const dedup = (
|
|
3146
|
-
const
|
|
3147
|
-
return
|
|
3178
|
+
const dedup = (members) => {
|
|
3179
|
+
const seenOnes = new Set()
|
|
3180
|
+
return members.filter((m) => m && !seenOnes.has(m.pub) && seenOnes.add(m.pub))
|
|
3148
3181
|
}
|
|
3149
|
-
const
|
|
3150
|
-
const
|
|
3151
|
-
return [...base, ...co.filter((m) => !
|
|
3182
|
+
const withCosealers = (base, co) => {
|
|
3183
|
+
const seenOnes = new Set(base.map((m) => m.pub))
|
|
3184
|
+
return [...base, ...co.filter((m) => !seenOnes.has(m.pub))]
|
|
3152
3185
|
}
|
|
3153
3186
|
|
|
3154
3187
|
async function recipientsOf (owner, { public: isPublic = false } = {}) {
|
|
@@ -3174,23 +3207,23 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3174
3207
|
// normal firma y no administra, y tiene que ver tu perfil.
|
|
3175
3208
|
if (owner === PROFILE_OWNER) {
|
|
3176
3209
|
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
3177
|
-
const
|
|
3210
|
+
const signers = (record?.members || [])
|
|
3178
3211
|
.filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
|
|
3179
3212
|
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
3180
|
-
return
|
|
3213
|
+
return withCosealers(dedup(signers), co)
|
|
3181
3214
|
}
|
|
3182
3215
|
if (owner.startsWith('ns:')) {
|
|
3183
3216
|
const owned = await nsMembers(owner.slice(3))
|
|
3184
3217
|
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
|
|
3185
3218
|
// SIEMPRE: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
3186
3219
|
const base = owned.length ? [...owned, ...admins] : await adminDevices()
|
|
3187
|
-
return
|
|
3220
|
+
return withCosealers(dedup(base), co)
|
|
3188
3221
|
}
|
|
3189
3222
|
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
3190
3223
|
const m = await memberOf(pub)
|
|
3191
3224
|
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
3192
|
-
if (m?.cn) return
|
|
3193
|
-
return
|
|
3225
|
+
if (m?.cn) return withCosealers(dedup([m, ...admins]), co)
|
|
3226
|
+
return withCosealers(dedup([...(m ? [m] : []), ...await adminDevices()]), co)
|
|
3194
3227
|
}
|
|
3195
3228
|
|
|
3196
3229
|
/**
|
|
@@ -3284,10 +3317,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3284
3317
|
for (const owner of secrets.owners()) {
|
|
3285
3318
|
// Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
|
|
3286
3319
|
// y no la privada. La lista de las públicas contiene siempre a la de las privadas.
|
|
3287
|
-
const
|
|
3320
|
+
const privateOnes = new Set((await recipientsOf(owner)).map((m) => m.pub))
|
|
3288
3321
|
for (const m of await recipientsOf(owner, { public: true })) {
|
|
3289
3322
|
if (!m.encPub || m.pub === master) continue
|
|
3290
|
-
const missing = secrets.missingFor(owner, m.pub, { only: (
|
|
3323
|
+
const missing = secrets.missingFor(owner, m.pub, { only: (isPublic) => isPublic || privateOnes.has(m.pub) })
|
|
3291
3324
|
if (!missing.length) continue
|
|
3292
3325
|
const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
|
|
3293
3326
|
row.owners[owner] = missing
|
|
@@ -3556,8 +3589,8 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3556
3589
|
if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
|
|
3557
3590
|
throw Object.assign(new Error(`var.set: "${m.cn}" cannot overwrite "${key}" — without an approval a service only fills in what is missing; replacing is for who administers`), { code: 'exists' })
|
|
3558
3591
|
}
|
|
3559
|
-
const
|
|
3560
|
-
if (!(await verifyDeviceSig({ publickey: a.pub, data:
|
|
3592
|
+
const body = authorBody(owner, key, sealed.e, a.ts)
|
|
3593
|
+
if (!(await verifyDeviceSig({ publickey: a.pub, data: body, signature: a.sig }))) {
|
|
3561
3594
|
throw new Error('var.set: the author signature does not verify over this envelope')
|
|
3562
3595
|
}
|
|
3563
3596
|
}
|
|
@@ -3576,19 +3609,19 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3576
3609
|
if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
|
|
3577
3610
|
const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
|
|
3578
3611
|
if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
|
|
3579
|
-
const
|
|
3580
|
-
const
|
|
3581
|
-
const
|
|
3582
|
-
const
|
|
3583
|
-
if (
|
|
3584
|
-
throw new Error(`var.set: the envelope leaves ${
|
|
3612
|
+
const mustHave = new Set(members.map((m) => m.pub))
|
|
3613
|
+
const fetched = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
|
|
3614
|
+
const missingOnes = [...mustHave].filter((p) => !fetched.has(p))
|
|
3615
|
+
const extra = [...fetched].filter((p) => !mustHave.has(p))
|
|
3616
|
+
if (missingOnes.length) {
|
|
3617
|
+
throw new Error(`var.set: the envelope leaves ${missingOnes.length} member(s) of "${owner}" out — they could not read it (ask the vault for the recipients with \`var.recipients\`)`)
|
|
3585
3618
|
}
|
|
3586
|
-
if (
|
|
3587
|
-
throw new Error(`var.set: the envelope wraps for ${
|
|
3619
|
+
if (extra.length) {
|
|
3620
|
+
throw new Error(`var.set: the envelope wraps for ${extra.length} key(s) the record does not name for "${owner}"`)
|
|
3588
3621
|
}
|
|
3589
3622
|
}
|
|
3590
3623
|
|
|
3591
|
-
async function refreshWraps (
|
|
3624
|
+
async function refreshWraps (reason) {
|
|
3592
3625
|
// HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
|
|
3593
3626
|
//
|
|
3594
3627
|
// `startVault` sin gestor de perfiles trae `hasPassword = () => true` y `openKey =
|
|
@@ -3597,18 +3630,18 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3597
3630
|
// con la llave de la máquina, y el repaso del llavero se saltaba ENTERO: el servicio se
|
|
3598
3631
|
// quedaba sin su envoltura y contestábamos un paquete que no podía abrir. Es el mismo
|
|
3599
3632
|
// fallo que se está arreglando, colado por la puerta de al lado.
|
|
3600
|
-
const
|
|
3601
|
-
const k =
|
|
3602
|
-
if (
|
|
3603
|
-
log(`[vault] ${
|
|
3633
|
+
const withLock = typeof openKey === 'function' && (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
3634
|
+
const k = withLock ? openKey() : null
|
|
3635
|
+
if (withLock && !k) {
|
|
3636
|
+
log(`[vault] ${reason}: the record changed but the profile key is not here — the record cannot be sealed while locked, so this should not happen; the keyring was NOT rebuilt`)
|
|
3604
3637
|
return null
|
|
3605
3638
|
}
|
|
3606
|
-
const r = await resealAll(k).catch((e) => { log(`[vault] ${
|
|
3607
|
-
if (r?.wrapped) log(`[vault] ${
|
|
3639
|
+
const r = await resealAll(k).catch((e) => { log(`[vault] ${reason}: could not rebuild the keyring: ${e.message}`); return null })
|
|
3640
|
+
if (r?.wrapped) log(`[vault] ${reason}: keyring rebuilt (${r.wrapped} wrap(s) in ${r.drawers} drawer(s))`)
|
|
3608
3641
|
// LOS SOBRES CADUCOS SE DICEN. Son envolturas de quien ya no debería poder abrir ese
|
|
3609
3642
|
// cajón: retirarlas en silencio esconde que alguien las tuvo de más.
|
|
3610
|
-
if (r?.dropped) log(`[vault] ${
|
|
3611
|
-
if (r?.failed?.length) log(`[vault] ${
|
|
3643
|
+
if (r?.dropped) log(`[vault] ${reason}: ${r.dropped} stale wrap(s) dropped (they belonged to members the record no longer names)`)
|
|
3644
|
+
if (r?.failed?.length) log(`[vault] ${reason}: ${r.failed.length} drawer(s) could NOT be resealed: ${r.failed.map((f) => f.owner).join(', ')}`)
|
|
3612
3645
|
return r
|
|
3613
3646
|
}
|
|
3614
3647
|
|
|
@@ -3632,9 +3665,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3632
3665
|
*
|
|
3633
3666
|
* Se puede quitar cuando no queden perfiles de antes de 0.81.0 (2027-03-01).
|
|
3634
3667
|
*/
|
|
3635
|
-
const
|
|
3636
|
-
async function
|
|
3637
|
-
if (!adminKey || Date.now() >
|
|
3668
|
+
const RECOVERY_MIGRATION_UNTIL = Date.parse('2027-03-01T00:00:00Z')
|
|
3669
|
+
async function migrateRecoveryToPhrase (adminKey) {
|
|
3670
|
+
if (!adminKey || Date.now() > RECOVERY_MIGRATION_UNTIL) return false
|
|
3638
3671
|
try {
|
|
3639
3672
|
// Si la frase ya la abre, no hay nada que migrar.
|
|
3640
3673
|
await secrets.recoveryOpensWith(adminKey)
|
|
@@ -3646,9 +3679,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3646
3679
|
// pasar `null` probaba la misma llave dos veces y la migración no migraba nada. Lo
|
|
3647
3680
|
// dijo el propio diagnóstico: «no abre ni con la contraseña ni con la de la máquina»,
|
|
3648
3681
|
// cuando la de la máquina no se había llegado a probar.
|
|
3649
|
-
const
|
|
3650
|
-
await secrets.recoveryOpensWith(
|
|
3651
|
-
const r = await secrets.rekeyRecovery(
|
|
3682
|
+
const machine = disk.machineKey()
|
|
3683
|
+
await secrets.recoveryOpensWith(machine) // si esta tampoco, no hay migración que hacer
|
|
3684
|
+
const r = await secrets.rekeyRecovery(machine, adminKey)
|
|
3652
3685
|
if (!r?.rekeyed) return false
|
|
3653
3686
|
log('[vault] the recovery copy was sealed with this machine\'s key: re-sealed under the profile password')
|
|
3654
3687
|
audit('secret.recovery-rekeyed', {})
|
|
@@ -3686,12 +3719,12 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3686
3719
|
const r = await convertPasswords({
|
|
3687
3720
|
store: passwordsStore,
|
|
3688
3721
|
sealed: passwords.sealed,
|
|
3689
|
-
cek: await
|
|
3722
|
+
cek: await oldPasswordsKey(),
|
|
3690
3723
|
recipients,
|
|
3691
3724
|
// LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
|
|
3692
3725
|
// Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
|
|
3693
3726
|
author: { publickey: master, sign: (body) => identity.signData(body) },
|
|
3694
|
-
dropOldKey:
|
|
3727
|
+
dropOldKey: dropOldKey,
|
|
3695
3728
|
log
|
|
3696
3729
|
})
|
|
3697
3730
|
out.converted = r?.entries || 0
|
|
@@ -3720,7 +3753,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3720
3753
|
async function resealAll (adminKey = null) {
|
|
3721
3754
|
// ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
|
|
3722
3755
|
// máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
|
|
3723
|
-
if (adminKey) await
|
|
3756
|
+
if (adminKey) await migrateRecoveryToPhrase(adminKey)
|
|
3724
3757
|
// Y LA LLAVE DE RECUPERACIÓN EXISTE. Nacía al escribir el primer secreto, así que una
|
|
3725
3758
|
// bóveda recién creada no tenía a quién envolver — y como crearla necesita la maestra,
|
|
3726
3759
|
// cerrada ya no había manera. El síntoma: escribir el perfil con la bóveda cerrada
|
|
@@ -3736,7 +3769,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3736
3769
|
// POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
|
|
3737
3770
|
// administra. Con una sola lista se le daría también la de una privada con dueño, que
|
|
3738
3771
|
// es justo lo que no puede pasar.
|
|
3739
|
-
const members = (
|
|
3772
|
+
const members = (isPublic) => recipientsOf(owner, { public: isPublic })
|
|
3740
3773
|
try {
|
|
3741
3774
|
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
3742
3775
|
const after = new Set(secrets.recipientsIn(owner))
|
|
@@ -3770,7 +3803,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3770
3803
|
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
3771
3804
|
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
3772
3805
|
// sin poder destapar lo que ella misma acababa de saldar.
|
|
3773
|
-
const members = (
|
|
3806
|
+
const members = (isPublic) => recipientsOf(owner, { public: isPublic })
|
|
3774
3807
|
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
3775
3808
|
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
3776
3809
|
let delegated = 0
|
|
@@ -3804,7 +3837,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
3804
3837
|
// (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
|
|
3805
3838
|
// para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
|
|
3806
3839
|
// siempre así: una se actualiza y la otra no.
|
|
3807
|
-
try { return await spreadKey(owner, (
|
|
3840
|
+
try { return await spreadKey(owner, (isPublic) => recipientsOf(owner, { public: isPublic }), null) } catch (_) { return null }
|
|
3808
3841
|
}
|
|
3809
3842
|
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
3810
3843
|
|
|
@@ -4137,10 +4170,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4137
4170
|
try {
|
|
4138
4171
|
const members = (await identity.profileMembers()).members
|
|
4139
4172
|
for (const { pub, caps } of changes) {
|
|
4140
|
-
const
|
|
4141
|
-
const
|
|
4142
|
-
if (
|
|
4143
|
-
log(`[vault] WARNING these permissions were DROPPED: ${
|
|
4173
|
+
const remaining = new Set(members.find((m) => m.pub === pub)?.caps || [])
|
|
4174
|
+
const lost = caps.filter((c) => !remaining.has(c))
|
|
4175
|
+
if (lost.length) {
|
|
4176
|
+
log(`[vault] WARNING these permissions were DROPPED: ${lost.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
|
|
4144
4177
|
}
|
|
4145
4178
|
}
|
|
4146
4179
|
} catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
|
|
@@ -4260,20 +4293,20 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4260
4293
|
// la bóveda seguía muda en el proxio con su llave ya admitida en el acta. Al dueño le
|
|
4261
4294
|
// pasó el 2026-09-05: hizo lo que el log pedía y la consola siguió sin contestar.
|
|
4262
4295
|
if (r?.locked === false) {
|
|
4263
|
-
try { if (await ensureCommKeyInActa()) await
|
|
4296
|
+
try { if (await ensureCommKeyInActa()) await reidentify?.() } catch (_) {}
|
|
4264
4297
|
}
|
|
4265
4298
|
// Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
|
|
4266
4299
|
// eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
|
|
4267
4300
|
// el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
|
|
4268
4301
|
// puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
|
|
4269
|
-
if (r?.locked === false) { try { await
|
|
4302
|
+
if (r?.locked === false) { try { await absorbSubacta('unlock') } catch (_) {} }
|
|
4270
4303
|
// Y LOS APARATOS MUERTOS FUERA DEL ACTA, en una sola (dueño, 2026-09-22). Es el mismo
|
|
4271
4304
|
// momento: la maestra está en memoria y cambiar el acta es uno de sus dos trabajos.
|
|
4272
4305
|
// Si falla no se arrastra —abrir no depende de esto—, pero se dice.
|
|
4273
4306
|
if (r?.locked === false) {
|
|
4274
4307
|
try {
|
|
4275
4308
|
const antes = await refreshActa()
|
|
4276
|
-
const
|
|
4309
|
+
const approvedBefore = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
|
|
4277
4310
|
const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
|
|
4278
4311
|
for (const d of removed) {
|
|
4279
4312
|
const id = await deviceIdOf(d.pub).catch(() => null)
|
|
@@ -4284,14 +4317,14 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4284
4317
|
// el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
|
|
4285
4318
|
// pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
|
|
4286
4319
|
// el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
|
|
4287
|
-
const
|
|
4288
|
-
const
|
|
4289
|
-
const
|
|
4290
|
-
const
|
|
4291
|
-
if (
|
|
4292
|
-
const
|
|
4293
|
-
audit('no-approver', { removed:
|
|
4294
|
-
log(`[vault] WARNING: nobody in the record can approve any more — ${
|
|
4320
|
+
const after = await refreshActa()
|
|
4321
|
+
const left = (after?.members || []).filter((m) => Acta.memberCan(after, m.pub, 'approve')).map((m) => m.pub)
|
|
4322
|
+
const gonePubs = lastApproverGone(approvedBefore, left, removed.map((d) => d.pub))
|
|
4323
|
+
const gone = removed.filter((d) => gonePubs.includes(d.pub))
|
|
4324
|
+
if (gone.length) {
|
|
4325
|
+
const whom = (await Promise.all(gone.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
|
|
4326
|
+
audit('no-approver', { removed: gone.length, seq })
|
|
4327
|
+
log(`[vault] WARNING: nobody in the record can approve any more — ${whom} was the last. From now on every device gets its keys WITHOUT approval, until you enrol one and grant it \`dotrino-vault caps <ID> +aprueba\``)
|
|
4295
4328
|
}
|
|
4296
4329
|
} catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
|
|
4297
4330
|
}
|
|
@@ -4335,7 +4368,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4335
4368
|
const answer = new Promise((resolve) => { settle = resolve })
|
|
4336
4369
|
// RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
|
|
4337
4370
|
// de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
|
|
4338
|
-
const
|
|
4371
|
+
const retire = () => {
|
|
4339
4372
|
if (!pendId || !waiters.delete(pendId)) return
|
|
4340
4373
|
const pend = approvals.take(pendId)
|
|
4341
4374
|
audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
|
|
@@ -4345,9 +4378,9 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4345
4378
|
awaitApproval({
|
|
4346
4379
|
device: yo, ns: 'vault', kind: 'update', what: 'update',
|
|
4347
4380
|
ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
|
|
4348
|
-
}).then((pend) => { pendId = pend.id; if (withdrawn)
|
|
4381
|
+
}).then((pend) => { pendId = pend.id; if (withdrawn) retire() })
|
|
4349
4382
|
.catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
|
|
4350
|
-
return { answer, withdraw: () => { withdrawn = true;
|
|
4383
|
+
return { answer, withdraw: () => { withdrawn = true; retire() } }
|
|
4351
4384
|
},
|
|
4352
4385
|
announceUpdate,
|
|
4353
4386
|
approvers: async () => {
|
|
@@ -4409,7 +4442,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4409
4442
|
// dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
|
|
4410
4443
|
listGrants: () => grants.list(),
|
|
4411
4444
|
revokeGrant: (id) => grants.revoke(id),
|
|
4412
|
-
revokeGrants: (
|
|
4445
|
+
revokeGrants: (filter) => grants.revokeAll(filter || {}),
|
|
4413
4446
|
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
4414
4447
|
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
4415
4448
|
approveDevice: async (code, adminKey) => {
|
|
@@ -4426,7 +4459,7 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4426
4459
|
// envolver hay que envolver siempre para todos los selladores, para que siempre
|
|
4427
4460
|
// tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
|
|
4428
4461
|
// puerta dejaba a los selladores con la envoltura vieja.
|
|
4429
|
-
await spreadKey(`ns:${m.cn}`, (
|
|
4462
|
+
await spreadKey(`ns:${m.cn}`, (isPublic) => recipientsOf(`ns:${m.cn}`, { public: isPublic }), adminKey).catch(async (e) => {
|
|
4430
4463
|
log('[vault] could not hand the key to the new service:', e.message)
|
|
4431
4464
|
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
4432
4465
|
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|
|
@@ -4557,10 +4590,10 @@ export async function startVault ({ platform, proxyUrl, log = console.log, onEnr
|
|
|
4557
4590
|
*/
|
|
4558
4591
|
localChannel (onReply) {
|
|
4559
4592
|
const id = 'local:' + Math.random().toString(36).slice(2)
|
|
4560
|
-
|
|
4593
|
+
channels.set(id, (obj) => { try { onReply(obj) } catch (_) {} })
|
|
4561
4594
|
return {
|
|
4562
4595
|
send: (payload) => { onMessage(id, payload).catch((e) => reply(id, { type: MSG.ERROR, error: e.message })) },
|
|
4563
|
-
close: () => {
|
|
4596
|
+
close: () => { channels.delete(id) }
|
|
4564
4597
|
}
|
|
4565
4598
|
},
|
|
4566
4599
|
revokeDevice: async (target) => {
|