@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 +51 -14
- package/bin/sea-entry.js +4 -0
- package/lib/src/browser.js +167 -0
- package/{src → lib/src/core}/approvals.js +1 -1
- package/{src → lib/src/core}/blocked.js +7 -10
- package/lib/src/core/browserDisk.js +109 -0
- package/{src → lib/src/core}/commKey.js +5 -8
- package/lib/src/core/disk.js +65 -0
- package/lib/src/core/libVersion.js +8 -0
- package/{src → lib/src/core}/machines.js +7 -10
- package/{src → lib/src/core}/notices.js +8 -12
- package/{src → lib/src/core}/notifiable.js +7 -10
- package/{src → lib/src/core}/sealKey.js +5 -8
- package/{src → lib/src/core}/sealer.js +12 -10
- package/{src → lib/src/core}/secretsStore.js +8 -11
- package/lib/src/core/sha256.js +69 -0
- package/{src → lib/src/core}/store.js +6 -9
- package/{src → lib/src/core}/subacta.js +5 -6
- package/{src → lib/src/core}/threadStore.js +5 -8
- package/{src → lib/src/core}/transport.js +4 -5
- package/lib/src/core/vault.js +4414 -0
- package/lib/src/proc.js +2 -84
- package/lib/src/procContext.js +97 -0
- package/package.json +3 -3
- package/src/ctl.js +41 -3
- package/src/daemon.js +1 -1
- package/src/disk.js +54 -0
- package/src/platform.js +139 -0
- package/src/service.js +247 -0
- package/src/tui/app.js +319 -127
- package/src/tui/gui.js +214 -0
- package/src/tui/i18n.js +25 -6
- package/src/tui/term.js +7 -2
- package/src/update.js +5 -1
- package/src/vault.js +10 -4455
- /package/{src → lib/src/core}/passwords.js +0 -0
- /package/{src → lib/src/core}/sealers.js +0 -0
package/README.md
CHANGED
|
@@ -128,10 +128,43 @@ firmado**. Se lee con `dotrino-vault activity`.
|
|
|
128
128
|
|
|
129
129
|
## Instalación
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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).
|
|
232
|
-
>
|
|
233
|
-
>
|
|
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
|
|
852
|
-
|
|
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;
|
|
862
|
-
|
|
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 '../
|
|
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 {{
|
|
21
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
24
22
|
*/
|
|
25
|
-
export function openBlocked ({
|
|
26
|
-
const 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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
38
|
-
const file =
|
|
39
|
-
|
|
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
|
|
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 {{
|
|
21
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
24
22
|
*/
|
|
25
|
-
export function openMachines ({
|
|
26
|
-
const 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 =
|
|
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
|
-
|
|
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 {
|