@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.
- package/package.json +7 -1
- package/src/index.js +9 -1
- package/src/node.js +4 -180
- package/vault/acta.js +5 -1
- package/vault/core.js +95 -15
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/machine.js +95 -0
- package/vault/remote.js +54 -9
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +36 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +797 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- 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
|
+
}
|