@dotrino/vaultd 0.155.0 → 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.
- 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 +2 -2
- package/src/daemon.js +1 -1
- package/src/disk.js +54 -0
- package/src/platform.js +139 -0
- package/src/vault.js +10 -4455
- /package/{src → lib/src/core}/passwords.js +0 -0
- /package/{src → lib/src/core}/sealers.js +0 -0
|
@@ -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 {
|
|
@@ -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 {{
|
|
25
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
29
26
|
*/
|
|
30
|
-
export function openNotices ({
|
|
31
|
-
const 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 (!
|
|
33
|
+
if (!disk.exists(file)) return []
|
|
36
34
|
try {
|
|
37
|
-
const d = JSON.parse(atRest.decrypt(
|
|
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
|
-
|
|
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.
|
|
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 {{
|
|
33
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
36
34
|
*/
|
|
37
|
-
export function openNotifiable ({
|
|
38
|
-
const 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 =
|
|
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
|
-
|
|
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
|
|
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 (
|
|
34
|
-
const file =
|
|
35
|
-
const
|
|
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
|
|
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 {
|