@dotrino/vaultd 0.155.0 → 0.157.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/README.md CHANGED
@@ -128,10 +128,43 @@ firmado**. Se lee con `dotrino-vault activity`.
128
128
 
129
129
  ## Instalación
130
130
 
131
- Tres vías, y todas dejan la misma bóveda: **instalador de Linux** (queda como
132
- servicio), **un comando** (`npx`, en cualquier sistema con Node) y **Docker**. En
133
- Linux la primera es la cómoda; en Windows y macOS todavía no hay instalador de un
134
- clic, así que van por las otras dos.
131
+ Todas las vías dejan la misma bóveda: **instalador** de Linux (`.deb`, `.rpm`,
132
+ tarball), de **Windows** (`-setup.exe`) y de **macOS** (`Dotrino Vault.app`), **un
133
+ comando** (`npx`, en cualquier sistema con Node) y **Docker**. Los instaladores traen
134
+ además **la ventana** (ver abajo); `npx` y Docker, la TUI.
135
+
136
+ ### La ventana (`desktop/`)
137
+
138
+ Una app de escritorio con **las mismas pantallas que la TUI**: bóvedas, aparatos,
139
+ emparejar (con el QR como imagen), permisos, cajones y variables, inicios de sesión, mi
140
+ perfil, llaves de seguridad y ajustes. No reimplementa nada: lanza `dotrino-vaultd
141
+ --gui-bridge` y pinta el modelo que le manda el puente (`src/tui/gui.js`), que corre la
142
+ misma sesión que la TUI. Una acción nueva en la TUI aparece en la ventana sin tocarla. Sin
143
+ puertos: el canal son las tuberías del proceso hijo. Rust + `iced`, con el tema del
144
+ ecosistema (Quicksand/Comfortaa, claro u oscuro según el sistema).
145
+
146
+ Busca la bóveda al lado de su ejecutable (así la ponen los instaladores), luego en el
147
+ `PATH` (`dotrino-vaultd`, o el `dotrino-vault` de npm). Para desarrollo:
148
+ `DOTRINO_VAULT_BRIDGE="node bin/dotrino-vault.js" cargo run` en `desktop/`.
149
+
150
+ **Windows — instalador:** `dotrino-vault-X.Y.Z-windows-x64-setup.exe` desde
151
+ [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest). Se instala para
152
+ tu usuario, sin administrador, en `%LOCALAPPDATA%\Programs\Dotrino Vault`, con su entrada
153
+ en el menú Inicio, y deja la bóveda **arrancando al iniciar sesión** (una entrada `Run`
154
+ que lanza `dotrino-vault-desktop.exe --start-daemon`, sin ventana). Se cambia en Ajustes o
155
+ con `dotrino-vault service autostart on|off`. Desinstalar no borra tus datos.
156
+
157
+ **macOS — `Dotrino Vault.app`:** el `.zip` universal (Apple Silicon e Intel) desde
158
+ [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest); arrástrala a
159
+ Aplicaciones. Dentro van la ventana y la bóveda. Para que arranque al iniciar sesión, en
160
+ Ajustes (un LaunchAgent de tu usuario, `~/Library/LaunchAgents/com.dotrino.vault.plist`).
161
+
162
+ > Ni el instalador de Windows ni la app de macOS llevan la firma de un desarrollador
163
+ > registrado: Windows enseña el aviso de SmartScreen («editor desconocido») y macOS el de
164
+ > Gatekeeper la primera vez (clic derecho → Abrir). Lo que prueba de dónde salieron es la
165
+ > atestación de sigstore: `gh attestation verify <archivo> --repo imdotrino/dotrino-vault`.
166
+ > En Windows y macOS la bóveda todavía **no se actualiza sola**: se instala la versión nueva
167
+ > encima.
135
168
 
136
169
  **Ubuntu / Debian — `.deb`:** descarga el `.deb` (versionado) desde
137
170
  [Releases](https://github.com/imdotrino/dotrino-vault/releases/latest) y haz doble
@@ -196,7 +229,7 @@ npx -y @dotrino/vaultd # Node ≥ 20
196
229
  npx -y @dotrino/vaultd --tui # la bóveda y su pantalla de control en la misma ventana
197
230
  ```
198
231
 
199
- Es la vía normal en **Windows y macOS**, y sirve igual en Linux. Arranca en primer
232
+ Sirve en cualquier sistema (y es la vía en ARM fuera de Docker). Arranca en primer
200
233
  plano: la bóveda vive mientras dejes esa ventana abierta. Los comandos de control se
201
234
  anteponen con `npx -p @dotrino/vaultd` (por ejemplo, `npx -p @dotrino/vaultd
202
235
  dotrino-vault pair`). Si no tienes Node, el instalador del ecosistema lo baja en tu
@@ -228,9 +261,9 @@ recupera.
228
261
 
229
262
  > Sin firma de código: tu sistema puede advertir que el binario no está firmado. Es
230
263
  > autohospedado y de código abierto; en Linux solo necesita permiso de ejecución (el
231
- > instalador lo da). Hay un build de Windows (`packaging/build-win.sh`) pero **no se
232
- > publica**: se construye desde Linux y no se ha probado en un Windows de verdad, y
233
- > la inyección del blob invalida la firma de Microsoft, así que saltaría SmartScreen.
264
+ > instalador lo da). El daemon de Windows (`packaging/build-win.sh`) se construye desde
265
+ > Linux y va dentro del instalador de Windows; el de macOS (`packaging/build-mac.sh`) se
266
+ > construye en un Mac y va dentro de la app.
234
267
 
235
268
  **Dónde vive todo y cómo se para.** Tus datos —llave incluida— viven en
236
269
  `~/.local/share/dotrino/vault` (Linux y macOS), `%LOCALAPPDATA%\Dotrino\vault`
@@ -848,8 +881,10 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
848
881
  ## Alcance
849
882
 
850
883
  - **v1 (este):** daemon headless en Node, **multi-perfil**. En **Linux** queda como
851
- servicio `systemd --user` (binario único Node SEA: `.deb` y tarball); en **Windows y
852
- macOS** se corre con `npx`, en primer plano; y en cualquier sistema, con **Docker**
884
+ servicio `systemd --user` (binario único Node SEA: `.deb`, `.rpm` y tarball); en
885
+ **Windows** con un instalador por usuario y en **macOS** como `Dotrino Vault.app`, los
886
+ dos con arranque al iniciar sesión; en los tres, con **una ventana** que tiene las
887
+ pantallas de la TUI (`desktop/`); y en cualquier sistema, con `npx` o **Docker**
853
888
  (imagen amd64/arm64 en GHCR). Identidad **cifrada en reposo y ligada a esta
854
889
  máquina**. Emparejamiento endurecido con los dos caminos de vinculación, acta del
855
890
  perfil (miembros, capacidades y `cn` de servicio), firma delegada, renovación
@@ -858,9 +893,9 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
858
893
  actividad. Contraseña opcional por perfil, **para editarlo** (verificador PBKDF2).
859
894
  - **v2:** cifrado en reposo **con la contraseña del perfil** (hoy la clave sale solo de
860
895
  la máquina) y atado a tu cuenta del sistema —DPAPI en Windows, Keychain en macOS— o
861
- al TPM; cifrar también los secretos y el store; **instalador de un clic para Windows
862
- y macOS**; UI de escritorio (Tauri) como cliente del daemon; firma de documentos con
863
- sellado de tiempo (`dotrino-signer`).
896
+ al TPM; cifrar también los secretos y el store; que la bóveda de Windows y macOS se
897
+ actualice sola, como la de Linux; firma de Apple y de Microsoft; firma de documentos
898
+ con sellado de tiempo (`dotrino-signer`).
864
899
 
865
900
  ## Estructura
866
901
 
@@ -870,7 +905,9 @@ ed25519 en formato OpenSSH (sin frase: la bóveda es el candado) y RSA/P-256 en
870
905
  - `src/daemon.js` — modo servicio: `state.json`, control por archivos + señales, apagado limpio.
871
906
  - `src/ctl.js` — CLI de control (habla con el daemon por archivos + señales, sin socket).
872
907
  - `src/vaultControl.js` — API de control programática (misma vía que la CLI); la usa la TUI.
873
- - `src/tui/` — interfaz de terminal, sin dependencias (`term.js` primitivas ANSI/raw-mode; `app.js` pantallas; `i18n.js` los textos es/en).
908
+ - `src/tui/` — interfaz de terminal, sin dependencias (`term.js` primitivas ANSI/raw-mode; `app.js` pantallas; `i18n.js` los textos es/en; `gui.js` el puente de la ventana).
909
+ - `src/service.js` — arrancar, reiniciar y arrancar al iniciar sesión en Linux, Windows y macOS.
910
+ - `desktop/` — la ventana (Rust + `iced`): pinta lo que le manda `gui.js`.
874
911
  - `src/transport.js` — conexión headless al proxy + `identify` firmado (con el acta).
875
912
  - `src/threadStore.js` — el store del usuario: hilos, "recientes" y perfil (`threads.json`).
876
913
  - `src/store.js` — árbol de contenidos (`vault.json`, versionado). Hoy es un esqueleto: se lee, nadie escribe.
package/bin/sea-entry.js CHANGED
@@ -17,6 +17,10 @@ if (argv[0] === '--tui') {
17
17
  if (!process.stdout.isTTY) { console.error('la TUI necesita un terminal interactivo (TTY).'); process.exit(2) }
18
18
  import('../src/tui/app.js').then(({ runTui }) => runTui())
19
19
  .catch((e) => { console.error('error en la TUI:', e.message); process.exit(1) })
20
+ } else if (argv[0] === '--gui-bridge') {
21
+ // El canal de la ventana de escritorio (JSON por stdin/stdout, ver src/tui/gui.js).
22
+ import('../src/tui/gui.js').then(({ runGuiBridge }) => runGuiBridge()).then(() => process.exit(0))
23
+ .catch((e) => { console.error('error en el puente de la ventana:', e.message); process.exit(1) })
20
24
  } else if (argv[0] === '--ctl') {
21
25
  // CLI de control: NO toca la identidad ni el proxy; habla con el daemon vía
22
26
  // el dir de datos (state.json) y señales (SIGUSR1 para emparejar).
@@ -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'
@@ -11,8 +11,6 @@
11
11
  *
12
12
  * Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se lanza.
13
13
  */
14
- import fs from 'node:fs'
15
- import path from 'node:path'
16
14
  import { cleanMachine, sameMachine } from '@dotrino/identity/machine'
17
15
 
18
16
  export const MACHINES_FILE = 'machines.json'
@@ -20,19 +18,20 @@ export const MACHINES_FILE = 'machines.json'
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 openMachines ({ dir, atRest, now = Date.now }) {
26
- const file = path.join(dir, MACHINES_FILE)
23
+ export function openMachines ({ disk, now = Date.now }) {
24
+ const file = MACHINES_FILE
25
+ const atRest = disk.atRest
27
26
  /** pub → { kind, os, name, at } */
28
27
  const 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('machines-unreadable', `cannot read ${MACHINES_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('machines-unreadable', `${MACHINES_FILE} cannot be opened: ${e.message}`) }
38
37
  if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('machines-unreadable', `${MACHINES_FILE} has an unknown shape`)
@@ -40,9 +39,7 @@ export function openMachines ({ 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
  return {