@dotrino/identity 0.114.1 → 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 (81) hide show
  1. package/package.json +7 -1
  2. package/src/index.js +9 -1
  3. package/src/node.js +4 -180
  4. package/vault/acta.js +5 -1
  5. package/vault/core.js +95 -15
  6. package/vault/facade.js +200 -0
  7. package/vault/index.html +10 -0
  8. package/vault/machine.js +95 -0
  9. package/vault/remote.js +54 -9
  10. package/vault/vault.js +29 -58
  11. package/vault/vendor/compat/VERSION.txt +3 -0
  12. package/vault/vendor/compat/advisory.js +141 -0
  13. package/vault/vendor/compat/index.js +188 -0
  14. package/vault/vendor/compat/ranges.js +93 -0
  15. package/vault/vendor/opaque/VERSION.txt +1 -1
  16. package/vault/vendor/passmanager/VERSION.txt +3 -0
  17. package/vault/vendor/passmanager/crypto.js +167 -0
  18. package/vault/vendor/passmanager/fields.js +183 -0
  19. package/vault/vendor/passmanager/generate.js +74 -0
  20. package/vault/vendor/passmanager/import.js +156 -0
  21. package/vault/vendor/passmanager/index.js +27 -0
  22. package/vault/vendor/passmanager/match.js +137 -0
  23. package/vault/vendor/passmanager/model.js +332 -0
  24. package/vault/vendor/passmanager/pubkey.js +33 -0
  25. package/vault/vendor/passmanager/sealed/convert.js +72 -0
  26. package/vault/vendor/passmanager/sealed/device.js +211 -0
  27. package/vault/vendor/passmanager/sealed/entry.js +209 -0
  28. package/vault/vendor/passmanager/sealed/index.js +20 -0
  29. package/vault/vendor/passmanager/sealed/keys.js +120 -0
  30. package/vault/vendor/passmanager/sealed/recovery.js +136 -0
  31. package/vault/vendor/passmanager/sealed/store.js +508 -0
  32. package/vault/vendor/passmanager/session-cache.js +60 -0
  33. package/vault/vendor/passmanager/totp.js +111 -0
  34. package/vault/vendor/passmanager/transport/protocol.js +83 -0
  35. package/vault/vendor/passmanager/transport/proxy.js +112 -0
  36. package/vault/vendor/passmanager/transport/sealed.js +74 -0
  37. package/vault/vendor/passmanager/vault/approval.js +63 -0
  38. package/vault/vendor/passmanager/vault/errors.js +34 -0
  39. package/vault/vendor/passmanager/vault/guard.js +114 -0
  40. package/vault/vendor/passmanager/vault/interface.js +34 -0
  41. package/vault/vendor/passmanager/vault/local.js +213 -0
  42. package/vault/vendor/passmanager/vault/remote.js +85 -0
  43. package/vault/vendor/passmanager/vault/responder.js +192 -0
  44. package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
  45. package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
  46. package/vault/vendor/passmanager/vault/sealed.js +362 -0
  47. package/vault/vendor/passmanager/webauthn.js +221 -0
  48. package/vault/vendor/proxy-client/VERSION.txt +1 -1
  49. package/vault/vendor/proxy-client/client.js +36 -0
  50. package/vault/vendor/roadmap/VERSION.txt +3 -0
  51. package/vault/vendor/roadmap/index.js +108 -0
  52. package/vault/vendor/roadmap/manifests.js +797 -0
  53. package/vault/vendor/store/VERSION.txt +3 -0
  54. package/vault/vendor/store/core.js +309 -0
  55. package/vault/vendor/vault/VERSION.txt +4 -1
  56. package/vault/vendor/vault/admin.js +437 -0
  57. package/vault/vendor/vault/browser.js +167 -0
  58. package/vault/vendor/vault/core/approvals.js +204 -0
  59. package/vault/vendor/vault/core/blocked.js +68 -0
  60. package/vault/vendor/vault/core/browserDisk.js +109 -0
  61. package/vault/vendor/vault/core/commKey.js +68 -0
  62. package/vault/vendor/vault/core/disk.js +65 -0
  63. package/vault/vendor/vault/core/libVersion.js +8 -0
  64. package/vault/vendor/vault/core/machines.js +66 -0
  65. package/vault/vendor/vault/core/notices.js +71 -0
  66. package/vault/vendor/vault/core/notifiable.js +73 -0
  67. package/vault/vendor/vault/core/passwords.js +125 -0
  68. package/vault/vendor/vault/core/sealKey.js +77 -0
  69. package/vault/vendor/vault/core/sealer.js +172 -0
  70. package/vault/vendor/vault/core/sealers.js +159 -0
  71. package/vault/vendor/vault/core/secretsStore.js +1326 -0
  72. package/vault/vendor/vault/core/sha256.js +69 -0
  73. package/vault/vendor/vault/core/store.js +68 -0
  74. package/vault/vendor/vault/core/subacta.js +101 -0
  75. package/vault/vendor/vault/core/threadStore.js +273 -0
  76. package/vault/vendor/vault/core/transport.js +270 -0
  77. package/vault/vendor/vault/core/vault.js +4414 -0
  78. package/vault/vendor/vault/enroll.js +8 -4
  79. package/vault/vendor/vault/procContext.js +97 -0
  80. package/vault/vendor/vault/revocation.js +67 -0
  81. package/vault/vendor/vault/sealed.js +84 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * PEDIDOS DE APROBACIÓN Y CONCESIONES (puro: sin red ni disco).
3
+ *
4
+ * Un aparato marcado con `approval on` no recibe claves privadas solo por tener el cert: el
5
+ * vault apunta el pedido, avisa a los aparatos con `approve` (el teléfono), y solo su firma
6
+ * libera los secretos. Lo que nadie aprueba vence solo a los 5 min.
7
+ *
8
+ * Lo que se guarda de cada pedido es lo justo para contestar después: el cajón, quién pide,
9
+ * su llave efímera `ek` (a la que se sella la respuesta) y **qué comando está corriendo y
10
+ * desde dónde**. Nunca un valor.
11
+ *
12
+ * LA CONCESIÓN POR COMANDO (dueño, 2026-09-11). Antes no había ventana: cada petición era un
13
+ * pedido, y para un servicio bien hecho eso era una por arranque. Dejó de alcanzar en cuanto
14
+ * los pedidos empezaron a decir QUÉ comando piden: si el dueño ya dijo que sí a ese comando
15
+ * exacto desde esa carpeta exacta, volver a timbrarle por lo mismo es ruido. Así que aprobar
16
+ * concede **una hora, contada desde el último uso** — o sea que un servicio que sigue
17
+ * pidiendo la mantiene viva indefinidamente, que es lo que se pidió. Lo que la corta: que
18
+ * cambie el comando o la carpeta (otra huella, otro pedido), una hora entera sin usarla,
19
+ * revocarla a mano, o reiniciar la bóveda (viven en memoria y no se escriben en ningún
20
+ * sitio: un permiso que sobrevive a un reinicio es un permiso que nadie recuerda haber dado).
21
+ *
22
+ * SIN COMANDO NO HAY CONCESIÓN. Un pedido que no dice qué está corriendo —porque vino de
23
+ * otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
24
+ * tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
25
+ * inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
26
+ *
27
+ * Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
28
+ * (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
29
+ * navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
30
+ * traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
31
+ * protección por la que esto existe. No se le busque un sustituto del comando para que
32
+ * entre en la ventana.
33
+ */
34
+ import { commandFingerprint } from '../procContext.js'
35
+
36
+ const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
37
+
38
+ /** Separador de la llave compuesta. Es un byte que no puede aparecer en un cajón ni en una pubkey. */
39
+ const SEP = String.fromCharCode(0)
40
+
41
+ /**
42
+ * LO QUE DURA UN PEDIDO SIN CONTESTAR, y por qué son dos números y no uno.
43
+ *
44
+ * CINCO MINUTOS es lo correcto para un COMANDO: hay un proceso bloqueado esperando, y
45
+ * aprobar media hora más tarde entregaría claves a algo cuyo contexto ya cambió — la
46
+ * carpeta, el comando o la máquina pueden ser otros. Que caduque rápido es la protección.
47
+ *
48
+ * UNA ACTUALIZACIÓN no se parece en nada (dueño, 2026-09-20): no hay nadie esperando, y
49
+ * caducar no protege de nada — solo significa que mañana se vuelve a pedir y se vuelve a
50
+ * perder, porque mirar el teléfono puede tardar horas. Un pedido que nunca llega a tiempo
51
+ * es una función que no existe.
52
+ *
53
+ * Por eso el plazo es DEL PEDIDO y no de la mesa. Quien pide dice cuánto aguanta lo suyo.
54
+ */
55
+ export const PENDING_TTL_MS = 5 * 60 * 1000
56
+ /**
57
+ * Y el de una actualización: un día. Cuadra con que la bóveda mire una vez al día — pedir
58
+ * otra vez reemplaza el pedido anterior del mismo cajón y aparato, así que siempre hay
59
+ * exactamente uno vivo, en vez de una cola de avisos caducados.
60
+ */
61
+ export const UPDATE_TTL_MS = 24 * 60 * 60 * 1000
62
+ /** Lo que dura una concesión SIN USARSE. Cada uso la vuelve a poner en una hora. */
63
+ export const GRANT_TTL_MS = 60 * 60 * 1000
64
+
65
+ export function createApprovals ({ now = Date.now, pendingTtlMs = PENDING_TTL_MS } = {}) {
66
+ /** id → { id, ns, device, deviceId, label, ek, ctx, ts, exp, from } */
67
+ const pending = new Map()
68
+ /**
69
+ * EL CONTEXTO VA EN LA LISTA, Y QUIEN LA SACA POR LA RED LO ENVUELVE.
70
+ *
71
+ * `ctx` es el comando y el path del dueño: por el mostrador local y en la consola de su
72
+ * propia máquina se enseña tal cual, pero al teléfono viaja por el proxio y ahí va dentro
73
+ * de un sobre (`handleApproval`). Si mañana aparece otro sitio que publique esta lista,
74
+ * esto es lo que hay que recordar.
75
+ */
76
+ // `kind` dice QUÉ se pide: `read` (llevarse las claves, lo de siempre) o `write` (guardar
77
+ // variables). La pantalla del teléfono no puede decir «pide tus claves» a una escritura.
78
+ const publicOf = (p) => ({ id: p.id, ns: p.ns, kind: p.kind, deviceId: p.deviceId, label: p.label, ts: p.ts, exp: p.exp, ctx: p.ctx || null })
79
+
80
+ return {
81
+ /** Apunta un pedido. Uno por cajón y aparato: pedir otra vez reemplaza al anterior. */
82
+ /**
83
+ * `from` es POR DÓNDE ENTRÓ la pregunta. La respuesta llega más tarde —cuando alguien
84
+ * apruebe— y tiene que volver por el mismo sitio: desde que la bóveda atiende también
85
+ * por un socket local, mandarla siempre por el proxio la dejaba en el vacío.
86
+ */
87
+ request ({ ns, device, deviceId, label = '', ek, ctx = null, from = null, ttlMs = null, kind = 'read' }) {
88
+ // Pedir las claves otra vez reemplaza al pedido anterior: es la misma pregunta. Una
89
+ // ESCRITURA no: cada una lleva su propio valor, y reemplazarla tiraría la anterior
90
+ // sin que nadie dijera que no.
91
+ if (kind !== 'write') for (const [id, p] of pending) if (p.kind !== 'write' && p.ns === ns && p.device === device) pending.delete(id)
92
+ const ts = now()
93
+ // `ttlMs` lo pone quien pide: una actualización aguanta un día y un comando cinco
94
+ // minutos, y la mesa no tiene por qué saber cuál es cuál (ver `PENDING_TTL_MS`).
95
+ const p = { id: rnd(), ns, kind, device, deviceId, label, ek, ctx, ts, exp: ts + (ttlMs || pendingTtlMs), from }
96
+ pending.set(p.id, p)
97
+ return publicOf(p)
98
+ },
99
+ /** Los pedidos vivos, sin la `ek` (no hace falta fuera de aquí). */
100
+ list () { this.sweep(); return [...pending.values()].map(publicOf) },
101
+ /** Saca un pedido para resolverlo (aprobar o denegar). `null` si no existe o venció. */
102
+ take (id) {
103
+ this.sweep()
104
+ const p = pending.get(id)
105
+ if (p) pending.delete(id)
106
+ return p || null
107
+ },
108
+ /** Tira lo vencido; devuelve los pedidos que vencieron, para anotarlos. */
109
+ sweep () {
110
+ const t = now(); const gone = []
111
+ for (const [id, p] of pending) if (p.exp <= t) { pending.delete(id); gone.push(publicOf(p)) }
112
+ return gone
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * LAS CONCESIONES VIVAS: «este comando, desde esta carpeta, ya está aprobado».
119
+ *
120
+ * La llave es cajón + aparato + huella del comando. Nada de esto se escribe en disco.
121
+ */
122
+ export function createGrants ({ now = Date.now, ttlMs = GRANT_TTL_MS } = {}) {
123
+ /** key → { id, ns, device, deviceId, fp, ctx, by, grantedAt, lastAt, exp, uses } */
124
+ const grants = new Map()
125
+ const keyOf = (ns, device, fp) => ns + SEP + device + SEP + fp
126
+ const publicOf = (g) => ({
127
+ id: g.id, ns: g.ns, deviceId: g.deviceId, fp: g.fp, ctx: g.ctx,
128
+ by: g.by, grantedAt: g.grantedAt, lastAt: g.lastAt, exp: g.exp, uses: g.uses
129
+ })
130
+
131
+ const api = {
132
+ /**
133
+ * Anota que este comando quedó aprobado. Devuelve la concesión, o `null` si el pedido no
134
+ * traía comando: sin huella no hay nada que recordar, y el próximo pedido volverá a
135
+ * timbrar. Que eso se diga en el log es cosa de quien llama.
136
+ */
137
+ grant ({ ns, device, deviceId = null, ctx = null, by = null }) {
138
+ const fp = commandFingerprint(ctx)
139
+ if (!fp) return null
140
+ const t = now()
141
+ const g = { id: rnd(), ns, device, deviceId, fp, ctx, by, grantedAt: t, lastAt: t, exp: t + ttlMs, uses: 0 }
142
+ grants.set(keyOf(ns, device, fp), g)
143
+ return publicOf(g)
144
+ },
145
+ /**
146
+ * ¿Está este comando ya aprobado? Si lo está, **la concesión se estira**: la hora cuenta
147
+ * desde el último uso, no desde que se dio. Devuelve la concesión (ya refrescada) o `null`.
148
+ */
149
+ allows ({ ns, device, ctx }) {
150
+ api.sweep()
151
+ const fp = commandFingerprint(ctx)
152
+ if (!fp) return null
153
+ const g = grants.get(keyOf(ns, device, fp))
154
+ if (!g) return null
155
+ const t = now()
156
+ g.lastAt = t
157
+ g.exp = t + ttlMs
158
+ g.uses += 1
159
+ return publicOf(g)
160
+ },
161
+ /** Las concesiones vivas. */
162
+ list () { api.sweep(); return [...grants.values()].map(publicOf) },
163
+ /** Corta una por su id. `true` si existía. */
164
+ revoke (id) {
165
+ for (const [k, g] of grants) if (g.id === id) { grants.delete(k); return true }
166
+ return false
167
+ },
168
+ /** Corta todas, o las de un cajón / un aparato. Devuelve cuántas se fueron. */
169
+ revokeAll ({ ns = null, device = null } = {}) {
170
+ let n = 0
171
+ for (const [k, g] of grants) {
172
+ if (ns && g.ns !== ns) continue
173
+ if (device && g.device !== device) continue
174
+ grants.delete(k); n++
175
+ }
176
+ return n
177
+ },
178
+ /** Tira lo vencido; devuelve lo que se fue, para anotarlo. */
179
+ sweep () {
180
+ const t = now(); const gone = []
181
+ for (const [k, g] of grants) if (g.exp <= t) { grants.delete(k); gone.push(publicOf(g)) }
182
+ return gone
183
+ }
184
+ }
185
+ return api
186
+ }
187
+
188
+ /**
189
+ * ¿SE LLEVÓ UNA LIMPIEZA AL ÚLTIMO QUE APROBABA? Devuelve los aprobadores que se fueron si
190
+ * antes había alguno y ahora no queda ninguno; si no, una lista vacía.
191
+ *
192
+ * Una cuenta SIN aprobadores puede ser legítima (todo desatendido, se actualiza sola), así
193
+ * que el aviso no es «no hay aprobadores»: es «LOS HABÍA y acabas de perder el último».
194
+ *
195
+ * @param {string[]} before llaves con `approve` antes
196
+ * @param {string[]} after llaves con `approve` después
197
+ * @param {string[]} removed llaves que salieron del acta
198
+ */
199
+ export function lastApproverGone (before, after, removed) {
200
+ if (!before.length || after.length) return []
201
+ return removed.filter((pub) => before.includes(pub))
202
+ }
203
+
204
+ export default { createApprovals, createGrants, lastApproverGone, PENDING_TTL_MS, UPDATE_TTL_MS, GRANT_TTL_MS }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * LOS APARATOS BLOQUEADOS: la lista que un aprobador decide y solo la bóveda deshace.
3
+ *
4
+ * Un agente (la terminal, por ahora) reporta un INCIDENTE cuando un aparato de la cuenta se
5
+ * equivoca de clave tres veces seguidas. El aprobador lo ve en su teléfono y elige: bloquear
6
+ * o ignorar. Bloquear NO es revocar: el aparato sigue en el acta —no hace falta la maestra, y
7
+ * la bóveda vive cerrada—, pero la bóveda deja de atenderle y los agentes, que reciben esta
8
+ * lista con el acta, le cierran la puerta. Se deshace aquí (`dotrino-vault unblock <ID>`) o
9
+ * desde la consola de administración; un aprobador solo bloquea.
10
+ *
11
+ * Va en el disco cifrado en reposo como las contraseñas: es parte del mapa de la cuenta.
12
+ * Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
13
+ * lanza, y la bóveda no arranca con una lista que no puede saber.
14
+ */
15
+
16
+ export const BLOCKED_FILE = 'blocked.json'
17
+
18
+ const fail = (code, message) => Object.assign(new Error(message), { code })
19
+
20
+ /**
21
+ * @param {{ disk: object, now?: () => number }} opts
22
+ */
23
+ export function openBlocked ({ disk, now = Date.now }) {
24
+ const file = BLOCKED_FILE
25
+ const atRest = disk.atRest
26
+ /** pub → { deviceId, label, since, by, reason } */
27
+ let items = load()
28
+
29
+ function load () {
30
+ let raw
31
+ try { raw = disk.read(file) } catch (e) {
32
+ throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
33
+ }
34
+ if (raw == null) return new Map()
35
+ let d
36
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
37
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
38
+ return new Map(Object.entries(d.items))
39
+ }
40
+
41
+ function save () {
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
43
+ }
44
+
45
+ const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
46
+
47
+ return {
48
+ has: (pub) => items.has(pub),
49
+ pubs: () => [...items.keys()],
50
+ list: () => [...items].map(([pub, b]) => publicOf(pub, b)),
51
+ /** Bloquea. Devuelve la entrada; si ya estaba, la que había (no se pisa el «desde cuándo»). */
52
+ block ({ pub, deviceId = null, label = '', by = null, reason = null }) {
53
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'block: a device key is required')
54
+ if (items.has(pub)) return publicOf(pub, items.get(pub))
55
+ const b = { deviceId, label, since: now(), by, reason }
56
+ items.set(pub, b)
57
+ save()
58
+ return publicOf(pub, b)
59
+ },
60
+ /** Desbloquea. `true` si estaba. */
61
+ unblock (pub) {
62
+ if (!items.has(pub)) return false
63
+ items.delete(pub)
64
+ save()
65
+ return true
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * EL DISCO DE LA BÓVEDA EN EL NAVEGADOR (pestaña y extensión).
3
+ *
4
+ * El núcleo lee y escribe de forma síncrona (`./disk.js`), y en el navegador el único
5
+ * almacén que aguanta es IndexedDB, que es asíncrono. Así que al abrir se cargan TODOS los
6
+ * archivos del perfil a memoria, y cada escritura se guarda por detrás. Son una docena de
7
+ * archivos pequeños: caben de sobra.
8
+ *
9
+ * NADA EN CLARO EN EL DISCO. Cada archivo se guarda cifrado con AES-GCM bajo una llave que
10
+ * se crea aquí NO EXTRAÍBLE y vive en la misma base: el navegador la puede usar y nadie la
11
+ * puede leer, que es lo más parecido que hay en un navegador a la llave ligada a la máquina
12
+ * del binario. Por eso el cifrado en reposo del núcleo (`atRest`) aquí no hace nada: lo hace
13
+ * el almacén entero, archivo por archivo.
14
+ *
15
+ * LO QUE NO PROMETE, dicho: una escritura que no llegó a IndexedDB antes de cerrar la
16
+ * pestaña se pierde. Se guarda en cuanto se escribe (sin esperar a juntar), y `flush()`
17
+ * espera a que todo esté guardado.
18
+ */
19
+ import { memoryDisk } from './disk.js'
20
+
21
+ const STORE = 'files'
22
+ const KEY_ID = '\u0000key'
23
+
24
+ function idb (req) {
25
+ return new Promise((resolve, reject) => {
26
+ req.onsuccess = () => resolve(req.result)
27
+ req.onerror = () => reject(req.error)
28
+ })
29
+ }
30
+
31
+ function openDb (name) {
32
+ const req = indexedDB.open(name, 1)
33
+ req.onupgradeneeded = () => { req.result.createObjectStore(STORE) }
34
+ return idb(req)
35
+ }
36
+
37
+ const tx = (db, mode) => db.transaction(STORE, mode).objectStore(STORE)
38
+
39
+ /**
40
+ * @param {string} name la base de este perfil (`dotrino-vault.<perfil>`)
41
+ * @returns {Promise<object>} un disco (`./disk.js`) con `flush()` y `close()`
42
+ */
43
+ export async function openBrowserDisk (name) {
44
+ if (typeof indexedDB === 'undefined') throw Object.assign(new Error('this browser has no IndexedDB: the vault cannot keep anything here'), { code: 'no-indexeddb' })
45
+ const db = await openDb(name)
46
+
47
+ let key = await idb(tx(db, 'readonly').get(KEY_ID))
48
+ if (!key) {
49
+ key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
50
+ await idb(tx(db, 'readwrite').put(key, KEY_ID))
51
+ }
52
+
53
+ // Todo a memoria. Un archivo que está y no se abre NO se salta: el núcleo decidiría con
54
+ // un archivo vacío (una lista de bloqueados vacía, un acta sin cajones), y eso es justo
55
+ // el repliegue que no se pone. Se para y se dice cuál.
56
+ const files = new Map()
57
+ const names = await idb(tx(db, 'readonly').getAllKeys())
58
+ for (const n of names) {
59
+ if (n === KEY_ID) continue
60
+ const rec = await idb(tx(db, 'readonly').get(n))
61
+ try {
62
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, key, rec.ct)
63
+ files.set(n, new TextDecoder().decode(plain))
64
+ } catch (e) {
65
+ throw Object.assign(new Error(`vault storage: ${n} cannot be opened (${e?.message || e})`), { code: 'disk-unreadable', file: n })
66
+ }
67
+ }
68
+
69
+ // Las escrituras van en orden y una detrás de otra: la última manda.
70
+ let queue = Promise.resolve()
71
+ let lastError = null
72
+ const persist = (n) => {
73
+ queue = queue.then(async () => {
74
+ if (!files.has(n)) { await idb(tx(db, 'readwrite').delete(n)); return }
75
+ const iv = crypto.getRandomValues(new Uint8Array(12))
76
+ const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(files.get(n)))
77
+ await idb(tx(db, 'readwrite').put({ iv, ct }, n))
78
+ }).catch((e) => {
79
+ lastError = e
80
+ console.error(`[vault] could not save ${n} in this browser: ${e?.message || e}`)
81
+ })
82
+ }
83
+
84
+ // LA LLAVE DE LA MÁQUINA (`machineKey`): 32 bytes al azar, creados una vez. Es con lo que
85
+ // el núcleo cierra lo que tiene que abrirse con el perfil cerrado; aquí vale lo que vale
86
+ // este almacén, igual que en el binario vale lo que vale su disco.
87
+ const MACHINE = 'machine.key'
88
+ if (!files.has(MACHINE)) {
89
+ const raw = crypto.getRandomValues(new Uint8Array(32))
90
+ let s = ''; for (const b of raw) s += String.fromCharCode(b)
91
+ files.set(MACHINE, btoa(s))
92
+ persist(MACHINE)
93
+ }
94
+ const machineKey = () => Uint8Array.from(atob(files.get(MACHINE)), (c) => c.charCodeAt(0))
95
+
96
+ const disk = memoryDisk({ files, machineKey, onChange: persist })
97
+ return {
98
+ ...disk,
99
+ name,
100
+ /** Espera a que lo escrito esté en IndexedDB. Lanza si algo no se pudo guardar. */
101
+ async flush () {
102
+ await queue
103
+ if (lastError) { const e = lastError; lastError = null; throw e }
104
+ },
105
+ close () { try { db.close() } catch (_) {} }
106
+ }
107
+ }
108
+
109
+ export default { openBrowserDisk }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * commKey.js — LA LLAVE DE COMUNICACIÓN de esta bóveda.
3
+ *
4
+ * La maestra tiene dos trabajos y ninguno más: **sellar el acta y reenvolver los sobres**
5
+ * (dueño, 2026-08-31). Hablar por la red no es suyo. Todo lo demás —identificarse ante el
6
+ * proxio, firmar lo que se sirve— lo hace esta llave, y **su autoridad la dice el acta**,
7
+ * donde entra como un miembro más.
8
+ *
9
+ * Por qué eso importa y no es un rodeo: mientras la maestra fuera la que se identificaba,
10
+ * no podía vivir bajo llave — `identify` firma en cada conexión y en cada reconexión, así
11
+ * que una bóveda cerrada no habría existido en la red. Sacándola de ahí, la maestra solo
12
+ * sale con el perfil abierto y el candado deja de ser una bandera.
13
+ *
14
+ * Tres propiedades, y las tres son a propósito:
15
+ *
16
+ * · **Firmar no es leer.** No abre ningún valor. Por eso puede vivir cifrada en reposo
17
+ * con la llave de la máquina y usarse SIN la frase — que es lo que hace que la bóveda
18
+ * siga sirviendo con el candado echado.
19
+ * · **ESTABLE.** No rota con cada acta, al revés que la vieja `sealPub`: es un miembro,
20
+ * y un miembro no cambia de llave cada vez que se toca el acta. Rota cuando se decide,
21
+ * como cualquier aparato.
22
+ * · **Acotada por `cn`.** Entra con `cn: 'vault'`, así que el acta la trata como un
23
+ * SERVICIO: `memberCanSign` sin cajón le dice que no. Puede hablar por la bóveda, no
24
+ * firmar por la persona. Si alguien se lleva el disco, se lleva eso y nada más.
25
+ */
26
+ import { readJson, writeJson } from './disk.js'
27
+ import { signWithDevice } from '@dotrino/identity/capabilities'
28
+
29
+ /** El `cn` con el que entra en el acta. Es un servicio del perfil, no un aparato tuyo. */
30
+ export const COMM_CN = 'vault'
31
+ /** Lo que puede: firmar (acotado por su `cn`). Nada de leer, guardar ni sellar. */
32
+ export const COMM_CAPS = Object.freeze(['sign'])
33
+
34
+ /** Abre (o estrena) la llave de comunicación del perfil. */
35
+ export function openCommKey (disk) {
36
+ const file = 'commkey.json'
37
+ let data = readJson(disk, file, null)
38
+ if (!data || typeof data !== 'object' || !data.pub) data = null
39
+
40
+ return {
41
+ /** La pública, o `null` si todavía no se ha estrenado. */
42
+ pub: () => data?.pub || null,
43
+
44
+ /**
45
+ * Estrena la llave si no existe y devuelve su pública. Idempotente: llamarla dos veces
46
+ * NO cambia de llave — cambiarla dejaría al acta nombrando una que ya no tenemos y a la
47
+ * bóveda sin poder identificarse hasta el siguiente sellado.
48
+ */
49
+ async ensure () {
50
+ if (data?.pub) return data.pub
51
+ const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
52
+ const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
53
+ const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
54
+ data = { v: 1, pub, priv, createdAt: Date.now() }
55
+ writeJson(disk, file, data)
56
+ return pub
57
+ },
58
+
59
+ /** Firma un cuerpo. `null` si aún no hay llave (no se estrena una al vuelo: ver `ensure`). */
60
+ async sign (body) {
61
+ if (!data?.priv) return null
62
+ const { signature } = await signWithDevice({ privateJwk: data.priv, publickey: data.pub, data: body })
63
+ return signature
64
+ }
65
+ }
66
+ }
67
+
68
+ export default { openCommKey, COMM_CN, COMM_CAPS }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * EL DISCO DE UNA BÓVEDA, el mismo para el binario y para el navegador.
3
+ *
4
+ * La bóveda guarda una docena de archivos pequeños (`vault.json`, `secrets.json`,
5
+ * `blocked.json`, la bitácora…) y los lee y escribe de forma SÍNCRONA: se consultan por
6
+ * cada mensaje que entra, y convertir cada lectura en un `await` cambiaría el orden en que
7
+ * pasan las cosas en todo el núcleo. Por eso el disco es una interfaz síncrona, y quien
8
+ * monta la bóveda pone la suya:
9
+ *
10
+ * · el binario, archivos de verdad bajo el directorio del perfil (`src/disk.js`);
11
+ * · el navegador, una copia en memoria que se guarda cifrada en IndexedDB por detrás
12
+ * (`./browserDisk.js`).
13
+ *
14
+ * Interfaz (nombres relativos al perfil, nunca rutas):
15
+ *
16
+ * read(name) → el texto, o `null` si no existe. Otro fallo LANZA: un archivo
17
+ * que está y no se puede leer no es un archivo vacío.
18
+ * write(name, text) → reemplaza entero, de golpe (a medio escribir no queda nada).
19
+ * append(name, text) → añade al final.
20
+ * exists(name), remove(name), rename(from, to), size(name)
21
+ * atRest → `{ encrypt(text), decrypt(text) }`, síncronos: el cifrado en
22
+ * reposo de ESTE disco. En el binario va ligado a la máquina; en el
23
+ * navegador el cifrado lo hace el almacén entero y aquí no se toca.
24
+ * machineKey() → 32 bytes: la llave de la máquina (`kekFor`). Es con lo que se
25
+ * cierra lo que tiene que abrirse con el perfil cerrado.
26
+ */
27
+
28
+ /** Lee un JSON cifrado en reposo. Lo que no está o no se puede leer devuelve `fallback`. */
29
+ export function readJson (disk, name, fallback) {
30
+ try {
31
+ const raw = disk.read(name)
32
+ if (raw == null) return fallback
33
+ return JSON.parse(disk.atRest.decrypt(raw))
34
+ } catch (_) { return fallback }
35
+ }
36
+
37
+ /** Escribe un JSON cifrado en reposo, entero y de golpe. */
38
+ export function writeJson (disk, name, obj) {
39
+ disk.write(name, disk.atRest.encrypt(JSON.stringify(obj, null, 2)))
40
+ }
41
+
42
+ /**
43
+ * Un disco en memoria, sin nada detrás. Es la base del del navegador (que lo vuelca a
44
+ * IndexedDB) y el de las pruebas. `files` es un `Map` nombre → texto.
45
+ */
46
+ export function memoryDisk ({ files = new Map(), atRest = null, machineKey = null, onChange = () => {} } = {}) {
47
+ const key = machineKey || (() => { throw Object.assign(new Error('this disk has no machine key'), { code: 'no-machine-key' }) })
48
+ return {
49
+ files,
50
+ read: (name) => (files.has(name) ? files.get(name) : null),
51
+ write: (name, text) => { files.set(name, String(text)); onChange(name) },
52
+ append: (name, text) => { files.set(name, (files.get(name) || '') + String(text)); onChange(name) },
53
+ exists: (name) => files.has(name),
54
+ remove: (name) => { if (files.delete(name)) onChange(name) },
55
+ rename: (from, to) => {
56
+ if (!files.has(from)) throw Object.assign(new Error(`rename: ${from} does not exist`), { code: 'ENOENT' })
57
+ files.set(to, files.get(from)); files.delete(from); onChange(from); onChange(to)
58
+ },
59
+ size: (name) => (files.has(name) ? new TextEncoder().encode(files.get(name)).length : 0),
60
+ atRest: atRest || { encrypt: (t) => t, decrypt: (t) => t },
61
+ machineKey: key
62
+ }
63
+ }
64
+
65
+ export default { readJson, writeJson, memoryDisk }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * LA VERSIÓN DE `@dotrino/vault`, escrita aquí porque el navegador no puede leer el
3
+ * `package.json`. Con esta se anuncia la bóveda de la pestaña y de la extensión
4
+ * (`@dotrino/compat`). Una prueba (`test/version-del-navegador.test.mjs`) para el release
5
+ * si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
6
+ * que no anunciar ninguna.
7
+ */
8
+ export const LIB_VERSION = '0.86.0'
@@ -0,0 +1,66 @@
1
+ /**
2
+ * DÓNDE CORRE CADA APARATO, según lo último que dijo (`@dotrino/identity/machine`).
3
+ *
4
+ * El acta guarda lo que el aparato dijo AL EMPAREJARSE, y eso no cambia sin una firma de la
5
+ * maestra. Aquí va lo que dice en cada pedido firmado: así un aparato que entró antes de que
6
+ * existiera el campo aparece con su máquina en cuanto se actualiza y habla, y uno que cambió
7
+ * de sistema sale al día. La lista enseña esto primero y, si no hay, lo del acta.
8
+ *
9
+ * NO DECIDE NADA: se enseña. Lo dice el propio aparato, dentro de lo que firma; nadie más lo
10
+ * puede escribir por él, pero tampoco prueba nada.
11
+ *
12
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se lanza.
13
+ */
14
+ import { cleanMachine, sameMachine } from '@dotrino/identity/machine'
15
+
16
+ export const MACHINES_FILE = 'machines.json'
17
+
18
+ const fail = (code, message) => Object.assign(new Error(message), { code })
19
+
20
+ /**
21
+ * @param {{ disk: object, now?: () => number }} opts
22
+ */
23
+ export function openMachines ({ disk, now = Date.now }) {
24
+ const file = MACHINES_FILE
25
+ const atRest = disk.atRest
26
+ /** pub → { kind, os, name, at } */
27
+ const items = load()
28
+
29
+ function load () {
30
+ let raw
31
+ try { raw = disk.read(file) } catch (e) {
32
+ throw fail('machines-unreadable', `cannot read ${MACHINES_FILE}: ${e.message}`)
33
+ }
34
+ if (raw == null) return new Map()
35
+ let d
36
+ try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('machines-unreadable', `${MACHINES_FILE} cannot be opened: ${e.message}`) }
37
+ if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('machines-unreadable', `${MACHINES_FILE} has an unknown shape`)
38
+ return new Map(Object.entries(d.items))
39
+ }
40
+
41
+ function save () {
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
43
+ }
44
+
45
+ return {
46
+ /**
47
+ * Lo que acaba de decir un aparato. Solo se escribe si cambió: casi todos los pedidos
48
+ * repiten lo de siempre, y reescribir el archivo en cada uno sería disco para nada.
49
+ * Lo que no tiene forma de máquina no borra lo que ya había.
50
+ */
51
+ note (pub, raw) {
52
+ if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'note: a device key is required')
53
+ const m = cleanMachine(raw)
54
+ if (!m) return false
55
+ if (sameMachine(items.get(pub), m)) return false
56
+ items.set(pub, { ...m, at: now() })
57
+ save()
58
+ return true
59
+ },
60
+ /** Lo último que dijo, o `null` si nunca dijo nada. */
61
+ of (pub) {
62
+ const m = items.get(pub)
63
+ return m ? { ...m } : null
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
3
+ *
4
+ * Son dos, de la bóveda o de uno de sus aparatos: «me actualicé» y «hay versión nueva y
5
+ * necesita permisos de administrador: instálala a mano» (`update-needs-root`). (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
6
+ * envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
7
+ * proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
8
+ * app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
9
+ *
10
+ * Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
11
+ *
12
+ * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
13
+ * sería decir «no pasó nada» de algo que sí pasó.
14
+ */
15
+
16
+ export const NOTICES_FILE = 'notices.json'
17
+ /** Cuánto se sigue contando un aviso. */
18
+ export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
19
+ /** Los avisos que existen. */
20
+ export const NOTICE_EVS = ['updated', 'update-needs-root']
21
+
22
+ const fail = (code, message) => Object.assign(new Error(message), { code })
23
+
24
+ /**
25
+ * @param {{ disk: object, now?: () => number }} opts
26
+ */
27
+ export function openNotices ({ disk, now = Date.now }) {
28
+ const file = NOTICES_FILE
29
+ const atRest = disk.atRest
30
+ let items = load()
31
+
32
+ function load () {
33
+ if (!disk.exists(file)) return []
34
+ try {
35
+ const d = JSON.parse(atRest.decrypt(disk.read(file)))
36
+ if (!d || !Array.isArray(d.items)) throw new Error('not a list')
37
+ return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
38
+ } catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
39
+ }
40
+
41
+ function save () {
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items })))
43
+ }
44
+
45
+ const alive = (n) => now() - n.ts < NOTICE_TTL_MS
46
+
47
+ return {
48
+ /**
49
+ * Apunta un aviso y lo devuelve tal como viaja. `ev`: `updated` (se actualizó) o
50
+ * `update-needs-root` (hay versión nueva y hay que instalarla a mano, con permisos de
51
+ * administrador). `product`: qué (`@dotrino/vaultd` es la propia bóveda);
52
+ * `deviceId`/`label`: el aparato, si fue uno.
53
+ */
54
+ add (ev, { version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
55
+ if (!NOTICE_EVS.includes(ev)) throw fail('bad-notice', `unknown notice: ${ev}`)
56
+ if (typeof version !== 'string' || !version) throw fail('bad-notice', `${ev}: a version is required`)
57
+ if (typeof product !== 'string' || !product) throw fail('bad-notice', `${ev}: a product is required`)
58
+ const n = {
59
+ id: [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join(''), ev, product, version, from: typeof from === 'string' ? from : null,
60
+ ...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
61
+ ts: now()
62
+ }
63
+ items = [...items.filter(alive), n]
64
+ save()
65
+ return n
66
+ },
67
+ updated (o) { return this.add('updated', o) },
68
+ /** Los avisos vivos, del más viejo al más nuevo. */
69
+ list () { return items.filter(alive).map((n) => ({ ...n })) }
70
+ }
71
+ }