@dotrino/vaultd 0.155.0 → 0.156.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/src/browser.js +167 -0
- package/{src → lib/src/core}/approvals.js +1 -1
- package/{src → lib/src/core}/blocked.js +7 -10
- package/lib/src/core/browserDisk.js +109 -0
- package/{src → lib/src/core}/commKey.js +5 -8
- package/lib/src/core/disk.js +65 -0
- package/lib/src/core/libVersion.js +8 -0
- package/{src → lib/src/core}/machines.js +7 -10
- package/{src → lib/src/core}/notices.js +8 -12
- package/{src → lib/src/core}/notifiable.js +7 -10
- package/{src → lib/src/core}/sealKey.js +5 -8
- package/{src → lib/src/core}/sealer.js +12 -10
- package/{src → lib/src/core}/secretsStore.js +8 -11
- package/lib/src/core/sha256.js +69 -0
- package/{src → lib/src/core}/store.js +6 -9
- package/{src → lib/src/core}/subacta.js +5 -6
- package/{src → lib/src/core}/threadStore.js +5 -8
- package/{src → lib/src/core}/transport.js +4 -5
- package/lib/src/core/vault.js +4414 -0
- package/lib/src/proc.js +2 -84
- package/lib/src/procContext.js +97 -0
- package/package.json +3 -3
- package/src/ctl.js +2 -2
- package/src/daemon.js +1 -1
- package/src/disk.js +54 -0
- package/src/platform.js +139 -0
- package/src/vault.js +10 -4455
- /package/{src → lib/src/core}/passwords.js +0 -0
- /package/{src → lib/src/core}/sealers.js +0 -0
|
@@ -0,0 +1,4414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dotrino-vault — núcleo del certificador personal (daemon headless).
|
|
3
|
+
*
|
|
4
|
+
* Custodia la clave MAESTRA del usuario (vía `@dotrino/identity`) y la expone como
|
|
5
|
+
* CA propia. EMPAREJAMIENTO ENDURECIDO (ver docs/pairing-protocol.md): el token de
|
|
6
|
+
* 5 min ya NO es autoridad suficiente — para obtener un cert el dispositivo debe
|
|
7
|
+
* (1) PROBAR posesión de su llave D firmando el ENROLL, y (2) el dueño debe APROBAR
|
|
8
|
+
* en el PC tras comparar un SAS (código de 6 dígitos) entre las dos pantallas. La
|
|
9
|
+
* maestra solo firma el cert DESPUÉS de esa aprobación humana.
|
|
10
|
+
*
|
|
11
|
+
* Toda la cripto es de `@dotrino/identity`. Este módulo solo orquesta.
|
|
12
|
+
*/
|
|
13
|
+
import { verifyChain as verifyChainRaw, pubkeyId, verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
14
|
+
import { createLoginDesk, registerLogin, vaultChannel, accountFingerprint } from '../passwordLogins.js'
|
|
15
|
+
import * as ActaPilar from '@dotrino/identity/acta'
|
|
16
|
+
import { createEnrollDesk, deviceIdOf, DEVICE_TTL_MS, scopeToCaps, scopeToCn } from '../enroll.js'
|
|
17
|
+
import { createAdminDesk, authorBody } from '../admin.js'
|
|
18
|
+
import { shouldNotifyRevoked } from '../revocation.js'
|
|
19
|
+
// El sobre del gestor, el mismo que usan la bóveda-en-pestaña y la extensión.
|
|
20
|
+
import { identitySealing } from '@dotrino/passmanager'
|
|
21
|
+
import { createTransport, masterPubkeyOf } from './transport.js'
|
|
22
|
+
import { startSealersPublisher } from './sealers.js'
|
|
23
|
+
import { openStore } from './store.js'
|
|
24
|
+
import { openThreadStore, STORE_READ_METHODS, PROFILE_EDIT_METHODS } from './threadStore.js'
|
|
25
|
+
import { openSecretsStore, assertVar, RECOVERY as RECOVERY_WRAP, PROFILE_OWNER } from './secretsStore.js'
|
|
26
|
+
import { openSubacta } from './subacta.js'
|
|
27
|
+
import { openBlocked } from './blocked.js'
|
|
28
|
+
import { openNotifiable } from './notifiable.js'
|
|
29
|
+
import { openMachines } from './machines.js'
|
|
30
|
+
import { machineLine } from '@dotrino/identity/machine'
|
|
31
|
+
import { openNotices } from './notices.js'
|
|
32
|
+
import { makeEphemeralKey, openSealed } from '../sealed.js'
|
|
33
|
+
import { declare as compatDeclare, check as compatCheck, annotate as compatAnnotate } from '@dotrino/compat'
|
|
34
|
+
import { loadManifest, brokenOf } from '@dotrino/roadmap'
|
|
35
|
+
// `PENDING_TTL_MS` se usa abajo, al esperar la firma del aprobador: sin importarlo, esa
|
|
36
|
+
// espera reventaba con un ReferenceError y la aprobación del mostrador de contraseñas no
|
|
37
|
+
// llegaba a existir. Solo se veía por ese camino —el único que lo usa—, y no había prueba
|
|
38
|
+
// que lo recorriera hasta que la hubo (dotrino-test, smoke:demonio, 2026-08-30).
|
|
39
|
+
import { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, GRANT_TTL_MS, UPDATE_TTL_MS } from './approvals.js'
|
|
40
|
+
import { sanitizeContext, commandFingerprint, commandLine } from '../procContext.js'
|
|
41
|
+
import { makeContentKey, encryptWithCek, wrapForMember } from '@dotrino/identity/content'
|
|
42
|
+
import { makeSealer } from './sealer.js'
|
|
43
|
+
import { openSealKeys } from './sealKey.js'
|
|
44
|
+
import { openCommKey, COMM_CN, COMM_CAPS } from './commKey.js'
|
|
45
|
+
import { seal } from '../sealed.js'
|
|
46
|
+
import { MSG, SCOPE, secretsScope, isValidSecretsNs, VAULT_PROTOCOL, VAULT_SPEAKS } from '../protocol.js'
|
|
47
|
+
import { sha256Hex } from './sha256.js'
|
|
48
|
+
|
|
49
|
+
const b64ToBytes = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
|
|
50
|
+
const byteLength = (s) => new TextEncoder().encode(s).length
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Tope de una respuesta de la bóveda por el transporte. El proxio (`PROXY_MAX_FRAME_BYTES`)
|
|
54
|
+
* corta el frame a 1 MB, y el sobre del proxio (destinatarios, tipos) va por encima de esto:
|
|
55
|
+
* se deja margen en vez de apurar el límite.
|
|
56
|
+
*/
|
|
57
|
+
const MAX_REPLY_BYTES = 768 * 1024
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Abre UN perfil del vault (una maestra, un dir, una conexión al proxy). El
|
|
61
|
+
* daemon multi-perfil (`manager.js`) levanta uno de estos por perfil.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} [opts]
|
|
64
|
+
* @param {string} [opts.dir] Dir de datos de ESTE perfil.
|
|
65
|
+
* @param {() => boolean} [opts.isLocked] Candado del perfil (contraseña opcional).
|
|
66
|
+
* Solo bloquea EDITAR el perfil (`profileSet`): firmar/leer y el resto del store
|
|
67
|
+
* siguen sirviendo a los dispositivos enrolados aunque esté bloqueado.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* LA BÓVEDA, la misma en las tres versiones (binario, pestaña y extensión).
|
|
71
|
+
*
|
|
72
|
+
* Todo lo que depende de dónde corre llega en `platform` (ver `src/platform.js` para el
|
|
73
|
+
* binario y `./browserPlatform.js` para el navegador):
|
|
74
|
+
*
|
|
75
|
+
* disk el disco del perfil (`./disk.js`)
|
|
76
|
+
* openIdentity({ openKey, log }) → la identidad del perfil, ya abierta
|
|
77
|
+
* product, version con qué se anuncia (`@dotrino/compat`)
|
|
78
|
+
* hostname() el nombre de esta máquina, o `null` si no se sabe
|
|
79
|
+
* readProcContext(pid) lo que el sistema dice de un proceso; `null` si no se puede mirar
|
|
80
|
+
* openLocalDesk({ name, onMessage, channels, maxBytes, log }) el atajo en la misma máquina
|
|
81
|
+
* makeClient quién crea el cliente del proxio (opcional)
|
|
82
|
+
* noticeGroupMs cuánto se agrupan los avisos de cambio
|
|
83
|
+
* opaque el servidor de OPAQUE, si no vale el del paquete (opcional)
|
|
84
|
+
* servePasswords `false`: el almacén de contraseñas lo atiende quien monta la bóveda,
|
|
85
|
+
* con la política de aquí (`passwordsNeedApproval`/`approvePasswordRequest`)
|
|
86
|
+
*
|
|
87
|
+
* Una plataforma que no puede algo (el navegador no tiene `/proc` ni sockets de archivo)
|
|
88
|
+
* no lo pone, y el núcleo lo trata como «no se sabe»: nunca como un sí.
|
|
89
|
+
*/
|
|
90
|
+
export async function startVault ({ platform, proxyUrl, log = console.log, onEnrollChallenge, isLocked = () => false, hasPassword = () => true, deriveAdminKey = null, openKey = null, secondaryParams = null, openWithSecondary = null, hasSecondary = () => false, forAdoption = false, onAdopted } = {}) {
|
|
91
|
+
if (!platform?.disk || typeof platform.openIdentity !== 'function') {
|
|
92
|
+
throw Object.assign(new Error('startVault: a platform with a disk and openIdentity is required'), { code: 'no-platform' })
|
|
93
|
+
}
|
|
94
|
+
const disk = platform.disk
|
|
95
|
+
const VERSION = platform.version
|
|
96
|
+
if (typeof VERSION !== 'string' || !VERSION) throw Object.assign(new Error('startVault: the platform must say which version this is'), { code: 'no-version' })
|
|
97
|
+
// La identidad del perfil la abre la plataforma: en el binario sale de su directorio
|
|
98
|
+
// (cifrada en reposo y con la maestra sellada bajo la contraseña), en el navegador es la
|
|
99
|
+
// que ya tiene abierta la pestaña o la extensión.
|
|
100
|
+
const identity = await platform.openIdentity({ openKey, log })
|
|
101
|
+
if (!identity.me?.publickey) await identity.setMyNickname('')
|
|
102
|
+
// CAMINO A: este perfil nació para adoptar la cuenta de un aparato. La identidad se crea
|
|
103
|
+
// igual (su llave es la que entrará como miembro), pero se marca para que `joinProfile`
|
|
104
|
+
// acepte cambiar su acta recién nacida por la que traiga el dispositivo. Sin la marca,
|
|
105
|
+
// adoptar sería pisar una cuenta con datos y se rechaza — que es lo correcto por defecto.
|
|
106
|
+
if (forAdoption) {
|
|
107
|
+
try { await identity.prepareForAdoption() } catch (e) { log('[vault] could not prepare the profile for adoption:', e.message) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// LA LLAVE DE SELLADO (§8.8/§8.9): con ella se FIRMAN los sobres de los secretos. No
|
|
111
|
+
// abre nada, así que se usa sin la frase; su autoridad se la da el acta, que la nombra
|
|
112
|
+
// y que sella únicamente la maestra. Se estrena una por acta: aquí está el proveedor.
|
|
113
|
+
const sealKeys = openSealKeys(disk)
|
|
114
|
+
const commKey = openCommKey(disk)
|
|
115
|
+
identity.setSealKeyProvider?.(() => sealKeys.mint())
|
|
116
|
+
|
|
117
|
+
const store = openStore(disk)
|
|
118
|
+
const threads = openThreadStore(disk)
|
|
119
|
+
/**
|
|
120
|
+
* LO QUE SIRVE LA BÓVEDA VA CON LA LLAVE VIGENTE (dueño, 2026-09-30). Tras una rotación
|
|
121
|
+
* de la llave de la cuenta, los sobres (`t: 'dotrino-cek'`) de generaciones anteriores
|
|
122
|
+
* que haya en el almacén se vuelven a cerrar con la vigente. Se llama al abrir y después
|
|
123
|
+
* de quitar un aparato. Si la bóveda no tiene la llave de contenido, no hay nada que
|
|
124
|
+
* hacer y se dice; si un sobre no abre, se para y se dice — no se deja a medias.
|
|
125
|
+
*/
|
|
126
|
+
async function resealStaleThreads (why) {
|
|
127
|
+
const mine = await identity.contentKey?.()
|
|
128
|
+
if (!mine) { log(`[vault] ${why}: no content key on this vault, envelopes not resealed`); return null }
|
|
129
|
+
const r = await threads.resealStaleEnvelopes({
|
|
130
|
+
gen: mine.gen,
|
|
131
|
+
reseal: async (env) => identity.sealContent(await identity.openContent(env))
|
|
132
|
+
})
|
|
133
|
+
if (r.changed) {
|
|
134
|
+
audit('resealed', { envelopes: r.changed, entries: r.entries, gen: mine.gen })
|
|
135
|
+
log(`[vault] ${why}: resealed ${r.changed} envelope(s) in ${r.entries} entr${r.entries === 1 ? 'y' : 'ies'} with content key generation ${mine.gen}`)
|
|
136
|
+
}
|
|
137
|
+
if (r.skipped) log(`[vault] ${why}: ${r.skipped} unmarked envelope(s) that the account key does not open were left as they are`)
|
|
138
|
+
return r
|
|
139
|
+
}
|
|
140
|
+
const approvals = createApprovals()
|
|
141
|
+
/**
|
|
142
|
+
* LO QUE YA SE APROBÓ: comando + carpeta, por una hora desde el último uso.
|
|
143
|
+
*
|
|
144
|
+
* En memoria a propósito. Reiniciar la bóveda las borra todas, que es lo correcto: un
|
|
145
|
+
* permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado.
|
|
146
|
+
*/
|
|
147
|
+
const grants = createGrants()
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Pedidos de aprobación que NO son de un cajón de secretos: quien espera es una
|
|
151
|
+
* promesa dentro del vault (la bóveda de contraseñas), no un aparato aguardando un
|
|
152
|
+
* sobre sellado. `id` del pedido → `resolve(boolean)`.
|
|
153
|
+
*
|
|
154
|
+
* Aparte a propósito: `approvals` es un módulo puro y no tiene por qué saber que hay
|
|
155
|
+
* dos clases de espera.
|
|
156
|
+
*/
|
|
157
|
+
const waiters = new Map()
|
|
158
|
+
/**
|
|
159
|
+
* ¿ESTE APARATO TIENE QUE PEDIR PERMISO PARA RECIBIR CLAVES PRIVADAS?
|
|
160
|
+
*
|
|
161
|
+
* Lo dice el ACTA, con el permiso `unattended`: quien lo tiene se las lleva solo; quien
|
|
162
|
+
* no, espera a que un aparato con `approve` lo firme.
|
|
163
|
+
*
|
|
164
|
+
* Antes era al revés y vivía aquí: una lista local de «estos SÍ piden permiso», así que
|
|
165
|
+
* un aparato nuevo nacía pudiendo llevarse las claves y nadie elegía eso — se elegía por
|
|
166
|
+
* omisión. Ahora hay que conceder a propósito quién puede llevárselas solo, y **si falta
|
|
167
|
+
* el dato se pide permiso**, que es lo que hay que hacer cuando no se sabe (dueño,
|
|
168
|
+
* 2026-09-01).
|
|
169
|
+
*
|
|
170
|
+
* Y al estar en el acta lo respeta cualquier bóveda de la cuenta, se ve en la pantalla de
|
|
171
|
+
* permisos como los demás, y se quita quitándolo — sin acordarse de un registro escondido
|
|
172
|
+
* en una máquina.
|
|
173
|
+
*/
|
|
174
|
+
/** ¿Hay en el acta alguien que pueda aprobar? Sin nadie, no se pide aprobación. */
|
|
175
|
+
function hasApprover (record) {
|
|
176
|
+
return !!record?.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function needsApproval (pub, record) {
|
|
180
|
+
if (!record) return true // sin acta no se decide que sí: se pide permiso
|
|
181
|
+
// SIN NADIE QUE APRUEBE, NO SE NECESITA APROBACIÓN (dueño, 2026-09-24: «la regla es, si
|
|
182
|
+
// no hay aprobador no se necesita aprobación»). Es la misma que ya seguía la
|
|
183
|
+
// actualización automática: esperar un sí que nadie puede dar solo deja al servicio
|
|
184
|
+
// colgado. Aquí se decide para las tres puertas —entregar claves, contraseñas y guardar
|
|
185
|
+
// variables— y por eso no hay un «nadie aprueba» aparte en ninguna de ellas.
|
|
186
|
+
if (!record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) return false
|
|
187
|
+
return !Acta.memberCan(record, pub, 'unattended')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* APUNTA UN PEDIDO QUE NO ENTREGA UN CAJÓN, avisa a quien aprueba y deja la respuesta en
|
|
192
|
+
* `onAnswer(sí|no)`. Lo usan la bóveda de contraseñas (un aparato espera su sí) y guardar
|
|
193
|
+
* variables (la escritura se aplica al aprobar). Si nadie contesta, vence solo con un no:
|
|
194
|
+
* nada se queda colgado para siempre.
|
|
195
|
+
*/
|
|
196
|
+
async function awaitApproval ({ device, ns, kind, what, op = null, ctx = null, onAnswer, ttlMs = PENDING_TTL_MS }) {
|
|
197
|
+
const deviceId = await deviceIdOf(device).catch(() => null)
|
|
198
|
+
const record = await refreshActa()
|
|
199
|
+
const label = (record?.members || []).find((m) => m.pub === device)?.label || ''
|
|
200
|
+
const pend = approvals.request({ ns, device, deviceId, label, ek: '', ctx, kind, ttlMs })
|
|
201
|
+
audit(`${what}.pending`, { device: deviceId, ns, id: pend.id, op })
|
|
202
|
+
log(`[vault] ${what}: ${deviceId || '????-????'} is waiting for approval (${pend.id})`)
|
|
203
|
+
waiters.set(pend.id, onAnswer)
|
|
204
|
+
const t = setTimeout(() => {
|
|
205
|
+
if (waiters.delete(pend.id)) {
|
|
206
|
+
audit(`${what}.expired`, { device: deviceId, ns, id: pend.id })
|
|
207
|
+
onAnswer(false)
|
|
208
|
+
}
|
|
209
|
+
}, ttlMs)
|
|
210
|
+
t.unref?.()
|
|
211
|
+
await notifyApprovers(pend, record)
|
|
212
|
+
return pend
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* QUÉ COMANDO ESTÁ PIDIENDO LAS CLAVES, Y DESDE QUÉ CARPETA — medido, no creído.
|
|
217
|
+
*
|
|
218
|
+
* Quien aprueba no puede decidir con «el aparato 904C-1002 pide el cajón proxy»: necesita
|
|
219
|
+
* ver qué se está ejecutando (dueño, 2026-09-11). El que pide lo cuenta en el pedido, pero
|
|
220
|
+
* contar no es probar, así que **si dice estar en esta misma máquina la bóveda lo lee del
|
|
221
|
+
* kernel** (`/proc/<pid>`) y lo compara. Si no cuadra, no es un matiz: es una mentira, y se
|
|
222
|
+
* deniega con un código que se puede buscar.
|
|
223
|
+
*
|
|
224
|
+
* SE MIRA EL PEDIDO, NUNCA EL CANAL. Sería más cómodo comprobar «vino por el socket local»,
|
|
225
|
+
* y está prohibido: el día que un handler decide algo por dónde entró el mensaje, el atajo
|
|
226
|
+
* local deja de ser un atajo y pasa a ser una puerta de atrás (`test/mostrador-local.test.mjs`).
|
|
227
|
+
* El `host` es un dato del pedido como cualquier otro, y mentirlo no consigue nada: lo que
|
|
228
|
+
* se gana es quedar marcado como no comprobado, que es exactamente lo que se quería evitar.
|
|
229
|
+
*
|
|
230
|
+
* Tres resultados, y son tres cosas distintas:
|
|
231
|
+
* · `null` — no dijo nada. Se pide aprobación como siempre y no hay concesión posible.
|
|
232
|
+
* · `verified: 'proc'` — lo leyó el kernel de esta máquina. Es lo que hay que enseñar.
|
|
233
|
+
* · `verified: 'declared'` — lo dice quien pide y no hay forma de comprobarlo (otra
|
|
234
|
+
* máquina, o un sistema sin `/proc`). Vale para decidir, pero la pantalla lo marca.
|
|
235
|
+
*
|
|
236
|
+
* Y EL LÍMITE, QUE NO SE ESCONDE: esto no autoriza nada. Cualquier proceso del mismo
|
|
237
|
+
* usuario puede leer el `service-identity.json` del servicio y pedir con esa llave, y puede
|
|
238
|
+
* llamarse como quiera. La frontera es el usuario, no el proceso: esto existe para que
|
|
239
|
+
* quien aprueba decida mirando lo que pasa, y para que quede rastro.
|
|
240
|
+
*/
|
|
241
|
+
function contextoDelPedido (declarado) {
|
|
242
|
+
const dicho = sanitizeContext(declarado)
|
|
243
|
+
if (!dicho) return null
|
|
244
|
+
// Otra máquina: aquí no hay ningún `/proc` que hable de ese proceso.
|
|
245
|
+
if (!dicho.host || dicho.host !== platform.hostname?.()) return { ...dicho, verified: 'declared' }
|
|
246
|
+
const medido = sanitizeContext(platform.readProcContext?.(dicho.pid) ?? null)
|
|
247
|
+
// El proceso ya no está, o no se puede mirar: no se sabe, y no saber se dice como no
|
|
248
|
+
// saber. Inventar un «comprobado» aquí sería el repliegue de siempre.
|
|
249
|
+
if (!medido) return { ...dicho, verified: 'declared' }
|
|
250
|
+
if (commandFingerprint(medido) !== commandFingerprint(dicho)) {
|
|
251
|
+
throw new Error(`ctx — it says it is running "${commandLine(dicho, 60)}" but pid ${dicho.pid} is "${commandLine(medido, 60)}"`)
|
|
252
|
+
}
|
|
253
|
+
return { ...medido, user: dicho.user, host: dicho.host, verified: 'proc' }
|
|
254
|
+
}
|
|
255
|
+
// LA BÓVEDA DE CONTRASEÑAS: entradas y su llave, en el dir del perfil y cifradas en
|
|
256
|
+
// reposo como todo lo demás. QUIÉN PUEDE PEDIR LO DICE EL ACTA (capacidad
|
|
257
|
+
// `passwords`), como cualquier otro permiso: aquí no hay una segunda lista de
|
|
258
|
+
// aparatos. Tenerla obligaba a acordarse de dos sitios al quitar un aparato, y era
|
|
259
|
+
// además un emparejamiento paralelo al del ecosistema.
|
|
260
|
+
const passwordsFile = 'passwords.json'
|
|
261
|
+
const passwordsAtRest = disk.atRest
|
|
262
|
+
// Los aparatos BLOQUEADOS por un aprobador (`src/blocked.js`). Cifrado en reposo como lo demás.
|
|
263
|
+
const blocked = openBlocked({ disk })
|
|
264
|
+
// Qué aprobadores dijeron que pueden recibir avisos (`src/notifiable.js`). No decide nada: se enseña.
|
|
265
|
+
const notifiable = openNotifiable({ disk })
|
|
266
|
+
// Dónde corre cada aparato, según lo último que dijo firmado (`src/machines.js`). Se enseña.
|
|
267
|
+
const machines = openMachines({ disk })
|
|
268
|
+
// Lo que hay que contarle a quien aprueba y no es un pedido (`src/notices.js`): hoy, «me actualicé».
|
|
269
|
+
const notices = openNotices({ disk })
|
|
270
|
+
const readPasswordsFile = () => {
|
|
271
|
+
try { return JSON.parse(passwordsAtRest.decrypt(disk.read(passwordsFile))) } catch (_) { return null }
|
|
272
|
+
}
|
|
273
|
+
const writePasswordsFile = (d) => {
|
|
274
|
+
disk.write(passwordsFile, passwordsAtRest.encrypt(JSON.stringify(d)))
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** El almacén que espera `@dotrino/passmanager`. Todo en un archivo, cifrado en reposo. */
|
|
278
|
+
const passwordsStore = {
|
|
279
|
+
async get (k) { return readPasswordsFile()?.data?.[k] },
|
|
280
|
+
async set (k, v) {
|
|
281
|
+
const d = readPasswordsFile() || { v: 1, data: {} }
|
|
282
|
+
d.data = { ...(d.data || {}), [k]: v }
|
|
283
|
+
writePasswordsFile(d)
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ENTRAR CON USUARIO Y CONTRASEÑA (`passwordLogins.js`). Va cifrado en reposo con la llave
|
|
288
|
+
// de la MÁQUINA y no con la de la contraseña del perfil, por lo mismo que la llave de
|
|
289
|
+
// comunicación: entrar tiene que funcionar con el perfil cerrado. Lo que hay dentro no
|
|
290
|
+
// abre nada por sí solo — el registro de OPAQUE no deja probar contraseñas sin el
|
|
291
|
+
// protocolo, y el paquete de llaves está cerrado con la llave que sale de la contraseña.
|
|
292
|
+
const loginsFile = 'logins.json'
|
|
293
|
+
const loginsAtRest = disk.atRest
|
|
294
|
+
const logins = createLoginDesk({
|
|
295
|
+
load: () => { try { return JSON.parse(loginsAtRest.decrypt(disk.read(loginsFile))) } catch (_) { return null } },
|
|
296
|
+
save: (state) => disk.write(loginsFile, loginsAtRest.encrypt(JSON.stringify(state))),
|
|
297
|
+
// El OPAQUE lo pone la plataforma cuando el suyo no sirve (la extensión lo corre en una
|
|
298
|
+
// página aparte: su CSP no deja instanciar WASM donde vive la bóveda).
|
|
299
|
+
...(platform.opaque ? { opaque: platform.opaque } : {})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
/** Los aparatos que el acta autoriza a pedir credenciales (`caps <ID> +contraseñas`). */
|
|
303
|
+
const passwordDevices = () => (actaCache?.members || []).filter((m) => Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* A QUIÉN SE LE ENVUELVE LA LLAVE DE UNA CONTRASEÑA — Y LA BÓVEDA NUNCA ESTÁ EN LA LISTA.
|
|
307
|
+
*
|
|
308
|
+
* Es la comprobación que sostiene todo el modelo sellado: si la maestra o la llave de
|
|
309
|
+
* comunicación de esta máquina recibieran envoltura, el demonio volvería a poder leer las
|
|
310
|
+
* contraseñas y no habríamos arreglado nada —solo lo habríamos escrito más bonito—. Por
|
|
311
|
+
* eso se filtran explícitamente aquí, que es donde se construye la lista, y no en quien la
|
|
312
|
+
* consume (`sealed-passwords.md` §2.1).
|
|
313
|
+
*
|
|
314
|
+
* `main` son los aparatos con `contraseñas`; `passkeys`, los que además pueden abrir la
|
|
315
|
+
* privada de una passkey (§2.8). Un miembro sin llave de cifrado no puede recibir nada: se
|
|
316
|
+
* queda fuera y se ve en `incompleteMembers`.
|
|
317
|
+
*/
|
|
318
|
+
const noEsLaBoveda = (pub) => pub !== master && pub !== commKey.pub()
|
|
319
|
+
const passwordRecipients = (kind = 'main') => (actaCache?.members || [])
|
|
320
|
+
.filter((m) => m.encPub && noEsLaBoveda(m.pub) && Acta.memberCan(actaCache, m.pub, 'passwords'))
|
|
321
|
+
.filter((m) => kind !== 'passkeys' || Acta.memberCan(actaCache, m.pub, 'passkeys'))
|
|
322
|
+
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* El acta vigente, en caché.
|
|
326
|
+
*
|
|
327
|
+
* `isAllowed`/`encPubOf` del responder son SÍNCRONOS (se llaman por cada mensaje que
|
|
328
|
+
* entra, y leer el acta ahí sería un await por mensaje), así que se refresca aparte.
|
|
329
|
+
* Se relee cada pocos segundos: revocar un aparato tarda eso en cortarle el acceso,
|
|
330
|
+
* no un reinicio.
|
|
331
|
+
*/
|
|
332
|
+
let actaCache = null
|
|
333
|
+
const refreshActa = async () => {
|
|
334
|
+
try { actaCache = (await identity.profileActa?.().catch(() => null))?.acta || null } catch (_) {}
|
|
335
|
+
return actaCache
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* LA LLAVE VIEJA DE LA BÓVEDA DE CONTRASEÑAS, la que había que quitar.
|
|
340
|
+
*
|
|
341
|
+
* Vivía DENTRO del mismo archivo que las entradas, cifrada con la llave de la máquina y
|
|
342
|
+
* no con la contraseña del perfil. O sea: el demonio con el perfil cerrado seguía
|
|
343
|
+
* descifrando, y una copia del disco las abría todas. Esto ya no la crea — solo la LEE,
|
|
344
|
+
* y solo para convertir lo que quedó escrito con ella; en cuanto la conversión termina,
|
|
345
|
+
* `soltarLlaveVieja` la borra del archivo.
|
|
346
|
+
*/
|
|
347
|
+
async function llaveViejaDeContrasenas () {
|
|
348
|
+
const d = readPasswordsFile()
|
|
349
|
+
if (!d?.cek) return null
|
|
350
|
+
const raw = b64ToBytes(d.cek)
|
|
351
|
+
return crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt'])
|
|
352
|
+
}
|
|
353
|
+
const soltarLlaveVieja = () => {
|
|
354
|
+
const d = readPasswordsFile()
|
|
355
|
+
if (!d?.cek) return
|
|
356
|
+
delete d.cek
|
|
357
|
+
writePasswordsFile(d)
|
|
358
|
+
log('[vault] passwords: the old vault key is gone from the disk')
|
|
359
|
+
audit('passwords.converted', {})
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const approvalsSweeper = setInterval(() => {
|
|
363
|
+
for (const g of approvals.sweep()) audit('secrets.expired', { id: g.id, ns: g.ns, device: g.deviceId })
|
|
364
|
+
// Las concesiones vencidas también se anotan: si un servicio vuelve a timbrar el
|
|
365
|
+
// teléfono, esta línea es la que explica por qué (estuvo una hora sin pedir).
|
|
366
|
+
for (const g of grants.sweep()) audit('secrets.grant.expired', { id: g.id, ns: g.ns, device: g.deviceId })
|
|
367
|
+
}, 30 * 1000); approvalsSweeper.unref?.()
|
|
368
|
+
const secrets = openSecretsStore(disk, {
|
|
369
|
+
sealer: makeSealer(),
|
|
370
|
+
// A QUIÉN se le envuelve la llave de cada cajón: los servicios de ese namespace (o el
|
|
371
|
+
// propio aparato, si el cajón es suyo) MÁS los aparatos que administran. Sale del
|
|
372
|
+
// acta, y por eso lo pone el vault: el store no conoce el acta.
|
|
373
|
+
recipients: (owner, opts) => recipientsOf(owner, opts),
|
|
374
|
+
// La FIRMA del sobre: dice que salió de esta bóveda y con qué acta (§8.8).
|
|
375
|
+
signer: (body) => signSeal(body),
|
|
376
|
+
// CON QUÉ se cierra la copia de recuperación cuando quien llama no trajo la frase:
|
|
377
|
+
// la llave del perfil si está ABIERTO, y si no la de la máquina. El orden importa —
|
|
378
|
+
// un perfil CON contraseña no se abre con la llave de la máquina, así que sin lo
|
|
379
|
+
// primero un aparato enrolado con la bóveda abierta se quedaba sin su cajón y solo
|
|
380
|
+
// se enteraba al arrancar (dueño, 2026-08-31).
|
|
381
|
+
defaultKey: () => openKey?.() || disk.machineKey()
|
|
382
|
+
})
|
|
383
|
+
// SIN CONTRASEÑA NO HAY SECRETO. Escribir no la pide (sellar solo necesita públicas,
|
|
384
|
+
// §8.1), pero la copia de recuperación —la que deja al dueño VER sus valores— se cierra
|
|
385
|
+
// con ella. Sin contraseña se cae a la llave de la máquina, que es la protección de
|
|
386
|
+
// siempre, pero su material vive en este mismo disco: una copia del disco lo abre.
|
|
387
|
+
// Se dice en voz alta: prometer una protección que no está puesta es peor que no
|
|
388
|
+
// tenerla (docs/secretos-sellados.md §2.3).
|
|
389
|
+
try {
|
|
390
|
+
if (!hasPassword()) {
|
|
391
|
+
log('[vault] this profile has NO password: private variables are sealed with a key derived from this machine,')
|
|
392
|
+
log('[vault] so a copy of this disk opens them. Set one with `dotrino-vault profile password`.')
|
|
393
|
+
}
|
|
394
|
+
} catch (_) {}
|
|
395
|
+
|
|
396
|
+
const master = await masterPubkeyOf(identity)
|
|
397
|
+
/**
|
|
398
|
+
* LA HUELLA DE LA CUENTA, no la de esta bóveda. Sale del `profileId` del acta (la pieza
|
|
399
|
+
* compartida la calcula), y con ella se arman la dirección `nombre@AB12-CD34-EF56` y el
|
|
400
|
+
* canal donde se anuncia esta cuenta.
|
|
401
|
+
*
|
|
402
|
+
* Antes salía de la llave de ESTA bóveda. Coinciden mientras la cuenta haya nacido aquí,
|
|
403
|
+
* y dejan de coincidir en cuanto hay una réplica o la bóveda adoptó la cuenta de otro
|
|
404
|
+
* aparato: cada bóveda daba entonces una dirección distinta para la misma cuenta, y el
|
|
405
|
+
* canal las separaba justo cuando lo que hace falta es que se junten.
|
|
406
|
+
*/
|
|
407
|
+
const fp = await accountFingerprint(identity)
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* El acta tiene que nombrar una llave de sellado QUE SEA NUESTRA. Si no nombra ninguna
|
|
411
|
+
* (un acta de antes de esto) o nombra una cuya privada no tenemos (el disco se
|
|
412
|
+
* restauró, o el acta la selló otro master), se estrena: firmar sobres es de esta
|
|
413
|
+
* máquina y no puede quedar a medias.
|
|
414
|
+
*
|
|
415
|
+
* Solo lo intenta el master —es el único que sella actas— y no bloquea el arranque: sin
|
|
416
|
+
* llave los sobres salen sin firma, que es lo que ya pasaba antes de §8.8.
|
|
417
|
+
*/
|
|
418
|
+
try {
|
|
419
|
+
const info = await identity.profileActa?.()
|
|
420
|
+
const acta = info?.acta
|
|
421
|
+
// CON EL PERFIL ABIERTO. Estrenar la llave de sellado SELLA EL ACTA, y una bóveda
|
|
422
|
+
// cerrada no sella nada — la misma regla que el bloque de abajo. Cerrada se queda sin
|
|
423
|
+
// llave y los sobres salen sin firma (lo que ya pasaba antes de §8.8), y se arregla sola
|
|
424
|
+
// la primera vez que alguien abra el perfil.
|
|
425
|
+
if (info?.isMaster && acta && !isLocked() && (!acta.sealPub || !sealKeys.has(acta.sealPub))) {
|
|
426
|
+
const r = await identity.rotateSealKey()
|
|
427
|
+
// Sin `%s`: este `log` va con un prefijo por delante, así que el formato no es lo
|
|
428
|
+
// primero y `console.log` no lo sustituye (salía «record #%s 2»).
|
|
429
|
+
log(`[vault] new sealing key in record #${r.seq}`)
|
|
430
|
+
}
|
|
431
|
+
} catch (e) { log('[vault] could not set up the sealing key:', e.message) }
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* LA LLAVE DE COMUNICACIÓN, dentro del acta.
|
|
435
|
+
*
|
|
436
|
+
* La maestra sella el acta y reenvuelve sobres; hablar por la red no es suyo. Esta es la
|
|
437
|
+
* que se identifica ante el proxio, y su autoridad la dice el ACTA: entra como un miembro
|
|
438
|
+
* más, con `cn: 'vault'`, así que el acta la trata como un servicio del perfil — puede
|
|
439
|
+
* hablar por la bóveda, no firmar por la persona.
|
|
440
|
+
*
|
|
441
|
+
* Solo se puede meter con el perfil ABIERTO (admitir un miembro es sellar el acta, y eso
|
|
442
|
+
* es de la maestra). Con el perfil cerrado no se toca nada: si ya está, se usa; y si no,
|
|
443
|
+
* `identify` se repliega a la maestra y lo dice. Por eso una bóveda que se actualiza
|
|
444
|
+
* tiene que abrirse UNA vez, y a partir de ahí ya puede vivir cerrada.
|
|
445
|
+
*/
|
|
446
|
+
/**
|
|
447
|
+
* METER LA LLAVE DE COMUNICACIÓN EN EL ACTA, si el perfil está abierto.
|
|
448
|
+
*
|
|
449
|
+
* `isLocked()` es la pregunta correcta — no `masterLocked`. Se coló así: en la PRIMERA
|
|
450
|
+
* arrancada tras actualizar, la maestra todavía está guardada en claro (se sella al abrir
|
|
451
|
+
* el perfil), o sea que `masterLocked` es `false` aunque el perfil esté cerrado. Con ese
|
|
452
|
+
* guardián, la bóveda de producción admitió su llave de comunicación y SELLÓ UN ACTA
|
|
453
|
+
* NUEVA (#76 → #77) estando cerrada — justo lo que la regla prohíbe, y encima rotando de
|
|
454
|
+
* paso la llave de sellado sin que nadie lo pidiera.
|
|
455
|
+
*
|
|
456
|
+
* SE LLAMA TAMBIÉN AL ABRIR, y esa es la mitad que faltaba. Antes solo corría al arrancar
|
|
457
|
+
* el servicio, y **un perfil con contraseña arranca cerrado siempre**: nunca pasaba por
|
|
458
|
+
* aquí, así que jamás llegaba a tener su llave. O sea que los perfiles que MÁS la
|
|
459
|
+
* necesitan —los protegidos, los que no pueden firmar con la maestra estando cerrados—
|
|
460
|
+
* eran precisamente los únicos que se quedaban sin ella, y seguían identificándose con la
|
|
461
|
+
* maestra o quedándose mudos. Se vio el 2026-09-02: de dos perfiles, el que tenía
|
|
462
|
+
* contraseña era el que no tenía `commkey.json`.
|
|
463
|
+
*/
|
|
464
|
+
async function ensureCommKeyInActa () {
|
|
465
|
+
try {
|
|
466
|
+
const info = await identity.profileActa?.()
|
|
467
|
+
const acta = info?.acta
|
|
468
|
+
if (!acta || isLocked() || identity.masterLocked) return false
|
|
469
|
+
const pub = await commKey.ensure()
|
|
470
|
+
if ((acta.members || []).some((m) => m?.pub === pub)) return false
|
|
471
|
+
if (!info?.isMaster) return false
|
|
472
|
+
await identity.admitMember({ pub, label: 'esta bóveda', cn: COMM_CN, caps: [...COMM_CAPS] })
|
|
473
|
+
log('[vault] this vault is now a member of its own record: it talks with its own key, not the master one')
|
|
474
|
+
return true
|
|
475
|
+
} catch (e) {
|
|
476
|
+
log('[vault] could not put the communication key in the record:', e.message)
|
|
477
|
+
return false
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
await ensureCommKeyInActa()
|
|
481
|
+
|
|
482
|
+
// `announce`: el canal de ESTA cuenta, para que una máquina prestada —que no tiene
|
|
483
|
+
// ninguna llave y solo sabe la dirección `nombre@AB12-CD34-EF56`— pueda encontrar a quien
|
|
484
|
+
// la atiende. Lo construye la pieza compartida, porque quien lo busca es la extensión.
|
|
485
|
+
const { client, identify: reidentificar, isIdentified } = await createTransport({
|
|
486
|
+
identity, url: proxyUrl, commKey, log, announce: vaultChannel(fp), makeClient: platform.makeClient || null
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
// El registro público de cadenas de selladores: deposita, si hay a dónde, los eslabones
|
|
490
|
+
// que le dicen a un tercero si esta cuenta sigue sellada por quien él cree. Ver
|
|
491
|
+
// `sealers.js` — es una comodidad para quien lee, nunca una dependencia de la bóveda.
|
|
492
|
+
startSealersPublisher({ identity, client, log })
|
|
493
|
+
|
|
494
|
+
async function revocationSet () {
|
|
495
|
+
const { revoked } = await identity.listDelegations()
|
|
496
|
+
return new Set(revoked.map((r) => r.nonce))
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* CONTESTAR SIN MATAR LA CONEXIÓN.
|
|
501
|
+
*
|
|
502
|
+
* El proxio corta los frames a 1 MB (`maxPayload`), y `ws` no «descarta» el que se pasa:
|
|
503
|
+
* CIERRA EL SOCKET con un 1009. La bóveda se queda muda, sin un solo error en su log, y
|
|
504
|
+
* desde fuera se ve igual que si estuviera apagada. Eso ya pasó y duró tres días.
|
|
505
|
+
*
|
|
506
|
+
* Así que una respuesta que no quepa no se manda: se sustituye por un error, que sí cabe.
|
|
507
|
+
* Una bóveda que dice «no cabe» se arregla; una muda no se puede ni diagnosticar.
|
|
508
|
+
*/
|
|
509
|
+
/**
|
|
510
|
+
* QUIÉN CONTESTA A QUIÉN. Por defecto el proxio; un remitente LOCAL (el socket de esta
|
|
511
|
+
* máquina) se apunta aquí con su propia forma de contestar.
|
|
512
|
+
*
|
|
513
|
+
* Que haya dos transportes no cambia NADA de quién puede pedir qué: la autorización sale
|
|
514
|
+
* del certificado y del acta, igual por los dos caminos. Alcanzar el socket no autoriza
|
|
515
|
+
* nada — es la misma puerta, con el camino más corto.
|
|
516
|
+
*/
|
|
517
|
+
const canales = new Map()
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* QUÉ SOMOS, Y QUÉ CORRE CADA APARATO (CONVENCIONES §14, `@dotrino/compat`).
|
|
521
|
+
*
|
|
522
|
+
* El agujero que esto tapa: una incompatibilidad de versiones se manifestaba como
|
|
523
|
+
* SILENCIO. El que llamaba no sabía si estábamos apagados, ocupados o hablando otro
|
|
524
|
+
* idioma, así que reintentaba para siempre — y aquí no se sabía que nos hablaban.
|
|
525
|
+
*
|
|
526
|
+
* **Informa, no bloquea** (dueño, 2026-09-04): el dictamen se enseña y se le pega a los
|
|
527
|
+
* errores, y no hay ni un `if` que deje de atender por él. Si algún día se decide
|
|
528
|
+
* bloquear, se decide arriba y se escribe aquí a propósito.
|
|
529
|
+
*
|
|
530
|
+
* La lista de rotas sale del registro común (`@dotrino/roadmap`), que viaja DENTRO de
|
|
531
|
+
* esta versión: la compatibilidad es política del código, no de la cuenta.
|
|
532
|
+
*/
|
|
533
|
+
const MI_VERSION = compatDeclare({
|
|
534
|
+
product: platform.product || 'vaultd', version: VERSION, protocol: VAULT_PROTOCOL, speaks: VAULT_SPEAKS
|
|
535
|
+
})
|
|
536
|
+
let ROTAS = []
|
|
537
|
+
try { ROTAS = brokenOf(loadManifest()) } catch (e) { log('[vault] no compatibility registry: ' + e.message) }
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Lo que dice correr cada aparato, por llave. Se apunta de lo que viene FIRMADO
|
|
541
|
+
* (`p.data.v`, dentro del sobre) y no del envoltorio: una versión que cualquiera puede
|
|
542
|
+
* escribir no vale para enseñarla como si fuera un dato.
|
|
543
|
+
*/
|
|
544
|
+
const versiones = new Map()
|
|
545
|
+
const apuntarVersion = (pub, v) => {
|
|
546
|
+
if (!pub || !v) return
|
|
547
|
+
const dictamen = compatCheck({ mine: MI_VERSION, theirs: v, broken: ROTAS })
|
|
548
|
+
versiones.set(pub, { ...v, at: Date.now(), compatible: dictamen.compatible, reason: dictamen.reason })
|
|
549
|
+
if (!dictamen.compatible) log(`[vault] ${v.product} ${v.version}: ${dictamen.reason}`)
|
|
550
|
+
return dictamen
|
|
551
|
+
}
|
|
552
|
+
/** El dictamen de un aparato, para enseñarlo o para pegárselo a un error. */
|
|
553
|
+
const versionDe = (pub) => versiones.get(pub) || null
|
|
554
|
+
/** Dónde corre un aparato: lo último que dijo firmado (`machines`) o, si no, lo del acta. */
|
|
555
|
+
const machineOf = (pub, record) => machines.of(pub) || (record?.members || []).find((m) => m.pub === pub)?.machine || null
|
|
556
|
+
|
|
557
|
+
// ----- LA SUBACTA: lo decidido que todavía no selló el acta (`src/subacta.js`) -----
|
|
558
|
+
//
|
|
559
|
+
// Sellar es de la maestra, así que con el perfil CERRADO una renuncia no surtía ningún
|
|
560
|
+
// efecto: el aparato conservaba todos sus permisos hasta que alguien fuera a la máquina
|
|
561
|
+
// a teclear la contraseña. Justo el caso que la renuncia existe para cubrir.
|
|
562
|
+
//
|
|
563
|
+
// Se honra ya, sin sellar, y es seguro porque una entrada de la subacta SOLO PUEDE
|
|
564
|
+
// QUITAR: no concede nada a nadie, y va firmada por el miembro al que afecta.
|
|
565
|
+
const subacta = openSubacta(disk)
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* EL ACTA, LEÍDA CON LA SUBACTA EN LA MANO.
|
|
569
|
+
*
|
|
570
|
+
* Envuelve al pilar en UN sitio en vez de pasar la lista en los diecisiete mostradores:
|
|
571
|
+
* una comprobación repetida diecisiete veces son dieciséis sitios donde olvidarla, y
|
|
572
|
+
* olvidarla aquí significa atender a un aparato que ya renunció.
|
|
573
|
+
*
|
|
574
|
+
* Solo se tocan las cinco que DECIDEN. El resto del pilar pasa tal cual.
|
|
575
|
+
*/
|
|
576
|
+
const Acta = {
|
|
577
|
+
...ActaPilar,
|
|
578
|
+
memberCan: (acta, pub, cap) => ActaPilar.memberCan(acta, pub, cap, subacta.forMember(pub)),
|
|
579
|
+
memberCanScope: (acta, pub, scope) => ActaPilar.memberCanScope(acta, pub, scope, subacta.forMember(pub)),
|
|
580
|
+
memberCanReadSecrets: (acta, pub, ns) => ActaPilar.memberCanReadSecrets(acta, pub, ns, subacta.forMember(pub)),
|
|
581
|
+
memberCanSign: (acta, pub, ns = null) => ActaPilar.memberCanSign(acta, pub, ns, subacta.forMember(pub)),
|
|
582
|
+
memberScopes: (acta, pub) => ActaPilar.memberScopes(acta, pub, subacta.forMember(pub)),
|
|
583
|
+
effectiveCaps: (acta, pub) => ActaPilar.effectiveCaps(acta, pub, subacta.forMember(pub))
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* ABSORBER lo pendiente en el acta. Solo con la bóveda abierta, que es la única que
|
|
588
|
+
* sella. Se vacía DESPUÉS y solo lo absorbido: mientras se sella puede entrar una nueva,
|
|
589
|
+
* y tirarla sería perder una renuncia sin aplicar.
|
|
590
|
+
*/
|
|
591
|
+
async function absorberSubacta (motivo = 'unlock') {
|
|
592
|
+
const pendientes = subacta.drain()
|
|
593
|
+
if (!pendientes.length) return 0
|
|
594
|
+
const hechas = []
|
|
595
|
+
for (const e of pendientes) {
|
|
596
|
+
try { await identity.absorbRenounce(e); hechas.push(e) }
|
|
597
|
+
catch (err) { log(`[vault] ${motivo}: could not absorb a pending renounce: ${err.message}`) }
|
|
598
|
+
}
|
|
599
|
+
if (hechas.length) {
|
|
600
|
+
subacta.clear(hechas)
|
|
601
|
+
log(`[vault] ${motivo}: ${hechas.length} pending renounce(s) absorbed into the record`)
|
|
602
|
+
}
|
|
603
|
+
return hechas.length
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* LA VERSIÓN SE APUNTA DONDE YA SE COMPRUEBA LA FIRMA, y por eso esto envuelve a
|
|
608
|
+
* `verifyChain` en vez de mirar el envoltorio del mensaje: `data` va firmado, así que
|
|
609
|
+
* lo que se apunta es lo que ese aparato dijo **con su llave**, no lo que cualquiera
|
|
610
|
+
* pueda escribir por fuera. Un inventario de versiones que se puede falsificar no es un
|
|
611
|
+
* inventario, es una pantalla bonita.
|
|
612
|
+
*
|
|
613
|
+
* Va envuelto y no repetido en los ocho mostradores porque una comprobación copiada
|
|
614
|
+
* ocho veces son siete sitios donde olvidarla.
|
|
615
|
+
*/
|
|
616
|
+
const verifyChain = async (args) => {
|
|
617
|
+
const chk = await verifyChainRaw(args)
|
|
618
|
+
if (chk.ok && args?.data?.v) apuntarVersion(chk.device, args.data.v)
|
|
619
|
+
// DÓNDE CORRE, por el mismo motivo: se apunta de lo firmado. Un fallo al guardarlo no
|
|
620
|
+
// puede tumbar el pedido —es un dato para la lista—, pero se dice.
|
|
621
|
+
if (chk.ok && args?.data?.machine) {
|
|
622
|
+
try { machines.note(chk.device, args.data.machine) } catch (e) { log(`[vault] could not record where a device runs: ${e.message}`) }
|
|
623
|
+
}
|
|
624
|
+
// UN BLOQUEADO NO PASA POR NINGÚN MOSTRADOR. Sigue en el acta —bloquear no la toca,
|
|
625
|
+
// porque la bóveda vive cerrada y eso lo decide un aprobador desde el teléfono—, así que
|
|
626
|
+
// el papel verifica; la puerta se cierra aquí, que es por donde entra todo, y con su
|
|
627
|
+
// propio `reason` para que el otro lado sepa qué le pasa y no lo confunda con un papel
|
|
628
|
+
// viejo (que se arregla renovando) ni con una revocación (que se arregla emparejando).
|
|
629
|
+
if (chk.ok && blocked.has(chk.device)) return { ok: false, reason: 'blocked', device: chk.device }
|
|
630
|
+
return chk
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
const reply = (to, obj) => {
|
|
635
|
+
try {
|
|
636
|
+
const enviar = canales.get(to) || ((o) => client.send(to, o))
|
|
637
|
+
// ANUNCIAMOS EN CADA RESPUESTA: el otro lado decide con esto, y decide él porque es
|
|
638
|
+
// el que puede tener la lista al día (§14). Va en el envoltorio y no dentro de un
|
|
639
|
+
// cuerpo firmado a propósito — es un dato para enseñar y para explicar un fallo, no
|
|
640
|
+
// una credencial: nada se autoriza con él.
|
|
641
|
+
obj = { ...obj, v: MI_VERSION }
|
|
642
|
+
const bytes = byteLength(JSON.stringify(obj))
|
|
643
|
+
if (bytes > MAX_REPLY_BYTES) {
|
|
644
|
+
log(`[vault] the reply to ${obj?.type} does not fit (${bytes} bytes > ${MAX_REPLY_BYTES}): sending an error instead of killing the connection`)
|
|
645
|
+
audit('reply-too-big', { type: obj?.type || null, bytes })
|
|
646
|
+
return enviar({ type: MSG.ERROR, error: `reply too big (${bytes} bytes): ask for less at a time` })
|
|
647
|
+
}
|
|
648
|
+
enviar(obj)
|
|
649
|
+
} catch (e) { log('[vault] could not reply:', e.message) }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// FRESCURA anti-replay: toda petición firmada debe traer `data.ts` dentro de una
|
|
653
|
+
// ventana de ±5 min (mismo criterio que el identify del proxy). Sin esto, un
|
|
654
|
+
// relay malicioso podía REPRODUCIR mensajes firmados viejos (re-pedir firmas,
|
|
655
|
+
// abrir renovaciones…) durante toda la vida del cert.
|
|
656
|
+
// AUDITORÍA: bitácora de actividad de seguridad (activity.log, JSONL) — qué
|
|
657
|
+
// dispositivo firmó/renovó/enroló y qué se rechazó. `dotrino-vault activity`
|
|
658
|
+
// la muestra. Sin contenido de payloads (privacidad): solo op, dispositivo, hora.
|
|
659
|
+
//
|
|
660
|
+
// VA CIFRADA, y una línea a la vez. Antes era el único archivo excluido del reposo, con
|
|
661
|
+
// el argumento de que «no guarda payloads»: no guarda valores, pero sí el mapa entero de
|
|
662
|
+
// la cuenta —qué aparatos hay, con qué permisos, contra qué cajones y a qué hora—, que
|
|
663
|
+
// es justo lo que quiere quien está eligiendo por dónde entrar. Y encima se creaba 0664,
|
|
664
|
+
// legible por cualquier usuario de la máquina.
|
|
665
|
+
//
|
|
666
|
+
// Cifrar por línea (y no el archivo entero) es lo que deja seguir AÑADIENDO: cada
|
|
667
|
+
// entrada se cierra sola, así que un `appendFileSync` sigue costando lo mismo y la
|
|
668
|
+
// rotación no necesita abrir nada.
|
|
669
|
+
const activityFile = 'activity.log'
|
|
670
|
+
const activityAtRest = disk.atRest
|
|
671
|
+
// MIGRACIÓN de las bitácoras escritas antes de 0.89, que están en claro. Se convierten
|
|
672
|
+
// de una vez al arrancar: es un archivo acotado (rota al MB) y así «nada en claro» es
|
|
673
|
+
// cierto también para lo que ya estaba escrito, no solo para lo que se escriba desde
|
|
674
|
+
// ahora. Se agota sola — cuando no queda ninguna línea en claro, no vuelve a tocar nada.
|
|
675
|
+
try {
|
|
676
|
+
const raw = disk.read(activityFile)
|
|
677
|
+
if (raw == null) throw new Error('no log yet')
|
|
678
|
+
const lines = raw.split('\n')
|
|
679
|
+
if (lines.some((l) => l && !l.startsWith('DOTRINO-ATREST-v1.'))) {
|
|
680
|
+
const sealedLines = lines.map((l) => (l && !l.startsWith('DOTRINO-ATREST-v1.') ? activityAtRest.encrypt(l) : l))
|
|
681
|
+
// Escribir aparte y renombrar: a medio convertir, la bitácora sería ilegible entera.
|
|
682
|
+
disk.write(activityFile, sealedLines.join('\n'))
|
|
683
|
+
// Sin `%d`: el `log` de la bóveda no formatea, así que el placeholder salía literal.
|
|
684
|
+
log(`[vault] activity log: ${sealedLines.filter(Boolean).length} line(s) sealed at rest`)
|
|
685
|
+
}
|
|
686
|
+
} catch (_) { /* no hay bitácora todavía */ }
|
|
687
|
+
/**
|
|
688
|
+
* LA BITÁCORA ES UNA CADENA, no una lista de líneas sueltas.
|
|
689
|
+
*
|
|
690
|
+
* Cifrarla (0.89) le dio confidencialidad y ninguna integridad: quien tenga la llave de
|
|
691
|
+
* la máquina puede reescribir una línea y nadie lo nota. Como evidencia no valía nada, y
|
|
692
|
+
* eso es justo lo que un auditor mira (`CUMPLIMIENTO.md` §2).
|
|
693
|
+
*
|
|
694
|
+
* Cada entrada lleva su `seq` y el **hash de la anterior**, así que tocar o quitar una
|
|
695
|
+
* rompe la cadena de ahí en adelante y se ve. Se encadena el TEXTO exacto de la línea,
|
|
696
|
+
* no un objeto re-serializado: así verificar no depende de que las claves salgan en el
|
|
697
|
+
* mismo orden.
|
|
698
|
+
*
|
|
699
|
+
* **Lo que esto NO resuelve, dicho para no venderlo de más:** quien tenga la máquina
|
|
700
|
+
* puede cortar el final de la cadena y quedarse con un prefijo válido. Eso no se cierra
|
|
701
|
+
* en local — pide anclar el último hash fuera (un TSA, otro aparato). Lo que sí cierra
|
|
702
|
+
* es reescribir el pasado sin dejar rastro, que es el caso que importaba.
|
|
703
|
+
*/
|
|
704
|
+
const AUDIT_MAX_BYTES = 1024 * 1024
|
|
705
|
+
/** Cuántos archivos viejos se guardan. Es la RETENCIÓN, y va declarada. */
|
|
706
|
+
const AUDIT_KEEP = 5
|
|
707
|
+
const sha256 = sha256Hex
|
|
708
|
+
|
|
709
|
+
let auditSeq = 0
|
|
710
|
+
let auditPrev = null
|
|
711
|
+
|
|
712
|
+
/** Reconstruye el estado de la cadena leyendo la última entrada que haya. */
|
|
713
|
+
try {
|
|
714
|
+
const lines = (disk.read(activityFile) || '').split('\n').filter(Boolean)
|
|
715
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
716
|
+
try {
|
|
717
|
+
const text = activityAtRest.decrypt(lines[i])
|
|
718
|
+
const e = JSON.parse(text)
|
|
719
|
+
if (typeof e.logSeq === 'number') { auditSeq = e.logSeq; auditPrev = sha256(text); break }
|
|
720
|
+
} catch (_) { /* una línea vieja o ilegible: se sigue hacia atrás */ }
|
|
721
|
+
}
|
|
722
|
+
} catch (_) { /* no hay bitácora todavía */ }
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* ROTA ARCHIVANDO, NO TIRANDO. Antes se quedaba con la mitad del archivo y la otra mitad
|
|
726
|
+
* desaparecía sin que nada lo dijera: una bitácora que pierde historia en silencio es
|
|
727
|
+
* peor que no tenerla, porque parece completa. Ahora los viejos se corren a `.1`…`.N` y,
|
|
728
|
+
* cuando uno se cae del borde, **queda anotado que se cayó**.
|
|
729
|
+
*/
|
|
730
|
+
const rotateAudit = () => {
|
|
731
|
+
let dropped = null
|
|
732
|
+
const older = `${activityFile}.${AUDIT_KEEP}`
|
|
733
|
+
try { if (disk.exists(older)) { disk.remove(older); dropped = `activity.log.${AUDIT_KEEP}` } } catch (_) {}
|
|
734
|
+
for (let i = AUDIT_KEEP - 1; i >= 1; i--) {
|
|
735
|
+
try { if (disk.exists(`${activityFile}.${i}`)) disk.rename(`${activityFile}.${i}`, `${activityFile}.${i + 1}`) } catch (_) {}
|
|
736
|
+
}
|
|
737
|
+
try { disk.rename(activityFile, `${activityFile}.1`) } catch (_) {}
|
|
738
|
+
return dropped
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const audit = (op, info = {}) => {
|
|
742
|
+
try {
|
|
743
|
+
// LOS CAMPOS DE LA CADENA VAN AL FINAL Y CON NOMBRE PROPIO, y las dos cosas por el
|
|
744
|
+
// mismo motivo: que quien llama no pueda pisarlos.
|
|
745
|
+
//
|
|
746
|
+
// Estaban delante y se llamaban `seq`/`prev`, así que `...info` los sobrescribía. Y
|
|
747
|
+
// no era hipotético: `renew` ya mandaba un `seq` —el del ACTA, que no tiene nada que
|
|
748
|
+
// ver— y dejaba dos entradas seguidas numeradas igual. Lo cazó el propio verificador
|
|
749
|
+
// contra la bitácora de producción a los diez minutos de desplegarlo, que es
|
|
750
|
+
// exactamente para lo que sirve.
|
|
751
|
+
const entry = { ...info, ts: Date.now(), op, logSeq: ++auditSeq, logPrev: auditPrev }
|
|
752
|
+
const text = JSON.stringify(entry)
|
|
753
|
+
disk.append(activityFile, activityAtRest.encrypt(text) + '\n')
|
|
754
|
+
auditPrev = sha256(text)
|
|
755
|
+
if (disk.size(activityFile) > AUDIT_MAX_BYTES) {
|
|
756
|
+
const dropped = rotateAudit()
|
|
757
|
+
// La primera entrada del archivo nuevo dice de dónde viene, y si se perdió algo.
|
|
758
|
+
audit('log.rotated', { keep: AUDIT_KEEP, ...(dropped ? { dropped } : {}) })
|
|
759
|
+
}
|
|
760
|
+
} catch (_) {}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
const FRESH_WINDOW_MS = 5 * 60 * 1000
|
|
764
|
+
const isFresh = (d) => typeof d?.ts === 'number' && Math.abs(Date.now() - d.ts) <= FRESH_WINDOW_MS
|
|
765
|
+
const staleReply = (from) => reply(from, { type: MSG.ERROR, error: 'stale request: ts outside the ±5 min window (possible replay, or the device clock is off)' })
|
|
766
|
+
|
|
767
|
+
// --- ENROLL / aprobación / revocación: núcleo COMPARTIDO (lib/src/enroll.js) ---
|
|
768
|
+
// El mismo módulo lo usan «este dispositivo es bóveda» (@dotrino/vault) y la copia
|
|
769
|
+
// vendorizada del iframe de identidad: un solo sitio donde vive el flujo, y por lo
|
|
770
|
+
// tanto un solo sitio donde se comprueba el código antes de firmar el cert.
|
|
771
|
+
const desk = createEnrollDesk({
|
|
772
|
+
identity,
|
|
773
|
+
iss: master,
|
|
774
|
+
proxy: client.url,
|
|
775
|
+
send: (to, obj) => reply(to, obj),
|
|
776
|
+
sendByPubkey: (pub, obj) => client.sendByPubkey(pub, obj),
|
|
777
|
+
audit,
|
|
778
|
+
log,
|
|
779
|
+
// Camino A: lo que esta bóveda le manda al aparato para entrar en SU acta. La llave de
|
|
780
|
+
// cifrado es lo que le permite leer el contenido de la cuenta que va a custodiar; sin
|
|
781
|
+
// ella entraría mandando una cuenta que no puede abrir.
|
|
782
|
+
encPub: identity.me?.encryptionPubkey || null,
|
|
783
|
+
vaultLabel: 'bóveda',
|
|
784
|
+
// Lo único que lleva el QR corto: una CITA del proxio, que es un código de 6
|
|
785
|
+
// caracteres de un solo uso y con minutos de vida. Antes iba la dirección de
|
|
786
|
+
// la conexión, que eran 4 caracteres; hoy esa dirección es una instancia de
|
|
787
|
+
// 24 (para poder rutearla entre proxios) y no cabe cómoda en un QR ni
|
|
788
|
+
// conviene dejarla impresa en algo que circula. Se pide una por
|
|
789
|
+
// emparejamiento: si el proxio es viejo y no las conoce, se cae solo a la
|
|
790
|
+
// invitación larga, que sigue funcionando.
|
|
791
|
+
connToken: async () => {
|
|
792
|
+
try { return (await client.requestPairingCode())?.code || null }
|
|
793
|
+
catch (_) { return null }
|
|
794
|
+
},
|
|
795
|
+
onAdopted: (info) => { try { onAdopted?.(info) } catch (_) {} },
|
|
796
|
+
// Se va el aparato, se van SUS variables. Guardarlas sería configuración de una llave
|
|
797
|
+
// que ya no entra, y volvería a la vida sola el día que se enrole otro aparato con esa
|
|
798
|
+
// misma llave. Va aquí porque a quitar se entra por dos puertas (el PC y la consola
|
|
799
|
+
// remota) y las dos pasan por `desk.revokeDevice`.
|
|
800
|
+
onDeviceRemoved: (sub) => {
|
|
801
|
+
try {
|
|
802
|
+
const n = secrets.forgetDevice(sub)
|
|
803
|
+
if (n) log(`[vault] dropped ${n} variable(s) of the removed device`)
|
|
804
|
+
} catch (e) { log('[vault] could not drop the device variables:', e.message) }
|
|
805
|
+
// Su cajón propio se va entero y eso es inmediato y completo (estaba sellado solo
|
|
806
|
+
// a él). Lo que comparte —la CEK de su namespace— hay que ROTARLO, porque quitarle
|
|
807
|
+
// la envoltura no basta: si guardó la CEK sigue abriendo todo lo cifrado con ella.
|
|
808
|
+
//
|
|
809
|
+
// Pero rotar exige la contraseña, y quitar un aparato es el interruptor de
|
|
810
|
+
// emergencia: el gesto que se hace desde el teléfono cuando se perdió una máquina.
|
|
811
|
+
// Un interruptor que pide una frase que quizá no tienes a mano no es un
|
|
812
|
+
// interruptor. Así que se INTENTA, y si no se puede queda anotado y a la vista.
|
|
813
|
+
markRotationDue(sub).catch((e) => log('[vault] could not rotate after the removal:', e.message))
|
|
814
|
+
},
|
|
815
|
+
defaultScope: [SCOPE.READ],
|
|
816
|
+
onChallenge ({ deviceId, scope, machine }) {
|
|
817
|
+
log(`\n[vault] Un dispositivo quiere conectarse:`)
|
|
818
|
+
log(` deviceId: ${deviceId}`)
|
|
819
|
+
if (machine) log(` machine: ${machineLine(machine)}`)
|
|
820
|
+
log(` Ingresa el código que MUESTRA el dispositivo:`)
|
|
821
|
+
log(` dotrino-vault approve <código> (o rechaza: dotrino-vault reject ${deviceId})\n`)
|
|
822
|
+
try { onEnrollChallenge?.({ deviceId, scope, machine: machine || null }) } catch (_) {}
|
|
823
|
+
}
|
|
824
|
+
})
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* EL ACTA MANDA, EL PAPEL SOLO ACOMPAÑA. Se pregunta DESPUÉS de verificar la cadena y
|
|
828
|
+
* ANTES de hacer nada, en todos los mostradores.
|
|
829
|
+
*
|
|
830
|
+
* El certificado dice a qué se comprometió esta bóveda cuando conectó el aparato; el
|
|
831
|
+
* acta dice lo que puede HOY. Y no coinciden: `caps <ID> -lee` sella el acta pero no
|
|
832
|
+
* reemite ni revoca el papel, que vive hasta 30 días. Sin esto, quitarle un permiso a un
|
|
833
|
+
* aparato no se lo quitaba — seguía leyendo, guardando o firmando un mes más.
|
|
834
|
+
*
|
|
835
|
+
* Va por `memberCanScope` (el pilar) y no a mano en cada sitio: la regla es la misma en
|
|
836
|
+
* las dos bóvedas y se comprobaba mal en las dos.
|
|
837
|
+
*
|
|
838
|
+
* @returns {boolean} `true` si puede seguir; si no, ya contestó el «no».
|
|
839
|
+
*/
|
|
840
|
+
/**
|
|
841
|
+
* CON QUÉ SE JUZGA UN PAPEL: el acta que tiene esta bóveda.
|
|
842
|
+
*
|
|
843
|
+
* Sustituye a `trustedIssuer: master`, que comparaba contra UNA llave fija y por eso el
|
|
844
|
+
* multivault no podía existir — una segunda selladora sellaba el acta y luego sus papeles
|
|
845
|
+
* los rechazaban los diez mostradores. Ahora se compara contra la lista que dice el acta.
|
|
846
|
+
*
|
|
847
|
+
* Sin acta se devuelven nulos a propósito: `verifyDelegation` responde `no-acta` y el
|
|
848
|
+
* mostrador deniega. Es lo contrario de un repliegue — no hay con qué decidir, así que no
|
|
849
|
+
* se decide que sí.
|
|
850
|
+
*/
|
|
851
|
+
async function contextoActa () {
|
|
852
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
853
|
+
if (!acta) return { actaSeq: null, sealers: null }
|
|
854
|
+
return { actaSeq: acta.seq, sealers: Acta.sealersOf(acta) }
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function actaAllows (from, chk, scope, what) {
|
|
858
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
859
|
+
// SIN ACTA NO SE ATIENDE. Esto era `if (!record || puede())`, o sea: sin acta, pasa. Un
|
|
860
|
+
// repliegue no dice «por si falta el dato», dice «si falta el dato, di que sí» — y el
|
|
861
|
+
// acta falta justo cuando algo se rompió (un archivo a medias, un `catch` que devolvió
|
|
862
|
+
// null), que es cuando menos hay que fiarse. Toda cuenta tiene acta desde hace versiones.
|
|
863
|
+
if (!record) {
|
|
864
|
+
audit('rejected', { what, reason: 'sin-acta' })
|
|
865
|
+
reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
866
|
+
return false
|
|
867
|
+
}
|
|
868
|
+
if (Acta.memberCanScope(record, chk.device, scope)) return true
|
|
869
|
+
audit('rejected', { what, reason: 'acta' })
|
|
870
|
+
reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member no longer has that permission' })
|
|
871
|
+
return false
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// --- handleSign / handleGet: idénticos (verifyChain de la cadena D←maestra) ---
|
|
875
|
+
async function handleSign (from, p) {
|
|
876
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'sign', reason: 'stale' }); return staleReply(from) }
|
|
877
|
+
const chk = await verifyChain({
|
|
878
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
879
|
+
expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked: await revocationSet()
|
|
880
|
+
})
|
|
881
|
+
if (!chk.ok) return denyChain(from, chk, p, 'sign')
|
|
882
|
+
|
|
883
|
+
// MANDA EL ACTA, NO EL PAPEL. Esto miraba solo el certificado, y era el único
|
|
884
|
+
// mostrador que lo hacía —`approve` y los secretos ya preguntaban al acta—. El agujero
|
|
885
|
+
// que dejaba: `caps <ID> -firma` quita `sign` del acta pero NO reemite ni revoca el
|
|
886
|
+
// cert del aparato, que sigue diciendo `vault:sign` hasta 30 días. Entonces su
|
|
887
|
+
// `signData` local veía que ya no puede y se lo PEDÍA a la bóveda… que decía que sí.
|
|
888
|
+
// O sea que quitarle el permiso de firmar no se lo quitaba: lo cambiaba de firmar él
|
|
889
|
+
// a que firmaras tú por él.
|
|
890
|
+
if (!await actaAllows(from, chk, SCOPE.SIGN, 'sign')) return
|
|
891
|
+
|
|
892
|
+
// Y CERRADA NO FIRMA NADA (dueño, 2026-08-31). La regla de antes —«el daemon sigue
|
|
893
|
+
// firmando con el perfil bloqueado, para que un reinicio no deje las apps muertas»— es
|
|
894
|
+
// ANTERIOR AL MODELO DE SOBRES y queda derogada: con los sobres la bóveda no necesita
|
|
895
|
+
// firmar nada para servir. Lo que hace abierta es rehacer los sobres.
|
|
896
|
+
//
|
|
897
|
+
// Y las apps no se quedan muertas por esto: un aparato al que el acta le da `sign`
|
|
898
|
+
// firma con SU llave y no pasa por aquí (ver `signData` en el pilar). Leer y guardar
|
|
899
|
+
// tampoco pasan por aquí, y siguen funcionando con el candado echado.
|
|
900
|
+
if (isLocked()) {
|
|
901
|
+
audit('rejected', { what: 'sign', reason: 'locked' })
|
|
902
|
+
return reply(from, { type: MSG.ERROR, error: 'vault locked: the master key does not sign while the vault is closed' })
|
|
903
|
+
}
|
|
904
|
+
const toSign = p.data?.payload
|
|
905
|
+
if (toSign == null) return reply(from, { type: MSG.ERROR, error: 'data.payload required' })
|
|
906
|
+
const { signature, publickey } = await identity.signData(toSign)
|
|
907
|
+
audit('sign', { device: await deviceIdOf(chk.device) })
|
|
908
|
+
reply(from, { type: MSG.SIGNED, signature, publickey, device: chk.device })
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async function handleGet (from, p) {
|
|
912
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
913
|
+
const chk = await verifyChain({
|
|
914
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
915
|
+
expectedScope: SCOPE.READ, ...(await contextoActa()), revoked: await revocationSet()
|
|
916
|
+
})
|
|
917
|
+
if (!chk.ok) return denyChain(from, chk, p, 'get')
|
|
918
|
+
if (!await actaAllows(from, chk, SCOPE.READ, 'get')) return
|
|
919
|
+
const id = p.data?.id || 'root'
|
|
920
|
+
reply(from, { type: MSG.DATA, id, node: store.getNode(id) })
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
// Store de hilos+aperturas (Fase 3): escrituras requieren vault:store; lecturas
|
|
924
|
+
// aceptan vault:store o vault:read. Cada op va firmada por D + cert (cadena D←maestra).
|
|
925
|
+
/**
|
|
926
|
+
* LOS DATOS DEL PERFIL (`docs/datos-del-perfil.md`). Tres métodos y ninguno más:
|
|
927
|
+
*
|
|
928
|
+
* · `profilePut` — guarda un dato. Privado = sobre ya sellado; público = en claro.
|
|
929
|
+
* · `profileBundle` — los sobres de ESTE aparato, para que arme su perfil.
|
|
930
|
+
* · `profilePublic` — lo público en claro, que puede pedir cualquiera.
|
|
931
|
+
*
|
|
932
|
+
* Lo que NO se comprueba aquí: el candado. Un sobre sellado no lo puede leer ni fabricar
|
|
933
|
+
* la bóveda, así que guardarlo no es una decisión suya y no necesita su maestra. Ese era
|
|
934
|
+
* el motivo real por el que editar el perfil exigía abrirla, y es el síntoma que abrió
|
|
935
|
+
* todo esto: se cerraba sola a los cinco minutos y editar dejaba de funcionar.
|
|
936
|
+
*/
|
|
937
|
+
/**
|
|
938
|
+
* ABRIR LA BÓVEDA DESDE EL ADMIN (`docs/abrir-a-distancia.md`).
|
|
939
|
+
*
|
|
940
|
+
* Dos pasos, y el segundo no se puede reproducir por DOS candados distintos:
|
|
941
|
+
*
|
|
942
|
+
* · el **nonce** hace que se NIEGUE a atenderlo dos veces (memoria, 10 min);
|
|
943
|
+
* · la **efímera** hace que no se PUEDA abrir dos veces — se tira al usarla, así que
|
|
944
|
+
* ese sobre queda inabrible hasta para nosotros, incluso con el disco en la mano.
|
|
945
|
+
*
|
|
946
|
+
* Por qué una efímera y no la llave de cifrado fija de la bóveda: esa cede a la llave de
|
|
947
|
+
* la máquina (modelo de amenazas §4.2), o sea que cae con el disco. La contraseña es
|
|
948
|
+
* justo lo único que un ladrón de disco NO tiene — cifrarla con algo que el disco
|
|
949
|
+
* entrega sería regalársela a quien grabe el tráfico hoy y robe el disco mañana.
|
|
950
|
+
*
|
|
951
|
+
* Y el molino lo hace el ADMIN: aquí llega la derivada, nunca la contraseña.
|
|
952
|
+
*/
|
|
953
|
+
const EPHEMERAL_TTL_MS = 60 * 1000
|
|
954
|
+
const aperturas = new Map() // nonce -> { privateKey, exp }
|
|
955
|
+
|
|
956
|
+
const unlockDesk = (typeof openWithSecondary === 'function' && typeof secondaryParams === 'function')
|
|
957
|
+
? {
|
|
958
|
+
async begin ({ by }) {
|
|
959
|
+
const params = secondaryParams()
|
|
960
|
+
if (!params) {
|
|
961
|
+
throw new Error('this profile has no admin password: set one with `dotrino-vault profile admin-password`')
|
|
962
|
+
}
|
|
963
|
+
// Se barre lo vencido aquí y no con un temporizador: es barato, y un temporizador
|
|
964
|
+
// más es una cosa más que puede quedarse corriendo cuando el perfil ya no existe.
|
|
965
|
+
const t = Date.now()
|
|
966
|
+
for (const [n, v] of aperturas) if (v.exp <= t) aperturas.delete(n)
|
|
967
|
+
const eph = await makeEphemeralKey()
|
|
968
|
+
aperturas.set('pendiente', { privateKey: eph.privateKey, exp: t + EPHEMERAL_TTL_MS })
|
|
969
|
+
audit('unlock.begin', { by })
|
|
970
|
+
return { ...params, ek: eph.ek }
|
|
971
|
+
},
|
|
972
|
+
|
|
973
|
+
async open ({ nonce, enc, by }) {
|
|
974
|
+
const guardada = aperturas.get('pendiente')
|
|
975
|
+
aperturas.delete('pendiente')
|
|
976
|
+
if (!guardada || guardada.exp <= Date.now()) {
|
|
977
|
+
return { ok: false, error: 'unlock: ask for a key first, and use it within a minute' }
|
|
978
|
+
}
|
|
979
|
+
let payload = null
|
|
980
|
+
try { payload = await openSealed({ privateKey: guardada.privateKey, enc }) } catch (_) { payload = null }
|
|
981
|
+
// EL NONCE VA DENTRO DEL SOBRE. Si fuera solo por fuera, un sobre capturado se
|
|
982
|
+
// reenvía con un nonce nuevo y el candado del nonce no sirve de nada.
|
|
983
|
+
if (!payload || payload.nonce !== nonce || typeof payload.key !== 'string') {
|
|
984
|
+
audit('rejected', { what: 'unlock', by, reason: 'sobre' })
|
|
985
|
+
return { ok: false, error: 'unlock: the envelope does not check out' }
|
|
986
|
+
}
|
|
987
|
+
const derivada = b64ToBytes(payload.key)
|
|
988
|
+
try {
|
|
989
|
+
const r = await openWithSecondary(derivada)
|
|
990
|
+
audit('unlock', { by })
|
|
991
|
+
log(`[vault] opened from the admin by ${by || '????-????'}`)
|
|
992
|
+
return { ok: true, result: { ok: true, locked: false } }
|
|
993
|
+
} catch (e) {
|
|
994
|
+
audit('rejected', { what: 'unlock', by, reason: e.code || 'error' })
|
|
995
|
+
// El freno viaja con el rechazo: sin él, el admin reintenta contra una puerta
|
|
996
|
+
// que ya no responde y no sabe por qué.
|
|
997
|
+
return { ok: false, code: e.code || null, error: e.message, tries: e.tries ?? null, waitSec: e.waitSec ?? null }
|
|
998
|
+
} finally {
|
|
999
|
+
derivada.fill(0)
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
: null
|
|
1004
|
+
|
|
1005
|
+
const PROFILE_METHODS = new Set(['profilePut', 'profileBundle', 'profilePublic', 'profileRecipients'])
|
|
1006
|
+
|
|
1007
|
+
async function handleProfile (from, p, d) {
|
|
1008
|
+
// LO PÚBLICO NO PIDE NADA. Es lo que ve quien pregunta desde fuera y no tiene ninguna
|
|
1009
|
+
// llave tuya: pedir un papel para leerlo sería contradecir lo que significa «público».
|
|
1010
|
+
// Va firmado, así que quien lo recibe comprueba que es tuyo sin fiarse de nadie.
|
|
1011
|
+
if (d.method === 'profilePublic') {
|
|
1012
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
|
|
1013
|
+
}
|
|
1014
|
+
const revoked = await revocationSet()
|
|
1015
|
+
// `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
|
|
1016
|
+
const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
|
|
1017
|
+
if (!chk.ok) return denyChain(from, chk, p, 'profile')
|
|
1018
|
+
if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
|
|
1019
|
+
|
|
1020
|
+
// A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
|
|
1021
|
+
// él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
|
|
1022
|
+
// ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
|
|
1023
|
+
// `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
|
|
1024
|
+
//
|
|
1025
|
+
// VA DETRÁS DEL CONTROL, y estuvo delante. Que solo haga falta material PÚBLICO para
|
|
1026
|
+
// envolver no significa que la lista sea pública: lo que devuelve es el INVENTARIO
|
|
1027
|
+
// —cuántas máquinas tienes, sus llaves, y la de recuperación— y eso la bóveda no se lo
|
|
1028
|
+
// enseña ni a un servicio de la casa (`modelo-de-amenazas.md` §2.5). Cualquiera que
|
|
1029
|
+
// supiera la llave de la cuenta, que es pública, lo sacaba entero preguntando. Y no
|
|
1030
|
+
// costaba nada evitarlo: la consola YA manda su firma y su papel en esta misma llamada
|
|
1031
|
+
// (`requestStore` de `@dotrino/identity`), solo que aquí no se miraban.
|
|
1032
|
+
if (d.method === 'profileRecipients') {
|
|
1033
|
+
const members = await recipientsOf(PROFILE_OWNER)
|
|
1034
|
+
return reply(from, {
|
|
1035
|
+
type: MSG.STORE_RESULT,
|
|
1036
|
+
method: d.method,
|
|
1037
|
+
result: { recoveryPub: secrets.recoveryPub(), members: members.map((m) => ({ pub: m.pub, encPub: m.encPub })) }
|
|
1038
|
+
})
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
try {
|
|
1042
|
+
if (d.method === 'profileBundle') {
|
|
1043
|
+
const b = secrets.profileBundleFor(chk.device)
|
|
1044
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: b })
|
|
1045
|
+
}
|
|
1046
|
+
// profilePut
|
|
1047
|
+
const { key, cls, sobre, value } = d.args || {}
|
|
1048
|
+
const quien = await deviceIdOf(chk.device).catch(() => null)
|
|
1049
|
+
let r
|
|
1050
|
+
if (cls === 'public') {
|
|
1051
|
+
r = await secrets.profilePutPublic(key, value, { by: quien })
|
|
1052
|
+
} else {
|
|
1053
|
+
r = await secrets.putSealed(PROFILE_OWNER, key, sobre, { by: quien })
|
|
1054
|
+
}
|
|
1055
|
+
audit('profile.put', { key, cls: cls || 'private', by: quien, gen: r?.gen ?? null })
|
|
1056
|
+
// Y a los demás aparatos, que si no se enteran al arrancar y vuelve la divergencia.
|
|
1057
|
+
await notifyMembers('profile', { key, by: quien })
|
|
1058
|
+
return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: r })
|
|
1059
|
+
} catch (e) {
|
|
1060
|
+
return reply(from, { type: MSG.ERROR, error: 'profile: ' + e.message })
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function handleStore (from, p) {
|
|
1065
|
+
const d = p.data
|
|
1066
|
+
// `Object.hasOwn` y no `threads.methods[d.method]`: con la comprobación laxa,
|
|
1067
|
+
// `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
|
|
1068
|
+
// se llamaba como si fuera del store.
|
|
1069
|
+
if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
|
|
1070
|
+
// Con `code`: un aparato más nuevo que esta bóveda pide métodos que aquí no existen, y
|
|
1071
|
+
// tiene que poder decir «actualiza la bóveda» en vez de enseñar la frase.
|
|
1072
|
+
return reply(from, { type: MSG.ERROR, error: 'store: invalid method', code: 'store-unknown-method' })
|
|
1073
|
+
}
|
|
1074
|
+
if (!isFresh(d)) return staleReply(from)
|
|
1075
|
+
// EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
|
|
1076
|
+
// (`docs/datos-del-perfil.md`). Dos diferencias, y las dos son el arreglo:
|
|
1077
|
+
//
|
|
1078
|
+
// · **No hace falta abrir la bóveda.** Lo que llega es un sobre ya sellado y
|
|
1079
|
+
// firmado, que ella no puede leer ni falsificar: aceptarlo no es una decisión
|
|
1080
|
+
// suya. El candado estaba ahí porque guardaba el perfil en claro; ya no.
|
|
1081
|
+
// · **Lo gatea `firma`, no `guarda`.** Escribir tu identidad es hablar por ti, no
|
|
1082
|
+
// guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
|
|
1083
|
+
// para lo suyo y no debe poder cambiar quién eres.
|
|
1084
|
+
if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
|
|
1085
|
+
// EL ALMACÉN SOLO ENTRA CIFRADO. Lo que va por aquí —hilos de las apps, contraseñas,
|
|
1086
|
+
// el `me` heredado— viaja por el proxio, que no cifra; en claro lo leería quien lo
|
|
1087
|
+
// opere. Los clientes del ecosistema cifran desde @dotrino/identity 0.91.0, y hasta
|
|
1088
|
+
// entonces uno sin la clave de contenido mandaba en claro «como antes»: aceptarlo aquí
|
|
1089
|
+
// era dejar ese camino abierto para cualquier cliente viejo. El perfil (arriba) va
|
|
1090
|
+
// aparte a propósito: lo privado viaja en sobres y lo público es público.
|
|
1091
|
+
if (!d.enc) {
|
|
1092
|
+
audit('rejected', { what: 'store', method: d.method, reason: 'unsealed' })
|
|
1093
|
+
return reply(from, { type: MSG.ERROR, error: 'store: requests must be sealed with the profile content key', code: 'store-unsealed' })
|
|
1094
|
+
}
|
|
1095
|
+
// CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
|
|
1096
|
+
// (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
|
|
1097
|
+
// leyendo y guardando contenido.
|
|
1098
|
+
if (PROFILE_EDIT_METHODS.has(d.method) && isLocked()) {
|
|
1099
|
+
audit('rejected', { what: 'store', method: d.method, reason: 'locked' })
|
|
1100
|
+
return reply(from, { type: MSG.ERROR, error: 'profile locked: unlock it on the vault machine (dotrino-vault unlock) to edit it' })
|
|
1101
|
+
}
|
|
1102
|
+
const revoked = await revocationSet()
|
|
1103
|
+
let chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.STORE, ...(await contextoActa()), revoked })
|
|
1104
|
+
if (!chk.ok && STORE_READ_METHODS.has(d.method)) {
|
|
1105
|
+
chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.READ, ...(await contextoActa()), revoked })
|
|
1106
|
+
}
|
|
1107
|
+
if (!chk.ok) return denyChain(from, chk, p, 'store')
|
|
1108
|
+
// El scope que valió es el que hay que preguntarle al acta: un método de lectura pudo
|
|
1109
|
+
// pasar con `read` en vez de `store`, y exigirle `store` al acta lo rechazaría a pesar
|
|
1110
|
+
// de tener permiso para lo que pidió.
|
|
1111
|
+
if (!await actaAllows(from, chk, chk.scope === SCOPE.READ ? SCOPE.READ : SCOPE.STORE, 'store')) return
|
|
1112
|
+
try {
|
|
1113
|
+
// CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
|
|
1114
|
+
// pero no ve nada de lo que el usuario guarda. Se abre aquí con la clave de la bóveda
|
|
1115
|
+
// (que también es miembro) y la respuesta vuelve igual.
|
|
1116
|
+
const cek = await identity.contentKey()
|
|
1117
|
+
if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key', code: 'vault-no-content-key' })
|
|
1118
|
+
const args = JSON.parse(await identity.openContent(d.enc))
|
|
1119
|
+
const result = await threads.methods[d.method](args)
|
|
1120
|
+
// Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
|
|
1121
|
+
// rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
|
|
1122
|
+
// Solo la operación y el aparato: nunca el contenido (`activity` es un registro
|
|
1123
|
+
// de seguridad, no una copia de lo que guardas).
|
|
1124
|
+
if (!STORE_READ_METHODS.has(d.method)) {
|
|
1125
|
+
audit('store', { device: await deviceIdOf(chk.device), method: d.method })
|
|
1126
|
+
}
|
|
1127
|
+
const enc = await identity.sealContent(JSON.stringify(result ?? null))
|
|
1128
|
+
reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
|
|
1129
|
+
} catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Lista (solo lectura) de dispositivos enrolados, para un panel en el navegador.
|
|
1133
|
+
// Cualquier cert válido tuyo puede verla; REVOCAR sigue siendo solo desde el PC.
|
|
1134
|
+
/**
|
|
1135
|
+
* Un aparato REVOCADO que vuelve a aparecer: se le reemite el `vault.revoked` FIRMADO
|
|
1136
|
+
* para que se entere y se auto-borre.
|
|
1137
|
+
*
|
|
1138
|
+
* Un «unauthorized: revoked» suelto no basta y no debe bastar: no va firmado, así que
|
|
1139
|
+
* el dispositivo tiene prohibido borrar nada con él (si no, cualquiera destruiría datos
|
|
1140
|
+
* ajenos con un mensaje). Lo único que puede borrar es un aviso firmado por la maestra
|
|
1141
|
+
* — y hasta ahora el daemon no lo reemitía nunca. Si el aparato estaba apagado cuando lo
|
|
1142
|
+
* quitaste, no se enteraba jamás: seguía enseñando el perfil como si nada.
|
|
1143
|
+
*/
|
|
1144
|
+
async function notifyIfRevoked (pubkey, nonce = null, certIss = null, reason = 'revoked') {
|
|
1145
|
+
if (typeof pubkey !== 'string') return
|
|
1146
|
+
try {
|
|
1147
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1148
|
+
// Sin acta (bóveda anterior al acta) hay que mirar las delegaciones, que es lo único
|
|
1149
|
+
// que queda. OJO con dónde se buscan: desde identity 0.42 `issued` es «lo que HOY
|
|
1150
|
+
// sirve para entrar», así que los retirados NO están ahí — viven en `revokedCerts`.
|
|
1151
|
+
let knownRevoked = false
|
|
1152
|
+
let fallbackNonce = null
|
|
1153
|
+
if (!record) {
|
|
1154
|
+
const delegations = await identity.listDelegations()
|
|
1155
|
+
const revoked = await revocationSet()
|
|
1156
|
+
const candidates = [...(delegations.revokedCerts || []), ...(delegations.issued || [])]
|
|
1157
|
+
const theirs = candidates.filter((x) => x.sub === pubkey && (x.revokedAt || revoked.has(x.nonce)))
|
|
1158
|
+
knownRevoked = theirs.length > 0
|
|
1159
|
+
fallbackNonce = theirs[0]?.nonce || null
|
|
1160
|
+
}
|
|
1161
|
+
if (!shouldNotifyRevoked({ reason, pubkey, master, certIss, members: record?.members || null, knownRevoked })) return
|
|
1162
|
+
// El aviso nombra el certificado que el aparato ACABA de presentar: es el que tiene
|
|
1163
|
+
// en la mano, y es contra ese contra el que comprueba antes de borrarse.
|
|
1164
|
+
await desk.emitRevoke(pubkey, nonce || fallbackNonce)
|
|
1165
|
+
audit('revoke.notified', { device: await deviceIdOf(pubkey).catch(() => null), reason })
|
|
1166
|
+
} catch (e) { log('[vault] could not re-emit the revocation:', e.message) }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Rechaza una petición y, si el aparato ya no es del perfil, SE LO DICE con el aviso
|
|
1171
|
+
* firmado — sea cual sea la operación que estuviera intentando.
|
|
1172
|
+
*
|
|
1173
|
+
* Un dispositivo que fue tuyo tiene que poder llegar hasta aquí precisamente para que se
|
|
1174
|
+
* le pueda mandar a paseo: es el único mensaje que le borra la cuenta, porque es el único
|
|
1175
|
+
* que va firmado por la maestra (un «unauthorized» suelto no borra nada y no debe: sería
|
|
1176
|
+
* destruir datos ajenos con un mensaje, el wipe-DoS de `docs/pairing-protocol.md §2.3`).
|
|
1177
|
+
*
|
|
1178
|
+
* Antes esto solo pasaba en `devices` —la pantalla de dispositivos— y solo si el papel
|
|
1179
|
+
* estaba REVOCADO. Un aparato quitado que estuviera guardando notas nunca preguntaba por
|
|
1180
|
+
* ahí, y uno al que simplemente se le venció el certificado no entraba en el caso: los
|
|
1181
|
+
* dos se quedaban enseñando la cuenta indefinidamente.
|
|
1182
|
+
*/
|
|
1183
|
+
async function denyChain (from, chk, p, what) {
|
|
1184
|
+
await notifyIfRevoked(p.data?.publickey, p.cert?.nonce || null, p.cert?.iss || null, chk.reason)
|
|
1185
|
+
if (what) audit('rejected', { what, reason: chk.reason })
|
|
1186
|
+
// `code` + `reason` como campos: quien llama decide por ellos, no por la frase.
|
|
1187
|
+
return reply(from, { type: MSG.ERROR, code: 'unauthorized', reason: chk.reason, error: 'unauthorized: ' + chk.reason })
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
/**
|
|
1191
|
+
* «¿SIGO SIENDO DE ESTA CASA?» — la única pregunta que se atiende SIN certificado.
|
|
1192
|
+
*
|
|
1193
|
+
* Existe por el aparato que se quedó sin papel: no puede firmar, ni leer, ni renovar, y
|
|
1194
|
+
* —esto es lo grave— tampoco tenía forma de enterarse de que lo echaron, porque todo lo
|
|
1195
|
+
* demás exige el certificado que ya no tiene. Se quedaba enseñando para siempre una
|
|
1196
|
+
* cuenta que ya no era suya. Va firmada con la llave del propio aparato, que es
|
|
1197
|
+
* exactamente lo que el acta nombra, así que decir quién pregunta no necesita más.
|
|
1198
|
+
*
|
|
1199
|
+
* La respuesta es sí o no, y nada más: al que sigue dentro no se le manda el acta —no la
|
|
1200
|
+
* pidió, y contarle el perfil entero a quien no trae papel es attach de más—. Al que ya no
|
|
1201
|
+
* está se le manda el aviso FIRMADO de expulsión, que es lo único que le borra la cuenta.
|
|
1202
|
+
* Que un desconocido pregunte no cuesta nada: lo que se le puede contestar es un aviso a
|
|
1203
|
+
* nombre de SU propia llave, que no le sirve contra nadie más (`verifyRevoke` exige que
|
|
1204
|
+
* el aviso nombre al aparato que lo recibe).
|
|
1205
|
+
*/
|
|
1206
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA ---------------------------------------------------
|
|
1207
|
+
//
|
|
1208
|
+
// Los tres únicos mensajes que se atienden SIN certificado, porque quien pregunta todavía
|
|
1209
|
+
// no tiene llaves: están dentro del paquete que solo abre su contraseña. La bóveda no ve
|
|
1210
|
+
// la contraseña en ningún momento (OPAQUE), y responde igual exista o no el usuario.
|
|
1211
|
+
//
|
|
1212
|
+
// Funcionan con el perfil CERRADO: el paquete se devuelve tal cual, sin abrir nada. Lo que
|
|
1213
|
+
// sí exige la bóveda abierta es CREAR el aparato, porque eso sella el acta.
|
|
1214
|
+
|
|
1215
|
+
function loginError (from, e) {
|
|
1216
|
+
if (e?.code === 'too-many-tries') {
|
|
1217
|
+
return reply(from, { type: MSG.ERROR, error: e.message, code: e.code, waitMs: e.waitMs || 0 })
|
|
1218
|
+
}
|
|
1219
|
+
if (e?.code === 'login-failed' || e?.code === 'no-exchange' || e?.code === 'bad-input' || e?.code === 'bad-user') {
|
|
1220
|
+
return reply(from, { type: MSG.ERROR, error: e.message, code: e.code })
|
|
1221
|
+
}
|
|
1222
|
+
log('[vault] login: %s', e?.message || e)
|
|
1223
|
+
reply(from, { type: MSG.ERROR, error: 'login failed', code: 'login-error' })
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
async function handleLoginStart (from, p) {
|
|
1227
|
+
try {
|
|
1228
|
+
const { lid, response } = await logins.loginBegin({ user: p?.user, request: p?.request })
|
|
1229
|
+
audit('login.start', { user: String(p?.user || '').slice(0, 32) })
|
|
1230
|
+
reply(from, { type: MSG.LOGIN_RESPONSE, lid, response })
|
|
1231
|
+
} catch (e) { loginError(from, e) }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function handleLoginFinish (from, p) {
|
|
1235
|
+
try {
|
|
1236
|
+
const r = await logins.loginEnd({ lid: p?.lid, finalization: p?.finalization, label: p?.label })
|
|
1237
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1238
|
+
audit('login.ok', { user: r.user, sid: r.sid.slice(0, 8) })
|
|
1239
|
+
log('[vault] login: %s entered (session %s)', r.user, r.sid.slice(0, 8))
|
|
1240
|
+
reply(from, { type: MSG.LOGIN_OK, sid: r.sid, blob: r.blob, cert: r.cert, iss: r.iss || identity.me?.publickey || null, acta: record })
|
|
1241
|
+
} catch (e) { loginError(from, e) }
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* SALIR. Va firmado con la llave del propio aparato —la que acaba de abrir— porque cerrar
|
|
1246
|
+
* la sesión de otro sería echarlo de su cuenta.
|
|
1247
|
+
*/
|
|
1248
|
+
async function handleLoginClose (from, p) {
|
|
1249
|
+
if (!isFresh(p?.data)) return staleReply(from)
|
|
1250
|
+
const d = p.data
|
|
1251
|
+
if (d?.op !== 'login.close' || typeof d.publickey !== 'string' || typeof d.user !== 'string' || typeof d.sid !== 'string') {
|
|
1252
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape', code: 'bad-input' })
|
|
1253
|
+
}
|
|
1254
|
+
if (!(await verifyDeviceSig({ publickey: d.publickey, data: d, signature: p.signature }))) {
|
|
1255
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature', code: 'bad-signature' })
|
|
1256
|
+
}
|
|
1257
|
+
const mine = logins.list().find((x) => x.user === d.user)
|
|
1258
|
+
if (!mine || mine.pub !== d.publickey) {
|
|
1259
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: that key does not own this login', code: 'not-yours' })
|
|
1260
|
+
}
|
|
1261
|
+
const r = logins.closeSession({ user: d.user, sid: d.sid })
|
|
1262
|
+
audit('login.close', { user: d.user, sid: d.sid.slice(0, 8), by: 'device' })
|
|
1263
|
+
reply(from, { type: MSG.LOGIN_CLOSED, ok: r.ok })
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
async function handleCheck (from, p) {
|
|
1267
|
+
if (!isFresh(p?.data)) return staleReply(from)
|
|
1268
|
+
const pub = p.data.publickey
|
|
1269
|
+
if (typeof pub !== 'string') return reply(from, { type: MSG.ERROR, error: 'unauthorized: shape' })
|
|
1270
|
+
if (!(await verifyDeviceSig({ publickey: pub, data: p.data, signature: p.signature }))) {
|
|
1271
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: bad-signature' })
|
|
1272
|
+
}
|
|
1273
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1274
|
+
const inside = (record?.members || []).some((m) => m?.pub === pub)
|
|
1275
|
+
audit('check', { device: await deviceIdOf(pub).catch(() => null), in: inside })
|
|
1276
|
+
if (inside) return reply(from, { type: MSG.CHECKED, in: true })
|
|
1277
|
+
await notifyIfRevoked(pub, null, null, 'revoked')
|
|
1278
|
+
reply(from, { type: MSG.CHECKED, in: false })
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
async function handleDevices (from, p) {
|
|
1282
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
1283
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
1284
|
+
if (!chk.ok) return denyChain(from, chk, p, null)
|
|
1285
|
+
// DOS RESPUESTAS, PORQUE SON DOS PREGUNTAS DISTINTAS.
|
|
1286
|
+
//
|
|
1287
|
+
// Un SERVICIO pregunta aquí para enterarse de dos cosas suyas: si le revocaron el papel
|
|
1288
|
+
// y cuál es el acta vigente. Eso no es tu inventario y no puede exigir `lee` — se lo
|
|
1289
|
+
// exigí, y en producción dejó ciegos a los servicios (`rejected devices/acta` en la
|
|
1290
|
+
// bitácora del VPS, dos veces, antes de que lo viera).
|
|
1291
|
+
//
|
|
1292
|
+
// La LISTA DE APARATOS sí es el perfil —cómo se llama cada máquina y qué puede— y esa
|
|
1293
|
+
// sigue pidiendo `lee`. Antes no preguntaba nada, así que un aparato al que le quitaste
|
|
1294
|
+
// el permiso seguía viéndolo todo hasta que su papel caducara.
|
|
1295
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
1296
|
+
// LA CADENA DE SELLADORES VIAJA CON LA LISTA, también para quien solo puede ver
|
|
1297
|
+
// revocaciones. Es lo que un aparato de Node necesita para FIRMAR a nombre de la cuenta
|
|
1298
|
+
// de forma que un extraño lo compruebe (`verifySignedFor` exige la cadena desde el
|
|
1299
|
+
// génesis), y hasta ahora ningún agente headless podía: solo el navegador la tenía. Es
|
|
1300
|
+
// corta a propósito (génesis, cambios de sellador, actual), así que no pasa por `fitChain`.
|
|
1301
|
+
const sealerChainOf = async () => { try { return (await identity.sealerChain?.()) || null } catch (e) { log('[vault] could not build the sealer chain:', e.message); return null } }
|
|
1302
|
+
const puedeVerTodo = !!record && Acta.memberCanScope(record, chk.device, SCOPE.READ)
|
|
1303
|
+
const { issued, revoked } = await identity.listDelegations()
|
|
1304
|
+
if (!puedeVerTodo) {
|
|
1305
|
+
if (!record) {
|
|
1306
|
+
audit('rejected', { what: 'devices', reason: 'sin-acta' })
|
|
1307
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
1308
|
+
}
|
|
1309
|
+
audit('devices', { device: await deviceIdOf(chk.device).catch(() => null), solo: 'revocaciones' })
|
|
1310
|
+
return reply(from, { type: MSG.DEVICES_RESULT, devices: [], revoked, blocked: blocked.pubs(), acta: record, sealerChain: await sealerChainOf() })
|
|
1311
|
+
}
|
|
1312
|
+
// El acta viaja con la lista: así cada dispositivo se entera de los cambios de
|
|
1313
|
+
// política (quién manda, quién puede qué) sin un canal aparte.
|
|
1314
|
+
// Si el dispositivo estuvo apagado y viene con un `seq` viejo, se le manda la CADENA
|
|
1315
|
+
// que falta (ventana de retención, §1.3) para que compruebe el encadenamiento en vez
|
|
1316
|
+
// de tragarse un salto a ciegas. Si se salió de la ventana, llega vacía y toca
|
|
1317
|
+
// re-emparejar — que es justo lo que debe pasar.
|
|
1318
|
+
// El `.catch()` de antes NO protegía de nada: si `actaHistory` no existe, el
|
|
1319
|
+
// TypeError es SÍNCRONO y salta antes de que haya promesa que encadenar. Pasó: el
|
|
1320
|
+
// método vivía en el núcleo pero no en el envoltorio de Node, así que esta línea
|
|
1321
|
+
// tiraba toda la respuesta y ningún navegador ya emparejado —que manda `sinceSeq`
|
|
1322
|
+
// siempre— volvía a sincronizar su acta. Un try/catch de verdad, y opcional.
|
|
1323
|
+
let chain = null
|
|
1324
|
+
if (typeof p.data?.sinceSeq === 'number') {
|
|
1325
|
+
try { chain = (await identity.actaHistory?.({ sinceSeq: p.data.sinceSeq }))?.chain || null }
|
|
1326
|
+
catch (e) { log('[vault] could not build the record chain:', e.message) }
|
|
1327
|
+
}
|
|
1328
|
+
// `sub` (pubkey completa) va incluida: es la DIRECCIÓN de cada dispositivo en el
|
|
1329
|
+
// proxy → permite a las apps AUTODESCUBRIR tus máquinas (p. ej. la terminal
|
|
1330
|
+
// lista tus agentes sin pegar nada). Solo la ve quien presenta un cert tuyo válido.
|
|
1331
|
+
// QUÉ CORRE CADA UNO, y si cuadra con lo que corremos aquí (CONVENCIONES §14). Va con
|
|
1332
|
+
// la lista porque es donde se mira: el dueño lo pidió en el administrador Y en la
|
|
1333
|
+
// lista de dispositivos de la bóveda. `null` significa «todavía no ha hablado desde
|
|
1334
|
+
// que arrancamos», que es distinto de «no declara» — y se dice distinto.
|
|
1335
|
+
const devices = await Promise.all(issued.map(async (x) => ({
|
|
1336
|
+
deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null, label: x.label || '', scope: x.scope, exp: x.exp, nonce: x.nonce,
|
|
1337
|
+
running: x.sub ? versionDe(x.sub) : null,
|
|
1338
|
+
machine: x.sub ? machineOf(x.sub, record) : null,
|
|
1339
|
+
notifiedAt: x.sub ? notifiable.since(x.sub) : null
|
|
1340
|
+
})))
|
|
1341
|
+
// LA SUBACTA VIAJA CON EL ACTA. Es la mitad de «se comparte entre todos los
|
|
1342
|
+
// dispositivos»: sin esto, una renuncia sin sellar solo la respeta esta máquina.
|
|
1343
|
+
// Cada entrada va firmada por su sujeto, así que quien la recibe la verifica sola.
|
|
1344
|
+
reply(from, fitChain({ type: MSG.DEVICES_RESULT, devices, revoked, blocked: blocked.pubs(), acta: record, chain, sealerChain: await sealerChainOf(), vault: MI_VERSION, subacta: subacta.entries() }))
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* NADA DE FRAMES QUE NO CABEN. El proxio corta a 1 MB y cierra la conexión con un 1009:
|
|
1349
|
+
* la respuesta no llega, la bóveda reconecta y no queda ni una línea de log de su lado.
|
|
1350
|
+
* Así estuvo muda tres días para todo el ecosistema (2026-08-24) mientras el bot social
|
|
1351
|
+
* repetía «no tienes ningún node de contenido enrolado».
|
|
1352
|
+
*
|
|
1353
|
+
* La cadena de actas es lo único que puede crecer sin techo aquí —cada acta es un
|
|
1354
|
+
* snapshot completo de los miembros—, así que se recorta por el FINAL: el tramo que sale
|
|
1355
|
+
* sigue siendo contiguo desde el `seq` del que pregunta, que es lo que necesita para
|
|
1356
|
+
* encadenar, y en la siguiente pregunta seguirá desde donde llegó.
|
|
1357
|
+
*/
|
|
1358
|
+
function fitChain (msg) {
|
|
1359
|
+
const size = (m) => JSON.stringify(m).length
|
|
1360
|
+
if (size(msg) <= MAX_REPLY_BYTES) return msg
|
|
1361
|
+
const full = msg.chain?.length || 0
|
|
1362
|
+
while (msg.chain?.length && size(msg) > MAX_REPLY_BYTES) msg.chain = msg.chain.slice(0, -1)
|
|
1363
|
+
if (!msg.chain?.length) msg.chain = null
|
|
1364
|
+
const left = msg.chain?.length || 0
|
|
1365
|
+
log(`[vault] record chain trimmed to fit the transport: ${left}/${full} link(s), ${size(msg)} bytes`)
|
|
1366
|
+
// Si tras podar la cadena SIGUE sin caber, no se avisa y se manda igual —eso mataba la
|
|
1367
|
+
// conexión—: se devuelve tal cual y `reply` lo sustituye por un error. El aviso de aquí
|
|
1368
|
+
// sobra porque el de `reply` dice lo mismo y además dice qué hizo.
|
|
1369
|
+
return msg
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// RENOVACIÓN automática: un dispositivo con cert VIGENTE y no revocado pide un
|
|
1373
|
+
// cert fresco (misma sub-clave y scope) sin QR ni aprobación — sigue siendo el
|
|
1374
|
+
// mismo dispositivo enrolado, solo extiende la ventana. Un cert vencido o
|
|
1375
|
+
// revocado NO puede renovarse (ahí sí toca re-emparejar con aprobación).
|
|
1376
|
+
async function handleRenew (from, p) {
|
|
1377
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'renew', reason: 'stale' }); return staleReply(from) }
|
|
1378
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
1379
|
+
if (!chk.ok) return denyChain(from, chk, p, 'renew')
|
|
1380
|
+
// Reusar el label del cert original (si sigue registrado en delegations).
|
|
1381
|
+
const { issued } = await identity.listDelegations()
|
|
1382
|
+
const prev = (issued || []).find((x) => x.nonce === p.cert.nonce)
|
|
1383
|
+
// EL SCOPE SALE DEL ACTA, no del cert viejo. El acta es la política (lo que el dueño
|
|
1384
|
+
// decidió con `caps`); el cert es su reflejo, y solo dura 30 días para poder cambiar.
|
|
1385
|
+
// Copiar `p.cert.scope` congelaba la política en el momento del emparejamiento: attach
|
|
1386
|
+
// `administra` no llegaba nunca al cert (la consola remota no podía funcionar) y
|
|
1387
|
+
// QUITARLO tampoco surtía efecto hasta que el cert caducara, hasta un mes después.
|
|
1388
|
+
// Si el miembro ya no está en el acta, no se renueva nada: lo echaron.
|
|
1389
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1390
|
+
// Sin acta no se renueva: caer al scope del papel viejo es reconceder a ciegas lo que
|
|
1391
|
+
// el acta ya no dice. Un repliegue así no protege de nada, tapa.
|
|
1392
|
+
if (!record) {
|
|
1393
|
+
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'sin-acta' })
|
|
1394
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: this vault has no record to decide with' })
|
|
1395
|
+
}
|
|
1396
|
+
const scope = Acta.memberScopes(record, p.cert.sub)
|
|
1397
|
+
if (!scope.length) {
|
|
1398
|
+
audit('rejected', { what: 'renew', device: await deviceIdOf(p.cert.sub), reason: 'not-a-member' })
|
|
1399
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: the record no longer lists this device' })
|
|
1400
|
+
}
|
|
1401
|
+
// RENOVAR NO RETIRA EL PAPEL ANTERIOR (`supersede: false`), y esto costó dos servicios.
|
|
1402
|
+
//
|
|
1403
|
+
// Retirarlo al emitir parece limpio —un aparato, un papel— pero convierte cualquier
|
|
1404
|
+
// renovación que falle DESPUÉS de emitirse en una expulsión permanente: la máquina se
|
|
1405
|
+
// queda con el papel viejo, que acaba de quedar revocado, y ya no puede ni pedir otro.
|
|
1406
|
+
// Pasó dos veces en la migración del VPS (el registro de selladores y el bot social):
|
|
1407
|
+
// el papel nuevo salió, la respuesta no llegó a guardarse, y al reintentar la bóveda
|
|
1408
|
+
// contestaba `unauthorized: revoked`. Re-emparejar a mano, con TTY, por una renovación.
|
|
1409
|
+
//
|
|
1410
|
+
// Que convivan dos papeles del mismo aparato ya no es el problema que era: ninguno
|
|
1411
|
+
// vence, pero lo que pueden hacer lo decide el ACTA en cada mostrador, así que el
|
|
1412
|
+
// viejo no autoriza nada que el nuevo no autorice. Y quitar el aparato de verdad sigue
|
|
1413
|
+
// siendo `revokeDevice`, que los retira TODOS de una vez.
|
|
1414
|
+
const { cert } = await identity.signDelegation(p.cert.sub, scope, { label: prev?.label || '', supersede: false })
|
|
1415
|
+
audit('renew', { device: await deviceIdOf(p.cert.sub), label: prev?.label || '', scope, seq: cert.seq })
|
|
1416
|
+
log(`[vault] cert renewed for ${await deviceIdOf(p.cert.sub)} · record #${cert.seq}`)
|
|
1417
|
+
// El acta viaja con el papel: sin ella quien lo recibe no puede comprobar que lo firmó
|
|
1418
|
+
// una selladora del perfil, que es lo que sustituyó a «lo firmó la maestra».
|
|
1419
|
+
reply(from, { type: MSG.RENEWED, cert, acta: record })
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// SECRETOS de servicios: un servicio enrolado (cert `vault:secrets:<ns>`)
|
|
1423
|
+
// pide el bundle de su namespace — el del SCOPE (que comparten todos los
|
|
1424
|
+
// aparatos que sirven ese ns) con el SUYO PROPIO encima (`secretsStore.js`).
|
|
1425
|
+
// Lo suyo se indexa por la llave que firma esta misma petición, así que no hay
|
|
1426
|
+
// manera de pedir lo de otro aparato. La respuesta va SELLADA a la llave ECDH
|
|
1427
|
+
// efímera `ek` que vino en el sobre firmado (el proxy transporta pero no
|
|
1428
|
+
// puede leer los valores) y el cuerpo va FIRMADO por la maestra (el
|
|
1429
|
+
// servicio verifica contra su iss pineada — un relay no puede inyectar
|
|
1430
|
+
// secretos falsos). Replay inerte: cada petición usa una ek nueva.
|
|
1431
|
+
// data: { op:'secrets', ns, ek, publickey, ts }
|
|
1432
|
+
/**
|
|
1433
|
+
* Un servicio que se enroló antes de que existieran las llaves de cifrado registra la
|
|
1434
|
+
* suya. Es la alternativa a re-enrolarlo: re-enrolar le cambia la pubkey, y su cajón
|
|
1435
|
+
* de variables va indexado por ella — se quedaría sin configuración sin decirlo.
|
|
1436
|
+
*/
|
|
1437
|
+
async function handleEncKey (from, p) {
|
|
1438
|
+
const ns = p.data?.ns
|
|
1439
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'enckey: invalid namespace' })
|
|
1440
|
+
const chk = await verifyChain({
|
|
1441
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1442
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1443
|
+
})
|
|
1444
|
+
if (!chk.ok) return denyChain(from, chk, p, 'enckey')
|
|
1445
|
+
// MISMA FRONTERA QUE SERVIR EL CAJÓN, y por la misma razón: esto no solo apunta una
|
|
1446
|
+
// llave pública, acto seguido le ENVUELVE la del cajón (`spreadKey`, más abajo). Aquí
|
|
1447
|
+
// solo se miraba el certificado, así que a un aparato al que el acta ya le había
|
|
1448
|
+
// quitado `secrets` se le entregaba la llave igual, hasta 30 días. Es el mismo fallo
|
|
1449
|
+
// que se cerró en `handleSign`, en otro mostrador.
|
|
1450
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1451
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1452
|
+
audit('rejected', { what: 'enckey', ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1453
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1454
|
+
}
|
|
1455
|
+
try {
|
|
1456
|
+
await identity.setMemberEncPub({ pub: chk.device, encPub: p.data.encPub })
|
|
1457
|
+
audit('enckey', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
1458
|
+
log(`[vault] ${ns}: encryption key registered for ${await deviceIdOf(chk.device).catch(() => '????-????')}`)
|
|
1459
|
+
// Ya puede recibir sobres: se le envuelve la llave de sus cajones en el acto, o
|
|
1460
|
+
// seguiría sin poder abrir nada hasta la siguiente escritura.
|
|
1461
|
+
//
|
|
1462
|
+
// Va por `recipientsOf` —el único sitio que sabe quién debe tener envoltura de un
|
|
1463
|
+
// cajón—, que incluye a los SELLADORES. Con `nsMembers` se envolvía solo para los del
|
|
1464
|
+
// ns y los selladores se quedaban con lo viejo (dueño, 2026-09-01).
|
|
1465
|
+
//
|
|
1466
|
+
// Y su cajón PROPIO también: un aparato con variables suyas se quedaba sin poder
|
|
1467
|
+
// abrirlas, que es el mismo agujero por otra puerta.
|
|
1468
|
+
for (const owner of [`ns:${ns}`, `dev:${chk.device}`]) {
|
|
1469
|
+
await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }))
|
|
1470
|
+
.catch((e) => log(`[vault] ${owner}: could not hand it the key: ${e.message}`))
|
|
1471
|
+
}
|
|
1472
|
+
// Con la llave de sellado, como todo lo que sirve la bóveda: la maestra no firma con la
|
|
1473
|
+
// bóveda cerrada, y esto tiene que funcionar cerrada.
|
|
1474
|
+
const body = { op: 'secrets.result', ns, enc: null, ok: true, ts: Date.now() }
|
|
1475
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1476
|
+
} catch (e) {
|
|
1477
|
+
reply(from, { type: MSG.ERROR, error: 'enckey: ' + e.message })
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
/**
|
|
1482
|
+
* UN SERVICIO GUARDA VARIABLES EN SU PROPIO CAJÓN (dueño, 2026-09-29: «para facilitar la
|
|
1483
|
+
* migración de ENVs a Dotrino»). Es lo que usa `dotrino-env import`: el `.env` que ya
|
|
1484
|
+
* existe en la máquina del servicio sube tal cual, sin copiarlo a mano en la consola.
|
|
1485
|
+
*
|
|
1486
|
+
* Va por el canal del servicio —el mismo que lee el cajón— y con SUS fronteras: el cert
|
|
1487
|
+
* con `vault:secrets:<ns>` y el CN del acta. Así un servicio solo escribe en su cajón y
|
|
1488
|
+
* nunca en el de otro. La guarda la hace la MISMA pieza que la consola (`varsDesk`): la
|
|
1489
|
+
* firma del autor, las envolturas, la espera de aprobación. Aquí no hay otra puerta.
|
|
1490
|
+
*
|
|
1491
|
+
* Los límites (dueño, 2026-09-29):
|
|
1492
|
+
* · AÑADIR una variable nueva es directo, haya o no quien apruebe (dueño, 2026-10-12);
|
|
1493
|
+
* · EDITAR una que ya existe pide aprobación —`unattended` no exime—;
|
|
1494
|
+
* · si nadie puede aprobar, no se pide y lo que ya existe no se toca (`exists`);
|
|
1495
|
+
* · BORRAR, nunca: eso es de la consola y del CLI de la bóveda.
|
|
1496
|
+
*
|
|
1497
|
+
* Dos operaciones: `store.recipients` (para quién se sella: solo públicas, nada que ocultar)
|
|
1498
|
+
* y `store` (los sobres ya hechos; la bóveda no los abre).
|
|
1499
|
+
*/
|
|
1500
|
+
/**
|
|
1501
|
+
* LA HUELLA DE UN CAJÓN, tal como lo recibiría este aparato: nombres y GENERACIÓN de cada
|
|
1502
|
+
* variable, sin un solo valor. La visibilidad NO entra: taparla no cambia lo que el servicio
|
|
1503
|
+
* lee (y no sube la generación ni avisa). La generación la sube el almacén al
|
|
1504
|
+
* escribir, así que la huella cambia cuando cambia la configuración y no cuando la
|
|
1505
|
+
* bóveda solo rehace envolturas al abrirse. (Una rotación de llaves —al quitar un
|
|
1506
|
+
* aparato— sí la sube: el servicio se reinicia una vez, que es raro y no hace daño.)
|
|
1507
|
+
*/
|
|
1508
|
+
function drawerDigest (ns, devicePub) {
|
|
1509
|
+
const b = secrets.bundleFor(ns, devicePub)
|
|
1510
|
+
const filas = Object.entries(b.entries || {})
|
|
1511
|
+
.map(([k, e]) => [k, b.legacy ? sha256Hex(String(e.v)) : (e.gen ?? null)])
|
|
1512
|
+
.sort((a, c) => (a[0] < c[0] ? -1 : 1))
|
|
1513
|
+
return sha256Hex(JSON.stringify(filas))
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
/**
|
|
1517
|
+
* ¿CAMBIÓ MI CONFIGURACIÓN? Sin llevarse nada (dueño, 2026-09-30: «revisa que no se pidan
|
|
1518
|
+
* aprobaciones sin motivo»).
|
|
1519
|
+
*
|
|
1520
|
+
* El servicio lo pregunta al reconectarse, por si se perdió un aviso. Antes lo preguntaba
|
|
1521
|
+
* pidiendo el cajón ENTERO —privadas incluidas— para sacarle una huella, y eso es un
|
|
1522
|
+
* pedido de claves: a un servicio que pide aprobación le sonaba el teléfono al dueño en
|
|
1523
|
+
* cada despliegue del proxio, para aprobar algo que no entregaba nada que el servicio no
|
|
1524
|
+
* tuviera ya. La huella no revela nada, así que no se aprueba: quien pregunta tiene que
|
|
1525
|
+
* ser igualmente el servicio de ese cajón (cert + CN del acta).
|
|
1526
|
+
*/
|
|
1527
|
+
async function handleDigest (from, p) {
|
|
1528
|
+
const ns = p.data?.ns
|
|
1529
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'digest: invalid namespace' })
|
|
1530
|
+
const chk = await verifyChain({
|
|
1531
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1532
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1533
|
+
})
|
|
1534
|
+
if (!chk.ok) return denyChain(from, chk, p, 'digest')
|
|
1535
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1536
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1537
|
+
audit('rejected', { what: 'digest', ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1538
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1539
|
+
}
|
|
1540
|
+
const body = { op: 'secrets.digest', ns, digest: drawerDigest(ns, chk.device), ts: Date.now() }
|
|
1541
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* UN APARATO PREGUNTA SI PUEDE ACTUALIZARSE (`op: 'update-ask'`, dueño 2026-10-08).
|
|
1546
|
+
*
|
|
1547
|
+
* Pedir aprobación es un interruptor DE CADA AGENTE, independiente del de la bóveda
|
|
1548
|
+
* (`updateApproval` de `src/settings.js` vale solo para la propia bóveda): el agente solo
|
|
1549
|
+
* manda esto cuando lo tiene encendido. Así que aquí no se consulta ningún ajuste:
|
|
1550
|
+
*
|
|
1551
|
+
* · alguien aprueba en este perfil → un pedido `kind: 'update'` a nombre del aparato, que
|
|
1552
|
+
* dura un día; se contesta «pendiente» y el resultado sale cuando alguien decide, por
|
|
1553
|
+
* donde vino la pregunta. Vencido es no.
|
|
1554
|
+
* · nadie aprueba → sí, sin preguntar: esperar un sí que nadie puede dar sería no
|
|
1555
|
+
* actualizarse nunca.
|
|
1556
|
+
*
|
|
1557
|
+
* Lo puede preguntar cualquier miembro del acta: no pide nada que no sea suyo.
|
|
1558
|
+
*/
|
|
1559
|
+
const updateAsks = new Map() // device|product|version → { id, dest } del pedido vivo
|
|
1560
|
+
const updateDecisions = new Map() // device|product|version → { ok: true, exp }: lo ya aprobado
|
|
1561
|
+
const cut = (v, n) => (typeof v === 'string' && v && v.length <= n ? v : null)
|
|
1562
|
+
async function handleUpdateAsk (from, p) {
|
|
1563
|
+
const chk = await verifyChain({
|
|
1564
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1565
|
+
...(await contextoActa()), revoked: await revocationSet()
|
|
1566
|
+
})
|
|
1567
|
+
if (!chk.ok) return denyChain(from, chk, p, 'update-ask')
|
|
1568
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1569
|
+
if (!record || !(record.members || []).some((m) => m.pub === chk.device)) {
|
|
1570
|
+
audit('rejected', { what: 'update-ask', reason: record ? 'acta' : 'sin-acta' })
|
|
1571
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
|
|
1572
|
+
}
|
|
1573
|
+
const product = cut(p.data?.product, 128)
|
|
1574
|
+
const version = cut(p.data?.version, 64)
|
|
1575
|
+
const fromVersion = cut(p.data?.from, 64)
|
|
1576
|
+
if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: 'update-ask: needs `product` and `version`' })
|
|
1577
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1578
|
+
const say = async (dest, extra) => {
|
|
1579
|
+
const body = { op: 'update-ask', product, version, ...extra, ts: Date.now() }
|
|
1580
|
+
reply(dest, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
|
|
1584
|
+
if (!approvers) {
|
|
1585
|
+
audit('update-ask', { device: deviceId, product, version, asked: false })
|
|
1586
|
+
log(`[vault] update-ask: ${deviceId || '????-????'} may update ${product} to ${version} on its own (nobody approves in this profile)`)
|
|
1587
|
+
return say(from, { ok: true, asked: false })
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
const key = `${chk.device}|${product}|${version}`
|
|
1591
|
+
const now = Date.now()
|
|
1592
|
+
for (const [k, d] of updateDecisions) if (d.exp <= now) updateDecisions.delete(k)
|
|
1593
|
+
// YA SE DECIDIÓ y quien preguntó no estaba para oírlo: se le dice lo decidido en vez de
|
|
1594
|
+
// volver a timbrar por lo mismo.
|
|
1595
|
+
const decided = updateDecisions.get(key)
|
|
1596
|
+
if (decided) return say(from, { ok: decided.ok, asked: true })
|
|
1597
|
+
// YA HAY UN PEDIDO VIVO por esto mismo (el agente se reinició y vuelve a preguntar): no se
|
|
1598
|
+
// timbra otra vez; la respuesta irá a quien pregunta ahora.
|
|
1599
|
+
const vivo = updateAsks.get(key)
|
|
1600
|
+
if (vivo && waiters.has(vivo.id)) {
|
|
1601
|
+
vivo.dest = from
|
|
1602
|
+
return say(from, { pending: true, id: vivo.id, exp: vivo.exp, asked: true })
|
|
1603
|
+
}
|
|
1604
|
+
const ask = { id: null, exp: null, dest: from }
|
|
1605
|
+
let pend
|
|
1606
|
+
try {
|
|
1607
|
+
pend = await awaitApproval({
|
|
1608
|
+
device: chk.device, ns: 'vault', kind: 'update', what: 'update',
|
|
1609
|
+
ctx: { product, version, from: fromVersion }, ttlMs: UPDATE_TTL_MS,
|
|
1610
|
+
onAnswer: async (ok) => {
|
|
1611
|
+
updateAsks.delete(key)
|
|
1612
|
+
// Solo se recuerda el SÍ. Un no puede ser un vencimiento (nadie lo vio), y
|
|
1613
|
+
// recordarlo dejaría al aparato un día sin poder volver a preguntar.
|
|
1614
|
+
if (ok === true) updateDecisions.set(key, { ok: true, exp: Date.now() + UPDATE_TTL_MS })
|
|
1615
|
+
await say(ask.dest, { ok: ok === true, asked: true, id: ask.id })
|
|
1616
|
+
}
|
|
1617
|
+
})
|
|
1618
|
+
} catch (e) {
|
|
1619
|
+
log(`[vault] update-ask: could not ask for approval: ${e.message}`)
|
|
1620
|
+
return reply(from, { type: MSG.ERROR, code: 'ask-failed', error: 'update-ask: could not ask for approval' })
|
|
1621
|
+
}
|
|
1622
|
+
ask.id = pend.id; ask.exp = pend.exp
|
|
1623
|
+
updateAsks.set(key, ask)
|
|
1624
|
+
return say(from, { pending: true, id: pend.id, exp: pend.exp, asked: true })
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
/**
|
|
1628
|
+
* UN APARATO DICE QUE SE ACTUALIZÓ (`op: 'update-done'`): la bóveda se lo cuenta a quien
|
|
1629
|
+
* aprueba en este perfil, por el mismo camino que su propio «me actualicé». Si el agente
|
|
1630
|
+
* avisa o no lo decide él (su interruptor); aquí solo se reparte.
|
|
1631
|
+
*
|
|
1632
|
+
* Y su hermano `op: 'update-needs-root'`: el aparato vio una versión nueva y no puede
|
|
1633
|
+
* instalarla solo porque necesita permisos de administrador. Mismo reparto, otro `ev`.
|
|
1634
|
+
*
|
|
1635
|
+
* QUIÉN ES lo dice el acta (id y nombre), no lo que el aparato cuente de sí mismo.
|
|
1636
|
+
*/
|
|
1637
|
+
async function handleUpdateDone (from, p) {
|
|
1638
|
+
const op = p.data.op
|
|
1639
|
+
const ev = op === 'update-needs-root' ? 'update-needs-root' : 'updated'
|
|
1640
|
+
const chk = await verifyChain({
|
|
1641
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1642
|
+
...(await contextoActa()), revoked: await revocationSet()
|
|
1643
|
+
})
|
|
1644
|
+
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1645
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1646
|
+
const member = (record?.members || []).find((m) => m.pub === chk.device)
|
|
1647
|
+
if (!member) {
|
|
1648
|
+
audit('rejected', { what: op, reason: record ? 'acta' : 'sin-acta' })
|
|
1649
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this device is not in the record' })
|
|
1650
|
+
}
|
|
1651
|
+
const product = cut(p.data?.product, 128)
|
|
1652
|
+
const version = cut(p.data?.version, 64)
|
|
1653
|
+
if (!product || !version) return reply(from, { type: MSG.ERROR, code: 'bad-request', error: `${op}: needs \`product\` and \`version\`` })
|
|
1654
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1655
|
+
try {
|
|
1656
|
+
await announceUpdate({ ev, version, from: cut(p.data?.from, 64), product, deviceId, label: member.label || '' })
|
|
1657
|
+
} catch (e) {
|
|
1658
|
+
log(`[vault] ${op}: ${deviceId || '????-????'} reported ${product} ${version}, but the approvers could not be told: ${e.message}`)
|
|
1659
|
+
return reply(from, { type: MSG.ERROR, code: e.code || 'announce-failed', error: `${op}: the approvers could not be told` })
|
|
1660
|
+
}
|
|
1661
|
+
const body = { op, ok: true, product, version, ts: Date.now() }
|
|
1662
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const storeNonces = new Map()
|
|
1666
|
+
async function handleVarStore (from, p) {
|
|
1667
|
+
const ns = p.data?.ns
|
|
1668
|
+
const op = p.data?.op
|
|
1669
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: `${op}: invalid namespace` })
|
|
1670
|
+
const chk = await verifyChain({
|
|
1671
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1672
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1673
|
+
})
|
|
1674
|
+
if (!chk.ok) return denyChain(from, chk, p, op)
|
|
1675
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1676
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1677
|
+
audit('rejected', { what: op, ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1678
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1679
|
+
}
|
|
1680
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1681
|
+
// FIRMADA COMO TODO LO QUE SIRVE LA BÓVEDA (con la llave de sellado que nombra el acta, y
|
|
1682
|
+
// el acta al lado): `store.recipients` dice PARA QUIÉN se sellan los valores, y si el
|
|
1683
|
+
// proxio pudiera cambiar esa lista por sus llaves, abriría las privadas al pasar.
|
|
1684
|
+
const answer = async (body) => reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record })
|
|
1685
|
+
try {
|
|
1686
|
+
if (op === 'store.recipients') {
|
|
1687
|
+
return await answer({
|
|
1688
|
+
op: 'store.recipients', ns, ts: Date.now(),
|
|
1689
|
+
recipients: { public: await varsDesk.recipients({ ns, public: true }), private: await varsDesk.recipients({ ns, public: false }) },
|
|
1690
|
+
// Para que quien importa sepa de antemano qué va a pasar con lo que ya existe.
|
|
1691
|
+
approval: hasApprover(record)
|
|
1692
|
+
})
|
|
1693
|
+
}
|
|
1694
|
+
// CAMBIA ESTADO: un solo uso. La frescura (±5 min) no basta — repetir el mismo sobre
|
|
1695
|
+
// dentro de esa ventana volvería a timbrar el teléfono de quien aprueba.
|
|
1696
|
+
const nonce = p.data?.nonce
|
|
1697
|
+
if (typeof nonce !== 'string' || nonce.length < 16) return reply(from, { type: MSG.ERROR, code: 'no-nonce', error: 'store: missing single-use nonce' })
|
|
1698
|
+
const now = Date.now()
|
|
1699
|
+
for (const [n, exp] of storeNonces) if (exp <= now) storeNonces.delete(n)
|
|
1700
|
+
if (storeNonces.has(nonce)) { audit('rejected', { what: 'store', ns, reason: 'replay' }); return reply(from, { type: MSG.ERROR, code: 'replay', error: 'store: nonce already used' }) }
|
|
1701
|
+
storeNonces.set(nonce, now + 10 * 60 * 1000)
|
|
1702
|
+
|
|
1703
|
+
const items = p.data?.items
|
|
1704
|
+
if (!Array.isArray(items) || !items.length) return reply(from, { type: MSG.ERROR, error: 'store: needs `items`, each with its key and its sealed envelope' })
|
|
1705
|
+
const result = await varsDesk.setMany({ ns, items, caller: chk.device, by: deviceId, fromService: true })
|
|
1706
|
+
if (result?.pending) {
|
|
1707
|
+
audit('store.pending', { device: deviceId, ns, id: result.pending, count: items.length })
|
|
1708
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} wants to store ${items.length} variable(s) — waiting for approval (${result.pending})`)
|
|
1709
|
+
} else {
|
|
1710
|
+
audit('store', { device: deviceId, ns, keys: result?.keys || [] })
|
|
1711
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} stored ${(result?.keys || []).length} new variable(s)`)
|
|
1712
|
+
await notifyMembers('vars', { by: deviceId, keys: result?.keys || [], ns })
|
|
1713
|
+
}
|
|
1714
|
+
return await answer({ op: 'store.result', ns, ts: Date.now(), keys: result?.keys || [], pending: result?.pending || null })
|
|
1715
|
+
} catch (e) {
|
|
1716
|
+
return reply(from, { type: MSG.ERROR, ...(e.code ? { code: e.code } : {}), error: `${op}: ${e.message}` })
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async function handleSecrets (from, p) {
|
|
1721
|
+
if (!isFresh(p.data)) { audit('rejected', { what: 'secrets', reason: 'stale' }); return staleReply(from) }
|
|
1722
|
+
// REGISTRAR LA LLAVE DE CIFRADO de un servicio ya enrolado. Va por aquí, y no por
|
|
1723
|
+
// un mensaje nuevo, para no tocar `protocol.js` — que está vendorizado en el iframe
|
|
1724
|
+
// de identidad y obligaría a re-vendorizar.
|
|
1725
|
+
//
|
|
1726
|
+
// No exige la contraseña del perfil, y el argumento importa: registrar una llave no
|
|
1727
|
+
// da acceso a nada por sí solo. Quien firma esta petición ya tiene la llave de firma
|
|
1728
|
+
// del servicio y su cert, o sea que ya lee ese namespace. No hay escalada.
|
|
1729
|
+
if (p.data?.op === 'enckey') return handleEncKey(from, p)
|
|
1730
|
+
if (p.data?.op === 'store.recipients' || p.data?.op === 'store') return handleVarStore(from, p)
|
|
1731
|
+
if (p.data?.op === 'digest') return handleDigest(from, p)
|
|
1732
|
+
if (p.data?.op === 'update-ask') return handleUpdateAsk(from, p)
|
|
1733
|
+
if (p.data?.op === 'update-done' || p.data?.op === 'update-needs-root') return handleUpdateDone(from, p)
|
|
1734
|
+
if (['approvals', 'approve', 'deny', 'grants', 'grant-revoke', 'block', 'blocked'].includes(p.data?.op)) return handleApproval(from, p)
|
|
1735
|
+
const ns = p.data?.ns
|
|
1736
|
+
if (!isValidSecretsNs(ns)) return reply(from, { type: MSG.ERROR, error: 'secrets: invalid namespace' })
|
|
1737
|
+
if (typeof p.data?.ek !== 'string') return reply(from, { type: MSG.ERROR, error: 'secrets: missing ek (requester ephemeral key)' })
|
|
1738
|
+
const chk = await verifyChain({
|
|
1739
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1740
|
+
expectedScope: secretsScope(ns), ...(await contextoActa()), revoked: await revocationSet()
|
|
1741
|
+
})
|
|
1742
|
+
if (!chk.ok) return denyChain(from, chk, p, 'secrets')
|
|
1743
|
+
// FRONTERA DEL CN (acta): además del scope del cert, el acta tiene que decir que este
|
|
1744
|
+
// miembro es el servicio `ns`. Así el límite no depende solo de qué cert se emitió: la
|
|
1745
|
+
// llave del proxy no ve nada que no sea del proxy, y está escrito donde se puede comprobar.
|
|
1746
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1747
|
+
if (!record || !Acta.memberCanReadSecrets(record, chk.device, ns)) {
|
|
1748
|
+
audit('rejected', { what: 'secrets', ns, reason: record ? 'cn' : 'sin-acta' })
|
|
1749
|
+
return reply(from, { type: MSG.ERROR, error: `unauthorized: cn — the record does not recognise this member as the "${ns}" service` })
|
|
1750
|
+
}
|
|
1751
|
+
// APROBACIÓN: si este APARATO está marcado (`dotrino-vault approval <ID> on`), liberarle
|
|
1752
|
+
// claves privadas exige el visto bueno de un aparato con `approve` — en CADA petición,
|
|
1753
|
+
// que para un servicio bien hecho es una por arranque: pide al (re)iniciar, se queda las
|
|
1754
|
+
// claves en memoria y no vuelve a pedir. El pedido se apunta, se avisa a quien aprueba y se
|
|
1755
|
+
// contesta «pendiente»; la respuesta de verdad sale cuando el teléfono firme
|
|
1756
|
+
// (`handleApproval`), sellada a la misma `ek`.
|
|
1757
|
+
// SOLO PÚBLICAS: NO SE PIDE APROBACIÓN (dueño, 2026-09-01).
|
|
1758
|
+
//
|
|
1759
|
+
// La aprobación existe para soltar CLAVES PRIVADAS. Una variable pública está guardada
|
|
1760
|
+
// en claro —eso es lo que significa marcarla— y ya la ve cualquiera que administre, así
|
|
1761
|
+
// que hacer sonar el teléfono del dueño para entregarla es molestarlo por nada.
|
|
1762
|
+
//
|
|
1763
|
+
// Lo que NO se relaja: quien pide sigue teniendo que ser un miembro identificado, con su
|
|
1764
|
+
// papel y con el `cn` de este cajón (las dos comprobaciones de arriba). Y el filtro lo
|
|
1765
|
+
// hace la BÓVEDA (`bundleFor({ publicOnly })`), no el que pide: si mandáramos todo y el
|
|
1766
|
+
// cliente eligiera, pedir «solo públicas» sería la manera de saltarse la aprobación.
|
|
1767
|
+
const publicOnly = p.data?.publicOnly === true
|
|
1768
|
+
// Entregar SIN aprobación porque no hay quien apruebe es la regla (ver `needsApproval`),
|
|
1769
|
+
// pero se deja dicho: quien mire el log tiene que poder ver por qué no sonó el teléfono.
|
|
1770
|
+
if (!publicOnly && record && !Acta.memberCan(record, chk.device, 'unattended') &&
|
|
1771
|
+
!record.members?.some((m) => Acta.memberCan(record, m.pub, 'approve'))) {
|
|
1772
|
+
audit('secrets.no-approver', { device: await deviceIdOf(chk.device).catch(() => null), ns })
|
|
1773
|
+
log(`[vault] ${ns}: served ${await deviceIdOf(chk.device).catch(() => '????-????')} without approval — nobody in the record can approve`)
|
|
1774
|
+
}
|
|
1775
|
+
if (!publicOnly && needsApproval(chk.device, record)) {
|
|
1776
|
+
const deviceId = await deviceIdOf(chk.device).catch(() => null)
|
|
1777
|
+
const label = (record?.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
1778
|
+
// QUÉ COMANDO, DESDE QUÉ CARPETA — medido contra `/proc` si se puede.
|
|
1779
|
+
let ctx = null
|
|
1780
|
+
try { ctx = contextoDelPedido(p.data?.ctx) } catch (e) {
|
|
1781
|
+
audit('rejected', { what: 'secrets', ns, reason: 'ctx-mismatch' })
|
|
1782
|
+
log(`[vault] ${ns}: refused ${deviceId || '????-????'} — ${e.message}`)
|
|
1783
|
+
return reply(from, { type: MSG.ERROR, code: 'ctx-mismatch', error: 'unauthorized: ' + e.message })
|
|
1784
|
+
}
|
|
1785
|
+
// ¿YA DIJISTE QUE SÍ A ESTE COMANDO? Entonces no se vuelve a timbrar: la concesión
|
|
1786
|
+
// dura una hora DESDE EL ÚLTIMO USO, así que un servicio que sigue pidiendo la
|
|
1787
|
+
// mantiene viva. Cambia el comando o la carpeta y esto ya no encaja: otro pedido.
|
|
1788
|
+
const vigente = grants.allows({ ns, device: chk.device, ctx })
|
|
1789
|
+
if (vigente) {
|
|
1790
|
+
try {
|
|
1791
|
+
const res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly: false })
|
|
1792
|
+
audit('secrets', { device: deviceId, ns, grant: vigente.id })
|
|
1793
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} served without ringing (approved command, ${vigente.uses} use(s))`)
|
|
1794
|
+
return reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
1795
|
+
} catch (e) {
|
|
1796
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
// (Aquí se rechazaba con `no-approver` cuando nadie podía aprobar. Desde 2026-09-24 ese
|
|
1800
|
+
// caso ya no llega: sin aprobadores, `needsApproval` dice que no hace falta aprobación.)
|
|
1801
|
+
// `from` SE GUARDA: la respuesta llega más tarde —cuando alguien apruebe— y tiene que
|
|
1802
|
+
// volver POR DONDE VINO. Si la pregunta entró por el mostrador local, mandarla por el
|
|
1803
|
+
// proxio la deja en el vacío: quien preguntó está escuchando en el socket, no ahí.
|
|
1804
|
+
// Lo cazó el E2E de aprobación, que se quedó esperando cinco minutos.
|
|
1805
|
+
const pend = approvals.request({ ns, device: chk.device, deviceId, label, ek: p.data.ek, ctx, from })
|
|
1806
|
+
audit('secrets.pending', { device: deviceId, ns, id: pend.id })
|
|
1807
|
+
log(`[vault] ${ns}: ${deviceId || '????-????'} is waiting for approval (${pend.id})` +
|
|
1808
|
+
(ctx ? ` — ${commandLine(ctx, 80)} [${ctx.cwd || '?'}]` : ' — it does not say what it is running'))
|
|
1809
|
+
const body = { op: 'secrets.pending', ns, id: pend.id, exp: pend.exp, ts: Date.now() }
|
|
1810
|
+
// El acta viaja también aquí: es con lo que el agente sabe qué llave podía firmar
|
|
1811
|
+
// esto. En `secrets.result` va dentro del sobre; en un «pendiente» no hay sobre.
|
|
1812
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body), acta: record || null })
|
|
1813
|
+
await notifyApprovers(pend, record)
|
|
1814
|
+
return
|
|
1815
|
+
}
|
|
1816
|
+
let res
|
|
1817
|
+
try { res = await resultFor(ns, chk.device, p.data.ek, record, { publicOnly }) } catch (e) {
|
|
1818
|
+
return reply(from, { type: MSG.ERROR, error: 'secrets: invalid ek' })
|
|
1819
|
+
}
|
|
1820
|
+
audit('secrets', { device: await deviceIdOf(chk.device), ns, ...(publicOnly ? { publicOnly: true } : {}) })
|
|
1821
|
+
reply(from, { type: MSG.SECRETS_RESULT, ...res })
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/** El bundle de `ns` para `devicePub`, sellado a su `ek` y firmado por la llave de sellado. */
|
|
1825
|
+
async function resultFor (ns, devicePub, ek, record, { publicOnly = false } = {}) {
|
|
1826
|
+
// Mientras el archivo siga en v3 el cable NO cambia: se mandan los valores como
|
|
1827
|
+
// siempre. Solo tras la migración viajan sobres, y entonces quien los abre es el
|
|
1828
|
+
// agente con su llave. Así el despliegue del daemon se deshace con un reinicio,
|
|
1829
|
+
// porque hasta el primer desbloqueo no ha cambiado nada de lo que ve nadie.
|
|
1830
|
+
const b = secrets.bundleFor(ns, devicePub, { publicOnly })
|
|
1831
|
+
// EL ACTA VIAJA CON EL BUNDLE (§8.8): es lo que le permite al agente comprobar que
|
|
1832
|
+
// los sobres los selló esta bóveda, y con qué llave —la que el acta nombra para el
|
|
1833
|
+
// `seq` con el que se firmaron—. No es un dato secreto: el acta es pública dentro
|
|
1834
|
+
// del perfil y el agente ya es miembro. Sin ella podría abrir igual, pero no sabría
|
|
1835
|
+
// de dónde salió lo que abre.
|
|
1836
|
+
// El acta va en LOS DOS caminos, también en el v3 heredado: ya no sirve solo para
|
|
1837
|
+
// decir de dónde salieron los sobres, sino para saber qué llave podía firmar esta
|
|
1838
|
+
// respuesta. Sin ella el agente no puede comprobar quién le contestó.
|
|
1839
|
+
const payload = b.legacy
|
|
1840
|
+
? { secrets: Object.fromEntries(Object.entries(b.entries).map(([k, e]) => [k, e.v])), acta: record || null }
|
|
1841
|
+
: { sealed: b, acta: record || null }
|
|
1842
|
+
// LO QUE SE MANDA CON AGUJEROS, SE DICE. La bóveda sabe aquí mismo que este aparato no
|
|
1843
|
+
// tiene envoltura de tal variable —`missingFor` lo contesta—, y hasta hoy se callaba:
|
|
1844
|
+
// contestaba un bundle que el agente no podía abrir y el registro decía «aprobado»,
|
|
1845
|
+
// como si se hubiera servido. Desde fuera parecía que el pedido funcionaba y volvía a
|
|
1846
|
+
// aparecer una y otra vez. Falta la envoltura, y eso solo se arregla ABRIENDO la
|
|
1847
|
+
// bóveda; decirlo aquí es lo que convierte un bucle mudo en algo que se puede arreglar.
|
|
1848
|
+
if (!b.legacy && !publicOnly) {
|
|
1849
|
+
const faltan = [...secrets.missingFor(`ns:${ns}`, devicePub), ...secrets.missingFor(`dev:${devicePub}`, devicePub)]
|
|
1850
|
+
if (faltan.length) {
|
|
1851
|
+
const quien = await deviceIdOf(devicePub).catch(() => '????-????')
|
|
1852
|
+
audit('secrets.no-wrapping', { device: quien, ns, vars: faltan })
|
|
1853
|
+
log(`[vault] ${ns}: ${quien} has no wrapping for ${faltan.join(', ')} — open the vault (dotrino-vault unlock) so it hands it the key`)
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const enc = await seal({ ek, payload })
|
|
1857
|
+
// La huella va con cada entrega: es la referencia con la que el servicio compara después
|
|
1858
|
+
// (`digest`) sin volver a pedir claves.
|
|
1859
|
+
const body = { op: 'secrets.result', ns, enc, digest: drawerDigest(ns, devicePub), ts: Date.now() }
|
|
1860
|
+
// LA MAESTRA NO FIRMA ESTO. Su trabajo es sellar el acta y reenvolver sobres; servir
|
|
1861
|
+
// no es suyo. Quien firma es la LLAVE DE SELLADO que el acta nombra (`sealPub`), la
|
|
1862
|
+
// misma que ya firma cada sobre — y por eso esto se puede servir con el perfil
|
|
1863
|
+
// bloqueado, y algún día desde una réplica que no tiene la maestra ni debe tenerla.
|
|
1864
|
+
return { body, seal: await sealOrFail(body) }
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* Firma con la llave de sellado, o revienta. No hay repliegue a la maestra: si el acta
|
|
1869
|
+
* no nombra una llave de sellado que sea nuestra, esta bóveda no está en condiciones de
|
|
1870
|
+
* servir, y decirlo es mejor que servir firmado por quien no toca.
|
|
1871
|
+
*/
|
|
1872
|
+
async function sealOrFail (body) {
|
|
1873
|
+
const s = await signSeal(body)
|
|
1874
|
+
if (!s) throw new Error('this vault has no sealing key named by the record: it cannot serve')
|
|
1875
|
+
return s
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
/**
|
|
1879
|
+
* PEDIDOS DE APROBACIÓN (aparatos marcados con `approval on`). Entran por `vault.secrets` con
|
|
1880
|
+
* `op: approvals | approve | deny`, firmados por un aparato al que el ACTA le da `approve`
|
|
1881
|
+
* — que, como `admin`, no se empareja: se concede a mano (`caps <ID> +aprueba`).
|
|
1882
|
+
*
|
|
1883
|
+
* DECIDE EL ACTA, NO EL PAPEL (dueño, 2026-09-26). El papel solo prueba que el aparato es
|
|
1884
|
+
* de esta bóveda y no está revocado; por eso no se le pide `vault:approve`. Pedírselo
|
|
1885
|
+
* contestaba `unauthorized: scope` a un teléfono SIN el permiso, el teléfono lo tomaba por
|
|
1886
|
+
* un papel atrasado, pedía renovarlo, y renovar firma con la maestra: con la bóveda
|
|
1887
|
+
* cerrada le llegaba «vault locked», que no era la causa. Ahora le llega `acta`, que sí lo
|
|
1888
|
+
* es. Y a uno CON el permiso ya no le hace falta renovar para aprobar: funciona con la
|
|
1889
|
+
* bóveda cerrada, como todos los aparatos.
|
|
1890
|
+
*/
|
|
1891
|
+
async function handleApproval (from, p) {
|
|
1892
|
+
const op = p.data?.op
|
|
1893
|
+
const chk = await verifyChain({
|
|
1894
|
+
data: p.data, signature: p.signature, cert: p.cert,
|
|
1895
|
+
...(await contextoActa()), revoked: await revocationSet()
|
|
1896
|
+
})
|
|
1897
|
+
if (!chk.ok) return denyChain(from, chk, p, 'approval')
|
|
1898
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
1899
|
+
if (!record || !Acta.memberCan(record, chk.device, 'approve')) {
|
|
1900
|
+
audit('rejected', { what: 'approval', reason: record ? 'acta' : 'sin-acta' })
|
|
1901
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member does not approve' })
|
|
1902
|
+
}
|
|
1903
|
+
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
1904
|
+
const answer = async (body) => {
|
|
1905
|
+
body = { ...body, ts: Date.now() }
|
|
1906
|
+
reply(from, { type: MSG.SECRETS_RESULT, body, seal: await sealOrFail(body) })
|
|
1907
|
+
}
|
|
1908
|
+
// AL PEDIR SU LISTA, EL APROBADOR DICE SI LE LLEGAN LOS AVISOS (`notify`). Va dentro de lo
|
|
1909
|
+
// que ya firma, así que no hay mensaje nuevo ni nada que el proxio pueda inventar.
|
|
1910
|
+
if (op === 'approvals' && typeof p.data?.notify === 'boolean') {
|
|
1911
|
+
try { notifiable.declare(chk.device, p.data.notify) } catch (e) { log(`[vault] could not record whether ${by || '????-????'} can be notified: ${e.message}`) }
|
|
1912
|
+
}
|
|
1913
|
+
// `notices`: lo que no es un pedido y quien aprueba tiene que saber (la bóveda se
|
|
1914
|
+
// actualizó). El timbre de una app nativa llega vacío, así que lo lee de aquí.
|
|
1915
|
+
if (op === 'approvals') return answer({ op: 'approvals', items: await conContextoSellado(approvals.list(), chk.device, record), notices: notices.list() })
|
|
1916
|
+
// LO QUE YA ESTÁ APROBADO SE VE Y SE CORTA DESDE DONDE SE APROBÓ. Una concesión que se
|
|
1917
|
+
// renueva con cada uso puede durar indefinidamente: si no se puede mirar ni quitar desde
|
|
1918
|
+
// el mismo sitio donde se dio, es un permiso invisible, y eso no lo damos por bueno.
|
|
1919
|
+
if (op === 'grants') return answer({ op: 'grants', items: await conContextoSellado(grants.list(), chk.device, record), ttlMs: GRANT_TTL_MS })
|
|
1920
|
+
// LOS BLOQUEADOS SE VEN desde donde se bloquean. Desbloquear no: eso es de la bóveda
|
|
1921
|
+
// (`dotrino-vault unblock <ID>`) o del administrador, que es lo que pidió el dueño.
|
|
1922
|
+
if (op === 'blocked') return answer({ op: 'blocked', items: blocked.list() })
|
|
1923
|
+
if (op === 'grant-revoke') {
|
|
1924
|
+
const gid = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1925
|
+
const ok = gid ? grants.revoke(gid) : false
|
|
1926
|
+
if (ok) {
|
|
1927
|
+
audit('secrets.grant.revoked', { id: gid, by })
|
|
1928
|
+
log(`[vault] grant ${gid} revoked by ${by} — that command will ring again`)
|
|
1929
|
+
}
|
|
1930
|
+
return answer({ op: 'grant-revoke.result', id: gid, ok })
|
|
1931
|
+
}
|
|
1932
|
+
const id = typeof p.data?.id === 'string' ? p.data.id : ''
|
|
1933
|
+
const pend = approvals.take(id)
|
|
1934
|
+
if (!pend) return reply(from, { type: MSG.ERROR, error: 'approval: unknown or expired request' })
|
|
1935
|
+
// UN INCIDENTE no entrega nada: se contesta BLOQUEAR (`block`) o IGNORAR (`deny`).
|
|
1936
|
+
// `approve` no significa nada aquí y se rechaza en voz alta: el pedido vuelve a la mesa
|
|
1937
|
+
// para que lo conteste con la palabra correcta.
|
|
1938
|
+
if (pend.kind === 'incident') {
|
|
1939
|
+
if (op !== 'block' && op !== 'deny') {
|
|
1940
|
+
approvals.request({ ...pend, ttlMs: Math.max(1000, pend.exp - Date.now()) })
|
|
1941
|
+
return reply(from, { type: MSG.ERROR, error: 'approval: an incident is answered with block or deny' })
|
|
1942
|
+
}
|
|
1943
|
+
if (op === 'deny') {
|
|
1944
|
+
audit('incident.ignored', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by })
|
|
1945
|
+
log(`[vault] incident ${id} about ${pend.deviceId} ignored by ${by}`)
|
|
1946
|
+
return answer({ op: 'deny.result', id, ok: true })
|
|
1947
|
+
}
|
|
1948
|
+
const b = blocked.block({ pub: pend.device, deviceId: pend.deviceId, label: pend.label, by, reason: pend.ctx?.kind || 'incident' })
|
|
1949
|
+
// Lo que ya tenía concedido se corta en el acto: bloquear y dejarle pasar la hora que
|
|
1950
|
+
// le quedaba sería un bloqueo de mentira.
|
|
1951
|
+
const cut = grants.revokeAll({ device: pend.device })
|
|
1952
|
+
audit('blocked', { device: pend.deviceId, kind: pend.ctx?.kind || null, id, by, grants: cut })
|
|
1953
|
+
log(`[vault] BLOCKED ${pend.deviceId}${pend.label ? ` (${pend.label})` : ''} by ${by} after incident ${id} · unblock with: dotrino-vault unblock ${pend.deviceId}`)
|
|
1954
|
+
// Se avisa a todos: los agentes (la terminal) traen la lista con el acta al recibirlo
|
|
1955
|
+
// y le cierran la sesión que tenga abierta. Sin esto el bloqueo esperaba al tic de 5 min.
|
|
1956
|
+
await notifyMembers('blocked', { deviceId: pend.deviceId, by })
|
|
1957
|
+
return answer({ op: 'block.result', id, ok: true, deviceId: pend.deviceId, since: b.since })
|
|
1958
|
+
}
|
|
1959
|
+
if (op === 'block') return reply(from, { type: MSG.ERROR, error: 'approval: only an incident can be blocked' })
|
|
1960
|
+
// PEDIDOS QUE NO ENTREGAN UN CAJÓN (la bóveda de contraseñas, guardar variables). Se
|
|
1961
|
+
// resuelven ANTES de tocar `resultFor`, que asume un cajón y una `ek`: aquí no hay nada
|
|
1962
|
+
// que sellar, solo una promesa esperando un sí o un no.
|
|
1963
|
+
if (waiters.has(id)) {
|
|
1964
|
+
const resolver = waiters.get(id)
|
|
1965
|
+
waiters.delete(id)
|
|
1966
|
+
const ok = op === 'approve'
|
|
1967
|
+
const what = pend.kind === 'write' ? 'vars' : pend.kind === 'update' ? 'update' : 'passwords'
|
|
1968
|
+
audit(ok ? `${what}.approved` : `${what}.denied`, { device: pend.deviceId, ns: pend.ns, id, by })
|
|
1969
|
+
log(`[vault] ${what}: request ${pend.id} of ${pend.deviceId} ${ok ? 'approved' : 'DENIED'} by ${by}`)
|
|
1970
|
+
// Lo aprobado SE HACE AQUÍ (una escritura se aplica ahora), y si falla quien aprobó
|
|
1971
|
+
// tiene que enterarse: decirle «hecho» a algo que no se guardó es el peor resultado.
|
|
1972
|
+
try { await resolver(ok) } catch (e) {
|
|
1973
|
+
log(`[vault] ${what}: ${pend.id} was approved but could not be applied: ${e.message}`)
|
|
1974
|
+
audit(`${what}.apply-failed`, { id, reason: e.message })
|
|
1975
|
+
return reply(from, { type: MSG.ERROR, error: `approval: approved, but it could not be applied: ${e.message}` })
|
|
1976
|
+
}
|
|
1977
|
+
return answer({ op: `${op}.result`, id, ok: true })
|
|
1978
|
+
}
|
|
1979
|
+
if (op === 'deny') {
|
|
1980
|
+
audit('secrets.denied', { device: pend.deviceId, ns: pend.ns, id, by })
|
|
1981
|
+
log(`[vault] ${pend.ns}: request of ${pend.deviceId} DENIED by ${by}`)
|
|
1982
|
+
try {
|
|
1983
|
+
const err = { type: MSG.ERROR, error: `unauthorized: denied — the "${pend.ns}" request was denied from ${by}` }
|
|
1984
|
+
const porLocal = pend.from && canales.get(pend.from)
|
|
1985
|
+
if (porLocal) porLocal(err)
|
|
1986
|
+
else client.sendByPubkey(pend.device, err)
|
|
1987
|
+
} catch (e) { log(`[vault] ${pend.ns}: could not tell ${pend.deviceId} it was denied: ${e.message}`) }
|
|
1988
|
+
return answer({ op: 'deny.result', id, ok: true })
|
|
1989
|
+
}
|
|
1990
|
+
if (op !== 'approve') return reply(from, { type: MSG.ERROR, error: 'approval: unknown op' })
|
|
1991
|
+
let res
|
|
1992
|
+
try { res = await resultFor(pend.ns, pend.device, pend.ek, record) } catch (e) {
|
|
1993
|
+
return reply(from, { type: MSG.ERROR, error: 'approval: could not seal the reply: ' + e.message })
|
|
1994
|
+
}
|
|
1995
|
+
// LA HORA EMPIEZA AQUÍ, y se estira con cada uso: decir que sí a este comando vale para
|
|
1996
|
+
// las siguientes veces que lo pida ESE MISMO comando desde ESA MISMA carpeta. Un pedido
|
|
1997
|
+
// que no dijo qué corría no deja nada anotado, y se dice por qué: si no, el dueño vería
|
|
1998
|
+
// que unos pedidos dejan de timbrar y otros no, sin ninguna explicación a mano.
|
|
1999
|
+
const grant = grants.grant({ ns: pend.ns, device: pend.device, deviceId: pend.deviceId, ctx: pend.ctx, by })
|
|
2000
|
+
audit('secrets.approved', { device: pend.deviceId, ns: pend.ns, id, by, ...(grant ? { grant: grant.id } : {}) })
|
|
2001
|
+
log(`[vault] ${pend.ns}: request of ${pend.deviceId} approved by ${by}`)
|
|
2002
|
+
log(grant
|
|
2003
|
+
? `[vault] ${pend.ns}: "${commandLine(pend.ctx, 80)}" approved for ${Math.round(GRANT_TTL_MS / 60000)} min from its last use (grant ${grant.id})`
|
|
2004
|
+
: `[vault] ${pend.ns}: no grant — this request did not say what it is running, so the next one will ring again`)
|
|
2005
|
+
// Va por `sendByPubkey`: si el que pedía ya no está conectado, lo recoge al volver.
|
|
2006
|
+
// EL FALLO NO SE TRAGA. Tragarlo era lo peor de este mostrador: la bóveda apuntaba
|
|
2007
|
+
// «aprobado», el que pedía no recibía nada, y no quedaba una sola línea que mirar —
|
|
2008
|
+
// así que el dueño aprobaba una y otra vez creyendo que el teléfono no llegaba.
|
|
2009
|
+
// Por el canal por el que preguntó. Si fue el proxio, `sendByPubkey`: así lo recoge al
|
|
2010
|
+
// volver aunque ahora esté apagado. Si fue el socket local, por el socket.
|
|
2011
|
+
try {
|
|
2012
|
+
const porLocal = pend.from && canales.get(pend.from)
|
|
2013
|
+
if (porLocal) porLocal({ type: MSG.SECRETS_RESULT, ...res })
|
|
2014
|
+
else client.sendByPubkey(pend.device, { type: MSG.SECRETS_RESULT, ...res })
|
|
2015
|
+
} catch (e) { log(`[vault] ${pend.ns}: approved, but the reply did not reach ${pend.deviceId}: ${e.message}`) }
|
|
2016
|
+
return answer({ op: 'approve.result', id, ok: true })
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
/**
|
|
2020
|
+
* EL COMANDO Y EL PATH VIAJAN DENTRO DE UN SOBRE, no en claro.
|
|
2021
|
+
*
|
|
2022
|
+
* La lista de pedidos sale hacia el teléfono por el proxio, que no cifra (CONVENCIONES
|
|
2023
|
+
* §4.1): mandar ahí el `argv` y el `cwd` le enseñaría al que opera el proxio las rutas del
|
|
2024
|
+
* disco del dueño y los argumentos de sus comandos, que es justo lo que este ecosistema
|
|
2025
|
+
* promete que no pasa. Así que el contexto se cifra **para el aparato que está
|
|
2026
|
+
* preguntando** —con la misma cripto de los secretos sellados: una llave de una vez,
|
|
2027
|
+
* envuelta a su `encPub` del acta— y él lo abre con `openSealedValue`, con una privada que
|
|
2028
|
+
* no sale de su dispositivo.
|
|
2029
|
+
*
|
|
2030
|
+
* Un aparato sin `encPub` en el acta no recibe el contexto, y se dice (`ctxSealed: false`)
|
|
2031
|
+
* en vez de mandárselo en claro «para que al menos lo vea»: eso sería cambiar la promesa
|
|
2032
|
+
* por comodidad, y encima sin avisar a nadie.
|
|
2033
|
+
*/
|
|
2034
|
+
async function conContextoSellado (items, aprobador, record) {
|
|
2035
|
+
const encPub = (record?.members || []).find((m) => m.pub === aprobador)?.encPub || null
|
|
2036
|
+
const salida = []
|
|
2037
|
+
for (const it of items) {
|
|
2038
|
+
const { ctx, ...resto } = it
|
|
2039
|
+
if (!ctx) { salida.push({ ...resto, ctx: null }); continue }
|
|
2040
|
+
if (!encPub) { salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'no-enc-key' }); continue }
|
|
2041
|
+
try {
|
|
2042
|
+
const cek = await makeContentKey()
|
|
2043
|
+
const envelope = await encryptWithCek({ cek, gen: 0, plaintext: JSON.stringify(ctx) })
|
|
2044
|
+
const wrap = await wrapForMember({ cek, memberEncPub: encPub })
|
|
2045
|
+
salida.push({ ...resto, ctx: null, ctxSealed: true, ctxWrap: wrap, ctxEnvelope: envelope })
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
// NO SE TRAGA: sin esto, un fallo al sellar se vería en el teléfono igual que un
|
|
2048
|
+
// pedido que no dice nada, y nadie sabría que hay algo que arreglar.
|
|
2049
|
+
log(`[vault] could not seal the command of ${it.id} for ${aprobador.slice(0, 24)}…: ${e.message}`)
|
|
2050
|
+
salida.push({ ...resto, ctx: null, ctxSealed: false, ctxReason: 'seal-failed' })
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
return salida
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* Aviso a los aparatos que aprueban (cola del proxio → timbre nativo si están apagados).
|
|
2058
|
+
*
|
|
2059
|
+
* LA MAESTRA NO FIRMA ESTO, y es el fallo que dejaba el mecanismo entero inservible.
|
|
2060
|
+
* Avisar es SERVIR, y servir es trabajo de la llave de sellado —la maestra solo sella el
|
|
2061
|
+
* acta y reenvuelve los sobres al abrir (CLAUDE.md, «la maestra tiene DOS trabajos»)—.
|
|
2062
|
+
* Con `identity.signData` esto reventaba con «vault locked» en cada pedido, o sea SIEMPRE:
|
|
2063
|
+
* la bóveda vive cerrada, que es justamente para lo que sirve el candado. Y como reventaba
|
|
2064
|
+
* antes de mandar nada, el proxio no tenía a quién encolar y el teléfono NO TIMBRABA
|
|
2065
|
+
* NUNCA. El pedido solo se veía si al dueño se le ocurría abrir la app por su cuenta.
|
|
2066
|
+
*
|
|
2067
|
+
* El aviso no lleva nada que no esté ya en el pedido, y el timbre que sale de aquí sigue
|
|
2068
|
+
* sin contenido: quien lo recibe pregunta por la lista, no la lee del aviso.
|
|
2069
|
+
*/
|
|
2070
|
+
async function notifyApprovers (pend, record) {
|
|
2071
|
+
try {
|
|
2072
|
+
// `kind` dice QUÉ se pide (llevarse claves, guardar variables, actualizar…): es lo que la
|
|
2073
|
+
// app necesita para que el aviso diga el porqué sin tener que abrir la lista. Los
|
|
2074
|
+
// detalles (comando, carpeta, nombres) NO van aquí: van sellados en la lista.
|
|
2075
|
+
const body = { ev: 'approval', id: pend.id, ns: pend.ns, kind: pend.kind || 'read', deviceId: pend.deviceId, label: pend.label, exp: pend.exp, ts: Date.now() }
|
|
2076
|
+
const seal = await sealOrFail(body)
|
|
2077
|
+
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
2078
|
+
if (!who.length) log(`[vault] ${pend.ns}: nobody can approve (grant it with: dotrino-vault caps <ID> +aprueba)`)
|
|
2079
|
+
// EL FALLO NO SE TRAGA. Un aviso que no sale es un teléfono que no timbra, y eso
|
|
2080
|
+
// desde fuera se ve igual que «nadie ha aprobado todavía»: se espera para siempre
|
|
2081
|
+
// sin nada que mirar. Es el mismo cuidado que en `notifyMembers`.
|
|
2082
|
+
for (const pub of who) {
|
|
2083
|
+
// A la app que APRUEBA (`vault`): en un teléfono la llave es de todas sus apps, y sin
|
|
2084
|
+
// decirlo el pedido timbraba a messenger (websocket-proxy ≥ 1.4.0).
|
|
2085
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }) }
|
|
2086
|
+
catch (e) { log(`[vault] could not ring ${pub.slice(0, 24)}… about ${pend.ns}: ${e.message}`) }
|
|
2087
|
+
}
|
|
2088
|
+
log(`[vault] ${pend.ns}: rang ${who.length} approver(s) for ${pend.id}`)
|
|
2089
|
+
} catch (e) { log('[vault] could not notify approvers:', e.message) }
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
/**
|
|
2093
|
+
* «ME ACTUALICÉ», a todo el que aprueba (dueño, 2026-10-08). Vale con la aprobación
|
|
2094
|
+
* encendida o apagada: lo que se instala solo también se cuenta.
|
|
2095
|
+
*
|
|
2096
|
+
* Lo manda la versión NUEVA ya corriendo —el daemon llama aquí al arrancar, si dejó dicho
|
|
2097
|
+
* que instaló—, que es la prueba de que ocurrió. El aviso queda además en `notices`, de
|
|
2098
|
+
* donde lo lee una app nativa (su timbre llega vacío).
|
|
2099
|
+
*
|
|
2100
|
+
* También reparte el «me actualicé» de un APARATO (`op: 'update-done'`): entonces lleva su
|
|
2101
|
+
* `product`, `deviceId` y `label`.
|
|
2102
|
+
*
|
|
2103
|
+
* Y con `ev: 'update-needs-root'` cuenta lo contrario: hay versión nueva y NO se instaló
|
|
2104
|
+
* sola porque necesita permisos de administrador (dueño, 2026-10-08: «si se requiere root
|
|
2105
|
+
* por algún motivo, simplemente se notifica al aprobador»). Mismo camino, otro `ev`.
|
|
2106
|
+
*
|
|
2107
|
+
* A diferencia de `notifyApprovers`, aquí el fallo SE LANZA: quien llama guarda el
|
|
2108
|
+
* marcador hasta que el aviso salió, y tragarlo sería borrarlo sin haber avisado.
|
|
2109
|
+
* @returns {Promise<{ approvers: number, sent: number, notice: object|null }>}
|
|
2110
|
+
*/
|
|
2111
|
+
async function announceUpdate ({ version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null, ev = 'updated' }) {
|
|
2112
|
+
const record = await refreshActa()
|
|
2113
|
+
const who = (record?.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).map((m) => m.pub)
|
|
2114
|
+
if (!who.length) return { approvers: 0, sent: 0, notice: null }
|
|
2115
|
+
// `deviceId`/`label`: solo cuando quien se actualizó es un APARATO (`update-done`). El
|
|
2116
|
+
// aviso de la propia bóveda no los lleva.
|
|
2117
|
+
const notice = notices.add(ev, { version, from, product, ...(deviceId ? { deviceId, label: label || '' } : {}) })
|
|
2118
|
+
const { id, ev: _ev, ts, ...rest } = notice
|
|
2119
|
+
const body = { ev, id, ...rest, ts }
|
|
2120
|
+
const seal = await sealOrFail(body)
|
|
2121
|
+
let sent = 0
|
|
2122
|
+
for (const pub of who) {
|
|
2123
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, seal }, { app: 'vault' }); sent++ }
|
|
2124
|
+
catch (e) { log(`[vault] could not tell ${pub.slice(0, 24)}… about the update of ${product} to ${version}: ${e.message}`) }
|
|
2125
|
+
}
|
|
2126
|
+
audit(ev === 'updated' ? 'update.announced' : 'update.needs-root', { product, version, from: notice.from, device: deviceId, approvers: who.length, sent })
|
|
2127
|
+
log(`[vault] update: told ${sent} of ${who.length} approver(s) that ${deviceId ? `${deviceId} (${product})` : 'this vault'} ${ev === 'updated' ? 'now runs' : 'needs administrator rights to install'} ${version}`)
|
|
2128
|
+
if (!sent) throw Object.assign(new Error('no approver could be told'), { code: 'announce-failed' })
|
|
2129
|
+
return { approvers: who.length, sent, notice }
|
|
2130
|
+
}
|
|
2131
|
+
|
|
2132
|
+
/**
|
|
2133
|
+
* UN INCIDENTE: un agente de la cuenta dice que OTRO aparato de la cuenta se equivocó de
|
|
2134
|
+
* clave tres veces seguidas (la terminal, `dotrino-terminal lock`). No se decide aquí
|
|
2135
|
+
* nada: se apunta como un pedido más, de tipo `incident`, y se timbra a quien aprueba,
|
|
2136
|
+
* que es quien bloquea o ignora desde el teléfono. Sin nadie que apruebe no hay a quién
|
|
2137
|
+
* preguntar: se anota en la bitácora y se dice, y el agente se queda con su freno local.
|
|
2138
|
+
*
|
|
2139
|
+
* Quien reporta tiene que ser un miembro con `sign` (es lo que lo hace agente), y el
|
|
2140
|
+
* señalado tiene que estar en el acta: un incidente sobre un desconocido no es un incidente.
|
|
2141
|
+
* Y si el señalado ya está bloqueado no se vuelve a timbrar: se contesta que lo está.
|
|
2142
|
+
*/
|
|
2143
|
+
async function handleIncident (from, p) {
|
|
2144
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
2145
|
+
const chk = await verifyChain({ data: p.data, signature: p.signature, cert: p.cert, ...(await contextoActa()), revoked: await revocationSet() })
|
|
2146
|
+
if (!chk.ok) return denyChain(from, chk, p, 'incident')
|
|
2147
|
+
const record = await refreshActa()
|
|
2148
|
+
if (!record || !Acta.memberCan(record, chk.device, 'sign')) {
|
|
2149
|
+
audit('rejected', { what: 'incident', reason: record ? 'acta' : 'sin-acta' })
|
|
2150
|
+
return reply(from, { type: MSG.ERROR, error: 'unauthorized: acta — this member cannot report incidents' })
|
|
2151
|
+
}
|
|
2152
|
+
const d = p.data || {}
|
|
2153
|
+
const kind = typeof d.kind === 'string' ? d.kind.slice(0, 32) : ''
|
|
2154
|
+
const about = typeof d.about === 'string' ? d.about : ''
|
|
2155
|
+
const tries = Number.isInteger(d.tries) ? d.tries : null
|
|
2156
|
+
if (d.op !== 'incident' || !kind || !about) return reply(from, { type: MSG.ERROR, error: 'incident: op, kind and about are required' })
|
|
2157
|
+
const target = (record.members || []).find((m) => m.pub === about)
|
|
2158
|
+
if (!target) return reply(from, { type: MSG.ERROR, code: 'unknown-device', error: 'incident: that device is not in the record' })
|
|
2159
|
+
const by = await deviceIdOf(chk.device).catch(() => null)
|
|
2160
|
+
const byLabel = (record.members || []).find((m) => m.pub === chk.device)?.label || ''
|
|
2161
|
+
const deviceId = await deviceIdOf(about).catch(() => null)
|
|
2162
|
+
audit('incident', { device: deviceId, by, kind, tries })
|
|
2163
|
+
if (blocked.has(about)) {
|
|
2164
|
+
log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: already blocked`)
|
|
2165
|
+
return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: true })
|
|
2166
|
+
}
|
|
2167
|
+
const approvers = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'approve')).length
|
|
2168
|
+
if (!approvers) {
|
|
2169
|
+
log(`[vault] incident (${kind}) about ${deviceId} reported by ${by}: nobody can approve, so nobody is asked (grant it with: dotrino-vault caps <ID> +aprueba)`)
|
|
2170
|
+
return reply(from, { type: MSG.INCIDENT_RESULT, id: null, approvers: 0, blocked: false })
|
|
2171
|
+
}
|
|
2172
|
+
// `ns` es el que reporta: la pantalla dice «X falló la clave de la terminal de Y». El
|
|
2173
|
+
// contexto va sellado al aprobador como el de cualquier pedido.
|
|
2174
|
+
const pend = approvals.request({
|
|
2175
|
+
ns: by || 'incident', kind: 'incident', device: about, deviceId, label: target.label || '', ek: '',
|
|
2176
|
+
ctx: { kind, tries, reporter: by, reporterLabel: byLabel }, from, ttlMs: UPDATE_TTL_MS
|
|
2177
|
+
})
|
|
2178
|
+
log(`[vault] incident (${kind}) about ${deviceId}${target.label ? ` (${target.label})` : ''} reported by ${by}: asking ${approvers} approver(s) (${pend.id})`)
|
|
2179
|
+
await notifyApprovers(pend, record)
|
|
2180
|
+
reply(from, { type: MSG.INCIDENT_RESULT, id: pend.id, approvers, blocked: false })
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
client.on('message', (from, payload) => onMessage(from, payload))
|
|
2184
|
+
|
|
2185
|
+
// EL MOSTRADOR LOCAL, siempre que se pueda. Si no se puede abrir (un sistema sin sockets
|
|
2186
|
+
// de archivo, un directorio de solo lectura) se dice y se sigue: los servicios usan el
|
|
2187
|
+
// proxio como hasta ahora. Lo que NO se hace es callarlo — un atajo que no está y nadie
|
|
2188
|
+
// lo dice es media hora buscando por qué algo va lento.
|
|
2189
|
+
// El socket se nombra por la LLAVE de esta bóveda: una máquina puede tener varias, y un
|
|
2190
|
+
// servicio solo conoce la maestra que lleva pineada. Así los dos lados calculan la misma
|
|
2191
|
+
// ruta sin mirar ningún índice.
|
|
2192
|
+
if (identity.me?.publickey && typeof platform.openLocalDesk === 'function') {
|
|
2193
|
+
platform.openLocalDesk({
|
|
2194
|
+
name: (await pubkeyId(identity.me.publickey)).slice(0, 16),
|
|
2195
|
+
onMessage, channels: canales, maxBytes: MAX_REPLY_BYTES, log
|
|
2196
|
+
})
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
async function onMessage (from, payload) {
|
|
2200
|
+
if (!payload || typeof payload !== 'object') return
|
|
2201
|
+
try {
|
|
2202
|
+
if (payload.type === MSG.HELLO) return desk.handleHello(from, payload)
|
|
2203
|
+
if (payload.type === MSG.ENROLL) return await desk.handleEnroll(from, payload)
|
|
2204
|
+
if (payload.type === MSG.ACTA_SEALED) return await desk.handleActaSealed(from, payload)
|
|
2205
|
+
if (payload.type === MSG.SIGN) return await handleSign(from, payload)
|
|
2206
|
+
if (payload.type === MSG.GET) return await handleGet(from, payload)
|
|
2207
|
+
if (payload.type === MSG.STORE) return await handleStore(from, payload)
|
|
2208
|
+
if (payload.type === MSG.CHECK) return await handleCheck(from, payload)
|
|
2209
|
+
if (payload.type === MSG.DEVICES) return await handleDevices(from, payload)
|
|
2210
|
+
if (payload.type === MSG.RENEW) return await handleRenew(from, payload)
|
|
2211
|
+
if (payload.type === MSG.SECRETS) return await handleSecrets(from, payload)
|
|
2212
|
+
if (payload.type === MSG.REWRAP_OK) return await handleRewrapOk(payload)
|
|
2213
|
+
if (payload.type === MSG.ADMIN) return await handleAdmin(from, payload)
|
|
2214
|
+
if (payload.type === MSG.RENOUNCE) return await handleRenounce(from, payload)
|
|
2215
|
+
if (payload.type === MSG.ADMIN_EVENT) return await handleAdminEvent(payload)
|
|
2216
|
+
if (payload.type === MSG.INCIDENT) return await handleIncident(from, payload)
|
|
2217
|
+
if (payload.type === MSG.REPLICA_ACK) return await handleReplicaAck(from, payload)
|
|
2218
|
+
if (payload.type === MSG.LOGIN_START) return await handleLoginStart(from, payload)
|
|
2219
|
+
if (payload.type === MSG.LOGIN_FINISH) return await handleLoginFinish(from, payload)
|
|
2220
|
+
if (payload.type === MSG.LOGIN_CLOSE) return await handleLoginClose(from, payload)
|
|
2221
|
+
} catch (e) {
|
|
2222
|
+
reply(from, { type: MSG.ERROR, error: e.message })
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
/**
|
|
2227
|
+
* AVISO DE OTRA BÓVEDA de la misma cuenta (multivault). Trae el acta nueva y aquí se
|
|
2228
|
+
* ADOPTA, que es lo que hace que conceder un permiso surta efecto en la otra máquina en
|
|
2229
|
+
* vez de esperar a que renueve su cert.
|
|
2230
|
+
*
|
|
2231
|
+
* No se comprueba quién lo manda y no hace falta: `adoptActa` aplica §2.4.1 —firma,
|
|
2232
|
+
* encadenado, `seq` que no baja y desempate— así que un acta ajena o vieja se rechaza
|
|
2233
|
+
* sola. Fiarse del remitente sería la comprobación débil; fiarse del acta es la fuerte.
|
|
2234
|
+
*/
|
|
2235
|
+
async function handleAdminEvent (payload) {
|
|
2236
|
+
const acta = payload?.acta
|
|
2237
|
+
if (!acta || typeof acta !== 'object') return
|
|
2238
|
+
// ESTE MENSAJE NO LO FIRMA NADIE, y no hace falta: el acta se autentica sola —va sellada
|
|
2239
|
+
// y `canAdopt` comprueba que la selló quien la MÍA reconoce—. Pero eso solo funciona si
|
|
2240
|
+
// hay una mía contra la que comparar: sin acta previa, `canAdopt` acepta cualquiera
|
|
2241
|
+
// («sin-acta-previa»), y este es el único mostrador donde una llega de un desconocido.
|
|
2242
|
+
// Anunciar no es cómo se entra en una cuenta —para eso está el enrolamiento—, así que
|
|
2243
|
+
// sin acta propia no se adopta: se dice y se para.
|
|
2244
|
+
const propia = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2245
|
+
if (!propia) return log('[vault] a record was announced but this vault has none of its own to compare it with: ignored')
|
|
2246
|
+
try {
|
|
2247
|
+
const r = await identity.adoptActa?.(acta)
|
|
2248
|
+
if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)
|
|
2249
|
+
// Y el RECHAZO también se dice. Callarlo dejaría el peor fallo de todos: se concede
|
|
2250
|
+
// un permiso en una máquina, la otra no se entera, y no hay una sola línea que lo
|
|
2251
|
+
// explique — que es exactamente lo que costó encontrar esto.
|
|
2252
|
+
else log(`[vault] IGNORED the record #${acta.seq} announced by another vault: ${r?.reason || 'no reason given'}`)
|
|
2253
|
+
} catch (e) { log('[vault] could not adopt the announced record:', e.message) }
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
// ----- LA BÓVEDA DE CONTRASEÑAS -----
|
|
2257
|
+
//
|
|
2258
|
+
// Se monta al final y envuelto: es una pieza opcional y un fallo suyo no puede tumbar
|
|
2259
|
+
// la CA. Solo se levanta cuando el acta autoriza a alguien — sin eso no hay a quién
|
|
2260
|
+
// responder, y crear la llave por si acaso sería crear un secreto que nadie pidió.
|
|
2261
|
+
//
|
|
2262
|
+
// Y se levanta EN CUANTO pasa, no en el siguiente arranque: conceder el permiso ya es
|
|
2263
|
+
// un acto explícito del dueño, y pedirle además que reinicie la bóveda es de las cosas
|
|
2264
|
+
// que se olvidan y luego parecen un fallo del gestor.
|
|
2265
|
+
let passwords = null
|
|
2266
|
+
/**
|
|
2267
|
+
* EL SELLADO del gestor: la misma cripto de los sobres del ecosistema
|
|
2268
|
+
* (`@dotrino/identity/content`), atada a la llave de cifrado de esta bóveda.
|
|
2269
|
+
*
|
|
2270
|
+
* Lo arma el PILAR (`identitySealing`), que es la MISMA pieza que usan las otras dos
|
|
2271
|
+
* bóvedas —la de la pestaña y la de dentro de la extensión—. Estaba escrito a mano aquí,
|
|
2272
|
+
* y son las tres puntas del mismo sobre: tres copias son dos que se pueden quedar atrás,
|
|
2273
|
+
* y ese fallo no hace ruido — la petición sale, al otro lado «no es para mí», y desde
|
|
2274
|
+
* fuera se ve como que nadie respondió.
|
|
2275
|
+
*
|
|
2276
|
+
* NO se pone `requireSealed` en el cliente, y no es un olvido: el cliente del vault es
|
|
2277
|
+
* UNO y lo comparte todo, y el protocolo de la CA viaja en claro a propósito (un
|
|
2278
|
+
* enrolamiento es público hasta que hay cert). Lo que corta aquí es `VaultResponder`,
|
|
2279
|
+
* que tira lo que llegue sin la marca `sealed`. Por eso `isSealed` mira la marca del
|
|
2280
|
+
* gestor y no otra.
|
|
2281
|
+
*
|
|
2282
|
+
* Sin esto la bóveda RECIBÍA los sobres y los tiraba —el cliente no tenía con qué
|
|
2283
|
+
* abrirlos— y el aparato se quedaba esperando sin que nada lo dijera.
|
|
2284
|
+
*/
|
|
2285
|
+
const passwordSealing = identitySealing(identity)
|
|
2286
|
+
|
|
2287
|
+
/**
|
|
2288
|
+
* LA POLÍTICA DE LAS CONTRASEÑAS, una sola para las tres bóvedas. La usa el mostrador de
|
|
2289
|
+
* aquí y la usa la extensión, que atiende su propio almacén pero decide con esto.
|
|
2290
|
+
*
|
|
2291
|
+
* EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en el
|
|
2292
|
+
* acto. Con el caché, la mesa seguía decidiendo con la foto del último refresco — y en el
|
|
2293
|
+
* sentido peligroso: un aparato al que le acabas de quitar el permiso se seguía sirviendo
|
|
2294
|
+
* solo.
|
|
2295
|
+
*/
|
|
2296
|
+
async function passwordsNeedApproval (pub) { return needsApproval(pub, await refreshActa()) }
|
|
2297
|
+
|
|
2298
|
+
/**
|
|
2299
|
+
* El teléfono: se apunta el pedido, se le avisa y la promesa espera su firma. Es el mismo
|
|
2300
|
+
* camino que ya recorren los cajones de secretos.
|
|
2301
|
+
*
|
|
2302
|
+
* EL PEDIDO DICE QUÉ SE QUIERE LEER (dueño, 2026-09-30: «es importante que se sepa el
|
|
2303
|
+
* porqué»): `kind: 'passwords'` con la operación y los CAMPOS (`password`, `totp`…) —
|
|
2304
|
+
* nunca la entrada ni el sitio, que no se ven aquí. Va sellado al que aprueba.
|
|
2305
|
+
*/
|
|
2306
|
+
async function approvePasswordRequest ({ pubkey, op, payload }) {
|
|
2307
|
+
let resolve
|
|
2308
|
+
const espera = new Promise((r) => { resolve = r })
|
|
2309
|
+
const fields = Array.isArray(payload?.keys) ? payload.keys.slice(0, 12).map((k) => String(k).slice(0, 40)) : null
|
|
2310
|
+
await awaitApproval({
|
|
2311
|
+
device: pubkey, ns: 'passwords', kind: 'passwords', what: 'passwords', op,
|
|
2312
|
+
ctx: { op: String(op || '').replace(/^pm2\./, ''), fields }, onAnswer: resolve
|
|
2313
|
+
})
|
|
2314
|
+
return espera
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const startPasswordDesk = async () => {
|
|
2318
|
+
// EL ALMACÉN LO ATIENDE QUIEN MONTA LA BÓVEDA (la extensión: su worker es el único que
|
|
2319
|
+
// escribe sus contraseñas, y dos escritores se pisarían). La política sigue siendo esta.
|
|
2320
|
+
if (platform.servePasswords === false) return
|
|
2321
|
+
if (passwords || !passwordDevices().length) return
|
|
2322
|
+
const { createPasswordDesk } = await import('./passwords.js')
|
|
2323
|
+
client.updateConfig({ sealing: passwordSealing })
|
|
2324
|
+
passwords = createPasswordDesk({
|
|
2325
|
+
client,
|
|
2326
|
+
store: passwordsStore,
|
|
2327
|
+
// LA BÓVEDA NO TIENE LLAVE. Lo que pone aquí es a quién envolverle las que hacen los
|
|
2328
|
+
// aparatos, y la pública de la copia de recuperación —la que abre la frase del perfil
|
|
2329
|
+
// y es la única forma de volver a repartir una entrada si un día no queda ningún
|
|
2330
|
+
// aparato—. Ninguna de las dos abre nada por sí sola.
|
|
2331
|
+
members: (kind) => passwordRecipients(kind),
|
|
2332
|
+
recoveryPub: () => secrets.recoveryPub(),
|
|
2333
|
+
canWrite: (pub) => !!actaCache && !!pub && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
2334
|
+
// UNA sola condición, y es del ACTA: el aparato tiene la capacidad `passwords`.
|
|
2335
|
+
// Quitársela —o quitar el aparato— le corta esto en la siguiente pasada, sin
|
|
2336
|
+
// que haya una segunda lista que acordarse de tocar.
|
|
2337
|
+
isAllowed: (pub) => !!actaCache && Acta.memberCan(actaCache, pub, 'passwords'),
|
|
2338
|
+
encPubOf: (pub) => (actaCache?.members || []).find((m) => m.pub === pub)?.encPub || null,
|
|
2339
|
+
// EN VIVO, no del caché: conceder o quitar `unattended` tiene que surtir efecto en
|
|
2340
|
+
// el acto. Con el caché, la mesa de contraseñas seguía decidiendo con la foto del
|
|
2341
|
+
// último refresco — y en el sentido peligroso: un aparato al que le acabas de quitar
|
|
2342
|
+
// el permiso se seguía sirviendo solo.
|
|
2343
|
+
needsApproval: passwordsNeedApproval,
|
|
2344
|
+
approve: approvePasswordRequest,
|
|
2345
|
+
audit,
|
|
2346
|
+
log,
|
|
2347
|
+
}).start()
|
|
2348
|
+
log(`[vault] passwords: serving ${passwordDevices().length} device(s)`)
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
// El acta ANTES de mirarla: es ella la que dice si hay a quién responder, y sin
|
|
2352
|
+
// refrescarla primero el mostrador no se levantaba nunca en un arranque en frío.
|
|
2353
|
+
await refreshActa()
|
|
2354
|
+
const actaSweeper = setInterval(() => {
|
|
2355
|
+
refreshActa().then(startPasswordDesk).catch(() => {})
|
|
2356
|
+
}, 5000)
|
|
2357
|
+
actaSweeper.unref?.()
|
|
2358
|
+
if (passwordDevices().length) await startPasswordDesk()
|
|
2359
|
+
// Ni código de enlace ni pegar nada: un gestor entra como cualquier otro aparato.
|
|
2360
|
+
else log('[vault] passwords: no device may ask yet · pair one with `dotrino-vault pair --scope contrasenas`')
|
|
2361
|
+
} catch (e) {
|
|
2362
|
+
log(`[vault] passwords: could not start (${e.message}); everything else keeps going`)
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
log(`[vault] listo · id ${fp} · ${store.getTree().children.length} nodos`)
|
|
2366
|
+
|
|
2367
|
+
// ----- API local (CLI/UI de control) -----
|
|
2368
|
+
// Emparejar / aprobar / rechazar / revocar viven en el núcleo compartido (`desk`).
|
|
2369
|
+
|
|
2370
|
+
// ----- AVISO DE CAMBIO a los agentes del ns -----
|
|
2371
|
+
//
|
|
2372
|
+
// Guardar un secreto no sirve de nada si quien lo usa no se entera. La bóveda
|
|
2373
|
+
// avisa (sin mandar valores: solo «el ns cambió») y el agente decide — el
|
|
2374
|
+
// estándar es que SALGA y lo levante su supervisor, para leer todo fresco y,
|
|
2375
|
+
// sobre todo, para que el valor viejo deje de existir en su memoria.
|
|
2376
|
+
//
|
|
2377
|
+
// AGRUPADO a propósito: cargar cinco valores seguidos con `secret set` son cinco
|
|
2378
|
+
// escrituras, pero un solo cambio de configuración. Sin esta ventana serían cinco
|
|
2379
|
+
// reinicios en cadena, y el agente se pasaría la carga entera reiniciándose.
|
|
2380
|
+
// La variable de entorno va en inglés (CONVENCIONES §8.1); nadie la tenía puesta.
|
|
2381
|
+
const NOTICE_GROUP_MS = Number(platform.noticeGroupMs) || 3000
|
|
2382
|
+
const pendingNotices = new Map() // clave (ns | 'dev:'+pub) → timer
|
|
2383
|
+
|
|
2384
|
+
async function notifyNsChange (ns) {
|
|
2385
|
+
let targets = []
|
|
2386
|
+
try {
|
|
2387
|
+
const { issued } = await identity.listDelegations()
|
|
2388
|
+
const revokedNonces = await revocationSet()
|
|
2389
|
+
const scope = secretsScope(ns)
|
|
2390
|
+
// Los agentes de ESE ns y nadie más: el aviso dice qué namespace cambió, así
|
|
2391
|
+
// que mandárselo a otro sería filtrarle que existe.
|
|
2392
|
+
//
|
|
2393
|
+
// Y UNO POR LLAVE, no uno por delegación: renovar el cert emite una
|
|
2394
|
+
// delegación nueva para la MISMA sub-clave, así que un agente que lleve
|
|
2395
|
+
// tiempo enrolado aparece varias veces y recibiría el aviso repetido.
|
|
2396
|
+
const seen = new Set()
|
|
2397
|
+
targets = (issued || []).filter((x) => {
|
|
2398
|
+
if (!x.sub || revokedNonces.has(x.nonce) || !(x.scope || []).includes(scope)) return false
|
|
2399
|
+
if (seen.has(x.sub)) return false
|
|
2400
|
+
seen.add(x.sub)
|
|
2401
|
+
return true
|
|
2402
|
+
})
|
|
2403
|
+
} catch (e) { return log('[vault] could not list who to notify:', e.message) }
|
|
2404
|
+
if (!targets.length) return
|
|
2405
|
+
|
|
2406
|
+
// FIRMA LA LLAVE DE SELLADO, con el acta al lado (el servicio lo comprueba con
|
|
2407
|
+
// `verifyResponder`). Firmaba la maestra, y con la bóveda cerrada —que es como vive— el
|
|
2408
|
+
// aviso reventaba antes de salir: el servicio no se enteraba de ningún cambio.
|
|
2409
|
+
const body = { op: 'secrets.changed', ns, ts: Date.now() }
|
|
2410
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2411
|
+
const seal = await sealOrFail(body)
|
|
2412
|
+
for (const d of targets) {
|
|
2413
|
+
try { client.sendByPubkey(d.sub, { type: MSG.SECRETS_CHANGED, body, seal, acta }) } catch (_) {}
|
|
2414
|
+
}
|
|
2415
|
+
audit('secrets.changed', { ns, notified: targets.length })
|
|
2416
|
+
log(`[vault] config for "${ns}" changed: notified ${targets.length} agent(s)`)
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
/**
|
|
2420
|
+
* Cambió una variable de UN aparato: el aviso va solo a ese aparato. El mensaje dice
|
|
2421
|
+
* qué NAMESPACE cambió (es lo que el agente sabe leer), así que hace falta su `cn`; un
|
|
2422
|
+
* miembro sin `cn` no es un servicio y no lee variables, de modo que no hay a quién
|
|
2423
|
+
* avisar y no se manda nada.
|
|
2424
|
+
*/
|
|
2425
|
+
async function notifyDeviceChange (pub) {
|
|
2426
|
+
let cn = null
|
|
2427
|
+
let record = null
|
|
2428
|
+
try {
|
|
2429
|
+
record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
2430
|
+
cn = (record?.members || []).find((m) => m.pub === pub)?.cn || null
|
|
2431
|
+
} catch (e) { return log('[vault] could not look up who to notify:', e.message) }
|
|
2432
|
+
if (!cn) return
|
|
2433
|
+
const body = { op: 'secrets.changed', ns: cn, ts: Date.now() }
|
|
2434
|
+
const seal = await sealOrFail(body)
|
|
2435
|
+
try { client.sendByPubkey(pub, { type: MSG.SECRETS_CHANGED, body, seal, acta: record }) } catch (_) {}
|
|
2436
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
2437
|
+
audit('secrets.changed', { ns: cn, device, notified: 1 })
|
|
2438
|
+
log(`[vault] config for device ${device} ("${cn}") changed: notified it`)
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
/**
|
|
2442
|
+
* Un cambio de configuración, un aviso: escrituras seguidas se agrupan en la misma
|
|
2443
|
+
* ventana. La CLAVE distingue los dos cajones (`<ns>` y `dev:<pub>`) para que tocar
|
|
2444
|
+
* lo de un aparato no cancele el aviso pendiente de todo su namespace.
|
|
2445
|
+
*/
|
|
2446
|
+
function scheduleNotice (ns) {
|
|
2447
|
+
schedule(ns, async () => {
|
|
2448
|
+
await notifyNsChange(ns)
|
|
2449
|
+
// Y a los replicadores el sobre nuevo. Sin esto repartirían el valor de ayer: el
|
|
2450
|
+
// aviso de cambio le dice al servicio que se reinicie, y al reiniciar volvería a
|
|
2451
|
+
// pedir — y si la bóveda está apagada, el replicador le daría lo viejo.
|
|
2452
|
+
await pushToReplicas(`secret:${ns}`)
|
|
2453
|
+
})
|
|
2454
|
+
}
|
|
2455
|
+
function scheduleDeviceNotice (pub) { schedule('dev:' + pub, () => notifyDeviceChange(pub)) }
|
|
2456
|
+
|
|
2457
|
+
function schedule (key, fn) {
|
|
2458
|
+
clearTimeout(pendingNotices.get(key))
|
|
2459
|
+
const t = setTimeout(() => {
|
|
2460
|
+
pendingNotices.delete(key)
|
|
2461
|
+
fn().catch((e) => log('[vault] change notice failed:', e.message))
|
|
2462
|
+
}, NOTICE_GROUP_MS)
|
|
2463
|
+
t.unref?.()
|
|
2464
|
+
pendingNotices.set(key, t)
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
// --- CONSOLA REMOTA (docs/consola-remota.md) ---------------------------------
|
|
2468
|
+
// Un dispositivo con cert `vault:admin` puede ADMITIR y EXPULSAR miembros sin venir
|
|
2469
|
+
// al PC. No puede cambiar permisos, traspasar el mando, conceder `admin` ni tocar los
|
|
2470
|
+
// secretos: esas operaciones no existen como mensaje, a propósito. Así un aparato con
|
|
2471
|
+
// `admin` robado hace daño acotado y reversible (se le revoca) en vez de poder dejar
|
|
2472
|
+
// al dueño fuera de su propia cuenta, que no tiene vuelta atrás.
|
|
2473
|
+
/** Últimas entradas de la bitácora (JSONL), de la más reciente hacia atrás. */
|
|
2474
|
+
function readActivity (limit = 100) {
|
|
2475
|
+
try {
|
|
2476
|
+
// Cada línea va cifrada en reposo: se abre antes de leerla. Sin esto la consola
|
|
2477
|
+
// remota recibía siempre una bitácora vacía.
|
|
2478
|
+
return (disk.read(activityFile) || '').split('\n').filter(Boolean).slice(-limit)
|
|
2479
|
+
.map((l) => { try { return JSON.parse(activityAtRest.decrypt(l)) } catch (_) { return null } }).filter(Boolean).reverse()
|
|
2480
|
+
} catch (_) { return [] }
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
/**
|
|
2484
|
+
* Avisa a TODOS los miembros de que el perfil cambió, firmado por la maestra. Sin
|
|
2485
|
+
* esto, administrar a distancia sería invisible para el resto de los dispositivos —
|
|
2486
|
+
* y esa visibilidad es lo que hace DETECTABLE a un admin comprometido. Va por
|
|
2487
|
+
* `sendByPubkey`, así que al que está apagado le llega cuando encienda (cola 24 h).
|
|
2488
|
+
*/
|
|
2489
|
+
async function notifyMembers (ev, info = {}) {
|
|
2490
|
+
try {
|
|
2491
|
+
// SIN FIRMA, y no falta nada: quien lo usa es el ACTA que viaja al lado, que se
|
|
2492
|
+
// autentica sola (otra bóveda la adopta por `canAdopt`; el teléfono solo refresca y
|
|
2493
|
+
// pregunta). Lo firmaba la maestra, que no firma con la bóveda cerrada: el aviso
|
|
2494
|
+
// reventaba antes de salir y el acta nueva no llegaba a nadie.
|
|
2495
|
+
const body = { ev, ...info, ts: Date.now() }
|
|
2496
|
+
// EL ACTA VIAJA CON EL AVISO. Sin esto, un miembro se enteraba de que «algo cambió»
|
|
2497
|
+
// pero no de QUÉ, y no veía el acta nueva hasta renovar su cert — hasta 30 días.
|
|
2498
|
+
// Para un aparato eso era lento; para OTRA BÓVEDA es fatal: se le concede `sella` y
|
|
2499
|
+
// no puede sellar, porque su copia del acta no lo dice todavía.
|
|
2500
|
+
// No es un dato secreto (es pública dentro del perfil y estos son sus miembros), y
|
|
2501
|
+
// quien la recibe la adopta por las reglas de §2.4.1, así que una vieja o ajena no
|
|
2502
|
+
// hace daño: se rechaza sola.
|
|
2503
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2504
|
+
const { issued } = await identity.listDelegations()
|
|
2505
|
+
// A QUIÉN SE AVISA: a los MIEMBROS DEL ACTA, no solo a quien esta bóveda enroló.
|
|
2506
|
+
//
|
|
2507
|
+
// Esto avisaba únicamente por `issued` —las delegaciones que emitió esta bóveda—, y
|
|
2508
|
+
// eso deja fuera exactamente el caso del multivault: la segunda bóveda entró por
|
|
2509
|
+
// `join`, así que la delegación la emitió la PRIMERA. Cuando la segunda sellaba un
|
|
2510
|
+
// acta nueva (admitir un aparato con su permiso `sella`), su lista de emitidas no
|
|
2511
|
+
// tenía a la primera dentro y el cambio no llegaba a ninguna parte: las dos bóvedas
|
|
2512
|
+
// quedaban con actas distintas y la del dueño sin enterarse de nada.
|
|
2513
|
+
//
|
|
2514
|
+
// El acta es la lista de quién es del perfil: esa es la lista correcta. `issued` se
|
|
2515
|
+
// suma porque cubre a quien tiene cert y todavía no aparece ahí.
|
|
2516
|
+
const miembros = (await identity.profileMembers?.().catch(() => null))?.members || []
|
|
2517
|
+
const yo = identity.me?.publickey || null
|
|
2518
|
+
// UNO POR LLAVE, no uno por delegación: renovar emite una delegación nueva para la
|
|
2519
|
+
// MISMA sub-clave, así que un aparato que lleve tiempo enrolado aparece varias veces
|
|
2520
|
+
// y recibía el mismo aviso repetido —una vez por renovación acumulada—. Mismo
|
|
2521
|
+
// cuidado que en `notifyNsChange`.
|
|
2522
|
+
const seen = new Set()
|
|
2523
|
+
const avisar = (pub) => {
|
|
2524
|
+
if (!pub || pub === yo || seen.has(pub)) return
|
|
2525
|
+
seen.add(pub)
|
|
2526
|
+
// El fallo NO se traga. Que un aviso no salga es exactamente lo que deja a dos
|
|
2527
|
+
// bóvedas con actas distintas sin que nadie se entere, y encontrarlo sin una línea
|
|
2528
|
+
// de log cuesta días (la última vez, tres).
|
|
2529
|
+
// CALLADO (`quiet`): se encola igual, pero no toca el timbre push. Un aviso de que
|
|
2530
|
+
// el perfil cambió puede esperar a que el aparato abra solo; timbrar por él hacía
|
|
2531
|
+
// sonar el teléfono con «alguien pide tus claves» sin ningún pedido detrás.
|
|
2532
|
+
// El timbre es para los pedidos (`notifyApprovers`), que sí esperan una mano.
|
|
2533
|
+
try { client.sendByPubkey(pub, { type: MSG.ADMIN_EVENT, body, acta }, { quiet: true, app: 'vault' }) }
|
|
2534
|
+
catch (e) { log(`[vault] could not notify ${pub.slice(0, 24)}… of "${ev}": ${e.message}`) }
|
|
2535
|
+
}
|
|
2536
|
+
for (const m of miembros) avisar(m.pub)
|
|
2537
|
+
for (const d of issued || []) avisar(d.sub)
|
|
2538
|
+
log(`[vault] notified ${seen.size} member(s) of "${ev}" (record #${acta?.seq ?? '?'})`)
|
|
2539
|
+
} catch (e) { log('[vault] could not notify members of the change:', e.message) }
|
|
2540
|
+
// Y a los replicadores, que no quieren un aviso sino el CONTENIDO: sin esto se
|
|
2541
|
+
// quedarían con el acta de ayer y repartiendo sobres de un aparato ya revocado.
|
|
2542
|
+
await pushToReplicas(ev)
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
/**
|
|
2546
|
+
* EL ARRASTRE AL ARRANCAR (`docs/replicas.md` §6). Un replicador dice por dónde va en
|
|
2547
|
+
* cuanto se conecta, y si va por detrás se le empuja.
|
|
2548
|
+
*
|
|
2549
|
+
* Sin esto, la bóveda solo empujaba al cambiar algo: un replicador que se enciende
|
|
2550
|
+
* después se quedaba vacío hasta el siguiente cambio, que puede no llegar en semanas.
|
|
2551
|
+
* Y el empujón de un enrolamiento se manda a un aparato que todavía no está conectado.
|
|
2552
|
+
* Lo destapó el smoke y era un hueco del diseño, no del código.
|
|
2553
|
+
*
|
|
2554
|
+
* El acuse va FIRMADO por el replicador y se comprueba contra el acta: sin eso, cualquiera
|
|
2555
|
+
* podría pedirle a la bóveda que reparta el acta y los sobres mandando un acuse inventado.
|
|
2556
|
+
*/
|
|
2557
|
+
async function handleReplicaAck (from, p) {
|
|
2558
|
+
const body = p?.body
|
|
2559
|
+
if (!body || body.op !== 'replica.ack') return
|
|
2560
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2561
|
+
if (!record) return
|
|
2562
|
+
// LA LLAVE VA DENTRO DEL MENSAJE FIRMADO, no se saca de `from`: `from` es el
|
|
2563
|
+
// identificador de la conexión en el proxio, no la llave del miembro. Compararlo con
|
|
2564
|
+
// el acta rechazaba a todo el mundo, y en el caso contrario habría sido peor.
|
|
2565
|
+
const quien = body.publickey
|
|
2566
|
+
if (typeof quien !== 'string' || !Acta.memberCan(record, quien, 'replica')) {
|
|
2567
|
+
return log('[vault] a replica ack arrived from someone the record does not allow to serve: ignored')
|
|
2568
|
+
}
|
|
2569
|
+
if (!(await verifyDeviceSig({ publickey: quien, data: body, signature: p.signature }))) {
|
|
2570
|
+
return log('[vault] a replica ack does not check out against its own key: ignored')
|
|
2571
|
+
}
|
|
2572
|
+
if (typeof body.seq === 'number' && body.seq >= record.seq) return
|
|
2573
|
+
log(`[vault] a replica is at #${body.seq} and we are at #${record.seq}: catching it up`)
|
|
2574
|
+
await pushToReplicas('catch-up')
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
/**
|
|
2578
|
+
* EMPUJAR A LOS REPLICADORES lo que tienen que poder repartir: el acta y los sobres.
|
|
2579
|
+
*
|
|
2580
|
+
* Se manda TODO firmado de antes —el acta la selló la maestra, cada sobre lo firmó la
|
|
2581
|
+
* llave de sellado— así que un replicador no puede falsificar nada de lo que le damos.
|
|
2582
|
+
* Y va cerrado a su destinatario, no a él: reparte sobres que no puede abrir. Es lo que
|
|
2583
|
+
* permite ponerlo en una máquina en la que no confías (`docs/replicas.md` §8.bis).
|
|
2584
|
+
*
|
|
2585
|
+
* Se empuja después de cada cambio, y no se espera acuse: si un replicador está apagado
|
|
2586
|
+
* se queda atrás y lo alcanza el siguiente empujón. Un replicador atrasado no es un
|
|
2587
|
+
* peligro —el cliente rechaza un acta con `seq` menor del que ya vio— sino menos
|
|
2588
|
+
* disponibilidad, que es exactamente lo que un replicador arriesga.
|
|
2589
|
+
*/
|
|
2590
|
+
async function pushToReplicas (motivo = 'change') {
|
|
2591
|
+
try {
|
|
2592
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2593
|
+
if (!record) return
|
|
2594
|
+
const replicas = (record.members || []).filter((m) => Acta.memberCan(record, m.pub, 'replica'))
|
|
2595
|
+
if (!replicas.length) return
|
|
2596
|
+
|
|
2597
|
+
// UN SOBRE POR (CAJÓN, APARATO), porque `bundleFor` lo talla a quien lo pide: lleva
|
|
2598
|
+
// solo las envolturas de ESE aparato. Mandar uno genérico no serviría de nada.
|
|
2599
|
+
const bundles = {}
|
|
2600
|
+
for (const m of record.members || []) {
|
|
2601
|
+
if (!m.cn) continue
|
|
2602
|
+
try { bundles[`${m.cn} ${m.pub}`] = secrets.bundleFor(m.cn, m.pub) } catch (_) {}
|
|
2603
|
+
}
|
|
2604
|
+
const body = { op: 'replica.push', seq: record.seq, acta: record, bundles, ts: Date.now() }
|
|
2605
|
+
const seal = await signSeal(body)
|
|
2606
|
+
if (!seal) return log('[vault] cannot push to the replicas: the record names no sealing key of ours')
|
|
2607
|
+
let n = 0
|
|
2608
|
+
for (const r of replicas) {
|
|
2609
|
+
try { client.sendByPubkey(r.pub, { type: MSG.REPLICA_PUSH, body, seal }); n++ }
|
|
2610
|
+
catch (e) { log(`[vault] could not push to a replica: ${e.message}`) }
|
|
2611
|
+
}
|
|
2612
|
+
log(`[vault] pushed record #${record.seq} and ${Object.keys(bundles).length} bundle(s) to ${n} replica(s) on ${motivo}`)
|
|
2613
|
+
} catch (e) { log('[vault] could not push to the replicas: ' + e.message) }
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
/**
|
|
2617
|
+
* MOSTRADOR DE VARIABLES para la consola remota (`lib/src/admin.js` enruta; la política
|
|
2618
|
+
* y la cripto viven aquí, que es donde están la clave y el disco).
|
|
2619
|
+
*
|
|
2620
|
+
* Dos reglas, y son toda la frontera:
|
|
2621
|
+
*
|
|
2622
|
+
* 1. **El valor de una PRIVADA no sale de esta máquina.** Ni para un aparato tuyo con
|
|
2623
|
+
* `admin`. Lo que viaja de una privada es su nombre y que es privada — con eso se
|
|
2624
|
+
* le puede poner un valor nuevo a ciegas, que es lo que hace falta para rotarla.
|
|
2625
|
+
* 2. **Lo que sale, sale CIFRADO** con la clave de contenido del perfil: el proxy
|
|
2626
|
+
* transporta el sobre y no ve nada. Igual que el contenido del usuario (`store`).
|
|
2627
|
+
*/
|
|
2628
|
+
/**
|
|
2629
|
+
* La llave de administración a partir de lo que venga en el sobre de la consola
|
|
2630
|
+
* remota. `undefined` si no trae contraseña — entonces el store cae a la de la
|
|
2631
|
+
* máquina, que es lo correcto para un perfil que no tiene ninguna.
|
|
2632
|
+
*
|
|
2633
|
+
* La contraseña no se guarda: se deriva, se usa y se suelta con el sobre.
|
|
2634
|
+
*/
|
|
2635
|
+
/**
|
|
2636
|
+
* La llave derivada de la contraseña, si el sobre la traía. Desde §8 solo la piden las
|
|
2637
|
+
* operaciones que LEEN (ver un valor, cambiar su visibilidad): escribir no.
|
|
2638
|
+
*/
|
|
2639
|
+
async function adminKeyFrom (payload) {
|
|
2640
|
+
const pwd = payload?.password
|
|
2641
|
+
if (typeof pwd !== 'string' || !pwd) return undefined
|
|
2642
|
+
if (typeof deriveAdminKey !== 'function') return undefined
|
|
2643
|
+
return deriveAdminKey(pwd)
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
const varsDesk = {
|
|
2647
|
+
async list ({ caller = null } = {}) {
|
|
2648
|
+
// EL VAULT DECIDE QUÉ ENVÍA PARA LEER (dueño, 2026-09-02: «la consola debe tener todos
|
|
2649
|
+
// los sobres; la diferencia es si el vault se los envía para la lectura o no»).
|
|
2650
|
+
//
|
|
2651
|
+
// Tener la envoltura y recibir el sobre son dos cosas: quien administra tiene la
|
|
2652
|
+
// envoltura de cada PÚBLICA, y por eso aquí se le manda el sobre de esas y solo de
|
|
2653
|
+
// esas. Las privadas de un cajón con dueño no se mandan — y aunque se mandaran no
|
|
2654
|
+
// podría abrirlas, porque su envoltura no existe. Dos cerrojos para lo mismo, a
|
|
2655
|
+
// propósito: que uno falle no basta para leerlas.
|
|
2656
|
+
//
|
|
2657
|
+
// Se sella la lista ENTERA, no solo los valores: el proxy tampoco tiene por qué
|
|
2658
|
+
// aprender cómo se llaman tus variables ni qué servicios corres.
|
|
2659
|
+
return {
|
|
2660
|
+
enc: await identity.sealContent(JSON.stringify({
|
|
2661
|
+
ns: withEnvelopes(listSecrets(), 'ns', caller), dev: withEnvelopes(await listDeviceSecrets(), 'dev', caller),
|
|
2662
|
+
// Aparatos que están en el acta pero no pueden abrir lo suyo: hay que
|
|
2663
|
+
// enseñarlo donde se administra, no solo en el log del servicio.
|
|
2664
|
+
incomplete: await incompleteMembers(),
|
|
2665
|
+
// Lo que quedó a deber un sellado: quien administra a distancia tiene que
|
|
2666
|
+
// poder verlo, porque es exactamente lo que él puede saldar (escribiendo una
|
|
2667
|
+
// variable con la contraseña) y la bóveda no.
|
|
2668
|
+
pending: await secretDebts(),
|
|
2669
|
+
// Y si el perfil NO tiene contraseña, decirlo: sus privadas se abren con la
|
|
2670
|
+
// llave de la máquina de la bóveda, cuyo material vive en ese mismo disco.
|
|
2671
|
+
// El comentario de aquí decía «la consola lo dice en voz alta» y la consola
|
|
2672
|
+
// no decía nada — el mismo error que el comentario mentiroso de `atrest.js`.
|
|
2673
|
+
hasPassword: (() => { try { return !!hasPassword() } catch (_) { return true } })(),
|
|
2674
|
+
// SI ESTÁ CERRADA, Y SI SE PUEDE ABRIR DESDE AQUÍ. La consola no tenía forma de
|
|
2675
|
+
// saberlo: se enteraba solo al chocar con un `vault-locked` intentando algo. Y
|
|
2676
|
+
// `remoteUnlock` distingue las dos razones por las que el formulario no aparece —
|
|
2677
|
+
// «no está cerrada» y «no hay contraseña de admin puesta»— que son cosas
|
|
2678
|
+
// distintas y llevan a acciones distintas.
|
|
2679
|
+
locked: (() => { try { return !!isLocked() } catch (_) { return true } })(),
|
|
2680
|
+
remoteUnlock: (() => { try { return !!hasSecondary() } catch (_) { return false } })()
|
|
2681
|
+
}))
|
|
2682
|
+
}
|
|
2683
|
+
},
|
|
2684
|
+
/**
|
|
2685
|
+
* PARA QUIÉN ENVOLVER. Lo contesta la bóveda porque la lista sale del ACTA
|
|
2686
|
+
* (`recipientsOf`), y tener dos sitios respondiendo a «quién puede abrir este cajón» es
|
|
2687
|
+
* exactamente cómo se acaba dejando fuera a alguien sin que nadie se entere.
|
|
2688
|
+
*/
|
|
2689
|
+
async recipients ({ ns, pub, public: isPublic }) {
|
|
2690
|
+
// LA VISIBILIDAD CAMBIA LA LISTA: una pública se envuelve además para quien
|
|
2691
|
+
// administra, así que hay que decirlo al preguntar. Pedirla sin decirlo y luego
|
|
2692
|
+
// guardarla como pública produciría un sobre que la consola no puede abrir — y la
|
|
2693
|
+
// bóveda lo rechazaría, que es lo correcto pero desorienta.
|
|
2694
|
+
return secrets.recipientsFor(ns ? `ns:${ns}` : `dev:${pub}`, { public: !!isPublic })
|
|
2695
|
+
},
|
|
2696
|
+
/**
|
|
2697
|
+
* GUARDAR UNA VARIABLE **SIN VERLA** (dueño, 2026-09-01).
|
|
2698
|
+
*
|
|
2699
|
+
* «La bóveda cerrada no puede ver el valor; debe confiar en la firma del admin y en el
|
|
2700
|
+
* contenido de esos sobres, y es la razón por la que al abrir la bóveda rehace los
|
|
2701
|
+
* sobres: por si alguno tiene alguna incoherencia.»
|
|
2702
|
+
*
|
|
2703
|
+
* Antes esto abría el sobre para sacar el valor en claro y volvía a cerrarlo. Ese
|
|
2704
|
+
* rodeo era el ÚNICO motivo por el que la llave de cifrado del perfil tenía que estar
|
|
2705
|
+
* accesible con la bóveda cerrada — o sea, la razón por la que una copia del disco
|
|
2706
|
+
* abría todo lo dirigido al perfil.
|
|
2707
|
+
*
|
|
2708
|
+
* Ahora llega hecho: el valor cifrado con una CEK nueva y esa CEK envuelta para cada
|
|
2709
|
+
* destinatario. Aquí se comprueba lo que se puede comprobar SIN la llave: que las
|
|
2710
|
+
* envolturas cubren exactamente a quien dice el acta. Lo que no se puede comprobar
|
|
2711
|
+
* —que dentro haya lo que dice— lo corrige el repaso al abrir la bóveda.
|
|
2712
|
+
*
|
|
2713
|
+
* Una PÚBLICA sigue viajando en claro y por el camino de siempre: marcarla pública es
|
|
2714
|
+
* precisamente decir que no hay nada que ocultar.
|
|
2715
|
+
*/
|
|
2716
|
+
async set ({ ns, pub, key, sealed, caller = null, public: isPublic, by: who = null }) {
|
|
2717
|
+
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
2718
|
+
if (sealed) {
|
|
2719
|
+
// PÚBLICA Y PRIVADA VAN IGUAL (dueño, 2026-09-02: «la única diferencia es si se
|
|
2720
|
+
// despachan o no, son políticas; dales el mismo tratamiento de seguridad»). La
|
|
2721
|
+
// marca solo decide si se entrega sin aprobación.
|
|
2722
|
+
// Si esto va a pedir aprobación, al aprobarse puede EDITAR; si no, solo añadir.
|
|
2723
|
+
const ask = needsApproval(caller, await refreshActa())
|
|
2724
|
+
const apply = async () => {
|
|
2725
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2726
|
+
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2727
|
+
await secrets.putSealed(owner, key, sealed, { by: who, public: isPublic })
|
|
2728
|
+
audit('secret.set', { ns: ns || null, key, sealed: true }); scheduleNotice(ns)
|
|
2729
|
+
await settleDebts(owner)
|
|
2730
|
+
}
|
|
2731
|
+
// Comprobado ANTES de dejarlo en espera: un sobre malo falla al guardarlo, no cinco
|
|
2732
|
+
// minutos después en la pantalla de quien aprueba. `apply` lo repite al aprobar.
|
|
2733
|
+
await checkAuthor(owner, key, sealed, caller, { allowOverwrite: ask })
|
|
2734
|
+
await checkRecipients(owner, sealed.wraps, !!isPublic)
|
|
2735
|
+
const pending = await holdWrite({ caller, ns, pub, keys: [key], apply, ask })
|
|
2736
|
+
if (pending) return { ok: true, key, pending }
|
|
2737
|
+
await apply()
|
|
2738
|
+
return { ok: true, key }
|
|
2739
|
+
}
|
|
2740
|
+
// NO HAY OTRO CAMINO (dueño, 2026-09-02: «no dejes caminos viejos ni fallbacks; no hay
|
|
2741
|
+
// que hacer retrocompatibilidad y es una regla del estado actual del proyecto, por eso
|
|
2742
|
+
// quedan luego backdoors»).
|
|
2743
|
+
//
|
|
2744
|
+
// Aquí estaba el `enc`: la consola sellaba el valor AL PERFIL y la bóveda lo ABRÍA
|
|
2745
|
+
// para volver a cerrarlo. Ese descifrado era el único motivo por el que la llave de
|
|
2746
|
+
// cifrado del perfil tenía que estar accesible con la bóveda cerrada — o sea, la
|
|
2747
|
+
// razón por la que una copia del disco abría todo lo dirigido al perfil. Dejarlo
|
|
2748
|
+
// «por si acaso» habría dejado el agujero entero abierto con otro nombre.
|
|
2749
|
+
throw Object.assign(
|
|
2750
|
+
new Error('var.set: the value must come already sealed (`sealed`); the old `enc` path is gone — build it with buildSealedVar()'),
|
|
2751
|
+
{ code: 'needs-sealed' })
|
|
2752
|
+
},
|
|
2753
|
+
/**
|
|
2754
|
+
* VARIAS DE UNA VEZ, y por eso existe: cada guardado suelto hace que la bóveda avise
|
|
2755
|
+
* al servicio de que su configuración cambió, y el servicio SALE para releerla entera
|
|
2756
|
+
* (`watchEnv`). Guardadas de una en una, quien administra a distancia reiniciaba el
|
|
2757
|
+
* servicio una vez por variable, y las primeras veces arrancaba con la configuración a
|
|
2758
|
+
* medio poner. Juntas: un guardado, un aviso, un reinicio.
|
|
2759
|
+
*
|
|
2760
|
+
* Los NOMBRES también viajan dentro del sobre —no solo los valores—: el proxy
|
|
2761
|
+
* transporta y no tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
2762
|
+
*/
|
|
2763
|
+
async setMany ({ ns, pub, items, caller = null, by: who = null, fromService = false }) {
|
|
2764
|
+
// CADA VARIABLE VIENE EN SU PROPIO SOBRE, ya hecho. La bóveda no abre ninguno: solo
|
|
2765
|
+
// comprueba quién los firma y a quién envuelven, igual que en `set`. Aquí estaba el
|
|
2766
|
+
// `enc` que las traía todas juntas selladas AL PERFIL, y abrirlo era lo que obligaba
|
|
2767
|
+
// a tener la llave de cifrado accesible con la bóveda cerrada.
|
|
2768
|
+
if (!Array.isArray(items) || !items.length) throw new Error('var.setMany: no variables came')
|
|
2769
|
+
const owner = ns ? `ns:${ns}` : `dev:${pub}`
|
|
2770
|
+
const keys = []
|
|
2771
|
+
// ¿SE PIDE APROBACIÓN? Desde la mesa del admin, lo de siempre (`needsApproval`: quien
|
|
2772
|
+
// tiene `unattended` escribe solo). Desde el propio SERVICIO que guarda en su cajón, la
|
|
2773
|
+
// aprobación es SOLO PARA EDITAR (dueño, 2026-10-12: «la aprobación solamente es para
|
|
2774
|
+
// editar una env; para almacenarla es directo»): una variable nueva no le quita nada a
|
|
2775
|
+
// nadie y se guarda en el acto; reemplazar una que ya existe pide el sí de quien
|
|
2776
|
+
// aprueba, tenga o no `unattended` el servicio (dueño, 2026-09-29: «el unattended
|
|
2777
|
+
// también pediría aprobación»). Una tanda con alguna que ya existe se pide ENTERA: va
|
|
2778
|
+
// todo o nada. Sin nadie que apruebe no se pide —la regla de siempre— y lo que ya
|
|
2779
|
+
// existe falla con `exists`.
|
|
2780
|
+
const record = await refreshActa()
|
|
2781
|
+
const ask = fromService
|
|
2782
|
+
? hasApprover(record) && items.some((it) => it?.key && secrets.has(owner, it.key))
|
|
2783
|
+
: needsApproval(caller, record)
|
|
2784
|
+
for (const it of items) {
|
|
2785
|
+
if (!it?.key || !it?.sealed) throw new Error('var.setMany: each variable needs its key and its sealed envelope')
|
|
2786
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2787
|
+
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2788
|
+
}
|
|
2789
|
+
// Se comprueban TODAS antes de escribir NINGUNA: media carga aplicada es una
|
|
2790
|
+
// configuración que nadie quiso, y el servicio se reinicia con ella.
|
|
2791
|
+
const apply = async () => {
|
|
2792
|
+
for (const it of items) {
|
|
2793
|
+
await checkAuthor(owner, it.key, it.sealed, caller, { allowOverwrite: ask })
|
|
2794
|
+
await checkRecipients(owner, it.sealed.wraps, !!it.public)
|
|
2795
|
+
}
|
|
2796
|
+
for (const it of items) {
|
|
2797
|
+
await secrets.putSealed(owner, it.key, it.sealed, { by: who, public: !!it.public })
|
|
2798
|
+
keys.push(it.key)
|
|
2799
|
+
}
|
|
2800
|
+
audit('secret.setMany', { ns: ns || null, keys }); scheduleNotice(ns)
|
|
2801
|
+
await settleDebts(owner)
|
|
2802
|
+
}
|
|
2803
|
+
const pending = await holdWrite({ caller, ns, pub, keys: items.map((it) => it.key), apply, ask })
|
|
2804
|
+
if (pending) return { ok: true, keys: [], pending }
|
|
2805
|
+
await apply()
|
|
2806
|
+
return { ok: true, keys }
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
/**
|
|
2811
|
+
* GUARDAR VARIABLES TAMBIÉN PIDE PERMISO (dueño, 2026-09-23: «si el admin no es
|
|
2812
|
+
* unattended, sí debería pedir permiso para almacenar variables»).
|
|
2813
|
+
*
|
|
2814
|
+
* Es el mismo permiso que ya decide si un aparato se lleva las claves solo: quien tiene
|
|
2815
|
+
* `unattended` escribe sin preguntar; quien no, deja la escritura en espera y el teléfono
|
|
2816
|
+
* decide. Un permiso, una regla, para leer y para escribir.
|
|
2817
|
+
*
|
|
2818
|
+
* No se espera DENTRO de la llamada: la consola corta a los 20 s y aprobar puede tardar
|
|
2819
|
+
* cinco minutos. Se contesta «pendiente» en el acto, el sobre se queda en memoria (va
|
|
2820
|
+
* sellado, la bóveda no lo abre) y se escribe al aprobar. Denegado o vencido, se tira.
|
|
2821
|
+
*
|
|
2822
|
+
* Devuelve el id del pedido si la escritura quedó en espera, o `null` si se puede hacer ya.
|
|
2823
|
+
*/
|
|
2824
|
+
async function holdWrite ({ caller, ns, pub, keys, apply, ask }) {
|
|
2825
|
+
if (!(ask ?? needsApproval(caller, await refreshActa()))) return null
|
|
2826
|
+
// Quien llama ya comprobó el sobre; `apply` lo vuelve a comprobar al aprobar, porque el
|
|
2827
|
+
// acta pudo cambiar en medio.
|
|
2828
|
+
const target = ns || await deviceIdOf(pub).catch(() => null) || '?'
|
|
2829
|
+
const pend = await awaitApproval({
|
|
2830
|
+
device: caller, ns: target, kind: 'write', what: 'vars',
|
|
2831
|
+
// LOS NOMBRES van en el contexto, que viaja SELLADO al que aprueba: el proxio no
|
|
2832
|
+
// tiene por qué aprender cómo se llama la configuración de un servicio.
|
|
2833
|
+
ctx: { keys },
|
|
2834
|
+
onAnswer: async (ok) => {
|
|
2835
|
+
if (!ok) return
|
|
2836
|
+
await apply()
|
|
2837
|
+
await notifyMembers('vars', { by: await deviceIdOf(caller).catch(() => null), keys, ns: ns || null })
|
|
2838
|
+
}
|
|
2839
|
+
})
|
|
2840
|
+
return pend.id
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
/**
|
|
2844
|
+
* BLOQUEAR O DESBLOQUEAR A MANO, desde la bóveda (CLI/TUI) o desde la consola de
|
|
2845
|
+
* administración. El camino normal para bloquear es el aprobador (`handleApproval`,
|
|
2846
|
+
* `block`); desbloquear solo pasa por aquí, que es lo que pidió el dueño.
|
|
2847
|
+
*/
|
|
2848
|
+
async function setBlocked (pub, on, { by = 'vault' } = {}) {
|
|
2849
|
+
const deviceId = await deviceIdOf(pub).catch(() => null)
|
|
2850
|
+
if (on) {
|
|
2851
|
+
const record = await refreshActa()
|
|
2852
|
+
const label = (record?.members || []).find((m) => m.pub === pub)?.label || ''
|
|
2853
|
+
const b = blocked.block({ pub, deviceId, label, by, reason: 'manual' })
|
|
2854
|
+
grants.revokeAll({ device: pub })
|
|
2855
|
+
audit('blocked', { device: deviceId, by, kind: 'manual' })
|
|
2856
|
+
log(`[vault] BLOCKED ${deviceId} by ${by}`)
|
|
2857
|
+
await notifyMembers('blocked', { deviceId, by })
|
|
2858
|
+
return b
|
|
2859
|
+
}
|
|
2860
|
+
const was = blocked.unblock(pub)
|
|
2861
|
+
if (was) {
|
|
2862
|
+
audit('unblocked', { device: deviceId, by })
|
|
2863
|
+
log(`[vault] unblocked ${deviceId} by ${by}`)
|
|
2864
|
+
await notifyMembers('unblocked', { deviceId, by })
|
|
2865
|
+
}
|
|
2866
|
+
return was
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
const admin = createAdminDesk({
|
|
2870
|
+
desk,
|
|
2871
|
+
deviceIdOf,
|
|
2872
|
+
audit,
|
|
2873
|
+
blocked: { list: () => blocked.list(), set: setBlocked },
|
|
2874
|
+
// Abrir a distancia. Va inyectado porque `admin.js` es puro y esto necesita cripto y
|
|
2875
|
+
// disco; `null` si este perfil no lo soporta, y entonces la operación contesta que no.
|
|
2876
|
+
unlockDesk,
|
|
2877
|
+
// El MISMO candado que frena firmar y editar el perfil: revocar reescribe el acta y
|
|
2878
|
+
// configurar toca los secretos, y ninguna de las dos se hace con la bóveda cerrada.
|
|
2879
|
+
isLocked,
|
|
2880
|
+
notify: notifyMembers,
|
|
2881
|
+
readActivity,
|
|
2882
|
+
vars: varsDesk,
|
|
2883
|
+
// CERT ∩ ACTA, igual que los secretos con su CN. El cert dice qué se emitió; el acta,
|
|
2884
|
+
// qué decidió el dueño AHORA. Sin el segundo, `caps <ID> -administra` no surtía efecto
|
|
2885
|
+
// hasta que el cert caducara: quitarle la administración a un aparato que ya no es de
|
|
2886
|
+
// fiar exigía revocarlo entero. Con el cruce, deja de administrar en el acto.
|
|
2887
|
+
verify: async ({ data, signature, cert }) => {
|
|
2888
|
+
const chk = await verifyChain({
|
|
2889
|
+
data, signature, cert,
|
|
2890
|
+
expectedScope: SCOPE.ADMIN, ...(await contextoActa()), revoked: await revocationSet()
|
|
2891
|
+
})
|
|
2892
|
+
// Quitarse a UNO MISMO desde la consola remota entra por aquí: la segunda petición
|
|
2893
|
+
// que mande el aparato ya llega con el certificado retirado. Que se entere con el
|
|
2894
|
+
// aviso firmado, en vez de con un error suelto que no puede borrar nada.
|
|
2895
|
+
if (!chk.ok) {
|
|
2896
|
+
await notifyIfRevoked(data?.publickey, cert?.nonce || null, cert?.iss || null, chk.reason)
|
|
2897
|
+
return chk
|
|
2898
|
+
}
|
|
2899
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
2900
|
+
if (!record || !Acta.memberCan(record, chk.device, 'admin')) return { ok: false, reason: record ? 'acta' : 'sin-acta' }
|
|
2901
|
+
return chk
|
|
2902
|
+
}
|
|
2903
|
+
})
|
|
2904
|
+
|
|
2905
|
+
/**
|
|
2906
|
+
* RENUNCIA: un miembro se quita capacidades a sí mismo y la bóveda la SELLA en el acta.
|
|
2907
|
+
*
|
|
2908
|
+
* NO se pide certificado, y es a propósito: el registro va firmado por el propio miembro
|
|
2909
|
+
* y solo puede QUITAR, así que honrarlo no puede hacer daño — y exigir un cert válido
|
|
2910
|
+
* dejaría fuera justo el caso que la justifica (un aparato que ya no es de fiar, o cuyo
|
|
2911
|
+
* cert caducó). Sin esto la renuncia se quedaba en el dispositivo: la bóveda seguía
|
|
2912
|
+
* teniendo escrito que podía firmar y le seguía aceptando peticiones.
|
|
2913
|
+
*/
|
|
2914
|
+
async function handleRenounce (from, p) {
|
|
2915
|
+
const record = p?.data?.record || p?.record
|
|
2916
|
+
if (!record || typeof record !== 'object') return reply(from, { type: MSG.ERROR, error: 'renounce: record required' })
|
|
2917
|
+
if (!(await Acta.verifyRenounce(record).catch(() => false))) {
|
|
2918
|
+
audit('rejected', { what: 'renounce', reason: 'signature' })
|
|
2919
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: the signature is not the member own' })
|
|
2920
|
+
}
|
|
2921
|
+
// UNA RENUNCIA QUE NO QUITA NADA NO SE HONRA, y esto es lo que corta el replay barato.
|
|
2922
|
+
//
|
|
2923
|
+
// El registro va firmado y lleva `ts`, pero nadie lo miraba: cualquiera que lo viera
|
|
2924
|
+
// pasar por el proxio podía volver a mandarlo cuando quisiera. Como `absorbRenounce`
|
|
2925
|
+
// SELLA UN ACTA NUEVA cada vez, repetirlo hacía subir el `seq` y empujar a todos los
|
|
2926
|
+
// aparatos sin que hubiera cambiado nada — y dejaba en la bitácora una renuncia con el
|
|
2927
|
+
// nombre del aparato, que es lo peor: la bitácora es la prueba, y ahí decía algo que
|
|
2928
|
+
// ese aparato no hizo.
|
|
2929
|
+
//
|
|
2930
|
+
// Mirando lo que el acta dice HOY, un registro repetido no quita nada y se para aquí.
|
|
2931
|
+
// Lo que esto NO cierra, y queda anotado: si el dueño vuelve a conceder ese permiso, el
|
|
2932
|
+
// mismo registro viejo lo quita otra vez. Cerrarlo del todo pide que el acta recuerde
|
|
2933
|
+
// la fecha de la última renuncia por miembro y rechace las que no sean posteriores.
|
|
2934
|
+
const actual = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
2935
|
+
if (!actual) {
|
|
2936
|
+
audit('rejected', { what: 'renounce', reason: 'sin-acta' })
|
|
2937
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: this vault has no record to apply it to' })
|
|
2938
|
+
}
|
|
2939
|
+
const tiene = new Set(Acta.effectiveCaps(actual, record.member) || [])
|
|
2940
|
+
if (!(record.caps || []).some((c) => tiene.has(c))) {
|
|
2941
|
+
audit('rejected', { what: 'renounce', reason: 'nada-que-quitar' })
|
|
2942
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: that member no longer holds any of those capabilities' })
|
|
2943
|
+
}
|
|
2944
|
+
try {
|
|
2945
|
+
// A LA SUBACTA PRIMERO, y aquí está el arreglo: desde este momento surte efecto,
|
|
2946
|
+
// esté la bóveda abierta o cerrada. Antes se intentaba sellar directamente, sellar es
|
|
2947
|
+
// de la maestra, y con el perfil cerrado la renuncia se perdía con un error — en el
|
|
2948
|
+
// único momento en que de verdad hace falta.
|
|
2949
|
+
const guardada = await subacta.add(record)
|
|
2950
|
+
if (!guardada.ok && guardada.reason === 'no-aceptable') {
|
|
2951
|
+
audit('rejected', { what: 'renounce', reason: 'no-aceptable' })
|
|
2952
|
+
return reply(from, { type: MSG.ERROR, error: 'renounce: the entry is not one this vault accepts' })
|
|
2953
|
+
}
|
|
2954
|
+
const device = await deviceIdOf(record.member).catch(() => null)
|
|
2955
|
+
audit('renounce', { device, caps: record.caps })
|
|
2956
|
+
log(`[vault] ${device} renounced: ${(record.caps || []).join(', ')} — in effect now`)
|
|
2957
|
+
|
|
2958
|
+
// Y SE SELLA SI SE PUEDE. Con la bóveda abierta entra en el acta y lo respeta toda la
|
|
2959
|
+
// cuenta; cerrada se queda pendiente y lo absorbe el `unlock`. Las dos ramas son
|
|
2960
|
+
// correctas, así que el fallo al sellar NO es un error para quien renunció.
|
|
2961
|
+
let seq = null
|
|
2962
|
+
try {
|
|
2963
|
+
const r = await identity.absorbRenounce(record)
|
|
2964
|
+
subacta.clear([record])
|
|
2965
|
+
seq = r?.seq ?? null
|
|
2966
|
+
} catch (e) {
|
|
2967
|
+
log(`[vault] the renounce stays pending until the vault is opened (${e.message})`)
|
|
2968
|
+
}
|
|
2969
|
+
await notifyMembers('renounce', { deviceId: device, caps: record.caps })
|
|
2970
|
+
reply(from, { type: MSG.RENOUNCE_RESULT, ok: true, seq, pending: seq === null })
|
|
2971
|
+
} catch (e) {
|
|
2972
|
+
reply(from, { type: MSG.ERROR, error: 'renounce: ' + e.message })
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
async function handleAdmin (from, p) {
|
|
2977
|
+
// La frescura se comprueba aquí (es del transporte, igual que en el resto de
|
|
2978
|
+
// handlers); el resto de la regla vive en el módulo puro.
|
|
2979
|
+
if (!isFresh(p.data)) return staleReply(from)
|
|
2980
|
+
const r = await admin.handle(p.data, { signature: p.signature, cert: p.cert })
|
|
2981
|
+
// El `code` viaja aparte del texto: el texto está en inglés y puede cambiar, el código
|
|
2982
|
+
// es lo que empareja quien llama (`vault-locked` ≠ «no autorizado»).
|
|
2983
|
+
// EL FRENO VIAJA CON EL RECHAZO. `tries` y `waitSec` solo los trae abrir a distancia, y
|
|
2984
|
+
// sin ellos el admin reintenta contra una puerta que ya no responde y no sabe por qué —
|
|
2985
|
+
// el mismo silencio que se arregló en el resto del ecosistema.
|
|
2986
|
+
if (!r.ok) {
|
|
2987
|
+
return reply(from, {
|
|
2988
|
+
type: MSG.ERROR,
|
|
2989
|
+
error: r.error,
|
|
2990
|
+
...(r.code ? { code: r.code } : {}),
|
|
2991
|
+
...(r.tries != null ? { tries: r.tries } : {}),
|
|
2992
|
+
...(r.waitSec != null ? { waitSec: r.waitSec } : {})
|
|
2993
|
+
})
|
|
2994
|
+
}
|
|
2995
|
+
reply(from, { type: MSG.ADMIN_RESULT, op: p.data.op, result: r.result })
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
// API local de secretos (CLI/UI del dueño; audita cada cambio). `isPublic` es opcional:
|
|
2999
|
+
// sin decir nada, la variable conserva su visibilidad (y una nueva nace privada).
|
|
3000
|
+
/**
|
|
3001
|
+
* La llave con la que se abre la copia maestra.
|
|
3002
|
+
*
|
|
3003
|
+
* Con contraseña, la deriva quien llama y llega aquí por operación. **Sin
|
|
3004
|
+
* contraseña se cae a la llave de la máquina**, que es exactamente la protección
|
|
3005
|
+
* que había antes de todo esto: el disco sigue cifrado, pero su material vive en
|
|
3006
|
+
* ese mismo disco, así que una copia del disco lo abre.
|
|
3007
|
+
*
|
|
3008
|
+
* Es un default deliberado —un perfil sin contraseña tiene que seguir funcionando—
|
|
3009
|
+
* pero NO es equivalente, y por eso la consola lo dice en voz alta (§2.3 del
|
|
3010
|
+
* diseño). Prometer una protección que no está puesta es peor que no tenerla.
|
|
3011
|
+
*/
|
|
3012
|
+
const adminKeyOr = (adminKey) => adminKey || disk.machineKey()
|
|
3013
|
+
|
|
3014
|
+
// `adminKey` es la llave derivada de la contraseña del perfil, y va POR OPERACIÓN: se
|
|
3015
|
+
// usa para sellar y se suelta. Solo hace falta para escribir una privada — servir,
|
|
3016
|
+
// listar y borrar no la piden (ver `secretsStore.js`).
|
|
3017
|
+
/**
|
|
3018
|
+
* Los miembros que deben poder abrir un cajón: los SERVICIOS de ese namespace
|
|
3019
|
+
* (miembros del acta con ese `cn`). La bóveda NO entra en la lista — envolverle la
|
|
3020
|
+
* CEK a ella misma sería devolverle la capacidad de leerlo todo, que es justo lo
|
|
3021
|
+
* que este diseño quita.
|
|
3022
|
+
*/
|
|
3023
|
+
async function nsMembers (ns) {
|
|
3024
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3025
|
+
if (!record) return []
|
|
3026
|
+
// LLEVAR EL `cn` NO ES PODER LEER. El acta manda también aquí: un miembro entra en la
|
|
3027
|
+
// lista si además puede leer los secretos de ese cajón (`memberCanReadSecrets`).
|
|
3028
|
+
//
|
|
3029
|
+
// Sin esa segunda mitad, el `cn` era la única puerta y `cn` es un texto libre: la LLAVE
|
|
3030
|
+
// DE COMUNICACIÓN de la bóveda entra en el acta con `cn: 'vault'` y `caps: ['sign']`,
|
|
3031
|
+
// así que un cajón llamado `vault` —nombre perfectamente válido— la contaba entre sus
|
|
3032
|
+
// dueños. No llegaba a recibir sobre (no tiene `encPub`, y `recipientsFor` los filtra),
|
|
3033
|
+
// pero la salvaguarda era un accidente: bastaba que alguien le apuntara una `encPub`
|
|
3034
|
+
// para envolverle la llave a la llave que firma con la bóveda CERRADA. Se comprueba el
|
|
3035
|
+
// permiso, que es lo que de verdad se quería preguntar.
|
|
3036
|
+
return (record.members || []).filter((m) => m.cn === ns && Acta.memberCanReadSecrets(record, m.pub, ns))
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
/**
|
|
3040
|
+
* Los APARATOS QUE ADMINISTRAN: miembros sin CN (no son servicios) con llave de
|
|
3041
|
+
* cifrado. Son los que pueden VER y REVERTIR desde la consola sin teclear la frase en
|
|
3042
|
+
* ningún sitio, que es todo el punto del §8.2 — la capacidad de leer se muda de una
|
|
3043
|
+
* frase que se escribe en cualquier parte a una llave que no sale del aparato.
|
|
3044
|
+
*
|
|
3045
|
+
* La bóveda NO entra en la lista, ni aquí ni en `nsMembers`: envolverle la llave a
|
|
3046
|
+
* ella misma sería devolverle la capacidad de leerlo todo, que es justo lo que este
|
|
3047
|
+
* diseño quita. Hay un test que lo afirma.
|
|
3048
|
+
*/
|
|
3049
|
+
async function adminDevices () {
|
|
3050
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3051
|
+
return (record?.members || []).filter((m) => !m.cn && m.encPub && m.pub !== master)
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
/**
|
|
3055
|
+
* Los destinatarios de un cajón. Y la regla que decide quién NO entra, que es la que
|
|
3056
|
+
* importa (decidida por el dueño el 2026-08-22):
|
|
3057
|
+
*
|
|
3058
|
+
* **UN CAJÓN CON DUEÑO NO SE ENVUELVE PARA QUIEN ADMINISTRA.** Si el acta dice que
|
|
3059
|
+
* hay un servicio que consume ese `ns`, sus variables son suyas y de nadie más: el
|
|
3060
|
+
* token de R2 o las llaves de TURN no se pueden abrir desde un navegador, ni aunque
|
|
3061
|
+
* alguien se lleve tu portátil con la sesión puesta.
|
|
3062
|
+
*
|
|
3063
|
+
* Dos cosas que hacen que esto se sostenga:
|
|
3064
|
+
*
|
|
3065
|
+
* · **No es una negativa, es que no existe.** Se podría haber dejado el envoltorio y
|
|
3066
|
+
* que la bóveda se negara a entregarlo, pero entonces la protección sería una
|
|
3067
|
+
* política: el envoltorio seguiría en el disco, y quien tuviera el disco más la
|
|
3068
|
+
* llave de un aparato que administra lo abriría sin preguntarle a nadie. Lo que no
|
|
3069
|
+
* se crea no se puede saltar.
|
|
3070
|
+
* · **El criterio es ESTRUCTURAL, no de tiempo de ejecución.** Se mira el acta —qué
|
|
3071
|
+
* dice la maestra que existe—, no quién está encendido. «Hay un servicio activo»
|
|
3072
|
+
* se puede forzar esperando a que ese servicio esté caído.
|
|
3073
|
+
*
|
|
3074
|
+
* Y sigue habiendo quien pueda repartir: el propio SERVICIO re-envuelve para un
|
|
3075
|
+
* miembro nuevo de su cajón (ya tiene la CEK, así que no gana nada), y la frase
|
|
3076
|
+
* abre la envoltura de recuperación, que `wrapAll` añade siempre.
|
|
3077
|
+
*/
|
|
3078
|
+
/**
|
|
3079
|
+
* Llega la envoltura que hizo un servicio. Se comprueba QUIÉN la firma —tiene que ser
|
|
3080
|
+
* el aparato al que se le pidió, no cualquiera que pase por el proxy— y se reparte a
|
|
3081
|
+
* quien esté esperándola. Guardarla es cosa de `delegateRewrap`, que es quien sabe
|
|
3082
|
+
* qué pidió; aquí solo se valida el remitente.
|
|
3083
|
+
*/
|
|
3084
|
+
async function handleRewrapOk (payload) {
|
|
3085
|
+
const d = payload?.data
|
|
3086
|
+
if (!d || d.op !== 'rewrap.ok' || !d.wrap) return
|
|
3087
|
+
const signer = payload?.cert?.sub
|
|
3088
|
+
if (!signer) return
|
|
3089
|
+
if (!(await verifyDeviceSig({ publickey: signer, data: d, signature: payload.signature }))) {
|
|
3090
|
+
return log('[vault] a handed key arrived BADLY SIGNED: ignored')
|
|
3091
|
+
}
|
|
3092
|
+
// Y que quien firma sea de ese cajón: si no, no tenía por qué poder abrir esa llave.
|
|
3093
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3094
|
+
const m = (record?.members || []).find((x) => x.pub === signer)
|
|
3095
|
+
const ns = d.owner?.startsWith('ns:') ? d.owner.slice(3) : null
|
|
3096
|
+
if (!m || (ns && m.cn !== ns)) return log('[vault] a handed key arrived from someone outside that drawer: ignored')
|
|
3097
|
+
for (const fn of [...rewrapWaiters]) { try { fn(d) } catch (_) {} }
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
/** Suscriptores de `vault.rewrap.ok` (la envoltura que devuelve un servicio). */
|
|
3101
|
+
const rewrapWaiters = new Set()
|
|
3102
|
+
const onRewrapOk = (fn) => { rewrapWaiters.add(fn); return () => rewrapWaiters.delete(fn) }
|
|
3103
|
+
|
|
3104
|
+
/**
|
|
3105
|
+
* LAS OTRAS BÓVEDAS —los miembros con el permiso `sella`— entran en TODOS los cajones,
|
|
3106
|
+
* también en los que tienen dueño. Decidido por el dueño el 2026-08-30.
|
|
3107
|
+
*
|
|
3108
|
+
* Y no contradice la regla de agosto —«un cajón con dueño no se envuelve para quien
|
|
3109
|
+
* administra»—, porque un cosellador **no es quien administra**: es un master. Aquella
|
|
3110
|
+
* regla saca a los aparatos de consola (un navegador, un portátil con la sesión puesta),
|
|
3111
|
+
* cuyo compromiso es probable y cuya necesidad es comodidad. Una segunda bóveda existe
|
|
3112
|
+
* justamente para poder REGENERAR los sobres el día que la primera no esté, y regenerar
|
|
3113
|
+
* exige abrir: dejarla fuera la haría inútil para el único desastre que viene a cubrir.
|
|
3114
|
+
*
|
|
3115
|
+
* El precio, que se dice y no se esconde: a partir de aquí son DOS los discos cuya
|
|
3116
|
+
* captura abre ese cajón. Eso es exactamente lo que cuesta sobrevivir a perder uno.
|
|
3117
|
+
*/
|
|
3118
|
+
async function cosealerMembers () {
|
|
3119
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3120
|
+
if (!record) return []
|
|
3121
|
+
// Sin `encPub` no hay a dónde envolver: se queda en deuda y se ve como `sinLlave`,
|
|
3122
|
+
// igual que cualquier otro miembro incompleto.
|
|
3123
|
+
return (record.members || []).filter((m) =>
|
|
3124
|
+
m.pub !== master && m.encPub && Acta.memberCan(record, m.pub, 'sealer'))
|
|
3125
|
+
}
|
|
3126
|
+
|
|
3127
|
+
/** Sin duplicar: un cosellador puede ser además el dueño del cajón. */
|
|
3128
|
+
/** Sin repetidos por llave: un aparato puede entrar por dos vías (dueño y admin). */
|
|
3129
|
+
const dedup = (miembros) => {
|
|
3130
|
+
const vistos = new Set()
|
|
3131
|
+
return miembros.filter((m) => m && !vistos.has(m.pub) && vistos.add(m.pub))
|
|
3132
|
+
}
|
|
3133
|
+
const conCoselladores = (base, co) => {
|
|
3134
|
+
const vistos = new Set(base.map((m) => m.pub))
|
|
3135
|
+
return [...base, ...co.filter((m) => !vistos.has(m.pub))]
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
async function recipientsOf (owner, { public: isPublic = false } = {}) {
|
|
3139
|
+
const co = await cosealerMembers()
|
|
3140
|
+
// UNA PÚBLICA SE ENVUELVE TAMBIÉN PARA QUIEN ADMINISTRA (dueño, 2026-09-02: «la consola
|
|
3141
|
+
// debe poder leer todas las variables… debe tener todos los sobres; la diferencia es si
|
|
3142
|
+
// el vault se los envía para la lectura o no» → «cambia la regla y di que se envuelven
|
|
3143
|
+
// las públicas»).
|
|
3144
|
+
//
|
|
3145
|
+
// Puede decidirse variable a variable porque cada escritura estrena su propia CEK: el
|
|
3146
|
+
// llavero guarda una generación por valor, no una por cajón.
|
|
3147
|
+
//
|
|
3148
|
+
// Y la PRIVADA de un cajón con dueño sigue sin envoltura para la consola, que es la
|
|
3149
|
+
// regla de agosto y su motivo no ha cambiado: el token de R2 no tiene por qué poder
|
|
3150
|
+
// abrirse desde un navegador. Lo que cambia es que «pública» ya sí quiere decir algo
|
|
3151
|
+
// para quien administra — puede leerla— además de que se despacha sin aprobación.
|
|
3152
|
+
const admins = isPublic ? await adminDevices() : []
|
|
3153
|
+
// EL PERFIL SE LE ENVUELVE A QUIEN PUEDE HABLAR POR TI (`docs/datos-del-perfil.md`).
|
|
3154
|
+
//
|
|
3155
|
+
// Va explícito y antes que nada porque si no cae en la rama de «cajón sin dueño» y
|
|
3156
|
+
// acabaría envuelto solo para quien ADMINISTRA — que no es lo que se decidió: leer y
|
|
3157
|
+
// escribir tu identidad es `firma`, y no todos los que firman administran. Un teléfono
|
|
3158
|
+
// normal firma y no administra, y tiene que ver tu perfil.
|
|
3159
|
+
if (owner === PROFILE_OWNER) {
|
|
3160
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta || null
|
|
3161
|
+
const firman = (record?.members || [])
|
|
3162
|
+
.filter((m) => m.encPub && Acta.memberCan(record, m.pub, 'sign'))
|
|
3163
|
+
.map((m) => ({ pub: m.pub, encPub: m.encPub }))
|
|
3164
|
+
return conCoselladores(dedup(firman), co)
|
|
3165
|
+
}
|
|
3166
|
+
if (owner.startsWith('ns:')) {
|
|
3167
|
+
const owned = await nsMembers(owner.slice(3))
|
|
3168
|
+
// Sin dueño (un cajón personal, o uno cuyo servicio ya no está) entra quien administra
|
|
3169
|
+
// SIEMPRE: si no, no quedaría nadie que pudiera abrirlo sin la frase.
|
|
3170
|
+
const base = owned.length ? [...owned, ...admins] : await adminDevices()
|
|
3171
|
+
return conCoselladores(dedup(base), co)
|
|
3172
|
+
}
|
|
3173
|
+
const pub = owner.slice(owner.indexOf(':') + 1)
|
|
3174
|
+
const m = await memberOf(pub)
|
|
3175
|
+
// El cajón propio de un aparato de SERVICIO es tan suyo como el de su ns.
|
|
3176
|
+
if (m?.cn) return conCoselladores(dedup([m, ...admins]), co)
|
|
3177
|
+
return conCoselladores(dedup([...(m ? [m] : []), ...await adminDevices()]), co)
|
|
3178
|
+
}
|
|
3179
|
+
|
|
3180
|
+
/**
|
|
3181
|
+
* Firma un sobre con la LLAVE DE SELLADO que nombra el acta (§8.8). Devuelve el `seq`
|
|
3182
|
+
* del acta junto a la firma: es lo que le dice a quien verifica con qué llave
|
|
3183
|
+
* comprobarla, porque esa llave rota con el acta (§8.9).
|
|
3184
|
+
*
|
|
3185
|
+
* Si no hay llave —o no es nuestra— el sobre sale SIN firma. Guardar la configuración
|
|
3186
|
+
* es más importante que poder demostrar después de dónde salió.
|
|
3187
|
+
*/
|
|
3188
|
+
async function signSeal (body) {
|
|
3189
|
+
try {
|
|
3190
|
+
const acta = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3191
|
+
if (!acta?.sealPub) return null
|
|
3192
|
+
const sig = await sealKeys.sign(acta.sealPub, body)
|
|
3193
|
+
return sig ? { seq: acta.seq, sig } : null
|
|
3194
|
+
} catch (_) { return null }
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
/**
|
|
3198
|
+
* Sellar no basta: hay que REPARTIR la llave. Tras cada escritura se envuelve la CEK
|
|
3199
|
+
* del cajón a sus miembros actuales — si no, el servicio recibe sobres que no puede
|
|
3200
|
+
* abrir y se queda reintentando para siempre, sin decir por qué.
|
|
3201
|
+
*
|
|
3202
|
+
* No avisa de cambio a nadie: el texto cifrado de los valores no se mueve, y avisar
|
|
3203
|
+
* reiniciaría a todos los nodos del ns para nada.
|
|
3204
|
+
*/
|
|
3205
|
+
/**
|
|
3206
|
+
* Se fue un miembro de un namespace: su CEK deja de ser de fiar. Se intenta rotar en
|
|
3207
|
+
* el acto y, si no se puede (perfil sin desbloquear), el cajón queda MARCADO — la
|
|
3208
|
+
* consola y el CLI lo enseñan, y la siguiente escritura desbloqueada lo salda.
|
|
3209
|
+
*
|
|
3210
|
+
* Una deuda que no se ve es una deuda que no se paga, y aquí la deuda es que alguien
|
|
3211
|
+
* que ya no está podría abrir lo que se escriba mañana.
|
|
3212
|
+
*/
|
|
3213
|
+
async function markRotationDue (sub) {
|
|
3214
|
+
const m = await memberOf(sub)
|
|
3215
|
+
const ns = m?.cn
|
|
3216
|
+
if (!ns) return
|
|
3217
|
+
try {
|
|
3218
|
+
const r = await secrets.rotate(`ns:${ns}`, await nsMembers(ns))
|
|
3219
|
+
if (r?.rotated != null) {
|
|
3220
|
+
log(`[vault] ns:${ns}: key rotated after removing a member (${r.rotated} variable(s) re-encrypted)`)
|
|
3221
|
+
audit('secret.rotate', { ns, keys: r.rotated })
|
|
3222
|
+
scheduleNotice(ns)
|
|
3223
|
+
return
|
|
3224
|
+
}
|
|
3225
|
+
} catch (e) {
|
|
3226
|
+
store.setSetting(`rotate-due:${ns}`, String(Date.now()))
|
|
3227
|
+
log(`[vault] ns:${ns}: PENDING ROTATION - a member left and its key could not be rotated (${e.message})`)
|
|
3228
|
+
audit('secret.rotate-due', { ns, reason: e.message })
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
/**
|
|
3233
|
+
* Lo que este perfil debe volver a sellar, para que las listas puedan DECIRLO. Son dos
|
|
3234
|
+
* deudas distintas y las dos acaban igual —los miembros no leen sus variables— así que
|
|
3235
|
+
* salen juntas:
|
|
3236
|
+
*
|
|
3237
|
+
* - `rotate`: se fue un miembro y no se pudo rotar la llave del namespace.
|
|
3238
|
+
* - `rewrap`: entró uno (o registró su llave de cifrado) y no se le pudo envolver.
|
|
3239
|
+
*
|
|
3240
|
+
* Ambas se saldan solas en la siguiente escritura con contraseña. Mientras tanto,
|
|
3241
|
+
* enseñarlas es la diferencia entre un servicio mal configurado y uno mal configurado
|
|
3242
|
+
* que además nadie ve.
|
|
3243
|
+
*/
|
|
3244
|
+
/**
|
|
3245
|
+
* Los aparatos INCOMPLETOS: los que están en el acta pero no pueden abrir alguna de
|
|
3246
|
+
* las variables que les tocan (§8.7). Pasa siempre que entra uno nuevo — envolverle su
|
|
3247
|
+
* llave exige abrir la CEK, y eso pide la frase, que por el camino del enrolamiento no
|
|
3248
|
+
* hay quien la teclee.
|
|
3249
|
+
*
|
|
3250
|
+
* Que no puedan leer es CORRECTO y no se relaja. Lo que se arregla aquí es que se
|
|
3251
|
+
* vea: hasta ahora un servicio recién enrolado aparecía en la lista como cualquier
|
|
3252
|
+
* otro y arrancaba sin configuración, repitiendo un error en su propio log que nadie
|
|
3253
|
+
* mira. Con esto, la consola y la TUI pueden decir exactamente qué falta y qué hacer
|
|
3254
|
+
* (`dotrino-vault secret settle`, que sí pide la frase).
|
|
3255
|
+
*
|
|
3256
|
+
* @returns {Promise<Array<{ pub: string, cn: string|null, owners: Record<string, string[]> }>>}
|
|
3257
|
+
*/
|
|
3258
|
+
async function incompleteMembers () {
|
|
3259
|
+
if (secrets.isLegacy?.()) return []
|
|
3260
|
+
// A QUIÉN LE FALTA ALGO LO DICE `recipientsOf`, Y SOLO ÉL — la misma lista con la que
|
|
3261
|
+
// se envuelve al escribir y se rehace el llavero al abrir. Aquí había otra: «un aparato
|
|
3262
|
+
// sin `cn` puede destapar cualquier cajón sin dueño». Para `@me` no es cierto —el
|
|
3263
|
+
// perfil se envuelve a quien puede FIRMAR por ti—, así que un aparato sin `firma` salía
|
|
3264
|
+
// con todos los datos del perfil «sin llave todavía», y abrir la bóveda, que es lo que
|
|
3265
|
+
// el aviso pedía, no podía apagarlo: el llavero ya estaba como decía el acta. Dos
|
|
3266
|
+
// listas para la misma pregunta acaban siempre así.
|
|
3267
|
+
const out = new Map()
|
|
3268
|
+
for (const owner of secrets.owners()) {
|
|
3269
|
+
// Por VISIBILIDAD: a quien administra se le envuelve la pública de un cajón con dueño
|
|
3270
|
+
// y no la privada. La lista de las públicas contiene siempre a la de las privadas.
|
|
3271
|
+
const privados = new Set((await recipientsOf(owner)).map((m) => m.pub))
|
|
3272
|
+
for (const m of await recipientsOf(owner, { public: true })) {
|
|
3273
|
+
if (!m.encPub || m.pub === master) continue
|
|
3274
|
+
const missing = secrets.missingFor(owner, m.pub, { only: (esPublica) => esPublica || privados.has(m.pub) })
|
|
3275
|
+
if (!missing.length) continue
|
|
3276
|
+
const row = out.get(m.pub) || { pub: m.pub, cn: m.cn || null, owners: {} }
|
|
3277
|
+
row.owners[owner] = missing
|
|
3278
|
+
out.set(m.pub, row)
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
return [...out.values()]
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
/**
|
|
3285
|
+
* Lo que quedó a deber una ROTACIÓN: un aparato salió conociendo la llave vigente y
|
|
3286
|
+
* no se pudo rotar en ese momento. Eso sí es una nota, porque es un hecho pasado que el
|
|
3287
|
+
* estado de hoy no enseña (quitarle la envoltura no le quita lo que ya supo).
|
|
3288
|
+
*/
|
|
3289
|
+
function rotationsDue () {
|
|
3290
|
+
const out = {}
|
|
3291
|
+
for (const [k, v] of Object.entries(store.listSettings?.() || {})) {
|
|
3292
|
+
// La salida va SIEMPRE por owner (`ns:proxy`), aunque el ajuste guarde solo el
|
|
3293
|
+
// nombre del namespace: una sola forma para quien lo lee.
|
|
3294
|
+
if (k.startsWith('rotate-due:')) out['ns:' + k.slice('rotate-due:'.length)] = { kind: 'rotate', at: Number(v) || 0 }
|
|
3295
|
+
}
|
|
3296
|
+
return out
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
/**
|
|
3300
|
+
* TODO lo que está a deber, por cajón: las rotaciones anotadas y, CALCULADO, cada
|
|
3301
|
+
* envoltura que falta. La falta de una envoltura no se anota nunca: se mira. Una nota
|
|
3302
|
+
* se desincroniza en cuanto alguien salda la deuda por un camino que no pasa por donde
|
|
3303
|
+
* se anotó (pasó dos veces: el reparto por el hermano y el rehacer al abrir), y
|
|
3304
|
+
* entonces la consola avisa de algo que ya no existe. Lo que se calcula no miente.
|
|
3305
|
+
* @returns {Promise<Record<string, { kind: 'rotate'|'rewrap', at?: number, members?: { pub: string, keys: string[] }[] }>>}
|
|
3306
|
+
*/
|
|
3307
|
+
async function secretDebts () {
|
|
3308
|
+
const out = rotationsDue()
|
|
3309
|
+
for (const m of await incompleteMembers()) {
|
|
3310
|
+
for (const [owner, keys] of Object.entries(m.owners)) {
|
|
3311
|
+
if (out[owner]?.kind === 'rotate') continue
|
|
3312
|
+
out[owner] = out[owner] || { kind: 'rewrap', members: [] }
|
|
3313
|
+
out[owner].members.push({ pub: m.pub, keys })
|
|
3314
|
+
}
|
|
3315
|
+
}
|
|
3316
|
+
return out
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
async function spreadKey (owner, members, adminKey) {
|
|
3320
|
+
if (secrets.isLegacy()) return null
|
|
3321
|
+
// Si este cajón quedó a deber una rotación (se fue alguien y no se pudo rotar),
|
|
3322
|
+
// se salda AHORA, que es cuando hay con qué. Rotar incluye re-envolver, así que
|
|
3323
|
+
// sustituye al reparto en vez de sumarse.
|
|
3324
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
3325
|
+
if (ns && store.getSetting(`rotate-due:${ns}`)) {
|
|
3326
|
+
const rot = await secrets.rotate(owner, members, adminKey)
|
|
3327
|
+
store.setSetting(`rotate-due:${ns}`, undefined)
|
|
3328
|
+
log(`[vault] ns:${ns}: pending rotation settled (${rot?.rotated ?? 0} variable(s) re-encrypted)`)
|
|
3329
|
+
audit('secret.rotate', { ns, keys: rot?.rotated ?? 0, pending: true })
|
|
3330
|
+
return rot
|
|
3331
|
+
}
|
|
3332
|
+
try {
|
|
3333
|
+
const r = await secrets.rewrap(owner, members, adminKey)
|
|
3334
|
+
if (r?.sinLlave?.length) {
|
|
3335
|
+
log(`[vault] ${owner}: ${r.sinLlave.length} member(s) without an encryption key - they will NOT be able to read their variables`)
|
|
3336
|
+
audit('secret.nokey', { owner, count: r.sinLlave.length })
|
|
3337
|
+
}
|
|
3338
|
+
return r
|
|
3339
|
+
} catch (e) {
|
|
3340
|
+
// SIN la contraseña no hay forma de envolverle su llave, y esto pasa por caminos
|
|
3341
|
+
// donde no hay a quién pedírsela: un servicio que acaba de registrar su llave de
|
|
3342
|
+
// cifrado llega por el proxio, no por una consola. Antes se perdía en un `.catch`
|
|
3343
|
+
// del que llamaba y el servicio se quedaba sin variables SIN QUE NADIE SE ENTERARA
|
|
3344
|
+
// — el modo de fallo que más caro sale aquí. Se dice y se audita; la deuda no se
|
|
3345
|
+
// anota: se CALCULA (`secretDebts`), así no hay nota que se quede vieja cuando la
|
|
3346
|
+
// salde un hermano o el abrir la bóveda.
|
|
3347
|
+
log(`[vault] ${owner}: could not hand out its key (${e.message}); its members will not read their variables until a sibling hands it out or the vault is opened`)
|
|
3348
|
+
audit('secret.rewrap-due', { owner, reason: e.message })
|
|
3349
|
+
throw e
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
/**
|
|
3354
|
+
* Escribir NO pide la frase (§8.1): el sobre se sella con las públicas de quien lo va
|
|
3355
|
+
* a leer. `by` es el aparato que lo escribió, y va al histórico.
|
|
3356
|
+
*/
|
|
3357
|
+
async function setSecret (ns, key, value, isPublic, { by = null } = {}) {
|
|
3358
|
+
await secrets.set(ns, key, value, isPublic, { by })
|
|
3359
|
+
await settleDebts(`ns:${ns}`)
|
|
3360
|
+
audit('secret.set', { ns, key }); scheduleNotice(ns)
|
|
3361
|
+
}
|
|
3362
|
+
|
|
3363
|
+
/**
|
|
3364
|
+
* SALDAR LAS DEUDAS DEL PERFIL, con la frase en la mano. Es lo que hay que llamar tras
|
|
3365
|
+
* desbloquear: heredarle a un aparato nuevo lo que ya estaba guardado, y rotar de
|
|
3366
|
+
* verdad el cajón del que salió alguien. Las dos cosas exigen ABRIR, y abrir es lo
|
|
3367
|
+
* único que la frase guarda (§8.3).
|
|
3368
|
+
*
|
|
3369
|
+
* No lanza: devuelve qué pasó con cada cajón, porque una deuda que no se puede saldar
|
|
3370
|
+
* tiene que seguir viéndose en la lista en vez de tumbar la operación entera.
|
|
3371
|
+
*/
|
|
3372
|
+
/**
|
|
3373
|
+
* PIDE AL SERVICIO QUE REPARTA la llave de su cajón a un miembro nuevo (§8.11).
|
|
3374
|
+
*
|
|
3375
|
+
* Es la única forma de completar a un aparato sin la frase y sin que nadie guarde
|
|
3376
|
+
* llaves de más: la bóveda no puede abrir la CEK, pero el servicio que la consume la
|
|
3377
|
+
* tiene abierta, y re-envolverla no le da ningún poder que no tuviera.
|
|
3378
|
+
*
|
|
3379
|
+
* La bóveda manda su propia envoltura de esa generación —la del servicio, que él ya
|
|
3380
|
+
* podía abrir— junto al ACTA firmada. El servicio saca de ahí la pública del
|
|
3381
|
+
* destinatario y contesta con la envoltura nueva, que se guarda con `putWrap`, que
|
|
3382
|
+
* solo AÑADE. Si el servicio no está encendido, la deuda se queda a la vista.
|
|
3383
|
+
*
|
|
3384
|
+
* @returns {Promise<{ done: number, asked: number }>}
|
|
3385
|
+
*/
|
|
3386
|
+
/**
|
|
3387
|
+
* PEDIRLE A UN HERMANO QUE ENVUELVA — Y QUE SE LO ENTREGUE A LA BÓVEDA (§8.11).
|
|
3388
|
+
*
|
|
3389
|
+
* Regla del dueño (2026-09-01): «un servicio reparte la llave AL VAULT para que este la
|
|
3390
|
+
* reparta después, pero no se la entrega directo; si lo hace sin pasar por el vault está
|
|
3391
|
+
* mal». Es lo que hace este camino, y conviene verlo en el orden de las líneas:
|
|
3392
|
+
*
|
|
3393
|
+
* 1. la BÓVEDA le pide el sobre al hermano (`sendByPubkey` → `REWRAP`);
|
|
3394
|
+
* 2. el hermano le contesta A ELLA, nunca al recién llegado;
|
|
3395
|
+
* 3. la BÓVEDA lo guarda (`putWrap`) y a partir de ahí lo sirve ella.
|
|
3396
|
+
*
|
|
3397
|
+
* El destinatario no habla con nadie, y la bóveda nunca ve la llave en claro: el sobre
|
|
3398
|
+
* llega ya cerrado a la pública del destinatario. Por eso esto funciona con el perfil
|
|
3399
|
+
* cerrado, que es justo cuando hace falta.
|
|
3400
|
+
*/
|
|
3401
|
+
async function delegateRewrap (owner, targetPub, { timeoutMs = 15000 } = {}) {
|
|
3402
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
3403
|
+
if (!ns) return { done: 0, asked: 0 }
|
|
3404
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3405
|
+
if (!record) return { done: 0, asked: 0 }
|
|
3406
|
+
// Quien puede repartir: un miembro de ESE cajón que no sea el propio destinatario.
|
|
3407
|
+
const helpers = (record.members || []).filter((m) => m.cn === ns && m.pub !== targetPub && m.encPub)
|
|
3408
|
+
const pending = secrets.wrapsToShare(owner, targetPub, helpers[0]?.pub || '')
|
|
3409
|
+
if (!helpers.length || !pending.length) return { done: 0, asked: 0 }
|
|
3410
|
+
|
|
3411
|
+
let done = 0
|
|
3412
|
+
for (const gen of pending) {
|
|
3413
|
+
// La llave de sellado firma (el servicio lo comprueba con `verifyResponder`): esto
|
|
3414
|
+
// tiene que funcionar con la bóveda cerrada, y la maestra entonces no firma.
|
|
3415
|
+
const body = { op: 'rewrap', owner, gen: gen.gen, wrap: gen.mine, target: targetPub, acta: record, ts: Date.now() }
|
|
3416
|
+
const seal = await sealOrFail(body)
|
|
3417
|
+
const answer = new Promise((resolve) => {
|
|
3418
|
+
const off = onRewrapOk((d) => {
|
|
3419
|
+
if (d.owner !== owner || d.gen !== gen.gen || d.target !== targetPub) return
|
|
3420
|
+
off(); resolve(d.wrap)
|
|
3421
|
+
})
|
|
3422
|
+
setTimeout(() => { off(); resolve(null) }, timeoutMs).unref?.()
|
|
3423
|
+
})
|
|
3424
|
+
try { client.sendByPubkey(helpers[0].pub, { type: MSG.REWRAP, body, seal }) } catch (e) {
|
|
3425
|
+
log(`[vault] ${owner}: could not ask for the key to be handed out (${e.message})`)
|
|
3426
|
+
continue
|
|
3427
|
+
}
|
|
3428
|
+
const wrap = await answer
|
|
3429
|
+
if (!wrap) continue
|
|
3430
|
+
try { secrets.putWrap(owner, gen.gen, targetPub, wrap); done++ } catch (e) {
|
|
3431
|
+
log(`[vault] ${owner}: the handed key was refused (${e.message})`)
|
|
3432
|
+
}
|
|
3433
|
+
}
|
|
3434
|
+
if (done) {
|
|
3435
|
+
audit('secret.delegated', { owner, target: await deviceIdOf(targetPub).catch(() => null), gens: done })
|
|
3436
|
+
log(`[vault] ${owner}: ${done} key(s) handed out by its own service`)
|
|
3437
|
+
}
|
|
3438
|
+
return { done, asked: pending.length }
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
/**
|
|
3442
|
+
* REHACER EL LLAVERO ENTERO. Se llama al ABRIR la bóveda, que es el único momento en que
|
|
3443
|
+
* se puede: envolver exige la llave del cajón, y esa solo se abre con la frase.
|
|
3444
|
+
*
|
|
3445
|
+
* Lo que falle aquí SALE en `failed`, y quien llame tiene que decirlo. Callarlo fue el
|
|
3446
|
+
* fallo: un cajón que no se pudo reenvolver deja a sus aparatos sin poder leer nada, y
|
|
3447
|
+
* desde fuera la bóveda parecía abierta y sana — «desbloqueado» a secas—. Se descubría
|
|
3448
|
+
* días después, por un servicio que pedía sus claves una y otra vez.
|
|
3449
|
+
*/
|
|
3450
|
+
/**
|
|
3451
|
+
* QUE LOS SOBRES EXISTAN PARA TODOS, SIEMPRE — TAMBIÉN AL SELLAR EL ACTA.
|
|
3452
|
+
*
|
|
3453
|
+
* Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
|
|
3454
|
+
* que todos los sobres para todos los dispositivos existan, antes y después de sellar un
|
|
3455
|
+
* acta; es la tarea de la llave maestra, no tiene otra».
|
|
3456
|
+
*
|
|
3457
|
+
* Y es literalmente su otro trabajo: la maestra firma el acta y regenera los sobres. Todo
|
|
3458
|
+
* cambio del acta cambia QUIÉN debe tener envoltura de qué —entra un aparato, se le da
|
|
3459
|
+
* `secrets`, se le quita, aparece un sellador nuevo—, así que sellar sin repasar los
|
|
3460
|
+
* sobres deja el llavero diciendo una cosa y el acta otra. Eso fue justo el agujero: un
|
|
3461
|
+
* servicio en el acta, con su permiso, y sin poder abrir nada.
|
|
3462
|
+
*
|
|
3463
|
+
* Con la bóveda ABIERTA se rehace de verdad. Cerrada no se puede —envolver exige la llave
|
|
3464
|
+
* del cajón— y entonces queda como DEUDA anotada, que es lo que salda el `unlock`. Lo que
|
|
3465
|
+
* no se hace es callarlo.
|
|
3466
|
+
*/
|
|
3467
|
+
/**
|
|
3468
|
+
* QUE LOS SOBRES EXISTAN PARA TODOS, CADA VEZ QUE EL ACTA CAMBIA.
|
|
3469
|
+
*
|
|
3470
|
+
* Regla del dueño (2026-09-01): «cuando se abre el vault, y siempre, debe asegurarse de
|
|
3471
|
+
* que todos los sobres para todos los dispositivos existan, antes y después de sellar un
|
|
3472
|
+
* acta; es la tarea de la llave maestra, no tiene otra». Y es literalmente su otro
|
|
3473
|
+
* trabajo: firmar el acta y regenerar los sobres.
|
|
3474
|
+
*
|
|
3475
|
+
* SE REHACE ENTERO, no se salda lo que falta. Saldar añade lo que no está pero no MIRA lo
|
|
3476
|
+
* que sobra: la envoltura de alguien a quien ya se le quitó el permiso se quedaría ahí
|
|
3477
|
+
* para siempre y nadie se enteraría. `resealAll` con `exact` deja cada cajón envuelto para
|
|
3478
|
+
* exactamente quien dice el acta —crea lo que falta y RETIRA lo caduco—, que es la única
|
|
3479
|
+
* forma de poder afirmar que el llavero está al día. No toca la red: es reenvolver con
|
|
3480
|
+
* las públicas del acta, así que esperar aquí no cuelga de ningún aparato apagado.
|
|
3481
|
+
*
|
|
3482
|
+
* NO HAY CAMINO PARA «LA BÓVEDA ESTÁ CERRADA» (dueño, 2026-09-01: «es un absurdo, con
|
|
3483
|
+
* bóveda cerrada no puede cambiar el acta»). Sellar el acta necesita la maestra, y cerrada
|
|
3484
|
+
* la maestra no está en memoria — o sea que si se llegó hasta aquí, estaba abierta. Un
|
|
3485
|
+
* `if` para ese caso no sería prudencia: sería un repliegue que le pone cara de trámite
|
|
3486
|
+
* normal a una contradicción. Si aun así falta la llave, se dice como lo que es.
|
|
3487
|
+
*/
|
|
3488
|
+
/**
|
|
3489
|
+
* ¿QUIÉN HIZO ESTE SOBRE? Tiene que decirlo, y tiene que ser un MIEMBRO DEL ACTA.
|
|
3490
|
+
*
|
|
3491
|
+
* Regla del dueño (2026-09-01): «los sobres deben traer información de quién los hizo,
|
|
3492
|
+
* deberían tener la firma para que no se cuelen sobres firmados por cualquiera; solo
|
|
3493
|
+
* puede haber sobres firmados por miembros del acta».
|
|
3494
|
+
*
|
|
3495
|
+
* La petición ya viene firmada y comprobada (`verifyChain` + el acta), así que un extraño
|
|
3496
|
+
* no llega hasta aquí. Esto es otra cosa y hace falta igual: la bóveda guarda unos bytes
|
|
3497
|
+
* que NO PUEDE LEER, y sin esta firma lo único que ataba esos bytes a un autor era el
|
|
3498
|
+
* momento de la llamada. Con ella, el registro guardado se explica solo — se puede
|
|
3499
|
+
* comprobar después, en frío, sin fiarse de ningún log.
|
|
3500
|
+
*
|
|
3501
|
+
* El autor tiene que ser QUIEN LLAMA. Aceptar un sobre firmado por otro sería admitir un
|
|
3502
|
+
* relevo: alguien con `admin` colando un sobre que fabricó un tercero, sin que ninguna de
|
|
3503
|
+
* las dos comprobaciones lo note.
|
|
3504
|
+
*/
|
|
3505
|
+
async function checkAuthor (owner, key, sealed, caller, { allowOverwrite = false } = {}) {
|
|
3506
|
+
const a = sealed?.author
|
|
3507
|
+
if (!a || typeof a.pub !== 'string' || typeof a.sig !== 'string' || typeof a.ts !== 'number') {
|
|
3508
|
+
throw new Error('var.set: the envelope must say WHO made it (`author: { pub, sig, ts }`)')
|
|
3509
|
+
}
|
|
3510
|
+
if (caller && a.pub !== caller) {
|
|
3511
|
+
throw new Error('var.set: the envelope was signed by someone other than the caller — a relayed envelope is not accepted')
|
|
3512
|
+
}
|
|
3513
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3514
|
+
const m = (record?.members || []).find((x) => x.pub === a.pub)
|
|
3515
|
+
if (!record || !m) {
|
|
3516
|
+
throw new Error('var.set: the envelope is signed by a key the record does not name')
|
|
3517
|
+
}
|
|
3518
|
+
// UN SERVICIO SOLO PARA SU CAJÓN (dueño, 2026-09-01: «un cn no puede poner un sobre
|
|
3519
|
+
// faltante fuera de su cn»). Es la misma regla que ya aplica el reparto delegado
|
|
3520
|
+
// (`handleRewrapOk`), y tiene que valer también aquí: si no, un servicio con `admin`
|
|
3521
|
+
// podría escribir en el cajón de otro. Quien administra sin `cn` sí puede: ese es su
|
|
3522
|
+
// trabajo, y para eso se le concede a mano.
|
|
3523
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
3524
|
+
if (m.cn && ns && m.cn !== ns) {
|
|
3525
|
+
throw new Error(`var.set: "${m.cn}" cannot write into "${owner}" — a service only authors envelopes for its own drawer`)
|
|
3526
|
+
}
|
|
3527
|
+
// Y UN SERVICIO NO PISA LO QUE YA HAY (dueño, 2026-09-01: «un cn no puede sobrescribir
|
|
3528
|
+
// un sobre existente; solamente el admin y el vault editan sobres, los otros pueden
|
|
3529
|
+
// crear los que faltan nomás»).
|
|
3530
|
+
//
|
|
3531
|
+
// SALVO QUE ALGUIEN LO APRUEBE (dueño, 2026-09-29: «si requiere aprobación hagamos que
|
|
3532
|
+
// pueda también editar; si no hay approval, solamente agregar — la aprobación nos da el
|
|
3533
|
+
// nivel de seguridad aceptable»). Un sí de quien aprueba es justo la decisión humana que
|
|
3534
|
+
// faltaba para reemplazar; sin él, sigue siendo solo rellenar.
|
|
3535
|
+
//
|
|
3536
|
+
// Es la misma regla que `putWrap` («solo añade, nunca pisa») y por el mismo motivo: un
|
|
3537
|
+
// servicio que pudiera reemplazar un sobre podría dejar sin leer a otro miembro con uno
|
|
3538
|
+
// basura — denegación de servicio disfrazada de escritura. Rellenar lo que falta no
|
|
3539
|
+
// quita nada a nadie; reemplazar sí, y eso es de quien administra.
|
|
3540
|
+
if (m.cn && !allowOverwrite && secrets.has?.(owner, key)) {
|
|
3541
|
+
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' })
|
|
3542
|
+
}
|
|
3543
|
+
const cuerpo = authorBody(owner, key, sealed.e, a.ts)
|
|
3544
|
+
if (!(await verifyDeviceSig({ publickey: a.pub, data: cuerpo, signature: a.sig }))) {
|
|
3545
|
+
throw new Error('var.set: the author signature does not verify over this envelope')
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
|
|
3549
|
+
/**
|
|
3550
|
+
* ¿ESTE SOBRE ENVUELVE A QUIEN TOCA? Lo único que se puede comprobar sin la llave.
|
|
3551
|
+
*
|
|
3552
|
+
* No se puede saber si dentro de cada envoltura está la CEK correcta —para eso habría que
|
|
3553
|
+
* abrirla, que es justo lo que ya no se hace—, pero sí QUIÉN puede abrirlas. Y eso ya
|
|
3554
|
+
* ataja lo que importa: que no falte nadie (un servicio que arrancaría sin configuración)
|
|
3555
|
+
* y que no sobre nadie (alguien a quien el acta no nombra leyendo el cajón).
|
|
3556
|
+
*
|
|
3557
|
+
* Lo de dentro lo corrige el repaso al abrir la bóveda, que es para lo que está.
|
|
3558
|
+
*/
|
|
3559
|
+
async function checkRecipients (owner, wraps, isPublic = false) {
|
|
3560
|
+
if (!wraps || typeof wraps !== 'object') throw new Error('var.set: the envelope carries no wraps')
|
|
3561
|
+
const { recoveryPub, members } = await secrets.recipientsFor(owner, { public: isPublic })
|
|
3562
|
+
if (!recoveryPub) throw new Error('var.set: this vault has no recovery key yet')
|
|
3563
|
+
const deben = new Set(members.map((m) => m.pub))
|
|
3564
|
+
const traidos = new Set(Object.keys(wraps).filter((k) => k !== RECOVERY_WRAP))
|
|
3565
|
+
const faltan = [...deben].filter((p) => !traidos.has(p))
|
|
3566
|
+
const sobran = [...traidos].filter((p) => !deben.has(p))
|
|
3567
|
+
if (faltan.length) {
|
|
3568
|
+
throw new Error(`var.set: the envelope leaves ${faltan.length} member(s) of "${owner}" out — they could not read it (ask the vault for the recipients with \`var.recipients\`)`)
|
|
3569
|
+
}
|
|
3570
|
+
if (sobran.length) {
|
|
3571
|
+
throw new Error(`var.set: the envelope wraps for ${sobran.length} key(s) the record does not name for "${owner}"`)
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3574
|
+
|
|
3575
|
+
async function refreshWraps (motivo) {
|
|
3576
|
+
// HAY CANDADO SOLO SI HAY LAS DOS COSAS: contraseña Y alguien que sepa abrirla.
|
|
3577
|
+
//
|
|
3578
|
+
// `startVault` sin gestor de perfiles trae `hasPassword = () => true` y `openKey =
|
|
3579
|
+
// null`, que leído a la letra dice «tiene contraseña pero no hay con qué abrirla» — el
|
|
3580
|
+
// estado imposible. Mirando solo `hasPassword` se daba por cerrada una bóveda que abre
|
|
3581
|
+
// con la llave de la máquina, y el repaso del llavero se saltaba ENTERO: el servicio se
|
|
3582
|
+
// quedaba sin su envoltura y contestábamos un paquete que no podía abrir. Es el mismo
|
|
3583
|
+
// fallo que se está arreglando, colado por la puerta de al lado.
|
|
3584
|
+
const conCandado = typeof openKey === 'function' && (() => { try { return !!hasPassword() } catch (_) { return true } })()
|
|
3585
|
+
const k = conCandado ? openKey() : null
|
|
3586
|
+
if (conCandado && !k) {
|
|
3587
|
+
log(`[vault] ${motivo}: 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`)
|
|
3588
|
+
return null
|
|
3589
|
+
}
|
|
3590
|
+
const r = await resealAll(k).catch((e) => { log(`[vault] ${motivo}: could not rebuild the keyring: ${e.message}`); return null })
|
|
3591
|
+
if (r?.wrapped) log(`[vault] ${motivo}: keyring rebuilt (${r.wrapped} wrap(s) in ${r.drawers} drawer(s))`)
|
|
3592
|
+
// LOS SOBRES CADUCOS SE DICEN. Son envolturas de quien ya no debería poder abrir ese
|
|
3593
|
+
// cajón: retirarlas en silencio esconde que alguien las tuvo de más.
|
|
3594
|
+
if (r?.dropped) log(`[vault] ${motivo}: ${r.dropped} stale wrap(s) dropped (they belonged to members the record no longer names)`)
|
|
3595
|
+
if (r?.failed?.length) log(`[vault] ${motivo}: ${r.failed.length} drawer(s) could NOT be resealed: ${r.failed.map((f) => f.owner).join(', ')}`)
|
|
3596
|
+
return r
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
/**
|
|
3600
|
+
* MIGRACIÓN, CON FECHA: la copia de recuperación pudo quedar sellada con la llave de la
|
|
3601
|
+
* MÁQUINA aunque el perfil tenga contraseña.
|
|
3602
|
+
*
|
|
3603
|
+
* Pasa por un camino perfectamente normal: escribir una variable NO pide la frase (§8.1),
|
|
3604
|
+
* así que si el cajón se estrenó con el perfil cerrado, `ensureRecovery` la selló con lo
|
|
3605
|
+
* único que había — la llave de la máquina. A partir de ahí, abrir el perfil y reenvolver
|
|
3606
|
+
* pasa la llave de la CONTRASEÑA, que no abre ese sobre: «wrong password», el llavero no
|
|
3607
|
+
* se rehace, y los servicios se quedan sin poder leer sus cajones para siempre. Es
|
|
3608
|
+
* exactamente lo que le pasó a un perfil real el 2026-09-01.
|
|
3609
|
+
*
|
|
3610
|
+
* Aquí se abre con la de la máquina y se vuelve a cerrar con la de la contraseña, que es
|
|
3611
|
+
* el `rekey` que aquel `password-set` no llegó a hacer. NO es un repliegue: no decide un
|
|
3612
|
+
* permiso, no deja pasar a nadie, y **se acaba** — al primer desbloqueo el sobre queda
|
|
3613
|
+
* bajo la frase y esta rama no vuelve a entrar. Y va en el sentido estricto: el material
|
|
3614
|
+
* de la llave de máquina vive en este mismo disco, así que pasar a la contraseña PROTEGE
|
|
3615
|
+
* más de lo que había, no menos.
|
|
3616
|
+
*
|
|
3617
|
+
* Se puede quitar cuando no queden perfiles de antes de 0.81.0 (2027-03-01).
|
|
3618
|
+
*/
|
|
3619
|
+
const MIGRACION_RECUPERACION_HASTA = Date.parse('2027-03-01T00:00:00Z')
|
|
3620
|
+
async function migrarRecuperacionALaFrase (adminKey) {
|
|
3621
|
+
if (!adminKey || Date.now() > MIGRACION_RECUPERACION_HASTA) return false
|
|
3622
|
+
try {
|
|
3623
|
+
// Si la frase ya la abre, no hay nada que migrar.
|
|
3624
|
+
await secrets.recoveryOpensWith(adminKey)
|
|
3625
|
+
return false
|
|
3626
|
+
} catch (_) {}
|
|
3627
|
+
try {
|
|
3628
|
+
// LA LLAVE DE LA MÁQUINA, EXPLÍCITA. `null` NO vale aquí: `defaultKey()` mira primero
|
|
3629
|
+
// `openKey()`, y con el perfil ABIERTO eso es la llave de la contraseña — o sea que
|
|
3630
|
+
// pasar `null` probaba la misma llave dos veces y la migración no migraba nada. Lo
|
|
3631
|
+
// dijo el propio diagnóstico: «no abre ni con la contraseña ni con la de la máquina»,
|
|
3632
|
+
// cuando la de la máquina no se había llegado a probar.
|
|
3633
|
+
const maquina = disk.machineKey()
|
|
3634
|
+
await secrets.recoveryOpensWith(maquina) // si esta tampoco, no hay migración que hacer
|
|
3635
|
+
const r = await secrets.rekeyRecovery(maquina, adminKey)
|
|
3636
|
+
if (!r?.rekeyed) return false
|
|
3637
|
+
log('[vault] the recovery copy was sealed with this machine\'s key: re-sealed under the profile password')
|
|
3638
|
+
audit('secret.recovery-rekeyed', {})
|
|
3639
|
+
return true
|
|
3640
|
+
} catch (e) {
|
|
3641
|
+
log(`[vault] the recovery copy does not open with the password nor with this machine's key (${e.message}): the drawers cannot be re-wrapped`)
|
|
3642
|
+
return false
|
|
3643
|
+
}
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
/**
|
|
3647
|
+
* LAS CONTRASEÑAS, AL FORMATO SELLADO (§2.7) Y CON SUS ENVOLTURAS AL DÍA (§2.5).
|
|
3648
|
+
*
|
|
3649
|
+
* Las dos cosas se hacen aquí, al ABRIR el perfil, porque las dos necesitan lo mismo: la
|
|
3650
|
+
* frase. Convertir hay que hacerlo una vez; repasar las envolturas, cada vez que cambie
|
|
3651
|
+
* quién puede leer — y este es el único momento en que hay con qué.
|
|
3652
|
+
*
|
|
3653
|
+
* La bóveda ve los valores SOLO mientras convierte, que es cuando tiene que reescribirlos.
|
|
3654
|
+
* Después no puede: lo último que hace la conversión es borrar la llave vieja.
|
|
3655
|
+
*/
|
|
3656
|
+
async function sealPasswords (adminKey = null) {
|
|
3657
|
+
if (!passwords) return null
|
|
3658
|
+
const recipients = {
|
|
3659
|
+
recoveryPub: secrets.recoveryPub(),
|
|
3660
|
+
main: passwordRecipients('main'),
|
|
3661
|
+
passkeys: passwordRecipients('passkeys')
|
|
3662
|
+
}
|
|
3663
|
+
if (!recipients.recoveryPub) {
|
|
3664
|
+
log('[vault] passwords: no recovery key yet — nothing can be sealed until there is one')
|
|
3665
|
+
return null
|
|
3666
|
+
}
|
|
3667
|
+
const out = {}
|
|
3668
|
+
try {
|
|
3669
|
+
const { convertPasswords } = await import('./passwords.js')
|
|
3670
|
+
const r = await convertPasswords({
|
|
3671
|
+
store: passwordsStore,
|
|
3672
|
+
sealed: passwords.sealed,
|
|
3673
|
+
cek: await llaveViejaDeContrasenas(),
|
|
3674
|
+
recipients,
|
|
3675
|
+
// LA MAESTRA FIRMA LO QUE ESCRIBE, como cualquier aparato que guarda una entrada.
|
|
3676
|
+
// Firmar no es leer: lo que se le envuelve a esta bóveda sigue siendo nada.
|
|
3677
|
+
author: { publickey: master, sign: (body) => identity.signData(body) },
|
|
3678
|
+
dropOldKey: soltarLlaveVieja,
|
|
3679
|
+
log
|
|
3680
|
+
})
|
|
3681
|
+
out.converted = r?.entries || 0
|
|
3682
|
+
if (r?.already) out.converted = 0
|
|
3683
|
+
} catch (e) {
|
|
3684
|
+
log(`[vault] passwords: could not convert to the sealed format (${e.message})`)
|
|
3685
|
+
out.convertError = e.message
|
|
3686
|
+
}
|
|
3687
|
+
try {
|
|
3688
|
+
const r = await passwords.sealed.rewrapAll({
|
|
3689
|
+
openRecovery: (wrap) => secrets.openRecoveryWrap(wrap, adminKey)
|
|
3690
|
+
})
|
|
3691
|
+
out.wrapped = r.wrapped
|
|
3692
|
+
out.dropped = r.dropped
|
|
3693
|
+
if (r.wrapped || r.dropped) {
|
|
3694
|
+
log(`[vault] passwords: keyring rebuilt (${r.wrapped} wrap(s), ${r.dropped} stale one(s) dropped)`)
|
|
3695
|
+
}
|
|
3696
|
+
if (r.failed?.length) log(`[vault] passwords: ${r.failed.length} generation(s) could NOT be rewrapped`)
|
|
3697
|
+
} catch (e) {
|
|
3698
|
+
log(`[vault] passwords: could not rebuild the keyring (${e.message})`)
|
|
3699
|
+
out.rewrapError = e.message
|
|
3700
|
+
}
|
|
3701
|
+
return out
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
async function resealAll (adminKey = null) {
|
|
3705
|
+
// ANTES de reenvolver nada: si la copia de recuperación se quedó bajo la llave de la
|
|
3706
|
+
// máquina, se pasa a la frase. Sin esto, todo lo de abajo falla con «wrong password».
|
|
3707
|
+
if (adminKey) await migrarRecuperacionALaFrase(adminKey)
|
|
3708
|
+
// Y LA LLAVE DE RECUPERACIÓN EXISTE. Nacía al escribir el primer secreto, así que una
|
|
3709
|
+
// bóveda recién creada no tenía a quién envolver — y como crearla necesita la maestra,
|
|
3710
|
+
// cerrada ya no había manera. El síntoma: escribir el perfil con la bóveda cerrada
|
|
3711
|
+
// fallaba con «the vault did not say who to seal the profile for», en un perfil nuevo
|
|
3712
|
+
// y solo en el primero. Abrir la bóveda es el momento de saldarlo, y es idempotente.
|
|
3713
|
+
// …salvo en un almacén v3, que no tiene ese concepto: ahí lo que toca es `migrate`, y
|
|
3714
|
+
// escribir o leer un sobre ya se rechaza diciéndolo. Crearla antes de migrar sería
|
|
3715
|
+
// dejar una llave suelta en un formato que va a reescribirse entero.
|
|
3716
|
+
if (!secrets.isLegacy()) await secrets.ensureRecovery(adminKey)
|
|
3717
|
+
const out = { drawers: 0, wrapped: 0, sealed: 0, dropped: 0, sinLlave: [], failed: [] }
|
|
3718
|
+
for (const owner of secrets.owners?.() || []) {
|
|
3719
|
+
const before = new Set(secrets.recipientsIn(owner))
|
|
3720
|
+
// POR VISIBILIDAD, no una lista fija: una pública lleva además la envoltura de quien
|
|
3721
|
+
// administra. Con una sola lista se le daría también la de una privada con dueño, que
|
|
3722
|
+
// es justo lo que no puede pasar.
|
|
3723
|
+
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
3724
|
+
try {
|
|
3725
|
+
const r = await secrets.rewrap(owner, members, adminKey, { exact: true })
|
|
3726
|
+
const after = new Set(secrets.recipientsIn(owner))
|
|
3727
|
+
out.drawers++
|
|
3728
|
+
out.wrapped += r?.wrapped || 0
|
|
3729
|
+
out.sealed += r?.sealed || 0
|
|
3730
|
+
out.dropped += [...before].filter((p) => !after.has(p)).length
|
|
3731
|
+
for (const p of r?.sinLlave || []) out.sinLlave.push(p)
|
|
3732
|
+
} catch (e) {
|
|
3733
|
+
log(`[vault] ${owner}: could not reseal (${e.message})`)
|
|
3734
|
+
out.failed.push({ owner, error: e.message })
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
if (out.dropped || out.sealed) audit('secret.reseal', { drawers: out.drawers, dropped: out.dropped, sealed: out.sealed })
|
|
3738
|
+
if (out.failed.length) audit('secret.reseal-failed', { drawers: out.failed.map((f) => f.owner) })
|
|
3739
|
+
return out
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
async function settleSecretDebts (adminKey = null) {
|
|
3743
|
+
const out = {}
|
|
3744
|
+
// Las deudas ANOTADAS más las que se ven MIRANDO: un aparato puede quedarse sin
|
|
3745
|
+
// envoltura sin que nadie llegara a anotar nada (basta con que el apunte se pierda
|
|
3746
|
+
// por un camino que no pase por `spreadKey`), y entonces `settle` contestaba «nada
|
|
3747
|
+
// pendiente» mientras el servicio repetía en su log que no podía leer. Se calcula,
|
|
3748
|
+
// que es barato y no depende de que alguien se acordara de apuntarlo.
|
|
3749
|
+
const debts = new Set(Object.keys(rotationsDue()))
|
|
3750
|
+
for (const m of await incompleteMembers()) for (const owner of Object.keys(m.owners)) debts.add(owner)
|
|
3751
|
+
|
|
3752
|
+
for (const owner of debts) {
|
|
3753
|
+
const k = owner.slice(owner.indexOf(':') + 1)
|
|
3754
|
+
// Los destinatarios de un cajón no son solo quien lo consume: también quien lo
|
|
3755
|
+
// administra (`recipientsOf`). Envolver solo para los primeros dejaba a la consola
|
|
3756
|
+
// sin poder destapar lo que ella misma acababa de saldar.
|
|
3757
|
+
const members = (esPublica) => recipientsOf(owner, { public: esPublica })
|
|
3758
|
+
try { out[owner] = await spreadKey(owner, members, adminKey) } catch (e) {
|
|
3759
|
+
// Sin frase, el último recurso es pedírselo a quien sí puede: el propio servicio.
|
|
3760
|
+
let delegated = 0
|
|
3761
|
+
for (const m of await incompleteMembers()) {
|
|
3762
|
+
if (!m.owners[owner]) continue
|
|
3763
|
+
delegated += (await delegateRewrap(owner, m.pub).catch(() => ({ done: 0 }))).done
|
|
3764
|
+
}
|
|
3765
|
+
out[owner] = delegated ? { delegated } : { error: e.message }
|
|
3766
|
+
}
|
|
3767
|
+
}
|
|
3768
|
+
return out
|
|
3769
|
+
}
|
|
3770
|
+
|
|
3771
|
+
/**
|
|
3772
|
+
* Si este cajón quedó a deber un re-envoltorio o una rotación, se intenta saldar ahora.
|
|
3773
|
+
* Sin frase solo se puede en un perfil que no tiene contraseña —ahí la copia de
|
|
3774
|
+
* recuperación se abre con la llave de la máquina—, y en uno que sí la tiene se queda
|
|
3775
|
+
* anotado, que es lo que las listas enseñan. No se propaga el error: la variable YA se
|
|
3776
|
+
* guardó, y el que escribe no tiene por qué enterarse de una deuda vieja.
|
|
3777
|
+
*/
|
|
3778
|
+
async function settleDebts (owner) {
|
|
3779
|
+
const ns = owner.startsWith('ns:') ? owner.slice(3) : null
|
|
3780
|
+
const owed = (ns && store.getSetting(`rotate-due:${ns}`)) ||
|
|
3781
|
+
(await incompleteMembers()).some((m) => m.owners[owner])
|
|
3782
|
+
if (!owed) return null
|
|
3783
|
+
// QUIÉN RECIBE ENVOLTURA LO DICE `recipientsOf`, Y SOLO ÉL.
|
|
3784
|
+
//
|
|
3785
|
+
// Antes cada llamador traía su propia lista (`nsMembers`, o el miembro suelto), y todas
|
|
3786
|
+
// se dejaban fuera a los SELLADORES — que `recipientsOf` sí incluye. Resultado: escribir
|
|
3787
|
+
// una variable envolvía para el servicio y dejaba al sellador con la generación vieja
|
|
3788
|
+
// (dueño, 2026-09-01: «al envolver hay que envolver siempre para todos los selladores,
|
|
3789
|
+
// para que siempre tengan la info fresca»). Dos listas para la misma pregunta acaban
|
|
3790
|
+
// siempre así: una se actualiza y la otra no.
|
|
3791
|
+
try { return await spreadKey(owner, (esPublica) => recipientsOf(owner, { public: esPublica }), null) } catch (_) { return null }
|
|
3792
|
+
}
|
|
3793
|
+
async function deleteSecret (ns, key) { const ok = await secrets.delete(ns, key); if (ok) { audit('secret.rm', { ns, key }); scheduleNotice(ns) } return ok }
|
|
3794
|
+
|
|
3795
|
+
/**
|
|
3796
|
+
* RENOMBRAR una variable. Para el servicio que la lee es un cambio de configuración como
|
|
3797
|
+
* cualquier otro —la de antes desaparece y aparece otra—, así que se avisa igual: si no,
|
|
3798
|
+
* seguiría arrancando con el nombre viejo hasta el siguiente reinicio.
|
|
3799
|
+
*/
|
|
3800
|
+
async function renameSecret (ns, from, to) {
|
|
3801
|
+
const r = await secrets.rename(`ns:${ns}`, from, to)
|
|
3802
|
+
if (r?.renamed) { audit('secret.rename', { ns, from, to }); scheduleNotice(ns) }
|
|
3803
|
+
return r
|
|
3804
|
+
}
|
|
3805
|
+
async function renameDeviceSecret (pub, from, to) {
|
|
3806
|
+
const r = await secrets.rename(`dev:${pub}`, from, to)
|
|
3807
|
+
if (r?.renamed) {
|
|
3808
|
+
audit('secret.rename', { device: await deviceIdOf(pub).catch(() => null), from, to, scope: 'device' })
|
|
3809
|
+
scheduleDeviceNotice(pub)
|
|
3810
|
+
}
|
|
3811
|
+
return r
|
|
3812
|
+
}
|
|
3813
|
+
|
|
3814
|
+
/**
|
|
3815
|
+
* CARGAR CONFIGURACIÓN ES UNA TRANSACCIÓN: muchas variables, UN aviso.
|
|
3816
|
+
*
|
|
3817
|
+
* De una en una, cada `set` es un cambio de configuración para la bóveda, y el agente
|
|
3818
|
+
* obedece el primero —sale, lo levanta el supervisor, lee lo que hubiera en ese
|
|
3819
|
+
* instante— mientras el dueño sigue tecleando. El resultado es un servicio corriendo
|
|
3820
|
+
* con media configuración, y encima con el arranque a medio hacer. La ventana de
|
|
3821
|
+
* agrupado (`NOTICE_GROUP_MS`) tapa el caso de un script, no el de una persona
|
|
3822
|
+
* escribiendo con quince segundos entre variable y variable.
|
|
3823
|
+
*
|
|
3824
|
+
* Así que la carga en grupo llega hasta aquí entera: se valida TODO primero, se
|
|
3825
|
+
* escribe en un solo guardado y sale UN aviso al final. Las visibilidades no entran:
|
|
3826
|
+
* no cambian lo que el servicio lee y por eso nunca avisaron.
|
|
3827
|
+
*
|
|
3828
|
+
* @param {string} ns
|
|
3829
|
+
* @param {Array<{op:'set'|'rm', key:string, value?:string, public?:boolean}>} items
|
|
3830
|
+
* @returns {string[]} las claves que efectivamente cambiaron (un `rm` de lo que no
|
|
3831
|
+
* estaba no cambia nada, y no tiene por qué reiniciar a nadie).
|
|
3832
|
+
*/
|
|
3833
|
+
async function applySecrets (ns, items, { by = null } = {}) {
|
|
3834
|
+
const list = assertItems(items)
|
|
3835
|
+
const changed = []
|
|
3836
|
+
await secrets.batch(async () => {
|
|
3837
|
+
for (const it of list) {
|
|
3838
|
+
if (it.op === 'rm') {
|
|
3839
|
+
if (await secrets.delete(ns, it.key)) { audit('secret.rm', { ns, key: it.key }); changed.push(it.key) }
|
|
3840
|
+
} else {
|
|
3841
|
+
await secrets.set(ns, it.key, it.value, it.public, { by })
|
|
3842
|
+
audit('secret.set', { ns, key: it.key })
|
|
3843
|
+
changed.push(it.key)
|
|
3844
|
+
}
|
|
3845
|
+
}
|
|
3846
|
+
})
|
|
3847
|
+
if (changed.length) {
|
|
3848
|
+
await settleDebts(`ns:${ns}`)
|
|
3849
|
+
scheduleNotice(ns)
|
|
3850
|
+
}
|
|
3851
|
+
return changed
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3854
|
+
/** Lo mismo para el cajón de UN aparato (el aviso va solo a él). */
|
|
3855
|
+
async function applyDeviceSecrets (pub, items, { by = null } = {}) {
|
|
3856
|
+
const list = assertItems(items)
|
|
3857
|
+
const m = await requireService(pub)
|
|
3858
|
+
const changed = []
|
|
3859
|
+
await secrets.batch(async () => {
|
|
3860
|
+
for (const it of list) {
|
|
3861
|
+
if (it.op === 'rm') {
|
|
3862
|
+
if (await secrets.deleteDevice(pub, it.key)) changed.push(it.key)
|
|
3863
|
+
} else {
|
|
3864
|
+
await secrets.setDevice(pub, it.key, it.value, it.public, { by })
|
|
3865
|
+
changed.push(it.key)
|
|
3866
|
+
}
|
|
3867
|
+
}
|
|
3868
|
+
})
|
|
3869
|
+
if (changed.length) {
|
|
3870
|
+
await settleDebts(`dev:${pub}`)
|
|
3871
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
3872
|
+
for (const it of list) {
|
|
3873
|
+
if (!changed.includes(it.key)) continue
|
|
3874
|
+
audit(it.op === 'rm' ? 'secret.rm' : 'secret.set', { device, ns: m?.cn || null, key: it.key, scope: 'device' })
|
|
3875
|
+
}
|
|
3876
|
+
scheduleDeviceNotice(pub)
|
|
3877
|
+
}
|
|
3878
|
+
return changed
|
|
3879
|
+
}
|
|
3880
|
+
|
|
3881
|
+
/**
|
|
3882
|
+
* Todo o nada: si una variable del grupo no vale, no se escribe NINGUNA. Media
|
|
3883
|
+
* configuración cargada es peor que ninguna — el servicio arranca con ella.
|
|
3884
|
+
*/
|
|
3885
|
+
function assertItems (items) {
|
|
3886
|
+
if (!Array.isArray(items) || !items.length) throw new Error('batch: no items')
|
|
3887
|
+
for (const it of items) {
|
|
3888
|
+
if (!it || (it.op !== 'set' && it.op !== 'rm')) throw new Error('batch: each item must be a set or an rm')
|
|
3889
|
+
if (it.op === 'rm') { if (!it.key) throw new Error('batch: rm needs a key') } else assertVar(it.key, it.value)
|
|
3890
|
+
}
|
|
3891
|
+
return items
|
|
3892
|
+
}
|
|
3893
|
+
/**
|
|
3894
|
+
* Los nombres, y el VALOR de las públicas. Pública quiere decir «este valor puede salir
|
|
3895
|
+
* de esta máquina»: taparlo justo aquí —en la máquina donde vive, delante de su dueño—
|
|
3896
|
+
* era lo único que la marca no significaba. La consola remota ya las enseña.
|
|
3897
|
+
*/
|
|
3898
|
+
/**
|
|
3899
|
+
* LE PONE A CADA PÚBLICA SU SOBRE Y LA ENVOLTURA DE QUIEN PREGUNTA, para que pueda leerla.
|
|
3900
|
+
*
|
|
3901
|
+
* Solo a las públicas: es la política de despacho, y es lo único que cambia entre una y
|
|
3902
|
+
* otra. A una privada no se le manda nada — y aunque se le mandara, quien administra no
|
|
3903
|
+
* tiene su envoltura en un cajón con dueño, así que no la abriría igual.
|
|
3904
|
+
*
|
|
3905
|
+
* Si falta la envoltura de quien pregunta (todavía no se le ha hecho, o el cajón se
|
|
3906
|
+
* escribió antes de que entrara) se manda la variable SIN sobre: se ve que existe y que es
|
|
3907
|
+
* pública, y no se puede leer. Eso es cierto y se arregla abriendo la bóveda; inventarse
|
|
3908
|
+
* un valor o esconder la fila sería peor.
|
|
3909
|
+
*/
|
|
3910
|
+
const withEnvelopes = (entries, kind, caller) => {
|
|
3911
|
+
if (!caller) return entries
|
|
3912
|
+
const attach = (owner, k) => {
|
|
3913
|
+
if (!k.public) return k
|
|
3914
|
+
const e = secrets.entryOf?.(owner, k.key)
|
|
3915
|
+
const wrap = e?.gen != null ? secrets.wrapOf?.(owner, e.gen, caller) : null
|
|
3916
|
+
return e?.e && wrap ? { ...k, e: e.e, wrap } : k
|
|
3917
|
+
}
|
|
3918
|
+
if (kind === 'ns') {
|
|
3919
|
+
const out = {}
|
|
3920
|
+
for (const [ns, keys] of Object.entries(entries)) out[ns] = keys.map((k) => attach(`ns:${ns}`, k))
|
|
3921
|
+
return out
|
|
3922
|
+
}
|
|
3923
|
+
return entries.map((row) => ({ ...row, keys: row.keys.map((k) => attach(`dev:${row.pub}`, k)) }))
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
function listSecrets () {
|
|
3927
|
+
// YA NO SE ENSEÑA EL VALOR DE UNA PÚBLICA (dueño, 2026-09-02). Desde que todas van en
|
|
3928
|
+
// sobre, «pública» dejó de significar «en claro»: dice a quién se le despacha sin
|
|
3929
|
+
// aprobación, nada más. Para ver un valor hay que poder abrirlo, igual que cualquiera —
|
|
3930
|
+
// y en un cajón con dueño, quien administra deliberadamente no puede.
|
|
3931
|
+
return secrets.list()
|
|
3932
|
+
}
|
|
3933
|
+
/** Cambiar SOLO quién puede ver el valor (no toca el valor ni avisa: el servicio lee lo mismo). */
|
|
3934
|
+
async function setSecretVisibility (ns, key, isPublic, adminKey) {
|
|
3935
|
+
const ok = await secrets.setVisibility(ns, key, isPublic, adminKey)
|
|
3936
|
+
if (ok) audit('secret.visibility', { ns, key, public: !!isPublic })
|
|
3937
|
+
return ok
|
|
3938
|
+
}
|
|
3939
|
+
|
|
3940
|
+
/**
|
|
3941
|
+
* El bundle de un ns ABIERTO, para diagnosticar y para las pruebas. No lo usa el
|
|
3942
|
+
* camino de servir —ahí los sobres salen cerrados y los abre el agente—, y por eso
|
|
3943
|
+
* este sí pide poder abrir la copia maestra.
|
|
3944
|
+
*/
|
|
3945
|
+
async function openSecrets (ns, devicePub = null, adminKey) {
|
|
3946
|
+
return secrets.openBundle(ns, devicePub, adminKey)
|
|
3947
|
+
}
|
|
3948
|
+
|
|
3949
|
+
/**
|
|
3950
|
+
* CAMBIA LOS PERMISOS DE UNO O VARIOS APARATOS EN UNA SOLA ACTA. `changes`: `[{ pub, caps }]`,
|
|
3951
|
+
* cada `caps` la lista COMPLETA de ese aparato.
|
|
3952
|
+
*/
|
|
3953
|
+
async function setCapsMany (changes) {
|
|
3954
|
+
if (!Array.isArray(changes) || !changes.length) throw Object.assign(new Error('no changes to save'), { code: 'NO_CHANGES' })
|
|
3955
|
+
if (typeof identity.setCapsMany !== 'function') {
|
|
3956
|
+
throw Object.assign(new Error('this build\'s @dotrino/identity cannot change several devices at once (needs setCapsMany)'), { code: 'caps-many-unavailable' })
|
|
3957
|
+
}
|
|
3958
|
+
const r = await identity.setCapsMany(changes)
|
|
3959
|
+
// ¿SE QUEDÓ ALGUNO POR EL CAMINO? El acta tiene una lista CERRADA de permisos y
|
|
3960
|
+
// `cleanCaps` descarta los que no conoce — correcto al recibir un acta ajena, y
|
|
3961
|
+
// pésimo aquí: conceder `+sella` con una versión del pilar que no sabe qué es
|
|
3962
|
+
// devolvía «Listo», resellaba el acta y no concedía nada. Costó una tarde en un
|
|
3963
|
+
// contenedor, porque la imagen traía la versión de npm y el árbol local otra.
|
|
3964
|
+
try {
|
|
3965
|
+
const members = (await identity.profileMembers()).members
|
|
3966
|
+
for (const { pub, caps } of changes) {
|
|
3967
|
+
const quedaron = new Set(members.find((m) => m.pub === pub)?.caps || [])
|
|
3968
|
+
const perdidos = caps.filter((c) => !quedaron.has(c))
|
|
3969
|
+
if (perdidos.length) {
|
|
3970
|
+
log(`[vault] WARNING these permissions were DROPPED: ${perdidos.join(', ')} — this build's @dotrino/identity does not know them (record unchanged for those)`)
|
|
3971
|
+
}
|
|
3972
|
+
}
|
|
3973
|
+
} catch (_) { /* comprobar es un extra: si falla, no rompe el cambio */ }
|
|
3974
|
+
const devices = await Promise.all(changes.map(async ({ pub, caps }) => ({ deviceId: await deviceIdOf(pub).catch(() => null), caps })))
|
|
3975
|
+
for (const d of devices) audit('caps', { device: d.deviceId, caps: d.caps })
|
|
3976
|
+
// El acta acaba de cambiar QUIÉN debe tener envoltura de qué: se repasa antes de
|
|
3977
|
+
// avisar, para que el aviso salga con el llavero ya al día.
|
|
3978
|
+
await refreshWraps('caps')
|
|
3979
|
+
// UN aviso: un aparato solo (la forma de siempre) o la lista, si fueron varios.
|
|
3980
|
+
await notifyMembers('caps', devices.length === 1 ? devices[0] : { devices })
|
|
3981
|
+
return r
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/** El miembro del acta con esa llave, o `null` (también si la bóveda todavía no tiene acta). */
|
|
3985
|
+
async function memberOf (pub) {
|
|
3986
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
3987
|
+
if (!record) return null
|
|
3988
|
+
return (record.members || []).find((m) => m.pub === pub) || null
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
/**
|
|
3992
|
+
* Variables de UN aparato. Se exige que sea un SERVICIO del acta (miembro con `cn`) porque
|
|
3993
|
+
* es el único que las lee: guardárselas a un teléfono sería configuración muerta, escrita
|
|
3994
|
+
* donde nadie la va a buscar el día que no funcione. Si la bóveda es anterior al acta no
|
|
3995
|
+
* hay contra qué comprobarlo y se acepta.
|
|
3996
|
+
*/
|
|
3997
|
+
async function requireService (pub) {
|
|
3998
|
+
const m = await memberOf(pub)
|
|
3999
|
+
if (!m) {
|
|
4000
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
4001
|
+
if (record) throw new Error('device: it is not a member of this profile')
|
|
4002
|
+
return null
|
|
4003
|
+
}
|
|
4004
|
+
if (!m.cn) throw new Error('device: it is not a service (only services read variables); pair it with `pair --service <ns>`')
|
|
4005
|
+
return m
|
|
4006
|
+
}
|
|
4007
|
+
|
|
4008
|
+
async function setDeviceSecret (pub, key, value, isPublic, { by = null } = {}) {
|
|
4009
|
+
const m = await requireService(pub)
|
|
4010
|
+
await secrets.setDevice(pub, key, value, isPublic, { by })
|
|
4011
|
+
await settleDebts(`dev:${pub}`)
|
|
4012
|
+
audit('secret.set', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
4013
|
+
scheduleDeviceNotice(pub)
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
async function deleteDeviceSecret (pub, key) {
|
|
4017
|
+
const m = await memberOf(pub)
|
|
4018
|
+
const ok = await secrets.deleteDevice(pub, key)
|
|
4019
|
+
if (ok) {
|
|
4020
|
+
audit('secret.rm', { device: await deviceIdOf(pub).catch(() => null), ns: m?.cn || null, key, scope: 'device' })
|
|
4021
|
+
scheduleDeviceNotice(pub)
|
|
4022
|
+
}
|
|
4023
|
+
return ok
|
|
4024
|
+
}
|
|
4025
|
+
|
|
4026
|
+
async function setDeviceSecretVisibility (pub, key, isPublic, adminKey) {
|
|
4027
|
+
const ok = await secrets.setDeviceVisibility(pub, key, isPublic, adminKey)
|
|
4028
|
+
if (ok) audit('secret.visibility', { device: await deviceIdOf(pub).catch(() => null), key, public: !!isPublic, scope: 'device' })
|
|
4029
|
+
return ok
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
/**
|
|
4033
|
+
* Las variables por aparato —nombres, y el valor de las PÚBLICAS, igual que `listSecrets`—
|
|
4034
|
+
* con quién es cada aparato pegado: una llave suelta no se puede administrar. `orphan`
|
|
4035
|
+
* marca las que quedaron de una llave que ya no está en el acta.
|
|
4036
|
+
*/
|
|
4037
|
+
async function listDeviceSecrets () {
|
|
4038
|
+
const record = (await identity.profileActa?.().catch(() => null))?.acta
|
|
4039
|
+
const members = record?.members || []
|
|
4040
|
+
const out = []
|
|
4041
|
+
for (const [pub, keys] of Object.entries(secrets.listDevices())) {
|
|
4042
|
+
const m = members.find((x) => x.pub === pub) || null
|
|
4043
|
+
out.push({
|
|
4044
|
+
pub,
|
|
4045
|
+
id: m?.id || await deviceIdOf(pub).catch(() => null),
|
|
4046
|
+
label: m?.label || '',
|
|
4047
|
+
cn: m?.cn || null,
|
|
4048
|
+
// Sin valores: desde 2026-09-02 una pública también va en sobre (ver `listSecrets`).
|
|
4049
|
+
keys,
|
|
4050
|
+
orphan: !!members.length && !m
|
|
4051
|
+
})
|
|
4052
|
+
}
|
|
4053
|
+
return out
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
return {
|
|
4057
|
+
identity, client, store, threads, secrets, master, fingerprint: fp, dir: disk.dir || null,
|
|
4058
|
+
// Alcanzable ≠ corriendo: si el proxio rechazó el `identify` esta bóveda está viva y
|
|
4059
|
+
// nadie puede hablarle. Lo mira `status` (ver `manager.js`, `summary`).
|
|
4060
|
+
isIdentified,
|
|
4061
|
+
// Se expone para poder PROBAR que una respuesta que no cabe no mata la conexión: es el
|
|
4062
|
+
// único punto por el que sale todo, y el fallo que cierra vive justo ahí.
|
|
4063
|
+
reply,
|
|
4064
|
+
/**
|
|
4065
|
+
* SOLTAR LA MAESTRA. Cerrar el perfil tiene que sacarla de la memoria, no solo dejar de
|
|
4066
|
+
* cargarla en el siguiente arranque: si no, el candado seguiría siendo una bandera al
|
|
4067
|
+
* lado de una llave descifrada, que es exactamente lo que se quitó de en medio.
|
|
4068
|
+
*
|
|
4069
|
+
* Se recarga el par: sin la llave del perfil el pilar lo deja SIN privada, así que a
|
|
4070
|
+
* partir de aquí firmar se niega con `vault-locked`. Servir sigue igual — eso lo hace la
|
|
4071
|
+
* llave de comunicación, que no depende de la contraseña.
|
|
4072
|
+
*/
|
|
4073
|
+
async dropMasterKey () {
|
|
4074
|
+
const r = await identity.reloadMasterKey?.()
|
|
4075
|
+
return { locked: r?.locked !== false }
|
|
4076
|
+
},
|
|
4077
|
+
/** Al abrir: recuperar la maestra y, si venía en claro de antes, dejarla sellada. */
|
|
4078
|
+
async takeMasterKey () {
|
|
4079
|
+
const r = await identity.reloadMasterKey?.()
|
|
4080
|
+
if (r?.locked === false) { try { await identity.sealMasterKey?.() } catch (_) {} }
|
|
4081
|
+
// ABRIR ES EL ÚNICO MOMENTO en que un perfil con contraseña está abierto, así que es
|
|
4082
|
+
// aquí donde puede estrenar su llave de comunicación (ver `ensureCommKeyInActa`).
|
|
4083
|
+
// Si falla, no se arrastra: abrir el perfil no puede depender de esto.
|
|
4084
|
+
//
|
|
4085
|
+
// Y SE VUELVE A IDENTIFICAR EN EL ACTO: `identify` solo corre al conectar y al cambiar
|
|
4086
|
+
// el token, así que estrenar la llave no servía de nada hasta el siguiente arranque —
|
|
4087
|
+
// la bóveda seguía muda en el proxio con su llave ya admitida en el acta. Al dueño le
|
|
4088
|
+
// pasó el 2026-09-05: hizo lo que el log pedía y la consola siguió sin contestar.
|
|
4089
|
+
if (r?.locked === false) {
|
|
4090
|
+
try { if (await ensureCommKeyInActa()) await reidentificar?.() } catch (_) {}
|
|
4091
|
+
}
|
|
4092
|
+
// Y ABSORBER LO PENDIENTE. La subacta ya lo estaba honrando con el perfil cerrado —de
|
|
4093
|
+
// eso vive—, pero mientras siga ahí solo lo sabe esta máquina: al sellarlo, entra en
|
|
4094
|
+
// el acta y lo respeta toda la cuenta. Si falla no se arrastra: abrir el perfil no
|
|
4095
|
+
// puede depender de esto, y lo pendiente sigue surtiendo efecto igual.
|
|
4096
|
+
if (r?.locked === false) { try { await absorberSubacta('unlock') } catch (_) {} }
|
|
4097
|
+
// Y LOS APARATOS MUERTOS FUERA DEL ACTA, en una sola (dueño, 2026-09-22). Es el mismo
|
|
4098
|
+
// momento: la maestra está en memoria y cambiar el acta es uno de sus dos trabajos.
|
|
4099
|
+
// Si falla no se arrastra —abrir no depende de esto—, pero se dice.
|
|
4100
|
+
if (r?.locked === false) {
|
|
4101
|
+
try {
|
|
4102
|
+
const antes = await refreshActa()
|
|
4103
|
+
const aprobaban = (antes?.members || []).filter((m) => Acta.memberCan(antes, m.pub, 'approve')).map((m) => m.pub)
|
|
4104
|
+
const { removed = [], seq } = await identity.pruneExpiredDevices?.() || {}
|
|
4105
|
+
for (const d of removed) {
|
|
4106
|
+
const id = await deviceIdOf(d.pub).catch(() => null)
|
|
4107
|
+
audit('pruned', { device: id, label: d.label || '', reason: 'expired', seq })
|
|
4108
|
+
log(`[vault] unlock: removed ${id || '????-????'} ${d.label ? `(${d.label}) ` : ''}from the record: its certificate expired and cannot be renewed · record #${seq}`)
|
|
4109
|
+
}
|
|
4110
|
+
// ¿SE LLEVÓ AL ÚLTIMO QUE APROBABA? Entonces se dice ALTO. Quitarlo es correcto —con
|
|
4111
|
+
// el papel muerto no podía firmar ninguna aprobación, estuviera o no en el acta—,
|
|
4112
|
+
// pero desde ese momento todo pedido de un aparato sin `unattended` se rechaza, y
|
|
4113
|
+
// el 2026-09-22 eso pasó en silencio: el teléfono se fue y nadie lo supo en dos días.
|
|
4114
|
+
const despues = await refreshActa()
|
|
4115
|
+
const quedan = (despues?.members || []).filter((m) => Acta.memberCan(despues, m.pub, 'approve')).map((m) => m.pub)
|
|
4116
|
+
const idosPub = lastApproverGone(aprobaban, quedan, removed.map((d) => d.pub))
|
|
4117
|
+
const idos = removed.filter((d) => idosPub.includes(d.pub))
|
|
4118
|
+
if (idos.length) {
|
|
4119
|
+
const quienes = (await Promise.all(idos.map(async (d) => `${await deviceIdOf(d.pub).catch(() => '????-????')}${d.label ? ` (${d.label})` : ''}`))).join(', ')
|
|
4120
|
+
audit('no-approver', { removed: idos.length, seq })
|
|
4121
|
+
log(`[vault] WARNING: nobody in the record can approve any more — ${quienes} 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\``)
|
|
4122
|
+
}
|
|
4123
|
+
} catch (e) { log(`[vault] unlock: could not remove expired devices: ${e.message}`) }
|
|
4124
|
+
}
|
|
4125
|
+
// Y DESPUÉS, LOS SOBRES A LA LLAVE VIGENTE: la poda de arriba pudo rotarla.
|
|
4126
|
+
if (r?.locked === false) {
|
|
4127
|
+
try { await resealStaleThreads('unlock') } catch (e) { log(`[vault] unlock: could not reseal old envelopes: ${e.message}`) }
|
|
4128
|
+
}
|
|
4129
|
+
return { locked: r?.locked !== false }
|
|
4130
|
+
},
|
|
4131
|
+
// Forzar un empujón: lo usa `replica push` de la CLI y el smoke, para no depender
|
|
4132
|
+
// de que justo acabe de cambiar algo.
|
|
4133
|
+
pushToReplicas,
|
|
4134
|
+
startPairing: desk.startPairing,
|
|
4135
|
+
stopPairing: desk.stopPairing,
|
|
4136
|
+
listPending: desk.listPending,
|
|
4137
|
+
// ¿Pide permiso este aparato para recibir claves privadas? Lo dice el ACTA
|
|
4138
|
+
// (`unattended`), no una lista de esta máquina — por eso ya no hay `setApproval` ni
|
|
4139
|
+
// `supervised`: se concede y se quita como cualquier otro permiso, con `caps`.
|
|
4140
|
+
needsApproval: async (pub) => needsApproval(pub, await refreshActa()),
|
|
4141
|
+
/**
|
|
4142
|
+
* QUIÉN PUEDE APROBAR EN ESTA CUENTA. Lo usa la actualización (§15): la bóveda pide
|
|
4143
|
+
* permiso al teléfono **solo si hay a quién pedírselo**; si no hay ningún aparato con
|
|
4144
|
+
* `aprueba`, se actualiza sola.
|
|
4145
|
+
*
|
|
4146
|
+
* Es la regla del dueño (2026-09-20) y evita la trampa evidente: una bóveda sin
|
|
4147
|
+
* aprobadores que se quedaría sin poder actualizarse nunca, esperando un sí que nadie
|
|
4148
|
+
* puede dar. Mismo sitio del que ya sale el «rang N approver(s)» de los secretos.
|
|
4149
|
+
*/
|
|
4150
|
+
/**
|
|
4151
|
+
* PEDIR PERMISO PARA ACTUALIZARSE a `version` (la instala el daemon si se aprueba). Es
|
|
4152
|
+
* un pedido como los demás —sale en Pedidos del teléfono, `kind: 'update'`—, pero dura
|
|
4153
|
+
* un día (`UPDATE_TTL_MS`): no hay nadie esperando delante de una terminal.
|
|
4154
|
+
* @returns {{ answer: Promise<boolean>, withdraw: () => void }} `answer`: sí o no (vencido
|
|
4155
|
+
* o retirado es no). `withdraw` quita el pedido si sigue vivo.
|
|
4156
|
+
*/
|
|
4157
|
+
askUpdateApproval: ({ version }) => {
|
|
4158
|
+
const yo = identity.me?.publickey || null
|
|
4159
|
+
let pendId = null
|
|
4160
|
+
let withdrawn = false
|
|
4161
|
+
let settle
|
|
4162
|
+
const answer = new Promise((resolve) => { settle = resolve })
|
|
4163
|
+
// RETIRAR: otro perfil ya contestó por la máquina, o ya no hace falta. El pedido sale
|
|
4164
|
+
// de la lista de quien aprueba en vez de quedarse un día pidiendo algo ya decidido.
|
|
4165
|
+
const retirar = () => {
|
|
4166
|
+
if (!pendId || !waiters.delete(pendId)) return
|
|
4167
|
+
const pend = approvals.take(pendId)
|
|
4168
|
+
audit('update.withdrawn', { device: pend?.deviceId || null, ns: 'vault', id: pendId })
|
|
4169
|
+
log(`[vault] update: request ${pendId} withdrawn`)
|
|
4170
|
+
settle(false)
|
|
4171
|
+
}
|
|
4172
|
+
awaitApproval({
|
|
4173
|
+
device: yo, ns: 'vault', kind: 'update', what: 'update',
|
|
4174
|
+
ctx: { product: '@dotrino/vaultd', version, from: VERSION }, ttlMs: UPDATE_TTL_MS, onAnswer: settle
|
|
4175
|
+
}).then((pend) => { pendId = pend.id; if (withdrawn) retirar() })
|
|
4176
|
+
.catch((e) => { log(`[vault] update: could not ask for approval: ${e.message}`); settle(false) })
|
|
4177
|
+
return { answer, withdraw: () => { withdrawn = true; retirar() } }
|
|
4178
|
+
},
|
|
4179
|
+
announceUpdate,
|
|
4180
|
+
approvers: async () => {
|
|
4181
|
+
const record = await refreshActa()
|
|
4182
|
+
if (!record) return []
|
|
4183
|
+
return (record.members || [])
|
|
4184
|
+
.filter((m) => Acta.memberCan(record, m.pub, 'approve'))
|
|
4185
|
+
// `notifiedAt`: cuándo dijo por última vez que le llegan los avisos (null = no lo ha
|
|
4186
|
+
// dicho, o caducó). No decide si se pide: se enseña junto al pedido sin respuesta.
|
|
4187
|
+
.map((m) => ({ id: m.id, label: m.label || '', pub: m.pub, notifiedAt: notifiable.since(m.pub) }))
|
|
4188
|
+
},
|
|
4189
|
+
// La bóveda de contraseñas (`passwords.js`). Aquí SÍ se lista: es donde está la
|
|
4190
|
+
// llave. Lo que no puede es listarla un aparato.
|
|
4191
|
+
passwordDevices,
|
|
4192
|
+
passwordsVault: () => passwords?.vault || null,
|
|
4193
|
+
// La política de las contraseñas, para quien atiende su propio almacén (la extensión).
|
|
4194
|
+
passwordsNeedApproval,
|
|
4195
|
+
approvePasswordRequest,
|
|
4196
|
+
// --- ENTRAR CON USUARIO Y CONTRASEÑA -------------------------------------------------
|
|
4197
|
+
//
|
|
4198
|
+
// El alta va en DOS pasos porque la contraseña no pasa por aquí: quien crea el aparato
|
|
4199
|
+
// (la CLI, en esta máquina) hace su mitad de OPAQUE, genera las llaves del aparato y las
|
|
4200
|
+
// cierra con la llave que sale de la contraseña. La bóveda solo guarda lo que no puede
|
|
4201
|
+
// abrir, sella el acta y firma el certificado — y eso último sí pide el perfil abierto.
|
|
4202
|
+
listLogins: () => logins.list(),
|
|
4203
|
+
loginRegisterBegin: ({ user, request, replace = false }) => logins.registerBegin({ user, request, replace }),
|
|
4204
|
+
// El alta es la MISMA pieza que usa la bóveda-pestaña (`registerLogin`): aquí solo se
|
|
4205
|
+
// añade lo que es del binario — la bitácora y el aviso a los demás aparatos.
|
|
4206
|
+
loginRegisterFinish: async (opts) => {
|
|
4207
|
+
const r = await registerLogin({ ...opts, identity, logins })
|
|
4208
|
+
if (r.replaced) { audit('login.passwd', { user: r.user }); return r }
|
|
4209
|
+
audit('login.create', { user: r.user, device: r.deviceId, caps: r.caps })
|
|
4210
|
+
await notifyMembers('enrolled', { deviceId: r.deviceId, by: 'login' })
|
|
4211
|
+
return r
|
|
4212
|
+
},
|
|
4213
|
+
// ENTRAR DESDE ESTA MISMA MÁQUINA. Es el mismo intercambio que hace un navegador por el
|
|
4214
|
+
// proxio, y pasa por el mismo freno: lo usa `logins passwd`, que necesita abrir el
|
|
4215
|
+
// paquete de llaves con la contraseña vieja antes de volver a cerrarlo con la nueva.
|
|
4216
|
+
loginBegin: ({ user, request }) => logins.loginBegin({ user, request }),
|
|
4217
|
+
loginEnd: ({ lid, finalization, label }) => logins.loginEnd({ lid, finalization, label }),
|
|
4218
|
+
closeLogin: ({ user, sid }) => {
|
|
4219
|
+
const r = logins.closeSession({ user, sid })
|
|
4220
|
+
if (r.ok) audit('login.close', { user, sid: String(sid).slice(0, 8), by: 'console' })
|
|
4221
|
+
return r
|
|
4222
|
+
},
|
|
4223
|
+
clearLoginBlock: ({ user }) => logins.clearBlock({ user }),
|
|
4224
|
+
removeLogin: async ({ user }) => {
|
|
4225
|
+
const found = logins.list().find((x) => x.user === user)
|
|
4226
|
+
if (!found) return { ok: false }
|
|
4227
|
+
logins.remove({ user })
|
|
4228
|
+
audit('login.remove', { user, device: found.deviceId })
|
|
4229
|
+
// Quitarlo de aquí no lo saca del acta: lo suyo es revocar el aparato, y eso ya existe.
|
|
4230
|
+
return { ok: true, pub: found.pub, deviceId: found.deviceId }
|
|
4231
|
+
},
|
|
4232
|
+
listApprovals: () => approvals.list(),
|
|
4233
|
+
// El perfil desde el cajón `@me` (lo que empujan los aparatos): ver `profileView`.
|
|
4234
|
+
profileView: () => secrets.profileView(),
|
|
4235
|
+
// LAS CONCESIONES VIVAS, para verlas y para cortarlas. Se enseñan en la máquina del
|
|
4236
|
+
// dueño (TUI/CLI), donde el contexto ya es suyo y no hay nada que sellar.
|
|
4237
|
+
listGrants: () => grants.list(),
|
|
4238
|
+
revokeGrant: (id) => grants.revoke(id),
|
|
4239
|
+
revokeGrants: (filtro) => grants.revokeAll(filtro || {}),
|
|
4240
|
+
// Aprobar desde el PC avisa igual que aprobar a distancia: el resto de tus
|
|
4241
|
+
// dispositivos se entera de que entró alguien, venga de donde venga.
|
|
4242
|
+
approveDevice: async (code, adminKey) => {
|
|
4243
|
+
const r = await desk.approve(code)
|
|
4244
|
+
// Un servicio que ENTRA a un namespace que ya tiene variables necesita su
|
|
4245
|
+
// envoltura de la CEK, o recibirá sobres que no puede abrir y se quedará
|
|
4246
|
+
// reintentando en silencio. Se reparte aquí, que es por donde pasan las dos
|
|
4247
|
+
// puertas de aprobar (el PC y la consola remota), y no en `enroll.js`, que es
|
|
4248
|
+
// el archivo vendorizado en el iframe de identidad.
|
|
4249
|
+
const m = r?.cert?.sub ? await memberOf(r.cert.sub) : null
|
|
4250
|
+
if (m?.cn) {
|
|
4251
|
+
// POR `recipientsOf`, NO por `nsMembers`. Quien debe tener envoltura de un cajón lo
|
|
4252
|
+
// dice UN sitio, y ahí dentro están también los SELLADORES (dueño, 2026-09-01: «al
|
|
4253
|
+
// envolver hay que envolver siempre para todos los selladores, para que siempre
|
|
4254
|
+
// tengan la info fresca»). Repartiendo solo a los del ns, un cajón tocado por esta
|
|
4255
|
+
// puerta dejaba a los selladores con la envoltura vieja.
|
|
4256
|
+
await spreadKey(`ns:${m.cn}`, (esPublica) => recipientsOf(`ns:${m.cn}`, { public: esPublica }), adminKey).catch(async (e) => {
|
|
4257
|
+
log('[vault] could not hand the key to the new service:', e.message)
|
|
4258
|
+
// Sin la frase la bóveda no puede envolvérsela… pero un HERMANO suyo sí: otro
|
|
4259
|
+
// servicio del mismo cajón ya tiene la llave abierta (§8.11). Si contesta, el
|
|
4260
|
+
// recién llegado arranca completo; si no hay ninguno encendido, la deuda queda
|
|
4261
|
+
// a la vista y se salda al abrir la bóveda.
|
|
4262
|
+
const r2 = await delegateRewrap(`ns:${m.cn}`, m.pub).catch(() => ({ done: 0 }))
|
|
4263
|
+
if (!r2.done) log(`[vault] ns:${m.cn}: nobody could hand it the key — it stays in debt until the vault is opened`)
|
|
4264
|
+
})
|
|
4265
|
+
}
|
|
4266
|
+
// Y el llavero ENTERO: el recién llegado también es destinatario de su propio cajón,
|
|
4267
|
+
// y si entra con `sella` lo es de TODOS. Repartir solo el suyo dejaba el resto atrás.
|
|
4268
|
+
await refreshWraps('enrolled')
|
|
4269
|
+
await notifyMembers('enrolled', { deviceId: r?.deviceId || null, by: 'pc' })
|
|
4270
|
+
return r
|
|
4271
|
+
},
|
|
4272
|
+
rejectDevice: (deviceId) => desk.reject(deviceId),
|
|
4273
|
+
// El mostrador que atiende a la consola remota. Se expone para poder probar la
|
|
4274
|
+
// frontera de verdad (que el valor de una privada no salga ni dentro del sobre).
|
|
4275
|
+
vars: varsDesk,
|
|
4276
|
+
setSecret, deleteSecret, renameSecret, renameDeviceSecret, listSecrets, setSecretVisibility, openSecrets,
|
|
4277
|
+
// Sella un `secrets.json` v3 entero. Es el punto de no retorno del despliegue y
|
|
4278
|
+
// por eso es una operación con nombre propio, no algo que ocurra de refilón al
|
|
4279
|
+
// desbloquear: deja `secrets.json.v3.bak` para poder volver.
|
|
4280
|
+
/**
|
|
4281
|
+
* Convierte el archivo de secretos al formato nuevo. `membersOf` es opcional y lo
|
|
4282
|
+
* normal es NO pasarlo: por defecto se usa la misma lista de destinatarios que
|
|
4283
|
+
* cualquier escritura —los servicios del cajón MÁS los aparatos que administran—.
|
|
4284
|
+
*
|
|
4285
|
+
* Pasarla a mano fue un error real: la conversión envolvía solo a los servicios, y
|
|
4286
|
+
* entonces la consola del dueño no podía ver nada de lo que ya había, aunque el
|
|
4287
|
+
* diseño dice que sí (§8.2). Lo de siempre: dos sitios decidiendo lo mismo.
|
|
4288
|
+
*/
|
|
4289
|
+
resealAll,
|
|
4290
|
+
/** Convierte las contraseñas al formato sellado y repasa sus envolturas (§2.5, §2.7). */
|
|
4291
|
+
sealPasswords,
|
|
4292
|
+
delegateRewrap,
|
|
4293
|
+
incompleteMembers,
|
|
4294
|
+
migrateSecrets: (membersOf, adminKey) => secrets.migrate(
|
|
4295
|
+
membersOf || ((owner) => recipientsOf(owner)), adminKey
|
|
4296
|
+
),
|
|
4297
|
+
settleSecretDebts,
|
|
4298
|
+
revealSecret: (owner, key, adminKey) => secrets.reveal(owner, key, adminKey),
|
|
4299
|
+
secretHistory: (owner, key) => secrets.history(owner, key),
|
|
4300
|
+
revealSecretHistory: (owner, key, ts, adminKey) => secrets.revealHistory(owner, key, ts, adminKey),
|
|
4301
|
+
revertSecret: (owner, key, ts, opts) => secrets.revert(owner, key, ts, opts),
|
|
4302
|
+
// Cambiar la contraseña del perfil obliga a volver a cerrar la copia maestra con
|
|
4303
|
+
// la llave nueva, o los secretos quedarían ilegibles. No toca los sobres.
|
|
4304
|
+
rekeySecrets: (oldKey, newKey) => secrets.rekeyRecovery(oldKey, newKey),
|
|
4305
|
+
setDeviceSecret, deleteDeviceSecret, listDeviceSecrets, setDeviceSecretVisibility,
|
|
4306
|
+
applySecrets, applyDeviceSecrets,
|
|
4307
|
+
listDevices: () => identity.listDelegations(),
|
|
4308
|
+
// Acta del perfil (quién es del perfil y qué puede cada uno): lo que muestran
|
|
4309
|
+
// `dotrino-vault members` y la consola de vault.dotrino.com.
|
|
4310
|
+
/**
|
|
4311
|
+
* Los miembros del acta, y QUÉ CORRE CADA UNO (CONVENCIONES §14). La versión no está en
|
|
4312
|
+
* el acta —el acta dice quién puede qué, no qué build es— así que se le pega aquí, que
|
|
4313
|
+
* es por donde salen `members` y la consola. El dueño lo pidió en los dos sitios.
|
|
4314
|
+
*
|
|
4315
|
+
* `running: null` es «todavía no ha hablado desde que arrancamos», que no es lo mismo
|
|
4316
|
+
* que «no declara»: lo segundo trae un dictamen con `code: 'undeclared'`.
|
|
4317
|
+
*/
|
|
4318
|
+
profileMembers: async () => {
|
|
4319
|
+
const r = await identity.profileMembers()
|
|
4320
|
+
return {
|
|
4321
|
+
...r,
|
|
4322
|
+
vault: MI_VERSION,
|
|
4323
|
+
// Y LO QUE RENUNCIÓ Y NO SE HA SELLADO. Ya surte efecto —de eso vive la subacta—
|
|
4324
|
+
// pero si no se ve, el dueño mira la lista, lee los permisos del acta y cree que
|
|
4325
|
+
// ese aparato los tiene. Un freno que no se explica es otro silencio.
|
|
4326
|
+
members: (r?.members || []).map((m) => ({
|
|
4327
|
+
...m,
|
|
4328
|
+
running: versionDe(m.pub),
|
|
4329
|
+
// Dónde corre: lo último que dijo firmado y, si nunca habló, lo del acta.
|
|
4330
|
+
machine: machines.of(m.pub) || m.machine || null,
|
|
4331
|
+
// Cuándo dijo que le llegan los avisos (null = no lo ha dicho, o caducó). Solo
|
|
4332
|
+
// tiene sentido en quien aprueba, y es un dato para enseñar: no decide nada.
|
|
4333
|
+
notifiedAt: notifiable.since(m.pub),
|
|
4334
|
+
renounced: subacta.forMember(m.pub).flatMap((e) => e.caps || [])
|
|
4335
|
+
}))
|
|
4336
|
+
}
|
|
4337
|
+
},
|
|
4338
|
+
// Los namespaces que quedaron a deber una rotación (se fue un miembro y no se pudo
|
|
4339
|
+
// rotar su llave). Lo enseñan `secret list` y la consola: si no se ve, no se salda.
|
|
4340
|
+
rotationsDue,
|
|
4341
|
+
secretDebts,
|
|
4342
|
+
// LOS BLOQUEADOS, y quitarlos de la lista (`dotrino-vault block|unblock <ID>`).
|
|
4343
|
+
listBlocked: () => blocked.list(),
|
|
4344
|
+
setBlocked,
|
|
4345
|
+
/**
|
|
4346
|
+
* QUIÉN tiene envoltura de la generación vigente de un cajón (llaves de firma, más
|
|
4347
|
+
* `#recovery`). Es diagnóstico: saber a cuántos se les envolvió no ayuda a abrir nada,
|
|
4348
|
+
* y en cambio es lo único que contesta de verdad a «¿quién puede leer esto?».
|
|
4349
|
+
*
|
|
4350
|
+
* Se expone porque hacía falta para COMPROBARLO en una prueba en vez de argumentarlo:
|
|
4351
|
+
* la condición del dueño para acotar los sobres por cajón y permisos —en vez de
|
|
4352
|
+
* envolver para todos— fue estar seguro de que no falta ninguno.
|
|
4353
|
+
*/
|
|
4354
|
+
secretRecipients: (owner) => secrets.recipientsIn(owner),
|
|
4355
|
+
// ¿Es ESTA bóveda la que sella el acta? Lo usa el freno de borrado (D12).
|
|
4356
|
+
isMaster: () => identity.isMaster(),
|
|
4357
|
+
setCaps: async (pub, caps) => setCapsMany([{ pub, caps }]),
|
|
4358
|
+
// VARIOS APARATOS EN UNA SOLA ACTA (dueño, 2026-09-26): el borrador de la TUI se sella
|
|
4359
|
+
// de golpe, con UN repaso del llavero y UN aviso, en vez de uno por aparato.
|
|
4360
|
+
setCapsMany,
|
|
4361
|
+
// Renombrar un dispositivo: es un nombre para el humano (no toca permisos ni llaves),
|
|
4362
|
+
// pero pasa por el acta y se avisa, para que el cambio no sea invisible en el resto.
|
|
4363
|
+
setLabel: async (pub, label) => {
|
|
4364
|
+
const r = await identity.setLabel(pub, label)
|
|
4365
|
+
const device = await deviceIdOf(pub).catch(() => null)
|
|
4366
|
+
audit('label', { device, label: r?.label ?? label })
|
|
4367
|
+
await notifyMembers('label', { deviceId: device, label: r?.label ?? label })
|
|
4368
|
+
return r
|
|
4369
|
+
},
|
|
4370
|
+
// QUITAR EL DISPOSITIVO. Se identifica por su llave (`sub`), no por un `nonce`: un
|
|
4371
|
+
// aparato puede tener varios certificados y retirar uno no lo echaba de la bóveda.
|
|
4372
|
+
// Se sigue aceptando `{ nonce }` para no romper una consola vieja en vuelo.
|
|
4373
|
+
//
|
|
4374
|
+
// Al salir alguien el acta cambia igual que al entrar, así que el llavero se repasa
|
|
4375
|
+
// también aquí: lo que sobra se quita (`resealAll` lo hace) y el que se queda sigue
|
|
4376
|
+
// completo. Un llavero que solo se repasa al añadir acumula envolturas de quien ya no
|
|
4377
|
+
// está, que es lo contrario de lo que se quiso al expulsarlo.
|
|
4378
|
+
/**
|
|
4379
|
+
* UN CANAL LOCAL, para quien corre en el mismo proceso (la consola de la pestaña que ES
|
|
4380
|
+
* la bóveda). Es el mostrador local del binario sin socket: el mismo enrutador, y cada
|
|
4381
|
+
* pedido se comprueba igual que si llegara por el proxio. `onReply` recibe cada respuesta.
|
|
4382
|
+
*/
|
|
4383
|
+
localChannel (onReply) {
|
|
4384
|
+
const id = 'local:' + Math.random().toString(36).slice(2)
|
|
4385
|
+
canales.set(id, (obj) => { try { onReply(obj) } catch (_) {} })
|
|
4386
|
+
return {
|
|
4387
|
+
send: (payload) => { onMessage(id, payload).catch((e) => reply(id, { type: MSG.ERROR, error: e.message })) },
|
|
4388
|
+
close: () => { canales.delete(id) }
|
|
4389
|
+
}
|
|
4390
|
+
},
|
|
4391
|
+
revokeDevice: async (target) => {
|
|
4392
|
+
const sub = typeof target === 'object' && target ? target.sub : null
|
|
4393
|
+
if (!sub) {
|
|
4394
|
+
const nonce = typeof target === 'string' ? target : target?.nonce
|
|
4395
|
+
const r = await desk.revoke(nonce)
|
|
4396
|
+
await notifyMembers('revoked', { certNonce: nonce, by: 'pc' })
|
|
4397
|
+
return r
|
|
4398
|
+
}
|
|
4399
|
+
const r = await desk.revokeDevice(sub)
|
|
4400
|
+
audit('revoke-device', { device: await deviceIdOf(sub).catch(() => null), certs: r?.nonces?.length ?? null })
|
|
4401
|
+
await refreshWraps('revoked')
|
|
4402
|
+
// Quitar un aparato rota la llave de la cuenta: lo guardado pasa a la nueva.
|
|
4403
|
+
try { await resealStaleThreads('revoke') } catch (e) { log(`[vault] revoke: could not reseal old envelopes: ${e.message}`) }
|
|
4404
|
+
await notifyMembers('revoked', { deviceId: await deviceIdOf(sub).catch(() => null), by: 'pc' })
|
|
4405
|
+
return r
|
|
4406
|
+
},
|
|
4407
|
+
close () {
|
|
4408
|
+
clearInterval(approvalsSweeper)
|
|
4409
|
+
for (const t of pendingNotices.values()) clearTimeout(t)
|
|
4410
|
+
pendingNotices.clear()
|
|
4411
|
+
try { client.close() } catch (_) {} identity.destroy()
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
}
|