@dotrino/vaultd 0.154.1 → 0.156.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 }
@@ -31,7 +31,7 @@
31
31
  * protección por la que esto existe. No se le busque un sustituto del comando para que
32
32
  * entre en la ventana.
33
33
  */
34
- import { commandFingerprint } from '../lib/src/proc.js'
34
+ import { commandFingerprint } from '../procContext.js'
35
35
 
36
36
  const rnd = () => [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join('')
37
37
 
@@ -12,27 +12,26 @@
12
12
  * Se carga UNA vez: un archivo que existe y no se puede leer NO es «nadie bloqueado» — se
13
13
  * lanza, y la bóveda no arranca con una lista que no puede saber.
14
14
  */
15
- import fs from 'node:fs'
16
- import path from 'node:path'
17
15
 
18
16
  export const BLOCKED_FILE = 'blocked.json'
19
17
 
20
18
  const fail = (code, message) => Object.assign(new Error(message), { code })
21
19
 
22
20
  /**
23
- * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
21
+ * @param {{ disk: object, now?: () => number }} opts
24
22
  */
25
- export function openBlocked ({ dir, atRest, now = Date.now }) {
26
- const file = path.join(dir, BLOCKED_FILE)
23
+ export function openBlocked ({ disk, now = Date.now }) {
24
+ const file = BLOCKED_FILE
25
+ const atRest = disk.atRest
27
26
  /** pub → { deviceId, label, since, by, reason } */
28
27
  let items = load()
29
28
 
30
29
  function load () {
31
30
  let raw
32
- try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
33
- if (e.code === 'ENOENT') return new Map()
31
+ try { raw = disk.read(file) } catch (e) {
34
32
  throw fail('blocked-unreadable', `cannot read ${BLOCKED_FILE}: ${e.message}`)
35
33
  }
34
+ if (raw == null) return new Map()
36
35
  let d
37
36
  try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('blocked-unreadable', `${BLOCKED_FILE} cannot be opened: ${e.message}`) }
38
37
  if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('blocked-unreadable', `${BLOCKED_FILE} has an unknown shape`)
@@ -40,9 +39,7 @@ export function openBlocked ({ dir, atRest, now = Date.now }) {
40
39
  }
41
40
 
42
41
  function save () {
43
- const tmp = file + '.tmp'
44
- fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
45
- fs.renameSync(tmp, file)
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
46
43
  }
47
44
 
48
45
  const publicOf = (pub, b) => ({ pub, deviceId: b.deviceId || null, label: b.label || '', since: b.since, by: b.by || null, reason: b.reason || null })
@@ -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 }
@@ -23,9 +23,7 @@
23
23
  * SERVICIO: `memberCanSign` sin cajón le dice que no. Puede hablar por la bóveda, no
24
24
  * firmar por la persona. Si alguien se lleva el disco, se lleva eso y nada más.
25
25
  */
26
- import path from 'node:path'
27
- import { readJson, writeJson } from './paths.js'
28
- import { atRestFor } from './atrest.js'
26
+ import { readJson, writeJson } from './disk.js'
29
27
  import { signWithDevice } from '@dotrino/identity/capabilities'
30
28
 
31
29
  /** El `cn` con el que entra en el acta. Es un servicio del perfil, no un aparato tuyo. */
@@ -34,10 +32,9 @@ export const COMM_CN = 'vault'
34
32
  export const COMM_CAPS = Object.freeze(['sign'])
35
33
 
36
34
  /** Abre (o estrena) la llave de comunicación del perfil. */
37
- export function openCommKey (dir) {
38
- const file = path.join(dir, 'commkey.json')
39
- const atRest = atRestFor(dir)
40
- let data = readJson(file, null, atRest)
35
+ export function openCommKey (disk) {
36
+ const file = 'commkey.json'
37
+ let data = readJson(disk, file, null)
41
38
  if (!data || typeof data !== 'object' || !data.pub) data = null
42
39
 
43
40
  return {
@@ -55,7 +52,7 @@ export function openCommKey (dir) {
55
52
  const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
56
53
  const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
57
54
  data = { v: 1, pub, priv, createdAt: Date.now() }
58
- writeJson(file, data, atRest)
55
+ writeJson(disk, file, data)
59
56
  return pub
60
57
  },
61
58
 
@@ -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
+ }
@@ -12,9 +12,6 @@
12
12
  * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
13
13
  * sería decir «no pasó nada» de algo que sí pasó.
14
14
  */
15
- import fs from 'node:fs'
16
- import path from 'node:path'
17
- import crypto from 'node:crypto'
18
15
 
19
16
  export const NOTICES_FILE = 'notices.json'
20
17
  /** Cuánto se sigue contando un aviso. */
@@ -25,25 +22,24 @@ export const NOTICE_EVS = ['updated', 'update-needs-root']
25
22
  const fail = (code, message) => Object.assign(new Error(message), { code })
26
23
 
27
24
  /**
28
- * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
25
+ * @param {{ disk: object, now?: () => number }} opts
29
26
  */
30
- export function openNotices ({ dir, atRest, now = Date.now }) {
31
- const file = path.join(dir, NOTICES_FILE)
27
+ export function openNotices ({ disk, now = Date.now }) {
28
+ const file = NOTICES_FILE
29
+ const atRest = disk.atRest
32
30
  let items = load()
33
31
 
34
32
  function load () {
35
- if (!fs.existsSync(file)) return []
33
+ if (!disk.exists(file)) return []
36
34
  try {
37
- const d = JSON.parse(atRest.decrypt(fs.readFileSync(file, 'utf8')))
35
+ const d = JSON.parse(atRest.decrypt(disk.read(file)))
38
36
  if (!d || !Array.isArray(d.items)) throw new Error('not a list')
39
37
  return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
40
38
  } catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
41
39
  }
42
40
 
43
41
  function save () {
44
- const tmp = file + '.tmp'
45
- fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items })), { mode: 0o600 })
46
- fs.renameSync(tmp, file)
42
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items })))
47
43
  }
48
44
 
49
45
  const alive = (n) => now() - n.ts < NOTICE_TTL_MS
@@ -60,7 +56,7 @@ export function openNotices ({ dir, atRest, now = Date.now }) {
60
56
  if (typeof version !== 'string' || !version) throw fail('bad-notice', `${ev}: a version is required`)
61
57
  if (typeof product !== 'string' || !product) throw fail('bad-notice', `${ev}: a product is required`)
62
58
  const n = {
63
- id: crypto.randomBytes(8).toString('hex'), ev, product, version, from: typeof from === 'string' ? from : null,
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,
64
60
  ...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
65
61
  ts: now()
66
62
  }
@@ -22,8 +22,6 @@
22
22
  * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
23
23
  * puede recibir avisos»: se lanza.
24
24
  */
25
- import fs from 'node:fs'
26
- import path from 'node:path'
27
25
 
28
26
  export const NOTIFIABLE_FILE = 'notifiable.json'
29
27
  /** Cuánto vale un «puedo recibir avisos» sin renovarse. */
@@ -32,19 +30,20 @@ export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
32
30
  const fail = (code, message) => Object.assign(new Error(message), { code })
33
31
 
34
32
  /**
35
- * @param {{ dir: string, atRest: { encrypt: (s: string) => string, decrypt: (s: string) => string }, now?: () => number }} opts
33
+ * @param {{ disk: object, now?: () => number }} opts
36
34
  */
37
- export function openNotifiable ({ dir, atRest, now = Date.now }) {
38
- const file = path.join(dir, NOTIFIABLE_FILE)
35
+ export function openNotifiable ({ disk, now = Date.now }) {
36
+ const file = NOTIFIABLE_FILE
37
+ const atRest = disk.atRest
39
38
  /** pub → ms de la última vez que dijo que sí */
40
39
  const items = load()
41
40
 
42
41
  function load () {
43
42
  let raw
44
- try { raw = fs.readFileSync(file, 'utf8') } catch (e) {
45
- if (e.code === 'ENOENT') return new Map()
43
+ try { raw = disk.read(file) } catch (e) {
46
44
  throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
47
45
  }
46
+ if (raw == null) return new Map()
48
47
  let d
49
48
  try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
50
49
  if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
@@ -52,9 +51,7 @@ export function openNotifiable ({ dir, atRest, now = Date.now }) {
52
51
  }
53
52
 
54
53
  function save () {
55
- const tmp = file + '.tmp'
56
- fs.writeFileSync(tmp, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })), { mode: 0o600 })
57
- fs.renameSync(tmp, file)
54
+ disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
58
55
  }
59
56
 
60
57
  return {
@@ -17,9 +17,7 @@
17
17
  * No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
18
18
  * (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
19
19
  */
20
- import path from 'node:path'
21
- import { readJson, writeJson } from './paths.js'
22
- import { atRestFor } from './atrest.js'
20
+ import { readJson, writeJson } from './disk.js'
23
21
  import { signWithDevice } from '@dotrino/identity/capabilities'
24
22
 
25
23
  /**
@@ -30,13 +28,12 @@ import { signWithDevice } from '@dotrino/identity/capabilities'
30
28
  const MAX_KEYS = 8
31
29
 
32
30
  /** Abre (o estrena) el llavero de sellado del perfil. */
33
- export function openSealKeys (dir) {
34
- const file = path.join(dir, 'sealkeys.json')
35
- const atRest = atRestFor(dir)
36
- const data = readJson(file, null, atRest) || { v: 1, keys: [] }
31
+ export function openSealKeys (disk) {
32
+ const file = 'sealkeys.json'
33
+ const data = readJson(disk, file, null) || { v: 1, keys: [] }
37
34
  if (!Array.isArray(data.keys)) data.keys = []
38
35
 
39
- const save = () => writeJson(file, data, atRest)
36
+ const save = () => writeJson(disk, file, data)
40
37
  const find = (pub) => data.keys.find((k) => k.pub === pub) || null
41
38
 
42
39
  return {