@dotrino/identity 0.115.0 → 0.116.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.
Files changed (78) hide show
  1. package/package.json +4 -1
  2. package/src/node.js +4 -180
  3. package/vault/core.js +66 -7
  4. package/vault/facade.js +200 -0
  5. package/vault/index.html +10 -0
  6. package/vault/remote.js +45 -5
  7. package/vault/vault.js +29 -58
  8. package/vault/vendor/compat/VERSION.txt +3 -0
  9. package/vault/vendor/compat/advisory.js +141 -0
  10. package/vault/vendor/compat/index.js +188 -0
  11. package/vault/vendor/compat/ranges.js +93 -0
  12. package/vault/vendor/opaque/VERSION.txt +1 -1
  13. package/vault/vendor/passmanager/VERSION.txt +3 -0
  14. package/vault/vendor/passmanager/crypto.js +167 -0
  15. package/vault/vendor/passmanager/fields.js +183 -0
  16. package/vault/vendor/passmanager/generate.js +74 -0
  17. package/vault/vendor/passmanager/import.js +156 -0
  18. package/vault/vendor/passmanager/index.js +27 -0
  19. package/vault/vendor/passmanager/match.js +137 -0
  20. package/vault/vendor/passmanager/model.js +332 -0
  21. package/vault/vendor/passmanager/pubkey.js +33 -0
  22. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  23. package/vault/vendor/passmanager/sealed/device.js +211 -0
  24. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  25. package/vault/vendor/passmanager/sealed/index.js +20 -0
  26. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  27. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  28. package/vault/vendor/passmanager/sealed/store.js +508 -0
  29. package/vault/vendor/passmanager/session-cache.js +60 -0
  30. package/vault/vendor/passmanager/totp.js +111 -0
  31. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  32. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  33. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  34. package/vault/vendor/passmanager/vault/approval.js +63 -0
  35. package/vault/vendor/passmanager/vault/errors.js +34 -0
  36. package/vault/vendor/passmanager/vault/guard.js +114 -0
  37. package/vault/vendor/passmanager/vault/interface.js +34 -0
  38. package/vault/vendor/passmanager/vault/local.js +213 -0
  39. package/vault/vendor/passmanager/vault/remote.js +85 -0
  40. package/vault/vendor/passmanager/vault/responder.js +192 -0
  41. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  42. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  43. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  44. package/vault/vendor/passmanager/webauthn.js +221 -0
  45. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  46. package/vault/vendor/proxy-client/client.js +36 -0
  47. package/vault/vendor/roadmap/VERSION.txt +3 -0
  48. package/vault/vendor/roadmap/index.js +108 -0
  49. package/vault/vendor/roadmap/manifests.js +797 -0
  50. package/vault/vendor/store/VERSION.txt +3 -0
  51. package/vault/vendor/store/core.js +309 -0
  52. package/vault/vendor/vault/VERSION.txt +4 -1
  53. package/vault/vendor/vault/admin.js +437 -0
  54. package/vault/vendor/vault/browser.js +167 -0
  55. package/vault/vendor/vault/core/approvals.js +204 -0
  56. package/vault/vendor/vault/core/blocked.js +68 -0
  57. package/vault/vendor/vault/core/browserDisk.js +109 -0
  58. package/vault/vendor/vault/core/commKey.js +68 -0
  59. package/vault/vendor/vault/core/disk.js +65 -0
  60. package/vault/vendor/vault/core/libVersion.js +8 -0
  61. package/vault/vendor/vault/core/machines.js +66 -0
  62. package/vault/vendor/vault/core/notices.js +71 -0
  63. package/vault/vendor/vault/core/notifiable.js +73 -0
  64. package/vault/vendor/vault/core/passwords.js +125 -0
  65. package/vault/vendor/vault/core/sealKey.js +77 -0
  66. package/vault/vendor/vault/core/sealer.js +172 -0
  67. package/vault/vendor/vault/core/sealers.js +159 -0
  68. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  69. package/vault/vendor/vault/core/sha256.js +69 -0
  70. package/vault/vendor/vault/core/store.js +68 -0
  71. package/vault/vendor/vault/core/subacta.js +101 -0
  72. package/vault/vendor/vault/core/threadStore.js +273 -0
  73. package/vault/vendor/vault/core/transport.js +270 -0
  74. package/vault/vendor/vault/core/vault.js +4414 -0
  75. package/vault/vendor/vault/enroll.js +8 -4
  76. package/vault/vendor/vault/procContext.js +97 -0
  77. package/vault/vendor/vault/revocation.js +67 -0
  78. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * admin.js — CONSOLA REMOTA: administrar el perfil desde un dispositivo emparejado.
3
+ *
4
+ * Diseño: `dotrino-vault/docs/consola-remota.md`. Módulo PURO (sin `node:*`, sin red,
5
+ * sin disco), igual que `enroll.js`, para que lo puedan usar el daemon del PC y «este
6
+ * dispositivo es bóveda» sin duplicar la regla.
7
+ *
8
+ * QUÉ SE DELEGA Y QUÉ NO — esto es el módulo entero, el resto es plomería:
9
+ *
10
+ * sí · ver el acta y la bitácora · REVOCAR a un miembro
11
+ * · VARIABLES DE ENTORNO: crearlas y darles valor (de un scope o de un aparato),
12
+ * y ver el valor de las marcadas PÚBLICAS
13
+ * no · AGREGAR UN APARATO (quitado 2026-08-31, ver abajo)
14
+ * · cambiar permisos · traspasar el mando · conceder `admin`
15
+ * · ver el valor de una variable PRIVADA · borrar variables
16
+ * · NADA que cambie algo mientras el perfil está CERRADO (ver
17
+ * `ADMIN_OPS_WHILE_LOCKED`): el candado es de la consola, y esto es la consola.
18
+ *
19
+ * SOBRE LAS VARIABLES, que es la rendija más nueva: lo que cruza la frontera no es «los
20
+ * secretos» sino los que su dueño MARCÓ como mostrables. Una privada se puede reescribir
21
+ * a ciegas desde la consola, pero su valor no sale de la máquina de la bóveda ni para un
22
+ * aparato tuyo con `admin`. Y los valores que sí salen viajan CIFRADOS con la clave de
23
+ * contenido del perfil (quien llama sella y abre): el proxy transporta y no ve nada.
24
+ *
25
+ * La frontera no es un capricho: un admin puede **admitir y expulsar**, pero no
26
+ * reescribir quién manda. Así un aparato con `admin` robado hace daño **acotado y
27
+ * reversible** (se le revoca) en vez de poder traspasarse el mando y dejar al dueño
28
+ * fuera de su propia cuenta, que no tiene vuelta atrás. Las operaciones que no se
29
+ * delegan **no existen como mensaje**: no hay nada que autorizar mal.
30
+ *
31
+ * POR QUÉ AGREGAR APARATOS YA NO SE HACE DESDE AQUÍ (dueño, 2026-08-31). Existía para
32
+ * ahorrarse ir hasta la máquina de la bóveda. El **multivault** quita esa fricción de raíz
33
+ * —cualquier otra selladora abierta admite el aparato— así que esto dejó de comprar nada y
34
+ * se quedaba pidiendo lo caro: que la maestra pudiera firmar a distancia.
35
+ *
36
+ * Y era incompatible con la regla dura del acta: la maestra solo sella el acta y reenvuelve
37
+ * sobres, y con la bóveda cerrada no firma nada. Admitir a alguien es emitir un certificado
38
+ * Y sellar el acta: las dos cosas son de una selladora abierta, y esta consola no lo es.
39
+ *
40
+ * **Quitado de raíz, no escondido**: no existe la operación, ni el mensaje, ni el botón.
41
+ * Una funcionalidad escondida sigue siendo superficie de ataque y vuelve sola en el
42
+ * siguiente refactor.
43
+ */
44
+
45
+ /** Las únicas operaciones que existen a distancia. Lista cerrada, como las capacidades. */
46
+ export const ADMIN_OPS = Object.freeze([
47
+ // `pending`, `pair`, `approve` y `reject` NO están, y no es un olvido: agregar aparatos
48
+ // a distancia se quitó (ver la cabecera). Lo que queda es mirar, quitar y configurar.
49
+ 'revoke', 'audit',
50
+ // Los BLOQUEADOS por un aprobador (`src/blocked.js`): verlos y desbloquearlos. Bloquear a
51
+ // distancia también, por si el administrador llega antes que el teléfono. No toca el acta.
52
+ 'blocked', 'block', 'unblock',
53
+ // Variables de entorno: verlas (nombres siempre; valor solo de las públicas) y
54
+ // ponerles valor (de las dos). Borrar NO está, y es a propósito: un aparato robado
55
+ // no debe poder dejar sin configuración a los servicios.
56
+ // `var.setMany` es la MISMA operación con varias variables dentro de un solo sobre: no
57
+ // añade permisos, quita reinicios (ver el enrutado abajo).
58
+ // Ver un valor, su histórico o volver a una versión NO están, y es a propósito: un
59
+ // aparato que administra no tiene sobres de lo privado (solo el servicio dueño y la
60
+ // recuperación). Eso lo hace la bóveda en su máquina (`secret show/history/revert`).
61
+ // No lo cablees.
62
+ // `var.recipients` es solo LEER, y hace falta para poder escribir: la consola sella el
63
+ // valor ella misma (§8.1) y para eso necesita saber a quién envolverle la llave. Estaba
64
+ // documentada en `buildSealedVar` y la consola la llamaba, pero no existía — así que
65
+ // crear una variable desde la consola contestaba «admin: invalid operation». No expone
66
+ // nada nuevo: son llaves PÚBLICAS, las mismas que ya viajan en el acta.
67
+ 'vars', 'var.set', 'var.setMany', 'var.recipients',
68
+ // ABRIR LA BÓVEDA A DISTANCIA (`docs/abrir-a-distancia.md`). Dos pasos: `unlock.begin`
69
+ // pide una llave efímera y los parámetros del molino; `unlock` trae la derivada dentro
70
+ // de un sobre que solo esta bóveda puede abrir. No hay una tercera.
71
+ 'unlock.begin', 'unlock'
72
+ ])
73
+
74
+ /**
75
+ * QUÉ SE ATIENDE CON EL CANDADO ECHADO.
76
+ *
77
+ * No es «solo mirar», y confundirlo fue un error: **guardar una variable NO necesita la
78
+ * maestra**. Sellar un valor usa las llaves PÚBLICAS de quien va a leerlo, así que la
79
+ * bóveda puede hacerlo sin ningún secreto suyo (`docs/secretos-sellados.md` §8.1) — es
80
+ * justo lo que hace barata la rotación. Meterlo en el mismo saco que revocar dejaba la
81
+ * consola sin poder configurar nada con el perfil cerrado, que es su trabajo diario.
82
+ *
83
+ * Lo que SÍ se queda fuera es `revoke`: reescribe el acta, y sellar el acta es de la
84
+ * maestra. Cerrada no hay con qué.
85
+ *
86
+ * (Si el cajón tiene su llave sellada con la frase, repartirla a los miembros puede quedar
87
+ * a deber hasta que alguien abra: eso ya lo dice la bóveda y se salda solo. Guardar el
88
+ * valor, que es lo que se pidió, se hace igual.)
89
+ *
90
+ * El candado ES de la consola —los aparatos ya emparejados siguen leyendo, guardando y
91
+ * firmando con SU llave— así que cerrar el perfil tiene que cerrar exactamente esto. No
92
+ * estaba: `admin.pair` y `admin.approve` pasaban con el perfil bloqueado, y `approve`
93
+ * firmaba un certificado de 30 días CON LA MAESTRA. Esas dos ya no existen —agregar
94
+ * aparatos se quitó de aquí—, pero la regla se queda para las que quedan: revocar reescribe
95
+ * el acta y configurar toca los secretos.
96
+ *
97
+ * Se dejan las de LEER a propósito: si se cortaran todas, la consola no podría ni
98
+ * enterarse de por qué no funciona y parecería rota. Así puede decir «está cerrada».
99
+ */
100
+ export const ADMIN_OPS_WHILE_LOCKED = Object.freeze([
101
+ 'audit', 'vars', 'var.set', 'var.setMany', 'var.recipients',
102
+ // Bloquear no sella nada: la lista es de la bóveda, no del acta.
103
+ 'blocked', 'block', 'unblock',
104
+ // Las dos de abrir, obviamente: son las únicas que solo tienen sentido con el candado
105
+ // echado. Que estén aquí NO las deja pasar sin más — siguen exigiendo cert, acta, nonce
106
+ // y frescura como todas, y además la contraseña dentro del sobre.
107
+ 'unlock.begin', 'unlock'
108
+ ])
109
+
110
+ /** Cuánto se recuerda un nonce ya usado (el doble de la ventana de frescura). */
111
+ export const ADMIN_NONCE_TTL_MS = 10 * 60 * 1000
112
+
113
+ /** Tope de entradas de bitácora por petición. */
114
+ export const AUDIT_MAX = 500
115
+
116
+ /**
117
+ * @param {Object} o
118
+ * @param {Object} o.desk `createEnrollDesk`, del que aquí solo se usa REVOCAR
119
+ * (`revoke`/`revokeDevice`). Emparejar y aprobar ya no se atienden a distancia.
120
+ * @param {(scope:string[])=>Promise<any>} o.verify verifica cadena+cert; devuelve `{ok, device, reason}`.
121
+ * @param {(limit:number)=>any[]} o.readActivity últimas entradas de la bitácora.
122
+ * @param {(pub:string)=>Promise<string>} o.deviceIdOf
123
+ * @param {{list:(a:object)=>Promise<any>, set:(a:object)=>Promise<any>, setMany?:(a:object)=>Promise<any>}} [o.vars]
124
+ * mostrador de VARIABLES DE ENTORNO. Va inyectado porque aquí no hay cripto ni disco:
125
+ * quien lo implementa (la bóveda) es quien sella con la clave de contenido del perfil y
126
+ * quien decide qué valor puede salir. Sin él, las ops de variables responden que esta
127
+ * bóveda no las atiende, en vez de fingir que se aplicaron.
128
+ * @param {(ev:string, info?:object)=>Promise<void>} [o.notify] aviso a todos los miembros.
129
+ * @param {(op:string, info?:object)=>void} [o.audit]
130
+ * @param {()=>boolean} [o.isLocked] candado del perfil. Cerrado, solo se atiende
131
+ * `ADMIN_OPS_WHILE_LOCKED`; lo demás responde con el código `vault-locked`.
132
+ */
133
+ /**
134
+ * EL CUERPO QUE FIRMA EL AUTOR DE UN SOBRE. Uno solo, para los dos lados.
135
+ *
136
+ * Quien escribe firma esto y la bóveda verifica esto MISMO. Si cada lado se armara su
137
+ * versión, la firma dejaría de cuadrar el día que alguien añada un campo —y el síntoma
138
+ * sería «la firma no verifica», que manda a mirar la cripto en vez del formato.
139
+ *
140
+ * Lleva el CAJÓN y la CLAVE, no solo el sobre: sin ellos, una firma válida para
141
+ * `ns:aws/TOKEN` valdría igual para `ns:proxy/TOKEN`, que es exactamente el sobre colado
142
+ * fuera de su sitio que esto viene a impedir.
143
+ */
144
+ export function authorBody (owner, key, e, ts) {
145
+ return { op: 'var.author', owner, key, iv: e?.iv || null, ct: e?.ct || null, ts }
146
+ }
147
+
148
+ export function createAdminDesk ({
149
+ desk, verify, readActivity = () => [], deviceIdOf, vars = null,
150
+ notify = async () => {}, audit = () => {}, isLocked = () => false,
151
+ // ABRIR A DISTANCIA. Va inyectado —como `vars`— porque abrir necesita cripto y disco, y
152
+ // este módulo es puro a propósito. `null` en una bóveda que no lo soporte: entonces la
153
+ // operación existe y contesta que no, en vez de romper con un TypeError.
154
+ unlockDesk = null,
155
+ // La lista de bloqueados, inyectada como `vars`: `{ list(), set(pub, on, { by }) }`.
156
+ blocked = null,
157
+ now = () => Date.now()
158
+ }) {
159
+ const ops = new Set(ADMIN_OPS)
160
+ const mientrasCerrada = new Set(ADMIN_OPS_WHILE_LOCKED)
161
+
162
+ // NONCE de un solo uso. `sign`/`get` son idempotentes y les basta la ventana de ±5
163
+ // min; `revoke` y `var.set` CAMBIAN ESTADO, así que reproducir uno dentro de esa
164
+ // ventana sí importa (re-aprobar un enrolamiento que el dueño ya rechazó, por
165
+ // ejemplo). Por eso el nonce, y por eso es obligatorio en todas las ops: una lista
166
+ // de excepciones es una invitación a equivocarse.
167
+ const seen = new Map()
168
+ function nonceAlreadyUsed (nonce) {
169
+ const t = now()
170
+ for (const [n, exp] of seen) if (exp <= t) seen.delete(n)
171
+ if (seen.has(nonce)) return true
172
+ seen.set(nonce, t + ADMIN_NONCE_TTL_MS)
173
+ return false
174
+ }
175
+
176
+ /**
177
+ * Atiende una petición ya verificada como *fresca*. Devuelve `{ ok, result }` o
178
+ * `{ ok: false, error }` — quien llama se encarga de responder por el transporte.
179
+ */
180
+ async function handle (data, { signature, cert } = {}) {
181
+ if (!data || !ops.has(data.op)) return { ok: false, error: 'admin: invalid operation' }
182
+ if (typeof data.nonce !== 'string' || data.nonce.length < 16) {
183
+ return { ok: false, error: 'admin: missing single-use nonce' }
184
+ }
185
+
186
+ const chk = await verify({ data, signature, cert })
187
+ if (!chk?.ok) {
188
+ audit('rejected', { what: 'admin', op: data.op, reason: chk?.reason })
189
+ return { ok: false, error: 'unauthorized: ' + (chk?.reason || 'cert') }
190
+ }
191
+ const by = await deviceIdOf(chk.device).catch(() => null)
192
+
193
+ // CERRADA NO SE SELLA EL ACTA. Va después de autorizar (el candado es estado del perfil,
194
+ // y a un desconocido no se le cuenta) y ANTES de quemar el nonce: quien reintente
195
+ // tras abrir la bóveda no tiene por qué generar otro.
196
+ //
197
+ // El código va aparte del texto porque el texto se traduce y el código no: quien
198
+ // llama distingue «está cerrada, ábrela» de «no tienes permiso» sin leer la frase.
199
+ if (isLocked() && !mientrasCerrada.has(data.op)) {
200
+ audit('rejected', { what: 'admin', op: data.op, by, reason: 'locked' })
201
+ return {
202
+ ok: false,
203
+ code: 'vault-locked',
204
+ error: 'vault locked: unlock the profile on the vault machine (dotrino-vault unlock) to administer it'
205
+ }
206
+ }
207
+
208
+ // El nonce se marca DESPUÉS de autorizar: si no, cualquiera podría quemarle los
209
+ // nonces a un admin legítimo mandando basura firmada por nadie.
210
+ if (nonceAlreadyUsed(data.nonce)) {
211
+ audit('rejected', { what: 'admin', op: data.op, by, reason: 'replay' })
212
+ return { ok: false, error: 'admin: nonce already used' }
213
+ }
214
+
215
+ try {
216
+
217
+ // ABRIR A DISTANCIA. La bóveda hace el trabajo; aquí solo se enruta, porque este
218
+ // módulo es puro —sin cripto, sin disco— y abrir necesita las dos cosas.
219
+ if (data.op === 'unlock.begin') {
220
+ if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
221
+ return { ok: true, result: await unlockDesk.begin({ by, device: chk.device }) }
222
+ }
223
+ if (data.op === 'unlock') {
224
+ if (!unlockDesk) return { ok: false, error: 'admin: this vault cannot be opened remotely (update it)' }
225
+ return await unlockDesk.open({ nonce: data.nonce, enc: data.enc, by, device: chk.device })
226
+ }
227
+
228
+ if (data.op === 'audit') {
229
+ const limit = Math.min(Math.max(Number(data.limit) || 100, 1), AUDIT_MAX)
230
+ return { ok: true, result: { entries: readActivity(limit) } }
231
+ }
232
+
233
+ // VARIABLES DE ENTORNO. El módulo solo enruta y audita: qué valor puede salir y con
234
+ // qué se cifra lo decide la bóveda (`vars`), que es la que tiene la clave y el disco.
235
+ if (data.op === 'vars' || data.op === 'var.set' || data.op === 'var.setMany' || data.op === 'var.recipients') {
236
+ if (!vars) return { ok: false, error: 'admin: this vault does not serve environment variables' }
237
+ // Un destino y solo uno: o un scope, o un aparato. Sin esto, mandar los dos dejaría
238
+ // que quien llama adivine dónde acabó su variable.
239
+ // Booleanos a propósito: comparar los VALORES («proxy» vs una pubkey) nunca da
240
+ // igual, así que mandar los dos destinos se colaba por el hueco.
241
+ const toScope = typeof data.ns === 'string' && !!data.ns
242
+ const toDevice = typeof data.pub === 'string' && !!data.pub
243
+ if (data.op === 'vars') {
244
+ // QUIÉN PREGUNTA, no solo su nombre: con su llave se le manda la envoltura de
245
+ // cada pública para que pueda abrirla en su casa.
246
+ const result = await vars.list({ by, caller: chk.device })
247
+ audit('admin.vars', { by })
248
+ return { ok: true, result }
249
+ }
250
+ if (toScope === toDevice) return { ok: false, error: 'admin: var.set needs exactly one target (ns or pub)' }
251
+
252
+ // A QUIÉN HAY QUE ENVOLVERLE. Se pide ANTES de sellar, porque sellar lo hace la
253
+ // consola: envolver solo necesita públicas, así que puede hacerlo un navegador sin
254
+ // que ninguna privada ande suelta (§8.1). La lista sale del ACTA y la sabe la
255
+ // bóveda, no la consola — así conceder un permiso a un aparato nuevo basta, sin que
256
+ // nadie tenga que acordarse de actualizar una lista en otro sitio.
257
+ //
258
+ // Depende de la VISIBILIDAD: una pública se envuelve además para quien administra,
259
+ // así que quien pregunta dice cuál va a escribir.
260
+ if (data.op === 'var.recipients') {
261
+ if (typeof vars.recipients !== 'function') {
262
+ return { ok: false, error: 'admin: this vault cannot say who to seal for (update it)' }
263
+ }
264
+ const result = await vars.recipients({
265
+ ns: toScope ? data.ns : null,
266
+ pub: toDevice ? data.pub : null,
267
+ public: data.public === true
268
+ })
269
+ audit('admin.var.recipients', { by, ns: toScope ? data.ns : null })
270
+ return { ok: true, result }
271
+ }
272
+
273
+ // VARIAS DE UNA VEZ. Mismo permiso y misma frontera que una sola: lo que cambia es
274
+ // que la bóveda las guarda juntas y manda UN aviso de cambio en vez de uno por
275
+ // variable — o sea, el servicio se reinicia una vez, con la configuración entera,
276
+ // en lugar de arrancar a medias mientras quien administra sigue escribiendo.
277
+ // Los nombres viajan DENTRO del sobre, igual que los valores: el proxy tampoco
278
+ // tiene por qué aprender cómo se llaman las variables de un servicio.
279
+ if (data.op === 'var.setMany') {
280
+ // Una bóveda anterior a esto sabe guardar de una en una y nada más. Decirlo es
281
+ // mejor que reventar con un TypeError que no explica qué falta actualizar.
282
+ if (typeof vars.setMany !== 'function') return { ok: false, error: 'admin: this vault cannot save several variables at once (update it)' }
283
+ if (!Array.isArray(data.items) || !data.items.length) {
284
+ return { ok: false, error: 'admin: var.setMany needs `items`, each with its key and its sealed envelope' }
285
+ }
286
+ const result = await vars.setMany({
287
+ ns: toScope ? data.ns : null,
288
+ pub: toDevice ? data.pub : null,
289
+ items: data.items,
290
+ caller: chk.device,
291
+ by
292
+ })
293
+ const keys = result?.keys || []
294
+ // EN ESPERA DE APROBACIÓN: todavía no cambió nada, así que no hay de qué avisar. El
295
+ // aviso sale cuando se aprueba y se escribe de verdad.
296
+ if (result?.pending) {
297
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: data.items.length })
298
+ return { ok: true, result }
299
+ }
300
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, keys })
301
+ await notify('vars', { by, keys, ns: toScope ? data.ns : null })
302
+ return { ok: true, result: result || { ok: true } }
303
+ }
304
+
305
+ if (typeof data.key !== 'string' || !data.key) return { ok: false, error: 'admin: var.set needs a key' }
306
+ if (!data.sealed || typeof data.sealed !== 'object') {
307
+ // El valor NUNCA viaja en claro, y desde 2026-09-02 tampoco sellado al perfil: el
308
+ // sobre llega HECHO y la bóveda no lo abre. Si llega de otra forma es un error de
309
+ // quien llama, no algo que se pueda «arreglar» aceptándolo.
310
+ return { ok: false, error: 'admin: var.set needs the value already sealed (`sealed`); build it with buildSealedVar()' }
311
+ }
312
+ const result = await vars.set({
313
+ ns: toScope ? data.ns : null,
314
+ pub: toDevice ? data.pub : null,
315
+ key: data.key,
316
+ sealed: data.sealed,
317
+ public: typeof data.public === 'boolean' ? data.public : undefined,
318
+ // LA LLAVE DE QUIEN LLAMA, no solo su nombre corto. `by` es el `deviceId`, que
319
+ // vale para el registro pero no para comprobar una firma: el sobre trae la de su
320
+ // AUTOR y hay que verificarla contra la llave de quien lo manda.
321
+ caller: chk.device,
322
+ by
323
+ })
324
+ if (result?.pending) {
325
+ audit('admin.var.pending', { by, ns: toScope ? data.ns : null, id: result.pending, count: 1 })
326
+ return { ok: true, result }
327
+ }
328
+ audit('admin.var.set', { by, ns: toScope ? data.ns : null, device: toDevice ? await deviceIdOf(data.pub).catch(() => null) : null, key: data.key })
329
+ // Cambiar la configuración de un servicio a distancia no puede ser invisible: es
330
+ // la contrapartida de delegar (F3 de docs/consola-remota.md).
331
+ await notify('vars', { by, key: data.key, ns: toScope ? data.ns : null })
332
+ return { ok: true, result: result || { ok: true } }
333
+ }
334
+
335
+ // QUITAR UN DISPOSITIVO se hace por `sub` (su llave): sale del acta Y se le retiran
336
+ // todos los certificados. Las dos cosas o ninguna.
337
+ //
338
+ // `certNonce` retira UN PAPEL y nada más — el aparato sigue siendo miembro. Sirve
339
+ // para eso y solo para eso, y se conserva por compatibilidad, pero no es «quitar»:
340
+ // usarlo para quitar dejaba un miembro sin certificados al que la bóveda ya nunca le
341
+ // mandaba el aviso de expulsión (mientras siga en el acta, un papel retirado
342
+ // significa «renueva»). Ese era el dispositivo fantasma.
343
+ if (data.op === 'blocked' || data.op === 'block' || data.op === 'unblock') {
344
+ if (!blocked) return { ok: false, error: 'admin: this vault cannot block devices' }
345
+ if (data.op === 'blocked') return { ok: true, result: { items: blocked.list() } }
346
+ if (typeof data.sub !== 'string' || !data.sub) return { ok: false, error: `admin: ${data.op} needs the device key (sub)` }
347
+ const r = await blocked.set(data.sub, data.op === 'block', { by })
348
+ audit(`admin.${data.op}`, { by, device: await deviceIdOf(data.sub).catch(() => null) })
349
+ return { ok: true, result: { ok: true, was: r } }
350
+ }
351
+ if (data.op === 'revoke') {
352
+ if (data.sub) {
353
+ const deviceId = await deviceIdOf(String(data.sub)).catch(() => null)
354
+ const r = await desk.revokeDevice(String(data.sub))
355
+ audit('admin.revoke-device', { by, device: deviceId, certs: r?.nonces?.length ?? null })
356
+ // Con el `deviceId`: el aviso a los demás dispositivos tiene que decir a QUIÉN
357
+ // quitaron, o no se puede saber si el que sobra eres tú.
358
+ await notify('revoked', { deviceId, by })
359
+ return { ok: true, result: r || { ok: true } }
360
+ }
361
+ const r = await desk.revoke(String(data.certNonce || ''))
362
+ audit('admin.revoke', { by, certNonce: data.certNonce })
363
+ await notify('revoked', { certNonce: data.certNonce, by })
364
+ return { ok: true, result: r || { ok: true } }
365
+ }
366
+ } catch (e) {
367
+ audit('rejected', { what: 'admin', op: data.op, by, reason: e.message })
368
+ return { ok: false, error: e.message }
369
+ }
370
+ return { ok: false, error: 'admin: invalid operation' }
371
+ }
372
+
373
+ return { handle, get nonceCount () { return seen.size } }
374
+ }
375
+
376
+ export default { createAdminDesk, ADMIN_OPS, ADMIN_OPS_WHILE_LOCKED, ADMIN_NONCE_TTL_MS }
377
+
378
+ /**
379
+ * FABRICAR EL SOBRE DE UNA VARIABLE — del lado de quien escribe.
380
+ *
381
+ * La bóveda ya no ve el valor (dueño, 2026-09-01): recibe el sobre hecho, comprueba lo que
382
+ * puede sin la llave —quién lo firma y a quién envuelve— y lo guarda. Esto es lo que hay que
383
+ * mandarle, y vive aquí para que no lo rearme cada consola por su cuenta.
384
+ *
385
+ * El orden importa y no es casual:
386
+ * 1. se pide a la BÓVEDA a quién hay que envolver (`var.recipients`). La lista sale del
387
+ * acta y la sabe ella; tener dos sitios respondiendo a eso es cómo se deja fuera a
388
+ * alguien sin que nadie se entere.
389
+ * 2. CEK nueva y el valor cifrado con ella;
390
+ * 3. esa CEK envuelta a la PÚBLICA de cada destinatario, más la de recuperación —sin la
391
+ * cual el cajón nacería ilegible para siempre;
392
+ * 4. y la firma del AUTOR sobre el cajón, la clave y el sobre.
393
+ *
394
+ * QUIÉN FIRMA SE PASA COMO FUNCIÓN, no como llave. En el navegador la privada del aparato
395
+ * vive dentro del iframe de identidad y NO SALE de ahí —esa es media garantía del diseño—,
396
+ * así que la consola no puede firmar aquí: pide la firma y ya. En Node, donde la llave sí
397
+ * está a mano, se envuelve `signWithDevice` en dos líneas. Un solo camino para los dos.
398
+ *
399
+ * @param {{recipients:{recoveryPub:string, members:Array<{pub:string,encPub:string}>},
400
+ * owner:string, key:string, value:string,
401
+ * author:{publickey:string, sign:(body:any)=>Promise<{signature:string}>}}} opts
402
+ */
403
+ export async function buildSealedVar ({ recipients, owner, key, value, author } = {}) {
404
+ const { makeContentKey, encryptWithCek, wrapForMember } = await import('@dotrino/identity/content')
405
+ if (!recipients?.recoveryPub) throw new Error('buildSealedVar: ask the vault for the recipients first (var.recipients)')
406
+ if (typeof value !== 'string' || !value) throw new Error('buildSealedVar: the value must be a non-empty string')
407
+ if (typeof author?.sign !== 'function' || typeof author?.publickey !== 'string') {
408
+ throw new Error('buildSealedVar: author needs { publickey, sign(body) }')
409
+ }
410
+
411
+ const cek = await makeContentKey()
412
+ const e = await encryptWithCek({ cek, gen: 0, plaintext: value })
413
+ // Envolver solo necesita PÚBLICAS: por eso esto se puede hacer en un navegador sin que
414
+ // ninguna llave privada ande suelta.
415
+ const wraps = { '#recovery': await wrapForMember({ cek, memberEncPub: recipients.recoveryPub }) }
416
+ for (const m of recipients.members || []) {
417
+ wraps[m.pub] = await wrapForMember({ cek, memberEncPub: m.encPub })
418
+ }
419
+ const ts = Date.now()
420
+ const { signature } = await author.sign(authorBody(owner, key, e, ts))
421
+ return { e, wraps, author: { pub: author.publickey, sig: signature, ts } }
422
+ }
423
+
424
+ /**
425
+ * El `author` de `buildSealedVar` a partir de una llave de aparato de Node (la que trae
426
+ * `makeDeviceKey` o un `service-identity.json`). En el navegador no se usa: allí la firma
427
+ * la da el iframe.
428
+ */
429
+ export function authorFromDeviceKey (device) {
430
+ return {
431
+ publickey: device.publickey,
432
+ sign: async (body) => {
433
+ const { signWithDevice } = await import('@dotrino/identity/capabilities')
434
+ return signWithDevice({ ...device, data: body })
435
+ }
436
+ }
437
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * LA BÓVEDA EN EL NAVEGADOR: la pestaña (`vault.dotrino.com`, dentro del iframe de
3
+ * identidad) y la extensión del gestor de contraseñas corren el MISMO núcleo que el
4
+ * binario (`./core/vault.js`). Lo único distinto es lo que pone la plataforma:
5
+ *
6
+ * · el disco es IndexedDB cifrado (`./core/browserDisk.js`), uno por perfil;
7
+ * · la identidad es la que ya tiene abierta la pestaña o la extensión;
8
+ * · no hay `/proc` ni socket de archivo: lo que declara quien pide se queda como
9
+ * declarado, y los servicios de la misma máquina llegan por el proxio.
10
+ *
11
+ * Y lo que sí suma: la consola que corre en este mismo navegador administra esta bóveda
12
+ * por un canal local (`core.setSelfVault`), con el MISMO protocolo y las mismas
13
+ * comprobaciones que contra el binario.
14
+ *
15
+ * Lo que el binario tiene y el navegador no, por su contexto: actualizarse solo (la pestaña
16
+ * se sirve siempre al día y la extensión la actualiza su tienda), las llaves de hardware
17
+ * como puerta del perfil y el candado que cifra la maestra con la contraseña. Ver
18
+ * `docs/three-versions.md`.
19
+ */
20
+ import { startVault } from './core/vault.js'
21
+ import { openBrowserDisk } from './core/browserDisk.js'
22
+ import { LIB_VERSION } from './core/libVersion.js'
23
+ import { IdentityFacade } from '@dotrino/identity/facade'
24
+ import { memberScopes } from '@dotrino/identity/acta'
25
+ import { deviceIdOf } from './enroll.js'
26
+
27
+ const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
28
+ /** Cada cuánto se rehace el papel con el que la consola local administra esta bóveda. */
29
+ const SELF_CERT_EVERY_MS = 60_000
30
+
31
+ /**
32
+ * @param {object} opts
33
+ * @param {object} opts.core el núcleo de identidad ya abierto (`createIdentityCore`)
34
+ * @param {string} [opts.proxyUrl]
35
+ * @param {Function} [opts.makeClient] quién crea el cliente del proxio. La extensión lo
36
+ * pasa siempre: un service worker no puede hacer `import()`.
37
+ * @param {Function} [opts.legacyLogins] los inicios de sesión que esta pestaña guardaba
38
+ * antes de correr el núcleo (`{ state, drop }` o `null`). Se traen UNA vez.
39
+ * @param {Function} [opts.onPendingChange] un aparato espera aprobación, o dejó de esperar
40
+ * @param {Function} [opts.onAdopted]
41
+ * @param {Function} [opts.log]
42
+ * @param {object} [opts.opaque] el servidor de OPAQUE, si el del paquete no se puede cargar aquí
43
+ * (la extensión: su CSP no deja WASM en la página de la bóveda).
44
+ * @param {boolean} [opts.servePasswords] `false` si las contraseñas las atiende quien monta
45
+ * la bóveda (la extensión, con su worker); decide con `passwordsNeedApproval`/`approvePasswordRequest`.
46
+ * @param {Function} [opts.openDisk] `(name) => disk`. Solo para las pruebas (Node no tiene
47
+ * IndexedDB); por defecto, `openBrowserDisk`.
48
+ */
49
+ export async function startBrowserVault ({ core, proxyUrl = DEFAULT_PROXY, makeClient = null, legacyLogins = null, onPendingChange = () => {}, onAdopted = () => {}, log = console.log, openDisk = openBrowserDisk, opaque = null, servePasswords = true } = {}) {
50
+ if (!core?.handlers) throw Object.assign(new Error('startBrowserVault: the identity core is required'), { code: 'no-identity' })
51
+ const identity = new IdentityFacade(core)
52
+ if (!identity.me?.publickey) throw Object.assign(new Error('no identity: create or unlock your profile before using this browser as a vault'), { code: 'no-identity' })
53
+ const { id: pid } = await identity.currentProfile()
54
+ if (!pid) throw Object.assign(new Error('no profile: the vault belongs to an account'), { code: 'no-profile' })
55
+
56
+ const disk = await openDisk(`dotrino-vault.${pid}`)
57
+
58
+ // LOS INICIOS DE SESIÓN QUE YA HABÍA. Antes de correr el núcleo, la pestaña guardaba los
59
+ // usuarios y contraseñas de la cuenta en su propio almacén. Se traen una vez —si el disco
60
+ // ya tiene los suyos, mandan esos— y después se borra lo viejo: dos copias acaban diciendo
61
+ // cosas distintas.
62
+ if (legacyLogins && !disk.exists('logins.json')) {
63
+ const old = await legacyLogins()
64
+ if (old?.state) {
65
+ disk.write('logins.json', disk.atRest.encrypt(JSON.stringify(old.state)))
66
+ await disk.flush()
67
+ try { await old.drop?.() } catch (_) {}
68
+ log('[vault] password logins moved into the vault storage')
69
+ }
70
+ }
71
+
72
+ const avisar = () => { try { onPendingChange() } catch (_) {} }
73
+ const vault = await startVault({
74
+ platform: {
75
+ disk,
76
+ product: 'vault',
77
+ version: LIB_VERSION,
78
+ openIdentity: async () => identity,
79
+ makeClient,
80
+ noticeGroupMs: 3000,
81
+ ...(opaque ? { opaque } : {}),
82
+ servePasswords
83
+ },
84
+ proxyUrl,
85
+ log,
86
+ isLocked: () => !!core.masterLocked,
87
+ // El navegador no tiene la contraseña del perfil con la que el binario cierra la copia
88
+ // de recuperación: se cierra con la llave de este almacén, y el núcleo lo dice.
89
+ hasPassword: () => false,
90
+ onEnrollChallenge: avisar,
91
+ onAdopted: (info) => { try { onAdopted(info) } catch (_) {} }
92
+ })
93
+
94
+ // EL PAPEL CON EL QUE LA CONSOLA DE ESTE NAVEGADOR ADMINISTRA ESTA BÓVEDA: la llave del
95
+ // perfil firmándose a sí misma con lo que el acta le deja hoy. Se rehace cada minuto
96
+ // porque el acta cambia (un permiso que te quitas, un sellado nuevo).
97
+ const master = identity.me.publickey
98
+ const selfCert = async () => {
99
+ const acta = (await identity.profileActa().catch(() => null))?.acta || null
100
+ if (!acta) return null
101
+ const scope = memberScopes(acta, master)
102
+ if (!scope.length) return null
103
+ return (await identity.signDelegation(master, scope, { label: 'self' })).cert
104
+ }
105
+ /**
106
+ * EL DUEÑO EN SU MÁQUINA ADMINISTRA. En el binario eso es la TUI, que no necesita ningún
107
+ * permiso del acta; aquí la consola habla el protocolo de la consola remota, que exige
108
+ * `admin`. Así que la llave de la cuenta se lo da a sí misma, una vez y a la vista en el
109
+ * acta: es verdad que este navegador administra la cuenta. `aprueba` NO, a propósito: con
110
+ * él los servicios pasarían a esperar el visto bueno de una pestaña que puede no estar.
111
+ */
112
+ const asegurarAdmin = async () => {
113
+ if (core.masterLocked) return
114
+ const acta = (await identity.profileActa().catch(() => null))?.acta || null
115
+ const yo = (acta?.members || []).find((m) => m.pub === master)
116
+ if (!yo || (yo.caps || []).includes('admin')) return
117
+ if (!(await identity.isMaster().catch(() => false))) return
118
+ await identity.setCaps(master, [...yo.caps, 'admin'])
119
+ log('[vault] this browser administers the account: admin added to its own key')
120
+ }
121
+ const open = (onReply) => vault.localChannel(onReply)
122
+ const presentar = async () => {
123
+ try {
124
+ await asegurarAdmin()
125
+ const cert = await selfCert()
126
+ core.setSelfVault?.(cert ? { cert, proxy: proxyUrl, open } : null)
127
+ } catch (e) { log('[vault] the console in this browser cannot administer this vault yet: ' + (e?.message || e)) }
128
+ }
129
+ await presentar()
130
+ const refresco = setInterval(presentar, SELF_CERT_EVERY_MS)
131
+
132
+ return {
133
+ ...vault,
134
+ iss: master,
135
+ proxy: proxyUrl,
136
+ disk,
137
+ /** Aprueba TIPEANDO el código que muestra el aparato (el núcleo comprueba el compromiso). */
138
+ approve: async (_deviceId, code) => { try { return await vault.approveDevice(code) } finally { avisar() } },
139
+ reject: (deviceId) => { try { return vault.rejectDevice(deviceId) } finally { avisar() } },
140
+ /**
141
+ * Las máquinas con papel de firma de esta cuenta (excluye los navegadores enrolados con
142
+ * etiqueta `cli`, que no atienden pedidos). Se queda el papel del acta más nueva.
143
+ */
144
+ async listMachines () {
145
+ const { issued } = await identity.listDelegations()
146
+ const bySub = new Map()
147
+ for (const x of (issued || [])) {
148
+ if (!x.sub || x.revokedAt) continue
149
+ if (!Array.isArray(x.scope) || !x.scope.includes('vault:sign')) continue
150
+ if (!x.label || x.label === 'cli' || x.label === 'self') continue
151
+ if (!bySub.has(x.sub) || (x.seq || 0) > (bySub.get(x.sub).seq || 0)) bySub.set(x.sub, x)
152
+ }
153
+ return Promise.all([...bySub.values()].map(async (x) => ({ ...x, deviceId: await deviceIdOf(x.sub) })))
154
+ },
155
+ revokeDevice: (sub) => vault.revokeDevice({ sub }),
156
+ revoke: (nonce) => vault.revokeDevice(nonce),
157
+ async close () {
158
+ clearInterval(refresco)
159
+ try { core.setSelfVault?.(null) } catch (_) {}
160
+ vault.close()
161
+ try { await disk.flush() } catch (e) { log('[vault] some changes could not be saved: ' + (e?.message || e)) }
162
+ disk.close()
163
+ }
164
+ }
165
+ }
166
+
167
+ export default { startBrowserVault }