@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.
@@ -24,13 +24,14 @@
24
24
  * Lo que sí abre es todo lo demás; los límites honestos están en
25
25
  * `docs/secretos-sellados.md` §3 y conviene leerlos antes de confiar de más.
26
26
  */
27
- import crypto from 'node:crypto'
28
27
  import {
29
28
  makeContentKey, makeGeneration, encryptWithCek, decryptWithCek, wrapForMember, openWrap
30
29
  } from '@dotrino/identity/content'
31
30
 
32
- const b64 = (b) => Buffer.from(b).toString('base64')
33
- const un64 = (s) => Buffer.from(s, 'base64')
31
+ const b64 = (b) => { let s = ''; for (const x of new Uint8Array(b)) s += String.fromCharCode(x); return btoa(s) }
32
+ const un64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0))
33
+ /** AES-256-GCM con WebCrypto: el mismo en Node y en el navegador. */
34
+ const aesKey = (raw, uses) => crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, uses)
34
35
 
35
36
  /** La copia maestra va cifrada con AES-256-GCM bajo la llave derivada de la contraseña. */
36
37
  const MASTER_V = 1
@@ -66,9 +67,11 @@ export function makeSealer () {
66
67
  if (!blob) return {}
67
68
  if (blob.v !== MASTER_V) throw new Error(`master copy: unknown format v${blob.v}`)
68
69
  try {
69
- const d = crypto.createDecipheriv('aes-256-gcm', adminKey, un64(blob.iv))
70
- d.setAuthTag(un64(blob.tag))
71
- return JSON.parse(Buffer.concat([d.update(un64(blob.ct)), d.final()]).toString('utf8'))
70
+ // WebCrypto quiere la etiqueta pegada al final del cifrado; en disco van aparte.
71
+ const ct = un64(blob.ct); const tag = un64(blob.tag)
72
+ const both = new Uint8Array(ct.length + tag.length); both.set(ct); both.set(tag, ct.length)
73
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: un64(blob.iv) }, await aesKey(adminKey, ['decrypt']), both)
74
+ return JSON.parse(new TextDecoder().decode(plain))
72
75
  } catch (_) {
73
76
  // El tag de AES-GCM ES el verificador: si no cuadra, la contraseña no era.
74
77
  // Por eso no hace falta guardar aparte un verificador de la contraseña, que
@@ -79,10 +82,9 @@ export function makeSealer () {
79
82
 
80
83
  async sealMaster (obj, adminKey) {
81
84
  assertKey(adminKey)
82
- const iv = crypto.randomBytes(12)
83
- const c = crypto.createCipheriv('aes-256-gcm', adminKey, iv)
84
- const ct = Buffer.concat([c.update(JSON.stringify(obj), 'utf8'), c.final()])
85
- return { v: MASTER_V, iv: b64(iv), ct: b64(ct), tag: b64(c.getAuthTag()) }
85
+ const iv = crypto.getRandomValues(new Uint8Array(12))
86
+ const both = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await aesKey(adminKey, ['encrypt']), new TextEncoder().encode(JSON.stringify(obj))))
87
+ return { v: MASTER_V, iv: b64(iv), ct: b64(both.subarray(0, both.length - 16)), tag: b64(both.subarray(both.length - 16)) }
86
88
  },
87
89
 
88
90
  /** La CEK de un cajón, creándola si es la primera variable que entra ahí. */
@@ -53,10 +53,8 @@
53
53
  * pide sobres. Así se prueba con un sellador falso y determinista, y la forma del
54
54
  * archivo se razona sin mirar una sola llave.
55
55
  */
56
- import path from 'node:path'
57
- import { readJson, writeJson } from './paths.js'
58
- import { atRestFor } from './atrest.js'
59
- import { isValidSecretsNs } from './protocol.js'
56
+ import { readJson, writeJson } from './disk.js'
57
+ import { isValidSecretsNs } from '../protocol.js'
60
58
 
61
59
  const SCHEMA_VERSION = 5
62
60
  /** v4: sellado, pero con la copia maestra bajo la frase. Se lee y se sirve; no se escribe. */
@@ -168,10 +166,9 @@ function migrateBag (bag) {
168
166
  * `defaultKey`: con qué sellar la privada de recuperación cuando el perfil NO tiene
169
167
  * contraseña. Vive aquí, en un solo sitio, para que dé igual por qué puerta se entre.
170
168
  */
171
- export function openSecretsStore (dir, { sealer = null, recipients = null, signer = null, defaultKey = null } = {}) {
172
- const file = path.join(dir, 'secrets.json')
173
- const atRest = atRestFor(dir)
174
- let data = readJson(file, null, atRest)
169
+ export function openSecretsStore (disk, { sealer = null, recipients = null, signer = null, defaultKey = null } = {}) {
170
+ const file = 'secrets.json'
171
+ let data = readJson(disk, file, null)
175
172
 
176
173
  // v1 → v2: el cajón por scope se conserva y estrena el `dev`.
177
174
  // v2 → v3: los valores dejan de ser strings sueltos y pasan a llevar su visibilidad.
@@ -196,13 +193,13 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
196
193
  const isLegacy = () => data.schemaVersion === LEGACY_VERSION
197
194
  const needsMigration = () => data.schemaVersion === SEALED_MASTER_VERSION
198
195
 
199
- writeJson(file, data, atRest) // reescribe al abrir: cifra lo que venía en claro
196
+ writeJson(disk, file, data) // reescribe al abrir: cifra lo que venía en claro
200
197
 
201
198
  // Un grupo de escrituras se guarda UNA vez (ver `batch`). Fuera de un grupo, `held`
202
199
  // vale 0 y cada cambio va al disco en el acto, como siempre.
203
200
  let held = 0
204
201
  let pending = false
205
- const flush = () => writeJson(file, data, atRest)
202
+ const flush = () => writeJson(disk, file, data)
206
203
  const save = () => { if (held) { pending = true; return } ; flush() }
207
204
 
208
205
  const assertNs = (ns) => {
@@ -1313,7 +1310,7 @@ export function openSecretsStore (dir, { sealer = null, recipients = null, signe
1313
1310
 
1314
1311
  // Copia de lo anterior antes de pisarlo. Con nodos en producción, deshacer tiene
1315
1312
  // que ser un `mv`, no una restauración. La borra el operador a mano.
1316
- writeJson(`${file}.v${desde}.bak`, data, atRest)
1313
+ writeJson(disk, `${file}.v${desde}.bak`, data)
1317
1314
  data = next
1318
1315
  flush()
1319
1316
  return { migrated: true, from: desde, sinLlave }
@@ -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 }
@@ -6,9 +6,7 @@
6
6
  * `@dotrino/identity` dentro del mismo dir (keypair, contactos, delegaciones,
7
7
  * revocaciones). Aquí vive solo lo del usuario: el árbol y los settings.
8
8
  */
9
- import path from 'node:path'
10
- import { readJson, writeJson } from './paths.js'
11
- import { atRestFor } from './atrest.js'
9
+ import { readJson, writeJson } from './disk.js'
12
10
 
13
11
  const SCHEMA_VERSION = 1
14
12
 
@@ -28,17 +26,16 @@ function findNode (node, id) {
28
26
  return null
29
27
  }
30
28
 
31
- export function openStore (dir) {
32
- const file = path.join(dir, 'vault.json')
33
- const atRest = atRestFor(dir)
34
- let data = readJson(file, null, atRest)
29
+ export function openStore (disk) {
30
+ const file = 'vault.json'
31
+ let data = readJson(disk, file, null)
35
32
  if (!data || data.schemaVersion !== SCHEMA_VERSION) {
36
33
  data = { schemaVersion: SCHEMA_VERSION, tree: newTree(), settings: {} }
37
34
  }
38
35
  // Se reescribe SIEMPRE al abrir: así un archivo de una instalación anterior
39
36
  // (en claro) queda cifrado sin pedirle nada al usuario.
40
- writeJson(file, data, atRest)
41
- const save = () => writeJson(file, data, atRest)
37
+ writeJson(disk, file, data)
38
+ const save = () => writeJson(disk, file, data)
42
39
 
43
40
  return {
44
41
  get raw () { return data },
@@ -32,9 +32,8 @@
32
32
  * hay conflictos ni hace falta un `seq`. Y **solo salen al absorberse** en el acta: nunca
33
33
  * por reloj, o un aparato apagado perdería justo la que lo protege.
34
34
  */
35
- import path from 'node:path'
36
35
  import { verifyRenounce } from '@dotrino/identity/acta'
37
- import { readJson, writeJson } from './paths.js'
36
+ import { readJson, writeJson } from './disk.js'
38
37
 
39
38
  /** Las operaciones que caben. La lista ES la regla: todas quitan, todas las firma su sujeto. */
40
39
  export const OPS = Object.freeze(['renounce'])
@@ -57,10 +56,10 @@ export async function isAcceptable (e) {
57
56
  * demás: sus entradas dicen qué aparatos se quedaron sin qué, y eso es material de la
58
57
  * cuenta aunque no sea un secreto.
59
58
  */
60
- export function openSubacta (dir, atRest) {
61
- const file = path.join(dir, 'subacta.json')
62
- let data = readJson(file, null, atRest) || { v: 1, entries: [] }
63
- const guardar = () => writeJson(file, data, atRest)
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)
64
63
 
65
64
  return {
66
65
  /** Las entradas vigentes, tal cual se le pasan a `memberCan` y compañía. */
@@ -13,11 +13,9 @@
13
13
  * vive el contenido de las apps y el perfil del usuario, que es exactamente lo
14
14
  * que el ecosistema promete que no queda en claro en ningún disco.
15
15
  */
16
- import path from 'node:path'
17
16
  import * as core from '@dotrino/store/core'
18
17
  import { resealStale } from '@dotrino/identity/content'
19
- import { readJson, writeJson } from './paths.js'
20
- import { atRestFor } from './atrest.js'
18
+ import { readJson, writeJson } from './disk.js'
21
19
 
22
20
  // El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
23
21
  // hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
@@ -35,10 +33,9 @@ export const RESEALED_KEEP_MS = 366 * 24 * 60 * 60 * 1000
35
33
  const MAX_SECURE_ITEMS = 2000
36
34
  const MAX_SECURE_BLOB = 64 * 1024 // por campo sellado (meta y valor)
37
35
 
38
- export function openThreadStore (dir) {
39
- const file = path.join(dir, 'threads.json')
40
- const atRest = atRestFor(dir)
41
- let data = readJson(file, null, atRest)
36
+ export function openThreadStore (disk) {
37
+ const file = 'threads.json'
38
+ let data = readJson(disk, file, null)
42
39
  if (!data || typeof data !== 'object') data = { v: 1, threads: {}, opens: {} }
43
40
  if (!data.threads) data.threads = {}
44
41
  if (!data.opens) data.opens = {}
@@ -47,7 +44,7 @@ export function openThreadStore (dir) {
47
44
  // primer aparato que todavía tenga la entrada.
48
45
  if (!data.tombs) data.tombs = {}
49
46
  core.pruneTombs(data.tombs, Date.now())
50
- const save = () => writeJson(file, data, atRest)
47
+ const save = () => writeJson(disk, file, data)
51
48
  save() // reescribe al abrir: cifra lo que venía en claro
52
49
  const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
53
50
  const needKeys = (keys) => {
@@ -20,7 +20,6 @@
20
20
  * con un `signData` de cortesía si hace falta. El daemon (vault.js) además puebla
21
21
  * `me` antes de conectar.
22
22
  */
23
- import { installNodeGlobals } from './node-globals.js'
24
23
 
25
24
  const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
26
25
 
@@ -31,13 +30,13 @@ export async function masterPubkeyOf (identity) {
31
30
  /**
32
31
  * Conecta el transporte y lo identifica con la maestra del vault.
33
32
  * @param {Object} opts
34
- * @param {import('@dotrino/identity/node').Identity} opts.identity
35
- * @param {string} opts.dir Directorio de persistencia.
33
+ * @param {import('@dotrino/identity/facade').IdentityFacade} opts.identity
34
+ * @param {Function} [opts.makeClient] quién crea el cliente del proxio. Sin él, el del
35
+ * ecosistema; el navegador lo pasa siempre (un service worker no puede hacer `import()`).
36
36
  * @param {string} [opts.url] URL del proxy (default wss://proxy.dotrino.com).
37
37
  * @returns {Promise<{ client, token:string, identify():Promise<void> }>}
38
38
  */
39
- export async function createTransport ({ identity, dir, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null, recheckMs = 60_000, patienceMs = 5 * 60_000, reannounceMs = 10 * 60_000 }) {
40
- installNodeGlobals(dir)
39
+ export async function createTransport ({ identity, url = DEFAULT_PROXY, commKey = null, log = () => {}, makeClient = null, announce = null, recheckMs = 60_000, patienceMs = 5 * 60_000, reannounceMs = 10 * 60_000 }) {
41
40
  // Import dinámico DESPUÉS de instalar los globals que el paquete usa.
42
41
  // `WebSocketProxyClient` (la clase) y NO el helper `getWebSocketProxyClient`:
43
42
  // ese es un SINGLETON de proceso, y con multi-perfil el vault necesita una