@dotrino/identity 0.115.0 → 0.117.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/package.json +4 -1
- package/src/node.js +4 -180
- package/vault/content.js +28 -1
- package/vault/core.js +66 -7
- package/vault/facade.js +200 -0
- package/vault/index.html +10 -0
- package/vault/remote.js +45 -5
- package/vault/vault.js +29 -58
- package/vault/vendor/compat/VERSION.txt +3 -0
- package/vault/vendor/compat/advisory.js +141 -0
- package/vault/vendor/compat/index.js +188 -0
- package/vault/vendor/compat/ranges.js +93 -0
- package/vault/vendor/opaque/VERSION.txt +1 -1
- package/vault/vendor/passmanager/VERSION.txt +3 -0
- package/vault/vendor/passmanager/crypto.js +167 -0
- package/vault/vendor/passmanager/fields.js +183 -0
- package/vault/vendor/passmanager/generate.js +74 -0
- package/vault/vendor/passmanager/import.js +156 -0
- package/vault/vendor/passmanager/index.js +27 -0
- package/vault/vendor/passmanager/match.js +137 -0
- package/vault/vendor/passmanager/model.js +332 -0
- package/vault/vendor/passmanager/pubkey.js +33 -0
- package/vault/vendor/passmanager/sealed/convert.js +72 -0
- package/vault/vendor/passmanager/sealed/device.js +211 -0
- package/vault/vendor/passmanager/sealed/entry.js +209 -0
- package/vault/vendor/passmanager/sealed/index.js +20 -0
- package/vault/vendor/passmanager/sealed/keys.js +120 -0
- package/vault/vendor/passmanager/sealed/recovery.js +136 -0
- package/vault/vendor/passmanager/sealed/store.js +508 -0
- package/vault/vendor/passmanager/session-cache.js +60 -0
- package/vault/vendor/passmanager/totp.js +111 -0
- package/vault/vendor/passmanager/transport/protocol.js +83 -0
- package/vault/vendor/passmanager/transport/proxy.js +112 -0
- package/vault/vendor/passmanager/transport/sealed.js +74 -0
- package/vault/vendor/passmanager/vault/approval.js +63 -0
- package/vault/vendor/passmanager/vault/errors.js +34 -0
- package/vault/vendor/passmanager/vault/guard.js +114 -0
- package/vault/vendor/passmanager/vault/interface.js +34 -0
- package/vault/vendor/passmanager/vault/local.js +213 -0
- package/vault/vendor/passmanager/vault/remote.js +85 -0
- package/vault/vendor/passmanager/vault/responder.js +192 -0
- package/vault/vendor/passmanager/vault/sealed-local.js +199 -0
- package/vault/vendor/passmanager/vault/sealed-responder.js +159 -0
- package/vault/vendor/passmanager/vault/sealed.js +362 -0
- package/vault/vendor/passmanager/webauthn.js +221 -0
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +136 -11
- package/vault/vendor/proxy-client/index.js +1 -0
- package/vault/vendor/proxy-client/parts.js +127 -0
- package/vault/vendor/proxy-client/webrtc.js +23 -0
- package/vault/vendor/roadmap/VERSION.txt +3 -0
- package/vault/vendor/roadmap/index.js +108 -0
- package/vault/vendor/roadmap/manifests.js +802 -0
- package/vault/vendor/store/VERSION.txt +3 -0
- package/vault/vendor/store/core.js +309 -0
- package/vault/vendor/vault/VERSION.txt +4 -1
- package/vault/vendor/vault/admin.js +437 -0
- package/vault/vendor/vault/browser.js +167 -0
- package/vault/vendor/vault/core/approvals.js +204 -0
- package/vault/vendor/vault/core/blocked.js +68 -0
- package/vault/vendor/vault/core/browserDisk.js +109 -0
- package/vault/vendor/vault/core/commKey.js +68 -0
- package/vault/vendor/vault/core/disk.js +65 -0
- package/vault/vendor/vault/core/libVersion.js +8 -0
- package/vault/vendor/vault/core/machines.js +66 -0
- package/vault/vendor/vault/core/notices.js +71 -0
- package/vault/vendor/vault/core/notifiable.js +73 -0
- package/vault/vendor/vault/core/passwords.js +125 -0
- package/vault/vendor/vault/core/sealKey.js +77 -0
- package/vault/vendor/vault/core/sealer.js +172 -0
- package/vault/vendor/vault/core/sealers.js +159 -0
- package/vault/vendor/vault/core/secretsStore.js +1326 -0
- package/vault/vendor/vault/core/sha256.js +69 -0
- package/vault/vendor/vault/core/store.js +68 -0
- package/vault/vendor/vault/core/subacta.js +101 -0
- package/vault/vendor/vault/core/threadStore.js +273 -0
- package/vault/vendor/vault/core/transport.js +270 -0
- package/vault/vendor/vault/core/vault.js +4414 -0
- package/vault/vendor/vault/enroll.js +8 -4
- package/vault/vendor/vault/procContext.js +97 -0
- package/vault/vendor/vault/revocation.js +67 -0
- package/vault/vendor/vault/sealed.js +84 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHA-256 SÍNCRONO, en JavaScript puro.
|
|
3
|
+
*
|
|
4
|
+
* El navegador solo trae `crypto.subtle.digest`, que es asíncrono, y la bitácora de la
|
|
5
|
+
* bóveda encadena cada línea con el hash de la anterior en el mismo paso en que la escribe:
|
|
6
|
+
* volverlo asíncrono dejaría dos entradas apuntando a la misma anterior. Así el binario y
|
|
7
|
+
* el navegador encadenan exactamente igual, y lo comprueba la prueba contra `node:crypto`.
|
|
8
|
+
*/
|
|
9
|
+
const K = new Uint32Array([
|
|
10
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
11
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
12
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
13
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
14
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
15
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
16
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
17
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2
|
|
18
|
+
])
|
|
19
|
+
|
|
20
|
+
/** @param {Uint8Array} data */
|
|
21
|
+
export function sha256Bytes (data) {
|
|
22
|
+
const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19])
|
|
23
|
+
const len = data.length
|
|
24
|
+
const total = ((len + 9 + 63) >> 6) << 6
|
|
25
|
+
const buf = new Uint8Array(total)
|
|
26
|
+
buf.set(data)
|
|
27
|
+
buf[len] = 0x80
|
|
28
|
+
const bits = len * 8
|
|
29
|
+
const view = new DataView(buf.buffer)
|
|
30
|
+
view.setUint32(total - 8, Math.floor(bits / 0x100000000))
|
|
31
|
+
view.setUint32(total - 4, bits >>> 0)
|
|
32
|
+
const w = new Uint32Array(64)
|
|
33
|
+
for (let off = 0; off < total; off += 64) {
|
|
34
|
+
for (let i = 0; i < 16; i++) w[i] = view.getUint32(off + i * 4)
|
|
35
|
+
for (let i = 16; i < 64; i++) {
|
|
36
|
+
const a = w[i - 15], b = w[i - 2]
|
|
37
|
+
const s0 = ((a >>> 7) | (a << 25)) ^ ((a >>> 18) | (a << 14)) ^ (a >>> 3)
|
|
38
|
+
const s1 = ((b >>> 17) | (b << 15)) ^ ((b >>> 19) | (b << 13)) ^ (b >>> 10)
|
|
39
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0
|
|
40
|
+
}
|
|
41
|
+
let [a, b, c, d, e, f, g, hh] = h
|
|
42
|
+
for (let i = 0; i < 64; i++) {
|
|
43
|
+
const S1 = ((e >>> 6) | (e << 26)) ^ ((e >>> 11) | (e << 21)) ^ ((e >>> 25) | (e << 7))
|
|
44
|
+
const ch = (e & f) ^ (~e & g)
|
|
45
|
+
const t1 = (hh + S1 + ch + K[i] + w[i]) >>> 0
|
|
46
|
+
const S0 = ((a >>> 2) | (a << 30)) ^ ((a >>> 13) | (a << 19)) ^ ((a >>> 22) | (a << 10))
|
|
47
|
+
const maj = (a & b) ^ (a & c) ^ (b & c)
|
|
48
|
+
const t2 = (S0 + maj) >>> 0
|
|
49
|
+
hh = g; g = f; f = e; e = (d + t1) >>> 0
|
|
50
|
+
d = c; c = b; b = a; a = (t1 + t2) >>> 0
|
|
51
|
+
}
|
|
52
|
+
h[0] = (h[0] + a) >>> 0; h[1] = (h[1] + b) >>> 0; h[2] = (h[2] + c) >>> 0; h[3] = (h[3] + d) >>> 0
|
|
53
|
+
h[4] = (h[4] + e) >>> 0; h[5] = (h[5] + f) >>> 0; h[6] = (h[6] + g) >>> 0; h[7] = (h[7] + hh) >>> 0
|
|
54
|
+
}
|
|
55
|
+
const out = new Uint8Array(32)
|
|
56
|
+
const ov = new DataView(out.buffer)
|
|
57
|
+
for (let i = 0; i < 8; i++) ov.setUint32(i * 4, h[i])
|
|
58
|
+
return out
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** El hash de un texto (UTF-8), en hexadecimal. Lo mismo que `createHash('sha256').update(text, 'utf8').digest('hex')`. */
|
|
62
|
+
export function sha256Hex (text) {
|
|
63
|
+
const bytes = typeof text === 'string' ? new TextEncoder().encode(text) : text
|
|
64
|
+
let s = ''
|
|
65
|
+
for (const b of sha256Bytes(bytes)) s += b.toString(16).padStart(2, '0')
|
|
66
|
+
return s
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export default { sha256Bytes, sha256Hex }
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store del árbol de contenidos del vault (`vault.json`). **Cifrado en reposo**
|
|
3
|
+
* con la misma clave ligada a la máquina que la identidad (`atrest.js`): el
|
|
4
|
+
* contenido del usuario no es menos sensible que la maestra. NO guarda
|
|
5
|
+
* identidad/dispositivos/certs: de eso se encarga
|
|
6
|
+
* `@dotrino/identity` dentro del mismo dir (keypair, contactos, delegaciones,
|
|
7
|
+
* revocaciones). Aquí vive solo lo del usuario: el árbol y los settings.
|
|
8
|
+
*/
|
|
9
|
+
import { readJson, writeJson } from './disk.js'
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1
|
|
12
|
+
|
|
13
|
+
function newTree () {
|
|
14
|
+
return { id: 'root', name: '', type: 'folder', children: [] }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findNode (node, id) {
|
|
18
|
+
if (!node) return null
|
|
19
|
+
if (node.id === id) return node
|
|
20
|
+
if (Array.isArray(node.children)) {
|
|
21
|
+
for (const c of node.children) {
|
|
22
|
+
const r = findNode(c, id)
|
|
23
|
+
if (r) return r
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function openStore (disk) {
|
|
30
|
+
const file = 'vault.json'
|
|
31
|
+
let data = readJson(disk, file, null)
|
|
32
|
+
if (!data || data.schemaVersion !== SCHEMA_VERSION) {
|
|
33
|
+
data = { schemaVersion: SCHEMA_VERSION, tree: newTree(), settings: {} }
|
|
34
|
+
}
|
|
35
|
+
// Se reescribe SIEMPRE al abrir: así un archivo de una instalación anterior
|
|
36
|
+
// (en claro) queda cifrado sin pedirle nada al usuario.
|
|
37
|
+
writeJson(disk, file, data)
|
|
38
|
+
const save = () => writeJson(disk, file, data)
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
get raw () { return data },
|
|
42
|
+
getTree () { return data.tree },
|
|
43
|
+
getNode (id) { return findNode(data.tree, id || 'root') },
|
|
44
|
+
addNode (parentId, node) {
|
|
45
|
+
const parent = findNode(data.tree, parentId || 'root')
|
|
46
|
+
if (!parent) throw new Error('parent node not found: ' + parentId)
|
|
47
|
+
if (!Array.isArray(parent.children)) parent.children = []
|
|
48
|
+
parent.children.push(node)
|
|
49
|
+
save()
|
|
50
|
+
return node
|
|
51
|
+
},
|
|
52
|
+
removeNode (id) {
|
|
53
|
+
const remove = (node) => {
|
|
54
|
+
if (!Array.isArray(node.children)) return false
|
|
55
|
+
const i = node.children.findIndex((c) => c.id === id)
|
|
56
|
+
if (i >= 0) { node.children.splice(i, 1); return true }
|
|
57
|
+
return node.children.some(remove)
|
|
58
|
+
}
|
|
59
|
+
const ok = remove(data.tree)
|
|
60
|
+
if (ok) save()
|
|
61
|
+
return ok
|
|
62
|
+
},
|
|
63
|
+
getSetting (k) { return data.settings[k] },
|
|
64
|
+
setSetting (k, v) { if (v === undefined) delete data.settings[k]; else data.settings[k] = v; save() },
|
|
65
|
+
/** Todos los ajustes (copia). Para quien necesita buscar por prefijo. */
|
|
66
|
+
listSettings () { return { ...data.settings } }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subacta.js — LO DECIDIDO QUE TODAVÍA NO SELLÓ EL ACTA.
|
|
3
|
+
*
|
|
4
|
+
* Idea del dueño (2026-09-04): *«una subacta efímera que se comparte entre todos los
|
|
5
|
+
* dispositivos y puede tener decisiones a futuro que se aceptan cuando el acta se abre»*.
|
|
6
|
+
*
|
|
7
|
+
* EL AGUJERO QUE TAPA. Sellar el acta es de la maestra, y con el perfil cerrado la maestra
|
|
8
|
+
* no está. Así que una renuncia —«me quito estos permisos»— no surtía NINGÚN efecto hasta
|
|
9
|
+
* que alguien iba a la máquina y tecleaba la contraseña. Justo el caso que la renuncia
|
|
10
|
+
* existe para cubrir: te roban el teléfono, renuncias desde él, y el ladrón sigue siendo
|
|
11
|
+
* admin hasta que llegues. El pilar ya tenía la pieza (`extraRenounces`) y no la pasaba
|
|
12
|
+
* nadie.
|
|
13
|
+
*
|
|
14
|
+
* LA REGLA DURA, y es la que sostiene todo lo demás:
|
|
15
|
+
*
|
|
16
|
+
* LA SUBACTA SOLO PUEDE QUITAR, NUNCA DAR.
|
|
17
|
+
*
|
|
18
|
+
* De ahí salen las tres propiedades que la hacen segura sin maestra:
|
|
19
|
+
*
|
|
20
|
+
* · **Se autentica sola.** Cada entrada la firma EL MIEMBRO AL QUE AFECTA. No hace falta
|
|
21
|
+
* sellador, así que no hay una segunda autoridad al lado del acta.
|
|
22
|
+
* · **Una entrada falsa o repetida cuesta disponibilidad, no autoridad.** Lo peor que
|
|
23
|
+
* consigue quien cuele una es quitarle permisos a alguien: molesto y reversible.
|
|
24
|
+
* · **Nadie puede quitarle nada a otro.** Si valiera la firma de cualquiera, un servicio
|
|
25
|
+
* con `secrets` en un VPS alquilado podría dejar sin `admin` a tu teléfono.
|
|
26
|
+
*
|
|
27
|
+
* Y por eso el catálogo de operaciones es una LISTA BLANCA de una sola: `renounce`. La
|
|
28
|
+
* lista es la regla — si algún día entra otra, tiene que quitar y venir firmada por su
|
|
29
|
+
* sujeto, y se escribe aquí a propósito, no se cuela.
|
|
30
|
+
*
|
|
31
|
+
* JUNTAR DOS ES UNIR. Dos entradas nunca se contradicen porque las dos quitan, así que no
|
|
32
|
+
* hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
|
|
33
|
+
* por reloj, o un aparato apagado perdería justo la que lo protege.
|
|
34
|
+
*/
|
|
35
|
+
import { verifyRenounce } from '@dotrino/identity/acta'
|
|
36
|
+
import { readJson, writeJson } from './disk.js'
|
|
37
|
+
|
|
38
|
+
/** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
|
|
39
|
+
export const OPS = Object.freeze(['renounce'])
|
|
40
|
+
|
|
41
|
+
/** Identidad de una entrada, para no guardar dos veces la misma. */
|
|
42
|
+
const idOf = (e) => `${e.op}|${e.member}|${[...(e.caps || [])].sort().join(',')}|${e.ts}`
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* ¿Es una entrada que se puede aceptar? Comprueba la FORMA y la FIRMA; que quite es
|
|
46
|
+
* estructural (el catálogo) y no una comprobación aparte.
|
|
47
|
+
*/
|
|
48
|
+
export async function isAcceptable (e) {
|
|
49
|
+
if (!e || !OPS.includes(e.op)) return false
|
|
50
|
+
if (e.op === 'renounce') return verifyRenounce(e).catch(() => false)
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* La subacta de un perfil. Vive en su directorio y va cifrada en reposo como todo lo
|
|
56
|
+
* demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
|
|
57
|
+
* cuenta aunque no sea un secreto.
|
|
58
|
+
*/
|
|
59
|
+
export function openSubacta (disk) {
|
|
60
|
+
const file = 'subacta.json'
|
|
61
|
+
let data = readJson(disk, file, null) || { v: 1, entries: [] }
|
|
62
|
+
const guardar = () => writeJson(disk, file, data)
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
/** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
|
|
66
|
+
entries () { return [...data.entries] },
|
|
67
|
+
get count () { return data.entries.length },
|
|
68
|
+
|
|
69
|
+
/** Las de UN miembro. Es lo que se mira al decidir sobre él. */
|
|
70
|
+
forMember (pub) { return data.entries.filter((e) => e.member === pub) },
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Acepta una entrada. Devuelve `{ok, reason}` — y un `false` aquí no es un fallo del
|
|
74
|
+
* que la manda: puede ser que ya la tuviéramos, que es el caso normal cuando dos
|
|
75
|
+
* aparatos se cuentan lo mismo.
|
|
76
|
+
*/
|
|
77
|
+
async add (e) {
|
|
78
|
+
if (!(await isAcceptable(e))) return { ok: false, reason: 'no-aceptable' }
|
|
79
|
+
const id = idOf(e)
|
|
80
|
+
if (data.entries.some((x) => idOf(x) === id)) return { ok: false, reason: 'ya-estaba' }
|
|
81
|
+
data.entries = [...data.entries, e]
|
|
82
|
+
guardar()
|
|
83
|
+
return { ok: true, reason: 'nueva' }
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* SACA TODO para absorberlo en el acta, y vacía. Se llama con la bóveda ABIERTA, que es
|
|
88
|
+
* la única que puede sellar. Vaciar aquí y fallar el sellado perdería la entrada, así
|
|
89
|
+
* que quien llama tiene que absorber primero y vaciar después (`clear`).
|
|
90
|
+
*/
|
|
91
|
+
drain () { return [...data.entries] },
|
|
92
|
+
|
|
93
|
+
/** Vacía lo ya absorbido. Se le pasa lo que se absorbió, no «todo»: mientras se sella
|
|
94
|
+
* puede haber entrado una nueva, y tirarla sería perder una renuncia sin aplicar. */
|
|
95
|
+
clear (absorbidas) {
|
|
96
|
+
const ids = new Set(absorbidas.map(idOf))
|
|
97
|
+
data.entries = data.entries.filter((e) => !ids.has(idOf(e)))
|
|
98
|
+
guardar()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sub-store de HILOS + APERTURAS del vault (Fase 3: store centralizado).
|
|
3
|
+
*
|
|
4
|
+
* Espeja el modelo de datos de `@dotrino/store` (store.dotrino.com):
|
|
5
|
+
* threads: { [threadKey]: Entry[] } (Entry tiene `id` + `ts`, opaco para el store)
|
|
6
|
+
* opens: { [appId]: { count, ts } } (contador de "recientes" del hub)
|
|
7
|
+
* para que un dispositivo emparejado pueda guardar su contenido EN el vault del
|
|
8
|
+
* usuario (su propio servidor) en vez de —o además de— el IndexedDB del navegador.
|
|
9
|
+
*
|
|
10
|
+
* File-backed (`threads.json`), síncrono y simple (sin cuota/IndexedDB). Es el
|
|
11
|
+
* backend autoritativo; el navegador usa su IndexedDB como caché y sincroniza.
|
|
12
|
+
* **Cifrado en reposo** con la clave ligada a la máquina (`atrest.js`): aquí
|
|
13
|
+
* vive el contenido de las apps y el perfil del usuario, que es exactamente lo
|
|
14
|
+
* que el ecosistema promete que no queda en claro en ningún disco.
|
|
15
|
+
*/
|
|
16
|
+
import * as core from '@dotrino/store/core'
|
|
17
|
+
import { resealStale } from '@dotrino/identity/content'
|
|
18
|
+
import { readJson, writeJson } from './disk.js'
|
|
19
|
+
|
|
20
|
+
// El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
|
|
21
|
+
// hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
|
|
22
|
+
// y el navegador lo volvía a subir en cada sincronización.
|
|
23
|
+
const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
|
|
24
|
+
|
|
25
|
+
// DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
|
|
26
|
+
// llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
|
|
27
|
+
// notas, un documento corto) y ridículos para un abuso.
|
|
28
|
+
// LO QUE SE VUELVE A CERRAR AL ROTAR NO SE BORRA: la versión anterior de cada sobre se
|
|
29
|
+
// guarda como respaldo AL MENOS UN AÑO (política del dueño, 2026-09-30). No se sirve por
|
|
30
|
+
// ningún método; solo está por si volver a cerrar sale mal.
|
|
31
|
+
export const RESEALED_KEEP_MS = 366 * 24 * 60 * 60 * 1000
|
|
32
|
+
|
|
33
|
+
const MAX_SECURE_ITEMS = 2000
|
|
34
|
+
const MAX_SECURE_BLOB = 64 * 1024 // por campo sellado (meta y valor)
|
|
35
|
+
|
|
36
|
+
export function openThreadStore (disk) {
|
|
37
|
+
const file = 'threads.json'
|
|
38
|
+
let data = readJson(disk, file, null)
|
|
39
|
+
if (!data || typeof data !== 'object') data = { v: 1, threads: {}, opens: {} }
|
|
40
|
+
if (!data.threads) data.threads = {}
|
|
41
|
+
if (!data.opens) data.opens = {}
|
|
42
|
+
if (!data.secure) data.secure = {}
|
|
43
|
+
// Lápidas de lo borrado (`@dotrino/store/core`): sin ellas un borrado vuelve desde el
|
|
44
|
+
// primer aparato que todavía tenga la entrada.
|
|
45
|
+
if (!data.tombs) data.tombs = {}
|
|
46
|
+
core.pruneTombs(data.tombs, Date.now())
|
|
47
|
+
const save = () => writeJson(disk, file, data)
|
|
48
|
+
save() // reescribe al abrir: cifra lo que venía en claro
|
|
49
|
+
const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
|
|
50
|
+
const needKeys = (keys) => {
|
|
51
|
+
if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) throw new Error('keys must be a list of thread keys')
|
|
52
|
+
return keys
|
|
53
|
+
}
|
|
54
|
+
const needMap = (value, what) => {
|
|
55
|
+
if (value == null) return {}
|
|
56
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be an object`)
|
|
57
|
+
return value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const methods = {
|
|
61
|
+
appendMessage ({ threadKey, entry }) {
|
|
62
|
+
core.writeEntry(data.threads, data.tombs, threadKey, entry, { max: MAX_PER_THREAD, now: Date.now(), newId: () => crypto.randomUUID() })
|
|
63
|
+
save(); return entry
|
|
64
|
+
},
|
|
65
|
+
listThread ({ threadKey, limit, before }) {
|
|
66
|
+
if (!core.validKey(threadKey) || !Object.hasOwn(data.threads, threadKey)) return []
|
|
67
|
+
let arr = data.threads[threadKey]
|
|
68
|
+
if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
|
|
69
|
+
if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
|
|
70
|
+
return arr
|
|
71
|
+
},
|
|
72
|
+
listThreadKeys () { return Object.keys(data.threads) },
|
|
73
|
+
getThreadSummaries () {
|
|
74
|
+
const out = {}
|
|
75
|
+
for (const [k, arr] of Object.entries(data.threads)) out[k] = { lastEntry: arr.length ? arr[arr.length - 1] : null, count: arr.length }
|
|
76
|
+
return out
|
|
77
|
+
},
|
|
78
|
+
removeThread ({ threadKey }) {
|
|
79
|
+
const removed = core.removeWholeThread(data.threads, data.tombs, threadKey, Date.now())
|
|
80
|
+
if (removed) save()
|
|
81
|
+
return { removed }
|
|
82
|
+
},
|
|
83
|
+
removeMessage ({ threadKey, id }) {
|
|
84
|
+
const removed = core.removeEntry(data.threads, data.tombs, threadKey, id, Date.now())
|
|
85
|
+
if (removed) save()
|
|
86
|
+
return { removed }
|
|
87
|
+
},
|
|
88
|
+
recordOpen ({ appId }) {
|
|
89
|
+
if (!appId || typeof appId !== 'string') throw new Error('appId required')
|
|
90
|
+
const prev = data.opens[appId]
|
|
91
|
+
data.opens[appId] = { count: (prev?.count || 0) + 1, ts: Date.now() }
|
|
92
|
+
save(); return data.opens[appId]
|
|
93
|
+
},
|
|
94
|
+
getOpens () { return { ...data.opens } },
|
|
95
|
+
clearOpens () { data.opens = {}; save(); return { ok: true } },
|
|
96
|
+
/** Mezcla el contador de un aparato (el mayor de cada lado) y devuelve el resultado. */
|
|
97
|
+
mergeOpens ({ opens }) {
|
|
98
|
+
if (core.mergeOpens(data.opens, needMap(opens, 'opens'))) save()
|
|
99
|
+
return { ...data.opens }
|
|
100
|
+
},
|
|
101
|
+
exportThreads () { return { threads: data.threads } },
|
|
102
|
+
// ----- SINCRONIZAR POR PARTES (@dotrino/store ≥ 0.11) -----
|
|
103
|
+
// `exportThreads`/`importThreads` a secas mueven TODO en un mensaje, y el proxio corta
|
|
104
|
+
// los frames en 1 MB: con unos cientos de KB de datos la sincronización dejaba de caber.
|
|
105
|
+
// Con esto el aparato pregunta qué hilos difieren (una huella por hilo), baja su índice
|
|
106
|
+
// (id y ts) y pide solo las entradas que le faltan, todo por páginas con tope de bytes.
|
|
107
|
+
// Las reglas —huella, mezcla, lápidas— son las de `@dotrino/store/core`, las mismas que
|
|
108
|
+
// aplica el navegador: si cada lado llevara las suyas, las huellas no coincidirían nunca.
|
|
109
|
+
getThreadDigests ({ keys } = {}) {
|
|
110
|
+
return core.digestsOf(data.threads, keys == null ? undefined : needKeys(keys))
|
|
111
|
+
},
|
|
112
|
+
getThreadIndexes ({ keys, cursor, maxBytes }) {
|
|
113
|
+
return core.indexPage(data.threads, data.tombs, needKeys(keys), cursor || null, budget(maxBytes))
|
|
114
|
+
},
|
|
115
|
+
getEntries ({ refs, maxBytes }) {
|
|
116
|
+
return core.entriesPage(data.threads, needMap(refs, 'refs'), budget(maxBytes))
|
|
117
|
+
},
|
|
118
|
+
importThreads ({ threads, tombs, mode = 'merge' }) {
|
|
119
|
+
if (!threads || typeof threads !== 'object') throw new Error('threads required')
|
|
120
|
+
if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
|
|
121
|
+
if (mode !== 'merge' && mode !== 'upsert') throw new Error(`unknown import mode: ${mode}`)
|
|
122
|
+
const now = Date.now()
|
|
123
|
+
const buried = core.applyTombs(data.threads, data.tombs, needMap(tombs, 'tombs'), now)
|
|
124
|
+
const merged = core.mergeEntries(data.threads, data.tombs, threads, { mode, max: MAX_PER_THREAD })
|
|
125
|
+
core.pruneTombs(data.tombs, now)
|
|
126
|
+
if (buried.tombsChanged || buried.changed.size || merged.size) save()
|
|
127
|
+
return { mode, count: Object.keys(data.threads).length, changed: [...new Set([...buried.changed, ...merged])] }
|
|
128
|
+
},
|
|
129
|
+
// ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
|
|
130
|
+
// Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
|
|
131
|
+
// el mismo perfil (apodo/avatar/datos) en todos los dispositivos.
|
|
132
|
+
profileSet ({ me }) {
|
|
133
|
+
if (!me || typeof me !== 'object') throw new Error('me required')
|
|
134
|
+
// nunca guardar llaves de dispositivo (son por-dispositivo)
|
|
135
|
+
const { publickey, encryptionPubkey, ...content } = me
|
|
136
|
+
data.profile = { ...content, updatedAt: content.updatedAt || Date.now() }
|
|
137
|
+
save(); return { ok: true, updatedAt: data.profile.updatedAt }
|
|
138
|
+
},
|
|
139
|
+
profileGet () { return { me: data.profile || null } },
|
|
140
|
+
|
|
141
|
+
// LOS DATOS DEL PERFIL EN SOBRES (`docs/datos-del-perfil.md`). Están aquí solo para
|
|
142
|
+
// que el filtro de métodos los reconozca: los atiende `handleProfile` en `vault.js`,
|
|
143
|
+
// antes de llegar a este store, porque su puerta es otra —`firma` y sin candado—.
|
|
144
|
+
// Si alguna vez alguien los llamara por este camino, contestan que no en vez de
|
|
145
|
+
// hacer algo a medias.
|
|
146
|
+
profilePut () { throw new Error('profilePut is handled before the store: see handleProfile') },
|
|
147
|
+
profileBundle () { throw new Error('profileBundle is handled before the store: see handleProfile') },
|
|
148
|
+
profilePublic () { throw new Error('profilePublic is handled before the store: see handleProfile') },
|
|
149
|
+
profileRecipients () { throw new Error('profileRecipients is handled before the store: see handleProfile') },
|
|
150
|
+
|
|
151
|
+
// ----- DATOS SENSIBLES del usuario (F4, docs/consola-remota.md §6) -----
|
|
152
|
+
//
|
|
153
|
+
// Contraseñas, notas, documentos: van al contenido del perfil, cifrados con la
|
|
154
|
+
// CEK de la cuenta y accesibles con `vault:store` — el mismo camino que hilos y
|
|
155
|
+
// perfil. NO tocan `secrets.json`: ese es el cajón de los SERVICIOS (proxy, geo),
|
|
156
|
+
// acotado por CN y con clave ligada a la máquina. Mismo nombre coloquial, distinto
|
|
157
|
+
// dueño.
|
|
158
|
+
//
|
|
159
|
+
// La bóveda guarda DOS SOBRES OPACOS por ficha y no abre ninguno:
|
|
160
|
+
// `meta` — lo que hace falta para pintar la lista (nombre, tipo, carpeta)
|
|
161
|
+
// `enc` — el valor en sí, que solo viaja cuando abres la ficha
|
|
162
|
+
// Los sella el dispositivo con la clave de contenido (`identity.sealContent`). Que
|
|
163
|
+
// sean dos y no uno es lo que permite listar sin bajar todas las contraseñas, y que
|
|
164
|
+
// el nombre («Banco») tampoco quede legible aquí.
|
|
165
|
+
//
|
|
166
|
+
// Alcance: esto es el ALMACÉN. Una app de contraseñas con generador y
|
|
167
|
+
// autocompletado es otra cosa y no vive aquí.
|
|
168
|
+
'secure.list' () {
|
|
169
|
+
return Object.values(data.secure)
|
|
170
|
+
.map(({ enc, ...rest }) => rest) // el valor NO viaja al listar
|
|
171
|
+
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
|
|
172
|
+
},
|
|
173
|
+
'secure.get' ({ id }) {
|
|
174
|
+
if (!id || typeof id !== 'string') throw new Error('id required')
|
|
175
|
+
return data.secure[id] || null
|
|
176
|
+
},
|
|
177
|
+
'secure.put' ({ id, meta, enc }) {
|
|
178
|
+
if (typeof enc !== 'string' || !enc) throw new Error('enc required (sealed value)')
|
|
179
|
+
if (meta != null && typeof meta !== 'string') throw new Error('meta must be a sealed string')
|
|
180
|
+
// Se comprueba el TAMAÑO, nunca el contenido: son sobres cerrados.
|
|
181
|
+
if (enc.length > MAX_SECURE_BLOB || (meta || '').length > MAX_SECURE_BLOB) throw new Error('secure: item too large')
|
|
182
|
+
const prev = id ? data.secure[id] : null
|
|
183
|
+
if (!prev && Object.keys(data.secure).length >= MAX_SECURE_ITEMS) throw new Error('secure: too many items')
|
|
184
|
+
const rec = {
|
|
185
|
+
id: prev?.id || id || crypto.randomUUID(),
|
|
186
|
+
ts: prev?.ts || Date.now(),
|
|
187
|
+
updatedAt: Date.now(),
|
|
188
|
+
meta: meta ?? prev?.meta ?? null,
|
|
189
|
+
enc
|
|
190
|
+
}
|
|
191
|
+
data.secure[rec.id] = rec
|
|
192
|
+
save()
|
|
193
|
+
return { id: rec.id, updatedAt: rec.updatedAt }
|
|
194
|
+
},
|
|
195
|
+
'secure.del' ({ id }) {
|
|
196
|
+
if (!id || typeof id !== 'string') throw new Error('id required')
|
|
197
|
+
const had = !!data.secure[id]
|
|
198
|
+
delete data.secure[id]
|
|
199
|
+
if (had) save()
|
|
200
|
+
return { removed: had ? 1 : 0 }
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
getStats () {
|
|
204
|
+
const threads = {}
|
|
205
|
+
for (const [k, arr] of Object.entries(data.threads)) threads[k] = { count: arr.length }
|
|
206
|
+
return { threadCount: Object.keys(data.threads).length, threads, opensCount: Object.keys(data.opens).length, secureCount: Object.keys(data.secure).length }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* VUELVE A CERRAR CON LA LLAVE VIGENTE todo sobre de la cuenta (`t: 'dotrino-cek'`) de
|
|
211
|
+
* una generación anterior que haya en lo guardado. Lo llama la BÓVEDA al abrirse y
|
|
212
|
+
* después de quitar un aparato —que es cuando la llave rota—; NO está en `methods`, así
|
|
213
|
+
* que ningún aparato puede dispararlo.
|
|
214
|
+
*
|
|
215
|
+
* Lo que sirve la bóveda va así siempre con la generación vigente: un aparato que entra
|
|
216
|
+
* después, que solo recibe esa, abre todo; y la vieja deja de abrir nada porque ya no se
|
|
217
|
+
* sirve nada cerrado con ella (dueño, 2026-09-30). Cada entrada que cambia estrena `ts`,
|
|
218
|
+
* para que los aparatos la bajen en su siguiente sincronización.
|
|
219
|
+
*
|
|
220
|
+
* También MIGRA los sobres de la cuenta anteriores a la marca (`{ gen, iv, ct }` a secas,
|
|
221
|
+
* identity < 0.107): si abren con la llave de la cuenta se vuelven a cerrar marcados; si no,
|
|
222
|
+
* no eran de la cuenta y se cuentan en `skipped` sin tocarlos.
|
|
223
|
+
*
|
|
224
|
+
* La versión anterior NO se tira: queda en `resealed` con la fecha, por si algo sale mal
|
|
225
|
+
* (lo pidió el dueño: «podría retenerlos… pero no los envía»), y se guarda AL MENOS un
|
|
226
|
+
* año (`RESEALED_KEEP_MS`). No se sirve por ningún método.
|
|
227
|
+
*
|
|
228
|
+
* @param {{ gen: number, reseal: (env: object) => Promise<object> }} o
|
|
229
|
+
* @returns {Promise<{ changed: number, entries: number, skipped: number }>}
|
|
230
|
+
*/
|
|
231
|
+
async function resealStaleEnvelopes ({ gen, reseal }) {
|
|
232
|
+
let changed = 0; let entries = 0; let skipped = 0
|
|
233
|
+
const now = Date.now()
|
|
234
|
+
if (!data.resealed) data.resealed = []
|
|
235
|
+
// Solo sale lo que ya cumplió su año de respaldo.
|
|
236
|
+
const antes = data.resealed.length
|
|
237
|
+
data.resealed = data.resealed.filter((x) => now - (x.at || 0) < RESEALED_KEEP_MS)
|
|
238
|
+
const podados = antes - data.resealed.length
|
|
239
|
+
for (const [key, arr] of Object.entries(data.threads)) {
|
|
240
|
+
for (let i = 0; i < arr.length; i++) {
|
|
241
|
+
const r = await resealStale(arr[i], { gen, reseal })
|
|
242
|
+
skipped += r.skipped || 0
|
|
243
|
+
if (!r.changed) continue
|
|
244
|
+
data.resealed.push({ threadKey: key, at: now, gen, before: arr[i] })
|
|
245
|
+
arr[i] = { ...r.value, ts: Math.max(now, (arr[i].ts || 0) + 1) }
|
|
246
|
+
changed += r.changed; entries++
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (entries || podados) save()
|
|
250
|
+
return { changed, entries, skipped }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { methods, raw: () => data, resealStaleEnvelopes }
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Métodos del store que son de SOLO LECTURA (para decidir el scope necesario).
|
|
258
|
+
*
|
|
259
|
+
* `secure.list`/`secure.get` NO están aquí a propósito, aunque sean lecturas: los datos
|
|
260
|
+
* sensibles piden `vault:store` (doc §6), que es MÁS estricto que `vault:read`. Un
|
|
261
|
+
* dispositivo al que solo le diste «leer» no lee tus contraseñas.
|
|
262
|
+
*/
|
|
263
|
+
export const STORE_READ_METHODS = new Set([
|
|
264
|
+
'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet',
|
|
265
|
+
'getThreadDigests', 'getThreadIndexes', 'getEntries'
|
|
266
|
+
])
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Métodos que EDITAN el perfil del usuario (quién es: apodo, avatar, campos).
|
|
270
|
+
* Son los únicos que el candado por contraseña bloquea (`vault.js`): el resto del
|
|
271
|
+
* store —contenido de las apps— sigue disponible con el perfil bloqueado.
|
|
272
|
+
*/
|
|
273
|
+
export const PROFILE_EDIT_METHODS = new Set(['profileSet'])
|