@dotrino/vault 0.85.0 → 0.86.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 +32 -32
- package/package.json +16 -4
- package/src/browser.js +167 -0
- package/src/core/approvals.js +204 -0
- package/src/core/blocked.js +68 -0
- package/src/core/browserDisk.js +109 -0
- package/src/core/commKey.js +68 -0
- package/src/core/disk.js +65 -0
- package/src/core/libVersion.js +8 -0
- package/src/core/machines.js +66 -0
- package/src/core/notices.js +71 -0
- package/src/core/notifiable.js +73 -0
- package/src/core/passwords.js +125 -0
- package/src/core/sealKey.js +77 -0
- package/src/core/sealer.js +172 -0
- package/src/core/sealers.js +159 -0
- package/src/core/secretsStore.js +1326 -0
- package/src/core/sha256.js +69 -0
- package/src/core/store.js +68 -0
- package/src/core/subacta.js +101 -0
- package/src/core/threadStore.js +273 -0
- package/src/core/transport.js +270 -0
- package/src/core/vault.js +4414 -0
- package/src/proc.js +2 -84
- package/src/procContext.js +97 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EL DISCO DE LA BÓVEDA EN EL NAVEGADOR (pestaña y extensión).
|
|
3
|
+
*
|
|
4
|
+
* El núcleo lee y escribe de forma síncrona (`./disk.js`), y en el navegador el único
|
|
5
|
+
* almacén que aguanta es IndexedDB, que es asíncrono. Así que al abrir se cargan TODOS los
|
|
6
|
+
* archivos del perfil a memoria, y cada escritura se guarda por detrás. Son una docena de
|
|
7
|
+
* archivos pequeños: caben de sobra.
|
|
8
|
+
*
|
|
9
|
+
* NADA EN CLARO EN EL DISCO. Cada archivo se guarda cifrado con AES-GCM bajo una llave que
|
|
10
|
+
* se crea aquí NO EXTRAÍBLE y vive en la misma base: el navegador la puede usar y nadie la
|
|
11
|
+
* puede leer, que es lo más parecido que hay en un navegador a la llave ligada a la máquina
|
|
12
|
+
* del binario. Por eso el cifrado en reposo del núcleo (`atRest`) aquí no hace nada: lo hace
|
|
13
|
+
* el almacén entero, archivo por archivo.
|
|
14
|
+
*
|
|
15
|
+
* LO QUE NO PROMETE, dicho: una escritura que no llegó a IndexedDB antes de cerrar la
|
|
16
|
+
* pestaña se pierde. Se guarda en cuanto se escribe (sin esperar a juntar), y `flush()`
|
|
17
|
+
* espera a que todo esté guardado.
|
|
18
|
+
*/
|
|
19
|
+
import { memoryDisk } from './disk.js'
|
|
20
|
+
|
|
21
|
+
const STORE = 'files'
|
|
22
|
+
const KEY_ID = '\u0000key'
|
|
23
|
+
|
|
24
|
+
function idb (req) {
|
|
25
|
+
return new Promise((resolve, reject) => {
|
|
26
|
+
req.onsuccess = () => resolve(req.result)
|
|
27
|
+
req.onerror = () => reject(req.error)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function openDb (name) {
|
|
32
|
+
const req = indexedDB.open(name, 1)
|
|
33
|
+
req.onupgradeneeded = () => { req.result.createObjectStore(STORE) }
|
|
34
|
+
return idb(req)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const tx = (db, mode) => db.transaction(STORE, mode).objectStore(STORE)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} name la base de este perfil (`dotrino-vault.<perfil>`)
|
|
41
|
+
* @returns {Promise<object>} un disco (`./disk.js`) con `flush()` y `close()`
|
|
42
|
+
*/
|
|
43
|
+
export async function openBrowserDisk (name) {
|
|
44
|
+
if (typeof indexedDB === 'undefined') throw Object.assign(new Error('this browser has no IndexedDB: the vault cannot keep anything here'), { code: 'no-indexeddb' })
|
|
45
|
+
const db = await openDb(name)
|
|
46
|
+
|
|
47
|
+
let key = await idb(tx(db, 'readonly').get(KEY_ID))
|
|
48
|
+
if (!key) {
|
|
49
|
+
key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
|
|
50
|
+
await idb(tx(db, 'readwrite').put(key, KEY_ID))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Todo a memoria. Un archivo que está y no se abre NO se salta: el núcleo decidiría con
|
|
54
|
+
// un archivo vacío (una lista de bloqueados vacía, un acta sin cajones), y eso es justo
|
|
55
|
+
// el repliegue que no se pone. Se para y se dice cuál.
|
|
56
|
+
const files = new Map()
|
|
57
|
+
const names = await idb(tx(db, 'readonly').getAllKeys())
|
|
58
|
+
for (const n of names) {
|
|
59
|
+
if (n === KEY_ID) continue
|
|
60
|
+
const rec = await idb(tx(db, 'readonly').get(n))
|
|
61
|
+
try {
|
|
62
|
+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: rec.iv }, key, rec.ct)
|
|
63
|
+
files.set(n, new TextDecoder().decode(plain))
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw Object.assign(new Error(`vault storage: ${n} cannot be opened (${e?.message || e})`), { code: 'disk-unreadable', file: n })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Las escrituras van en orden y una detrás de otra: la última manda.
|
|
70
|
+
let queue = Promise.resolve()
|
|
71
|
+
let lastError = null
|
|
72
|
+
const persist = (n) => {
|
|
73
|
+
queue = queue.then(async () => {
|
|
74
|
+
if (!files.has(n)) { await idb(tx(db, 'readwrite').delete(n)); return }
|
|
75
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
76
|
+
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(files.get(n)))
|
|
77
|
+
await idb(tx(db, 'readwrite').put({ iv, ct }, n))
|
|
78
|
+
}).catch((e) => {
|
|
79
|
+
lastError = e
|
|
80
|
+
console.error(`[vault] could not save ${n} in this browser: ${e?.message || e}`)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// LA LLAVE DE LA MÁQUINA (`machineKey`): 32 bytes al azar, creados una vez. Es con lo que
|
|
85
|
+
// el núcleo cierra lo que tiene que abrirse con el perfil cerrado; aquí vale lo que vale
|
|
86
|
+
// este almacén, igual que en el binario vale lo que vale su disco.
|
|
87
|
+
const MACHINE = 'machine.key'
|
|
88
|
+
if (!files.has(MACHINE)) {
|
|
89
|
+
const raw = crypto.getRandomValues(new Uint8Array(32))
|
|
90
|
+
let s = ''; for (const b of raw) s += String.fromCharCode(b)
|
|
91
|
+
files.set(MACHINE, btoa(s))
|
|
92
|
+
persist(MACHINE)
|
|
93
|
+
}
|
|
94
|
+
const machineKey = () => Uint8Array.from(atob(files.get(MACHINE)), (c) => c.charCodeAt(0))
|
|
95
|
+
|
|
96
|
+
const disk = memoryDisk({ files, machineKey, onChange: persist })
|
|
97
|
+
return {
|
|
98
|
+
...disk,
|
|
99
|
+
name,
|
|
100
|
+
/** Espera a que lo escrito esté en IndexedDB. Lanza si algo no se pudo guardar. */
|
|
101
|
+
async flush () {
|
|
102
|
+
await queue
|
|
103
|
+
if (lastError) { const e = lastError; lastError = null; throw e }
|
|
104
|
+
},
|
|
105
|
+
close () { try { db.close() } catch (_) {} }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default { openBrowserDisk }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commKey.js — LA LLAVE DE COMUNICACIÓN de esta bóveda.
|
|
3
|
+
*
|
|
4
|
+
* La maestra tiene dos trabajos y ninguno más: **sellar el acta y reenvolver los sobres**
|
|
5
|
+
* (dueño, 2026-08-31). Hablar por la red no es suyo. Todo lo demás —identificarse ante el
|
|
6
|
+
* proxio, firmar lo que se sirve— lo hace esta llave, y **su autoridad la dice el acta**,
|
|
7
|
+
* donde entra como un miembro más.
|
|
8
|
+
*
|
|
9
|
+
* Por qué eso importa y no es un rodeo: mientras la maestra fuera la que se identificaba,
|
|
10
|
+
* no podía vivir bajo llave — `identify` firma en cada conexión y en cada reconexión, así
|
|
11
|
+
* que una bóveda cerrada no habría existido en la red. Sacándola de ahí, la maestra solo
|
|
12
|
+
* sale con el perfil abierto y el candado deja de ser una bandera.
|
|
13
|
+
*
|
|
14
|
+
* Tres propiedades, y las tres son a propósito:
|
|
15
|
+
*
|
|
16
|
+
* · **Firmar no es leer.** No abre ningún valor. Por eso puede vivir cifrada en reposo
|
|
17
|
+
* con la llave de la máquina y usarse SIN la frase — que es lo que hace que la bóveda
|
|
18
|
+
* siga sirviendo con el candado echado.
|
|
19
|
+
* · **ESTABLE.** No rota con cada acta, al revés que la vieja `sealPub`: es un miembro,
|
|
20
|
+
* y un miembro no cambia de llave cada vez que se toca el acta. Rota cuando se decide,
|
|
21
|
+
* como cualquier aparato.
|
|
22
|
+
* · **Acotada por `cn`.** Entra con `cn: 'vault'`, así que el acta la trata como un
|
|
23
|
+
* SERVICIO: `memberCanSign` sin cajón le dice que no. Puede hablar por la bóveda, no
|
|
24
|
+
* firmar por la persona. Si alguien se lleva el disco, se lleva eso y nada más.
|
|
25
|
+
*/
|
|
26
|
+
import { readJson, writeJson } from './disk.js'
|
|
27
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
28
|
+
|
|
29
|
+
/** El `cn` con el que entra en el acta. Es un servicio del perfil, no un aparato tuyo. */
|
|
30
|
+
export const COMM_CN = 'vault'
|
|
31
|
+
/** Lo que puede: firmar (acotado por su `cn`). Nada de leer, guardar ni sellar. */
|
|
32
|
+
export const COMM_CAPS = Object.freeze(['sign'])
|
|
33
|
+
|
|
34
|
+
/** Abre (o estrena) la llave de comunicación del perfil. */
|
|
35
|
+
export function openCommKey (disk) {
|
|
36
|
+
const file = 'commkey.json'
|
|
37
|
+
let data = readJson(disk, file, null)
|
|
38
|
+
if (!data || typeof data !== 'object' || !data.pub) data = null
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
/** La pública, o `null` si todavía no se ha estrenado. */
|
|
42
|
+
pub: () => data?.pub || null,
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Estrena la llave si no existe y devuelve su pública. Idempotente: llamarla dos veces
|
|
46
|
+
* NO cambia de llave — cambiarla dejaría al acta nombrando una que ya no tenemos y a la
|
|
47
|
+
* bóveda sin poder identificarse hasta el siguiente sellado.
|
|
48
|
+
*/
|
|
49
|
+
async ensure () {
|
|
50
|
+
if (data?.pub) return data.pub
|
|
51
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
52
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
53
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
54
|
+
data = { v: 1, pub, priv, createdAt: Date.now() }
|
|
55
|
+
writeJson(disk, file, data)
|
|
56
|
+
return pub
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
/** Firma un cuerpo. `null` si aún no hay llave (no se estrena una al vuelo: ver `ensure`). */
|
|
60
|
+
async sign (body) {
|
|
61
|
+
if (!data?.priv) return null
|
|
62
|
+
const { signature } = await signWithDevice({ privateJwk: data.priv, publickey: data.pub, data: body })
|
|
63
|
+
return signature
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default { openCommKey, COMM_CN, COMM_CAPS }
|
package/src/core/disk.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EL DISCO DE UNA BÓVEDA, el mismo para el binario y para el navegador.
|
|
3
|
+
*
|
|
4
|
+
* La bóveda guarda una docena de archivos pequeños (`vault.json`, `secrets.json`,
|
|
5
|
+
* `blocked.json`, la bitácora…) y los lee y escribe de forma SÍNCRONA: se consultan por
|
|
6
|
+
* cada mensaje que entra, y convertir cada lectura en un `await` cambiaría el orden en que
|
|
7
|
+
* pasan las cosas en todo el núcleo. Por eso el disco es una interfaz síncrona, y quien
|
|
8
|
+
* monta la bóveda pone la suya:
|
|
9
|
+
*
|
|
10
|
+
* · el binario, archivos de verdad bajo el directorio del perfil (`src/disk.js`);
|
|
11
|
+
* · el navegador, una copia en memoria que se guarda cifrada en IndexedDB por detrás
|
|
12
|
+
* (`./browserDisk.js`).
|
|
13
|
+
*
|
|
14
|
+
* Interfaz (nombres relativos al perfil, nunca rutas):
|
|
15
|
+
*
|
|
16
|
+
* read(name) → el texto, o `null` si no existe. Otro fallo LANZA: un archivo
|
|
17
|
+
* que está y no se puede leer no es un archivo vacío.
|
|
18
|
+
* write(name, text) → reemplaza entero, de golpe (a medio escribir no queda nada).
|
|
19
|
+
* append(name, text) → añade al final.
|
|
20
|
+
* exists(name), remove(name), rename(from, to), size(name)
|
|
21
|
+
* atRest → `{ encrypt(text), decrypt(text) }`, síncronos: el cifrado en
|
|
22
|
+
* reposo de ESTE disco. En el binario va ligado a la máquina; en el
|
|
23
|
+
* navegador el cifrado lo hace el almacén entero y aquí no se toca.
|
|
24
|
+
* machineKey() → 32 bytes: la llave de la máquina (`kekFor`). Es con lo que se
|
|
25
|
+
* cierra lo que tiene que abrirse con el perfil cerrado.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Lee un JSON cifrado en reposo. Lo que no está o no se puede leer devuelve `fallback`. */
|
|
29
|
+
export function readJson (disk, name, fallback) {
|
|
30
|
+
try {
|
|
31
|
+
const raw = disk.read(name)
|
|
32
|
+
if (raw == null) return fallback
|
|
33
|
+
return JSON.parse(disk.atRest.decrypt(raw))
|
|
34
|
+
} catch (_) { return fallback }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Escribe un JSON cifrado en reposo, entero y de golpe. */
|
|
38
|
+
export function writeJson (disk, name, obj) {
|
|
39
|
+
disk.write(name, disk.atRest.encrypt(JSON.stringify(obj, null, 2)))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Un disco en memoria, sin nada detrás. Es la base del del navegador (que lo vuelca a
|
|
44
|
+
* IndexedDB) y el de las pruebas. `files` es un `Map` nombre → texto.
|
|
45
|
+
*/
|
|
46
|
+
export function memoryDisk ({ files = new Map(), atRest = null, machineKey = null, onChange = () => {} } = {}) {
|
|
47
|
+
const key = machineKey || (() => { throw Object.assign(new Error('this disk has no machine key'), { code: 'no-machine-key' }) })
|
|
48
|
+
return {
|
|
49
|
+
files,
|
|
50
|
+
read: (name) => (files.has(name) ? files.get(name) : null),
|
|
51
|
+
write: (name, text) => { files.set(name, String(text)); onChange(name) },
|
|
52
|
+
append: (name, text) => { files.set(name, (files.get(name) || '') + String(text)); onChange(name) },
|
|
53
|
+
exists: (name) => files.has(name),
|
|
54
|
+
remove: (name) => { if (files.delete(name)) onChange(name) },
|
|
55
|
+
rename: (from, to) => {
|
|
56
|
+
if (!files.has(from)) throw Object.assign(new Error(`rename: ${from} does not exist`), { code: 'ENOENT' })
|
|
57
|
+
files.set(to, files.get(from)); files.delete(from); onChange(from); onChange(to)
|
|
58
|
+
},
|
|
59
|
+
size: (name) => (files.has(name) ? new TextEncoder().encode(files.get(name)).length : 0),
|
|
60
|
+
atRest: atRest || { encrypt: (t) => t, decrypt: (t) => t },
|
|
61
|
+
machineKey: key
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default { readJson, writeJson, memoryDisk }
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA VERSIÓN DE `@dotrino/vault`, escrita aquí porque el navegador no puede leer el
|
|
3
|
+
* `package.json`. Con esta se anuncia la bóveda de la pestaña y de la extensión
|
|
4
|
+
* (`@dotrino/compat`). Una prueba (`test/version-del-navegador.test.mjs`) para el release
|
|
5
|
+
* si no coincide con la del paquete: una versión anunciada que no es la que corre es peor
|
|
6
|
+
* que no anunciar ninguna.
|
|
7
|
+
*/
|
|
8
|
+
export const LIB_VERSION = '0.86.0'
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DÓNDE CORRE CADA APARATO, según lo último que dijo (`@dotrino/identity/machine`).
|
|
3
|
+
*
|
|
4
|
+
* El acta guarda lo que el aparato dijo AL EMPAREJARSE, y eso no cambia sin una firma de la
|
|
5
|
+
* maestra. Aquí va lo que dice en cada pedido firmado: así un aparato que entró antes de que
|
|
6
|
+
* existiera el campo aparece con su máquina en cuanto se actualiza y habla, y uno que cambió
|
|
7
|
+
* de sistema sale al día. La lista enseña esto primero y, si no hay, lo del acta.
|
|
8
|
+
*
|
|
9
|
+
* NO DECIDE NADA: se enseña. Lo dice el propio aparato, dentro de lo que firma; nadie más lo
|
|
10
|
+
* puede escribir por él, pero tampoco prueba nada.
|
|
11
|
+
*
|
|
12
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se lanza.
|
|
13
|
+
*/
|
|
14
|
+
import { cleanMachine, sameMachine } from '@dotrino/identity/machine'
|
|
15
|
+
|
|
16
|
+
export const MACHINES_FILE = 'machines.json'
|
|
17
|
+
|
|
18
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
22
|
+
*/
|
|
23
|
+
export function openMachines ({ disk, now = Date.now }) {
|
|
24
|
+
const file = MACHINES_FILE
|
|
25
|
+
const atRest = disk.atRest
|
|
26
|
+
/** pub → { kind, os, name, at } */
|
|
27
|
+
const items = load()
|
|
28
|
+
|
|
29
|
+
function load () {
|
|
30
|
+
let raw
|
|
31
|
+
try { raw = disk.read(file) } catch (e) {
|
|
32
|
+
throw fail('machines-unreadable', `cannot read ${MACHINES_FILE}: ${e.message}`)
|
|
33
|
+
}
|
|
34
|
+
if (raw == null) return new Map()
|
|
35
|
+
let d
|
|
36
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('machines-unreadable', `${MACHINES_FILE} cannot be opened: ${e.message}`) }
|
|
37
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('machines-unreadable', `${MACHINES_FILE} has an unknown shape`)
|
|
38
|
+
return new Map(Object.entries(d.items))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save () {
|
|
42
|
+
disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
/**
|
|
47
|
+
* Lo que acaba de decir un aparato. Solo se escribe si cambió: casi todos los pedidos
|
|
48
|
+
* repiten lo de siempre, y reescribir el archivo en cada uno sería disco para nada.
|
|
49
|
+
* Lo que no tiene forma de máquina no borra lo que ya había.
|
|
50
|
+
*/
|
|
51
|
+
note (pub, raw) {
|
|
52
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'note: a device key is required')
|
|
53
|
+
const m = cleanMachine(raw)
|
|
54
|
+
if (!m) return false
|
|
55
|
+
if (sameMachine(items.get(pub), m)) return false
|
|
56
|
+
items.set(pub, { ...m, at: now() })
|
|
57
|
+
save()
|
|
58
|
+
return true
|
|
59
|
+
},
|
|
60
|
+
/** Lo último que dijo, o `null` si nunca dijo nada. */
|
|
61
|
+
of (pub) {
|
|
62
|
+
const m = items.get(pub)
|
|
63
|
+
return m ? { ...m } : null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LO QUE LA BÓVEDA TIENE QUE CONTARLE A QUIEN APRUEBA, y que no es un pedido.
|
|
3
|
+
*
|
|
4
|
+
* Son dos, de la bóveda o de uno de sus aparatos: «me actualicé» y «hay versión nueva y
|
|
5
|
+
* necesita permisos de administrador: instálala a mano» (`update-needs-root`). (`ev: 'updated'`, dueño 2026-10-08: «las actualizaciones, que se
|
|
6
|
+
* envíe una notificación de que ocurrieron a los aprobadores»). Sale como aviso por el
|
|
7
|
+
* proxio, pero el timbre de una app nativa llega VACÍO —lo leen Google y Apple—, así que la
|
|
8
|
+
* app pregunta por su lista (`op: 'approvals'`) y lo lee de aquí, en `notices`.
|
|
9
|
+
*
|
|
10
|
+
* Caduca a las 24 h: es una noticia, no un registro (eso es la bitácora).
|
|
11
|
+
*
|
|
12
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer se LANZA: callar
|
|
13
|
+
* sería decir «no pasó nada» de algo que sí pasó.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const NOTICES_FILE = 'notices.json'
|
|
17
|
+
/** Cuánto se sigue contando un aviso. */
|
|
18
|
+
export const NOTICE_TTL_MS = 24 * 60 * 60 * 1000
|
|
19
|
+
/** Los avisos que existen. */
|
|
20
|
+
export const NOTICE_EVS = ['updated', 'update-needs-root']
|
|
21
|
+
|
|
22
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
26
|
+
*/
|
|
27
|
+
export function openNotices ({ disk, now = Date.now }) {
|
|
28
|
+
const file = NOTICES_FILE
|
|
29
|
+
const atRest = disk.atRest
|
|
30
|
+
let items = load()
|
|
31
|
+
|
|
32
|
+
function load () {
|
|
33
|
+
if (!disk.exists(file)) return []
|
|
34
|
+
try {
|
|
35
|
+
const d = JSON.parse(atRest.decrypt(disk.read(file)))
|
|
36
|
+
if (!d || !Array.isArray(d.items)) throw new Error('not a list')
|
|
37
|
+
return d.items.filter((n) => n && typeof n.id === 'string' && typeof n.ts === 'number')
|
|
38
|
+
} catch (e) { throw fail('notices-unreadable', `${NOTICES_FILE} cannot be read: ${e.message}`) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function save () {
|
|
42
|
+
disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items })))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const alive = (n) => now() - n.ts < NOTICE_TTL_MS
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
/**
|
|
49
|
+
* Apunta un aviso y lo devuelve tal como viaja. `ev`: `updated` (se actualizó) o
|
|
50
|
+
* `update-needs-root` (hay versión nueva y hay que instalarla a mano, con permisos de
|
|
51
|
+
* administrador). `product`: qué (`@dotrino/vaultd` es la propia bóveda);
|
|
52
|
+
* `deviceId`/`label`: el aparato, si fue uno.
|
|
53
|
+
*/
|
|
54
|
+
add (ev, { version, from = null, product = '@dotrino/vaultd', deviceId = null, label = null }) {
|
|
55
|
+
if (!NOTICE_EVS.includes(ev)) throw fail('bad-notice', `unknown notice: ${ev}`)
|
|
56
|
+
if (typeof version !== 'string' || !version) throw fail('bad-notice', `${ev}: a version is required`)
|
|
57
|
+
if (typeof product !== 'string' || !product) throw fail('bad-notice', `${ev}: a product is required`)
|
|
58
|
+
const n = {
|
|
59
|
+
id: [...crypto.getRandomValues(new Uint8Array(8))].map((b) => b.toString(16).padStart(2, '0')).join(''), ev, product, version, from: typeof from === 'string' ? from : null,
|
|
60
|
+
...(typeof deviceId === 'string' && deviceId ? { deviceId, label: typeof label === 'string' ? label : '' } : {}),
|
|
61
|
+
ts: now()
|
|
62
|
+
}
|
|
63
|
+
items = [...items.filter(alive), n]
|
|
64
|
+
save()
|
|
65
|
+
return n
|
|
66
|
+
},
|
|
67
|
+
updated (o) { return this.add('updated', o) },
|
|
68
|
+
/** Los avisos vivos, del más viejo al más nuevo. */
|
|
69
|
+
list () { return items.filter(alive).map((n) => ({ ...n })) }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QUÉ APROBADORES PUEDEN RECIBIR UN AVISO: lo dice cada uno, y caduca.
|
|
3
|
+
*
|
|
4
|
+
* Un aparato con `approve` que no recibe avisos es un aprobador solo en el papel: el pedido
|
|
5
|
+
* no le suena y solo lo ve si abre la app por su cuenta.
|
|
6
|
+
*
|
|
7
|
+
* Así que el aprobador lo declara: al pedir su lista de pedidos (`op: 'approvals'`, que ya
|
|
8
|
+
* manda firmado cada vez que se abre) dice `notify: true` si tiene los avisos encendidos.
|
|
9
|
+
* Aquí se apunta CUÁNDO lo dijo por última vez, y vale `NOTIFIABLE_TTL_MS`: una app que se
|
|
10
|
+
* desinstala deja de renovarlo.
|
|
11
|
+
*
|
|
12
|
+
* NO DECIDE NADA: SE ENSEÑA (dueño, 2026-10-08). Un día decidió si la bóveda pedía permiso
|
|
13
|
+
* para actualizarse —sin nadie que lo hubiera dicho, se actualizaba sola— y se quitó: era
|
|
14
|
+
* una condición más, y el silencio de un aprobador abría la puerta. Quién aprueba lo dice
|
|
15
|
+
* el acta y nada más; los avisos salen a todos los que tienen `approve`, lo hayan dicho o
|
|
16
|
+
* no. Esto sirve para VER a cuál no le está sonando el pedido (`members`, `status`, la
|
|
17
|
+
* consola).
|
|
18
|
+
*
|
|
19
|
+
* Y NO SE LE PREGUNTA AL PROXIO, que es quien guarda las suscripciones: es la pieza de la
|
|
20
|
+
* que el diseño no se fía, y creerle sería dejarle decidir cuándo no hace falta permiso.
|
|
21
|
+
*
|
|
22
|
+
* Cifrado en reposo como lo demás. Un archivo que existe y no se puede leer NO es «nadie
|
|
23
|
+
* puede recibir avisos»: se lanza.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export const NOTIFIABLE_FILE = 'notifiable.json'
|
|
27
|
+
/** Cuánto vale un «puedo recibir avisos» sin renovarse. */
|
|
28
|
+
export const NOTIFIABLE_TTL_MS = 30 * 24 * 60 * 60 * 1000
|
|
29
|
+
|
|
30
|
+
const fail = (code, message) => Object.assign(new Error(message), { code })
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ disk: object, now?: () => number }} opts
|
|
34
|
+
*/
|
|
35
|
+
export function openNotifiable ({ disk, now = Date.now }) {
|
|
36
|
+
const file = NOTIFIABLE_FILE
|
|
37
|
+
const atRest = disk.atRest
|
|
38
|
+
/** pub → ms de la última vez que dijo que sí */
|
|
39
|
+
const items = load()
|
|
40
|
+
|
|
41
|
+
function load () {
|
|
42
|
+
let raw
|
|
43
|
+
try { raw = disk.read(file) } catch (e) {
|
|
44
|
+
throw fail('notifiable-unreadable', `cannot read ${NOTIFIABLE_FILE}: ${e.message}`)
|
|
45
|
+
}
|
|
46
|
+
if (raw == null) return new Map()
|
|
47
|
+
let d
|
|
48
|
+
try { d = JSON.parse(atRest.decrypt(raw)) } catch (e) { throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} cannot be opened: ${e.message}`) }
|
|
49
|
+
if (d?.v !== 1 || !d.items || typeof d.items !== 'object') throw fail('notifiable-unreadable', `${NOTIFIABLE_FILE} has an unknown shape`)
|
|
50
|
+
return new Map(Object.entries(d.items))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function save () {
|
|
54
|
+
disk.write(file, atRest.encrypt(JSON.stringify({ v: 1, items: Object.fromEntries(items) })))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
/**
|
|
59
|
+
* Lo que acaba de decir un aprobador. `true` lo apunta (o lo renueva); `false` lo quita
|
|
60
|
+
* ya, sin esperar a que caduque. Cualquier otra cosa no dice nada y no cambia nada: un
|
|
61
|
+
* cliente viejo, que no manda el campo, no borra lo que dijo uno nuevo.
|
|
62
|
+
*/
|
|
63
|
+
declare (pub, canNotify) {
|
|
64
|
+
if (typeof pub !== 'string' || !pub) throw fail('bad-pub', 'declare: a device key is required')
|
|
65
|
+
if (canNotify === true) { items.set(pub, now()); save() } else if (canNotify === false && items.delete(pub)) save()
|
|
66
|
+
},
|
|
67
|
+
/** Cuándo lo dijo por última vez, o `null` si nunca o si ya caducó. */
|
|
68
|
+
since (pub) {
|
|
69
|
+
const t = items.get(pub)
|
|
70
|
+
return typeof t === 'number' && now() - t < NOTIFIABLE_TTL_MS ? t : null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LA BÓVEDA DE CONTRASEÑAS del dueño, atendida por el vault — Y QUE EL VAULT NO PUEDE LEER.
|
|
3
|
+
*
|
|
4
|
+
* Hasta 0.122 esto tenía una llave propia (`passwordsKey()`) guardada **dentro del mismo
|
|
5
|
+
* archivo que las entradas**, cifrada con la llave de la máquina. O sea: el demonio con el
|
|
6
|
+
* perfil CERRADO seguía descifrando contraseñas, y cualquiera con una copia del disco las
|
|
7
|
+
* leía todas. Era el mismo esquema que tuvo la maestra hasta que se selló.
|
|
8
|
+
*
|
|
9
|
+
* Ahora la bóveda es un cartero también para las contraseñas
|
|
10
|
+
* (`dotrino-passmanager/docs/sealed-passwords.md`): guarda sobres dirigidos a los aparatos
|
|
11
|
+
* y envolturas que no puede abrir. Lo que sí hace, y es lo que sostiene el modelo:
|
|
12
|
+
*
|
|
13
|
+
* · dice A QUIÉN hay que envolver cada llave —sale del acta, y **la bóveda nunca está en
|
|
14
|
+
* esa lista**: ni su maestra, ni su llave de comunicación;
|
|
15
|
+
* · comprueba que quien escribe sea un aparato del acta con `passwords`, por su firma;
|
|
16
|
+
* · filtra por destinatario al entregar, y decide si hace falta un dedo encima.
|
|
17
|
+
*
|
|
18
|
+
* Lo que SIGUE igual: el protocolo es de a una credencial, `list` no existe en remoto, y la
|
|
19
|
+
* aprobación es la misma del resto del vault (`caps +aprueba`).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { SealedStore, SealedResponder } from '@dotrino/passmanager'
|
|
23
|
+
import { verifyDeviceSig } from '@dotrino/identity/capabilities'
|
|
24
|
+
|
|
25
|
+
export const PASSWORDS_CAP = 'passwords'
|
|
26
|
+
export const PASSKEYS_CAP = 'passkeys'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* `client` cliente del proxio del vault, ya identificado
|
|
31
|
+
* `store` `{ get(k), set(k,v) }` donde viven los sobres
|
|
32
|
+
* `members(kind)` → `[{ pub, encPub }]` a quién envolverle cada llave (`main`/`passkeys`),
|
|
33
|
+
* SIN la bóveda. Lo pone `vault.js`, que es quien lee el acta.
|
|
34
|
+
* `recoveryPub()` la pública de la copia de recuperación (la que abre la frase)
|
|
35
|
+
* `canWrite(pubkey)` ¿ese aparato puede ESCRIBIR contraseñas? (acta: `passwords`)
|
|
36
|
+
* `isAllowed(pubkey)` ¿puede pedir? — lo decide el acta, no esto
|
|
37
|
+
* `encPubOf(pubkey)` su llave de cifrado, para sellarle la respuesta
|
|
38
|
+
* `needsApproval(pubkey)` (async) si ese aparato tiene que pedir aprobación (`unattended`)
|
|
39
|
+
* `approve({ pubkey, op })` pide el visto bueno (el teléfono) y espera
|
|
40
|
+
* `audit(op, info)` la bitácora del vault
|
|
41
|
+
*/
|
|
42
|
+
export function createPasswordDesk (opts = {}) {
|
|
43
|
+
const {
|
|
44
|
+
client, store,
|
|
45
|
+
members = () => [],
|
|
46
|
+
recoveryPub = () => null,
|
|
47
|
+
canWrite = () => false,
|
|
48
|
+
isAllowed = () => false,
|
|
49
|
+
encPubOf = () => null,
|
|
50
|
+
needsApproval = () => true,
|
|
51
|
+
approve = async () => false,
|
|
52
|
+
audit = () => {},
|
|
53
|
+
log = () => {},
|
|
54
|
+
} = opts
|
|
55
|
+
|
|
56
|
+
const sealed = new SealedStore(store, {
|
|
57
|
+
recipients: (kind) => members(kind),
|
|
58
|
+
/**
|
|
59
|
+
* ¿LA FIRMÓ QUIEN DICE, Y PUEDE ESCRIBIR? Es lo único que la bóveda puede comprobar
|
|
60
|
+
* sobre una entrada cuyo contenido no ve. Las dos mitades importan: la firma ata el
|
|
61
|
+
* sobre a una llave, y el acta dice si esa llave tiene algo que hacer aquí.
|
|
62
|
+
*/
|
|
63
|
+
verifyAuthor: async ({ body, author }) => {
|
|
64
|
+
if (!canWrite(author?.pub)) return false
|
|
65
|
+
return verifyDeviceSig({ publickey: author.pub, data: body, signature: author.sig })
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const responder = new SealedResponder({
|
|
70
|
+
client,
|
|
71
|
+
store: sealed,
|
|
72
|
+
recipients: async () => ({
|
|
73
|
+
recoveryPub: recoveryPub(),
|
|
74
|
+
main: await members('main'),
|
|
75
|
+
passkeys: await members('passkeys'),
|
|
76
|
+
}),
|
|
77
|
+
isAllowed,
|
|
78
|
+
encPubOf,
|
|
79
|
+
// Dos condiciones, y las dos tienen que darse: que ese APARATO esté marcado para
|
|
80
|
+
// aprobar, y que lo que pide sea PRIVADO. Rellenar un nombre no es sacar un secreto, y
|
|
81
|
+
// pedir permiso para todo enseña a decir que sí sin mirar.
|
|
82
|
+
needsApproval: async (pubkey) => await needsApproval(pubkey),
|
|
83
|
+
approve,
|
|
84
|
+
onRequest: (r) => {
|
|
85
|
+
// La bitácora NO lleva qué credencial se pidió: eso es contenido del usuario.
|
|
86
|
+
audit('passwords', { op: r.op, outcome: r.outcome, device: (r.from || '').slice(0, 24) })
|
|
87
|
+
if (r.outcome !== 'ok') log(`[vault] passwords: ${r.op} ${r.outcome}`)
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
start () { responder.start(); return this },
|
|
93
|
+
stop () { responder.stop() },
|
|
94
|
+
/**
|
|
95
|
+
* EL CANDADO DEL PERFIL YA NO PINTA NADA AQUÍ, y eso es la noticia: esta bóveda no
|
|
96
|
+
* tiene ninguna llave que cerrar. Se conserva el método porque quien lo llama no tiene
|
|
97
|
+
* por qué saberlo, y porque decirlo en la bitácora ayuda a entender el cambio.
|
|
98
|
+
*/
|
|
99
|
+
lock () { audit('passwords.lock', { sealed: true }) },
|
|
100
|
+
unlock () {},
|
|
101
|
+
/** Para la consola local y las pruebas. Aquí tampoco se abre nada. */
|
|
102
|
+
sealed,
|
|
103
|
+
responder,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* CONVERTIR LO QUE HAY (§2.7). Es un acto único y es **segundo trabajo de la maestra**: se
|
|
109
|
+
* hace al ABRIR la bóveda, que es cuando la frase está a mano y hay con qué reescribirlo
|
|
110
|
+
* todo.
|
|
111
|
+
*
|
|
112
|
+
* El trabajo está en `@dotrino/passmanager/sealed` y no aquí, porque lo hacen las CUATRO
|
|
113
|
+
* bóvedas: esta, la pestaña, la de dentro de la extensión y `passmanager serve`. Cuatro
|
|
114
|
+
* copias serían cuatro formas sutilmente distintas de reescribir lo mismo, y la que se
|
|
115
|
+
* quedara atrás no fallaría al convertir — fallaría al leerlo otra bóveda, meses después.
|
|
116
|
+
*
|
|
117
|
+
* Lo que sí es de aquí: `dropOldKey`, porque dónde vive la llave vieja lo sabe cada una.
|
|
118
|
+
*/
|
|
119
|
+
export async function convertPasswords ({ store, sealed, cek, recipients, author, dropOldKey, log = () => {} } = {}) {
|
|
120
|
+
const { convertToSealed } = await import('@dotrino/passmanager/sealed')
|
|
121
|
+
return convertToSealed({
|
|
122
|
+
store, sealed, cek, recipients, author, dropOldKey,
|
|
123
|
+
log: (m) => log('[vault] ' + m)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* La LLAVE DE SELLADO de esta bóveda: con ella FIRMA los sobres de los secretos, para
|
|
3
|
+
* que se sepa que salieron de aquí. Diseño: `docs/secretos-sellados.md` §8.8 y §8.9.
|
|
4
|
+
*
|
|
5
|
+
* Tres cosas que la hacen distinta de todo lo demás que hay en este directorio:
|
|
6
|
+
*
|
|
7
|
+
* · **Firmar no es leer.** Esta llave no abre ningún valor. Por eso puede vivir
|
|
8
|
+
* cifrada en reposo con la llave de la máquina y usarse **sin la frase** — que es
|
|
9
|
+
* justo lo que hace posible administrar sin contraseña.
|
|
10
|
+
* · **Su autoridad la da el ACTA**, que la nombra (`sealPub`) y que sella únicamente
|
|
11
|
+
* la maestra. Ella no se autoriza a sí misma.
|
|
12
|
+
* · **Rota con el acta.** Cada acta nueva puede estrenar una, así que aquí se guarda
|
|
13
|
+
* un puñado: la vigente para firmar, y las anteriores porque un sobre se firmó con
|
|
14
|
+
* la que mandaba entonces y hay que poder seguir firmando… no, seguir
|
|
15
|
+
* **identificando** cuál era. Verificar se hace con la pública que dice el acta.
|
|
16
|
+
*
|
|
17
|
+
* No guarda ninguna correspondencia con `seq`: quién mandaba y cuándo lo dice el acta
|
|
18
|
+
* (`sealKeys` con su tramo). Aquí solo están las privadas, indexadas por su pública.
|
|
19
|
+
*/
|
|
20
|
+
import { readJson, writeJson } from './disk.js'
|
|
21
|
+
import { signWithDevice } from '@dotrino/identity/capabilities'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Cuántas llaves anteriores se conservan. Firmar solo usa la vigente; las viejas se
|
|
25
|
+
* guardan por si hay que re-firmar algo sellado con ellas (recoger el histórico), y
|
|
26
|
+
* porque tirarlas no ahorra nada: son cuatro líneas de JSON.
|
|
27
|
+
*/
|
|
28
|
+
const MAX_KEYS = 8
|
|
29
|
+
|
|
30
|
+
/** Abre (o estrena) el llavero de sellado del perfil. */
|
|
31
|
+
export function openSealKeys (disk) {
|
|
32
|
+
const file = 'sealkeys.json'
|
|
33
|
+
const data = readJson(disk, file, null) || { v: 1, keys: [] }
|
|
34
|
+
if (!Array.isArray(data.keys)) data.keys = []
|
|
35
|
+
|
|
36
|
+
const save = () => writeJson(disk, file, data)
|
|
37
|
+
const find = (pub) => data.keys.find((k) => k.pub === pub) || null
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** La pública de la llave vigente (la última estrenada), o `null` si no hay ninguna. */
|
|
41
|
+
current: () => data.keys[data.keys.length - 1]?.pub || null,
|
|
42
|
+
|
|
43
|
+
/** ¿Tenemos la privada de esta pública? Es lo que decide si podemos firmar por ella. */
|
|
44
|
+
has: (pub) => !!find(pub),
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Estrena una llave y devuelve su PÚBLICA, para que el acta la nombre. La privada
|
|
48
|
+
* se queda aquí; no sale de esta máquina ni viaja a ninguna parte.
|
|
49
|
+
*
|
|
50
|
+
* Es lo que se le pasa a `identity.setSealKeyProvider`, así que se llama una vez
|
|
51
|
+
* por acta sellada.
|
|
52
|
+
*/
|
|
53
|
+
async mint () {
|
|
54
|
+
const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'])
|
|
55
|
+
const pub = JSON.stringify(await crypto.subtle.exportKey('jwk', pair.publicKey))
|
|
56
|
+
const priv = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
57
|
+
data.keys.push({ pub, priv, createdAt: Date.now() })
|
|
58
|
+
if (data.keys.length > MAX_KEYS) data.keys = data.keys.slice(-MAX_KEYS)
|
|
59
|
+
save()
|
|
60
|
+
return pub
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Firma un cuerpo con la llave que el acta nombra. Devuelve `null` si esa llave no es
|
|
65
|
+
* nuestra —el disco se restauró, o el acta la puso otro master—: entonces el sobre
|
|
66
|
+
* sale SIN firma, que es peor que firmado pero mucho mejor que no poder guardar.
|
|
67
|
+
*/
|
|
68
|
+
async sign (sealPub, body) {
|
|
69
|
+
const k = sealPub ? find(sealPub) : null
|
|
70
|
+
if (!k) return null
|
|
71
|
+
const { signature } = await signWithDevice({ privateJwk: k.priv, publickey: k.pub, data: body })
|
|
72
|
+
return signature
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default { openSealKeys }
|