@dotrino/vaultd 0.114.0 → 0.116.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.114.0",
3
+ "version": "0.116.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "@dotrino/passmanager": "^0.10.1",
27
27
  "@dotrino/proxy-client": "0.18.2",
28
28
  "@dotrino/roadmap": "0.2.0",
29
+ "@dotrino/store": "0.11.0",
29
30
  "ws": "^8.18.0"
30
31
  },
31
32
  "license": "MIT",
package/src/approvals.js CHANGED
@@ -23,6 +23,13 @@
23
23
  * otra máquina por el proxio, donde mandarlo en claro filtraría las rutas del dueño— no
24
24
  * tiene huella con la que emparejarse, y entonces se pide aprobación cada vez. No se
25
25
  * inventa una huella «vacía» que valdría para todo: eso sería exactamente un repliegue.
26
+ *
27
+ * Y PARA EL GESTOR DE CONTRASEÑAS ESO ES LO QUE SE QUIERE, no una carencia que arreglar
28
+ * (dueño, 2026-09-11: «para el gestor de contraseñas es perfecto»). La extensión es un
29
+ * navegador hablando por el proxio, no un proceso con línea de comandos, así que nunca va a
30
+ * traer huella — y así cada acceso a una credencial vuelve a pasar por tu mano, que es la
31
+ * protección por la que esto existe. No se le busque un sustituto del comando para que
32
+ * entre en la ventana.
26
33
  */
27
34
  import { commandFingerprint } from '../lib/src/proc.js'
28
35
 
package/src/client.js CHANGED
@@ -11,7 +11,8 @@
11
11
  * estrictamente (firmado por la maestra que vio en el QR, y para SU clave) antes de
12
12
  * guardarlo. La maestra nunca sale del vault.
13
13
  */
14
- import { makeDeviceKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
14
+ import { makeDeviceKey, makeDeviceEncKey, importDeviceEncKey, signWithDevice, verifyDelegation, verifyDeviceSig, makePairingCode, commitCode, pubkeyId } from '@dotrino/identity/capabilities'
15
+ import { myContentKey, encryptWithCek, decryptWithKeyring } from '@dotrino/identity/content'
15
16
  import { sealersOf } from '@dotrino/identity/acta'
16
17
 
17
18
  /**
@@ -61,20 +62,25 @@ function waitFor (client, predicate, timeoutMs = 30000) {
61
62
  * @param {string} [opts.label]
62
63
  * @param {(c:{deviceId:string,code:string})=>void} [opts.onChallenge] Para MOSTRAR el código que el usuario tipea en la bóveda.
63
64
  * @param {number} [opts.approveTimeoutMs] Cuánto esperar la aprobación humana (def 3 min).
64
- * @returns {Promise<{ device, cert, iss:string }>} GUARDAR `device` (incluye la privada) + `cert`. `iss` = qr.iss verificado.
65
+ * @returns {Promise<{ device, cert, iss:string, acta }>} GUARDAR `device` (incluye las privadas de firma y de
66
+ * cifrado), `cert` y `acta` (trae la clave de contenido envuelta para este aparato: sin ella el
67
+ * almacén de la bóveda no se puede usar). `iss` = qr.iss verificado.
65
68
  */
66
69
  export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeoutMs = 180000 } = {}) {
67
70
  if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('invalid qr (v2): missing iss/proxy/token/sn')
68
71
  const client = await freshClient({ proxyUrl: qr.proxy, dir })
69
72
  try {
70
73
  const device = await makeDeviceKey({ label })
74
+ // La llave de CIFRADO del aparato: con su pública en el acta, la bóveda le envuelve la
75
+ // clave de contenido al admitirlo. Sin ella no podría usar el almacén, que solo entra cifrado.
76
+ const enc = await makeDeviceEncKey()
71
77
  const myDeviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
72
78
  // ESTE dispositivo genera el código y lo MUESTRA; solo viaja su COMPROMISO. El vault
73
79
  // lo aprende cuando un humano lo tipea, y solo firma el cert si el compromiso coincide.
74
80
  const code = makePairingCode()
75
81
  const commit = await commitCode({ code, dpub: device.publickey, sn: qr.sn })
76
82
  // ENROLL firmado con D = prueba de posesión (un token robado ya no basta).
77
- const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
83
+ const data = { op: 'enroll', dpub: device.publickey, encPub: enc.encPublickey, token: qr.token, sn: qr.sn, commit, label, ts: Date.now() }
78
84
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
79
85
 
80
86
  const enrolled = new Promise((resolve, reject) => {
@@ -102,7 +108,7 @@ export async function enroll ({ qr, label = '', dir, onChallenge, approveTimeout
102
108
  if (!v.ok) throw new Error('invalid cert: ' + v.reason)
103
109
  if (res.cert.sub !== device.publickey) throw new Error('cert issued for a different device')
104
110
  // OJO: devolvemos qr.iss (la maestra que el usuario VIO), NO res.iss.
105
- return { device, cert: res.cert, iss: qr.iss }
111
+ return { device: { ...device, encPublickey: enc.encPublickey, encPrivateJwk: enc.encPrivateJwk }, cert: res.cert, iss: qr.iss, acta: res.acta }
106
112
  } finally { client.close() }
107
113
  }
108
114
 
@@ -171,17 +177,28 @@ export async function requestRenew ({ masterPubkey, proxyUrl, device, cert, dir
171
177
  } finally { client.close() }
172
178
  }
173
179
 
174
- /** Llama un método del store de hilos/aperturas del vault (scope vault:store). */
175
- export async function requestStore ({ masterPubkey, proxyUrl, device, cert, method, args, dir } = {}) {
180
+ /**
181
+ * Llama un método del almacén de la bóveda (scope vault:store), CIFRADO de punta a punta con
182
+ * la clave de contenido del perfil: la bóveda no acepta el almacén en claro, porque el proxio
183
+ * no cifra. `acta` es la que devolvió `enroll` (o una más nueva): de su `keyring` sale la
184
+ * clave envuelta para este aparato.
185
+ */
186
+ export async function requestStore ({ masterPubkey, proxyUrl, device, cert, acta, method, args, dir } = {}) {
187
+ if (!device?.encPrivateJwk) throw Object.assign(new Error('this device has no encryption key: enrol it again'), { code: 'no-enc-key' })
188
+ const myEncPrivateKey = await importDeviceEncKey(device.encPrivateJwk)
189
+ const mine = await myContentKey({ keyring: acta?.keyring, myPub: device.publickey, myEncPrivateKey })
190
+ if (!mine) throw Object.assign(new Error('this device does not hold the profile content key yet'), { code: 'no-content-key' })
191
+ const enc = await encryptWithCek({ cek: mine.cek, gen: mine.gen, plaintext: JSON.stringify(args ?? {}) })
176
192
  const client = await freshClient({ proxyUrl, dir })
177
193
  try {
178
- const data = { op: 'store', method, args: args || {}, publickey: device.publickey, ts: Date.now() }
194
+ const data = { op: 'store', method, enc, publickey: device.publickey, ts: Date.now() }
179
195
  const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
180
196
  const pending = waitFor(client, (p) => p.type === MSG.STORE_RESULT || p.type === MSG.ERROR)
181
197
  client.sendByPubkey(masterPubkey, { type: MSG.STORE, data, signature, cert })
182
198
  const res = await pending
183
- if (res.type === MSG.ERROR) throw new Error(res.error)
184
- return res.result
199
+ if (res.type === MSG.ERROR) throw Object.assign(new Error(res.error), res.code ? { code: res.code } : {})
200
+ if (!res.result?.__enc) throw Object.assign(new Error('the vault replied to the store without encrypting it'), { code: 'vault-reply-unsealed' })
201
+ return JSON.parse(await decryptWithKeyring({ envelope: res.result.__enc, keyring: acta.keyring, myPub: device.publickey, myEncPrivateKey }))
185
202
  } finally { client.close() }
186
203
  }
187
204
 
@@ -14,10 +14,14 @@
14
14
  * que el ecosistema promete que no queda en claro en ningún disco.
15
15
  */
16
16
  import path from 'node:path'
17
+ import * as core from '@dotrino/store/core'
17
18
  import { readJson, writeJson } from './paths.js'
18
19
  import { atRestFor } from './atrest.js'
19
20
 
20
- const MAX_PER_THREAD = 1000
21
+ // El MISMO tope que acepta la página del almacén. Era 1000: una app que guarda más en un
22
+ // hilo (los compradores de facturero, por ejemplo) perdía aquí lo más viejo sin decir nada,
23
+ // y el navegador lo volvía a subir en cada sincronización.
24
+ const MAX_PER_THREAD = core.MAX_PER_THREAD_LIMIT
21
25
 
22
26
  // DATOS SENSIBLES (F4): topes para que un dispositivo con `vault:store` no pueda
23
27
  // llenar el disco de la bóveda. Son generosos para el uso real (unas contraseñas,
@@ -33,24 +37,31 @@ export function openThreadStore (dir) {
33
37
  if (!data.threads) data.threads = {}
34
38
  if (!data.opens) data.opens = {}
35
39
  if (!data.secure) data.secure = {}
40
+ // Lápidas de lo borrado (`@dotrino/store/core`): sin ellas un borrado vuelve desde el
41
+ // primer aparato que todavía tenga la entrada.
42
+ if (!data.tombs) data.tombs = {}
43
+ core.pruneTombs(data.tombs, Date.now())
36
44
  const save = () => writeJson(file, data, atRest)
37
45
  save() // reescribe al abrir: cifra lo que venía en claro
38
- const trim = (arr) => { if (arr.length > MAX_PER_THREAD) arr.splice(0, arr.length - MAX_PER_THREAD) }
46
+ const budget = (n) => Math.min(Math.max(Number(n) || core.PAGE_BYTES, 1024), core.PAGE_BYTES)
47
+ const needKeys = (keys) => {
48
+ if (!Array.isArray(keys) || !keys.every((k) => typeof k === 'string')) throw new Error('keys must be a list of thread keys')
49
+ return keys
50
+ }
51
+ const needMap = (value, what) => {
52
+ if (value == null) return {}
53
+ if (typeof value !== 'object' || Array.isArray(value)) throw new Error(`${what} must be an object`)
54
+ return value
55
+ }
39
56
 
40
57
  const methods = {
41
58
  appendMessage ({ threadKey, entry }) {
42
- if (!threadKey || typeof threadKey !== 'string') throw new Error('threadKey required')
43
- if (!entry || typeof entry !== 'object') throw new Error('entry required')
44
- if (!entry.id) entry.id = crypto.randomUUID()
45
- if (!entry.ts) entry.ts = Date.now()
46
- const arr = data.threads[threadKey] || (data.threads[threadKey] = [])
47
- const i = arr.findIndex((e) => e.id === entry.id)
48
- if (i >= 0) arr[i] = { ...arr[i], ...entry }; else arr.push(entry)
49
- trim(arr); save(); return entry
59
+ core.writeEntry(data.threads, data.tombs, threadKey, entry, { max: MAX_PER_THREAD, now: Date.now(), newId: () => crypto.randomUUID() })
60
+ save(); return entry
50
61
  },
51
62
  listThread ({ threadKey, limit, before }) {
52
- if (!threadKey) return []
53
- let arr = data.threads[threadKey] || []
63
+ if (!core.validKey(threadKey) || !Object.hasOwn(data.threads, threadKey)) return []
64
+ let arr = data.threads[threadKey]
54
65
  if (typeof before === 'number') arr = arr.filter((e) => (e.ts || 0) < before)
55
66
  if (typeof limit === 'number' && limit > 0) arr = arr.slice(-limit)
56
67
  return arr
@@ -62,14 +73,14 @@ export function openThreadStore (dir) {
62
73
  return out
63
74
  },
64
75
  removeThread ({ threadKey }) {
65
- const removed = data.threads[threadKey]?.length || 0
66
- delete data.threads[threadKey]; save(); return { removed }
76
+ const removed = core.removeWholeThread(data.threads, data.tombs, threadKey, Date.now())
77
+ if (removed) save()
78
+ return { removed }
67
79
  },
68
80
  removeMessage ({ threadKey, id }) {
69
- const arr = data.threads[threadKey] || []; const before = arr.length
70
- data.threads[threadKey] = arr.filter((e) => e.id !== id)
71
- if (data.threads[threadKey].length === 0) delete data.threads[threadKey]
72
- save(); return { removed: before - (data.threads[threadKey]?.length || 0) }
81
+ const removed = core.removeEntry(data.threads, data.tombs, threadKey, id, Date.now())
82
+ if (removed) save()
83
+ return { removed }
73
84
  },
74
85
  recordOpen ({ appId }) {
75
86
  if (!appId || typeof appId !== 'string') throw new Error('appId required')
@@ -79,17 +90,38 @@ export function openThreadStore (dir) {
79
90
  },
80
91
  getOpens () { return { ...data.opens } },
81
92
  clearOpens () { data.opens = {}; save(); return { ok: true } },
93
+ /** Mezcla el contador de un aparato (el mayor de cada lado) y devuelve el resultado. */
94
+ mergeOpens ({ opens }) {
95
+ if (core.mergeOpens(data.opens, needMap(opens, 'opens'))) save()
96
+ return { ...data.opens }
97
+ },
82
98
  exportThreads () { return { threads: data.threads } },
83
- importThreads ({ threads, mode = 'merge' }) {
99
+ // ----- SINCRONIZAR POR PARTES (@dotrino/store ≥ 0.11) -----
100
+ // `exportThreads`/`importThreads` a secas mueven TODO en un mensaje, y el proxio corta
101
+ // los frames en 1 MB: con unos cientos de KB de datos la sincronización dejaba de caber.
102
+ // Con esto el aparato pregunta qué hilos difieren (una huella por hilo), baja su índice
103
+ // (id y ts) y pide solo las entradas que le faltan, todo por páginas con tope de bytes.
104
+ // Las reglas —huella, mezcla, lápidas— son las de `@dotrino/store/core`, las mismas que
105
+ // aplica el navegador: si cada lado llevara las suyas, las huellas no coincidirían nunca.
106
+ getThreadDigests ({ keys } = {}) {
107
+ return core.digestsOf(data.threads, keys == null ? undefined : needKeys(keys))
108
+ },
109
+ getThreadIndexes ({ keys, cursor, maxBytes }) {
110
+ return core.indexPage(data.threads, data.tombs, needKeys(keys), cursor || null, budget(maxBytes))
111
+ },
112
+ getEntries ({ refs, maxBytes }) {
113
+ return core.entriesPage(data.threads, needMap(refs, 'refs'), budget(maxBytes))
114
+ },
115
+ importThreads ({ threads, tombs, mode = 'merge' }) {
84
116
  if (!threads || typeof threads !== 'object') throw new Error('threads required')
85
117
  if (mode === 'replace') { data.threads = threads; save(); return { mode, count: Object.keys(threads).length } }
86
- for (const [k, arr] of Object.entries(threads)) {
87
- const cur = data.threads[k] || (data.threads[k] = [])
88
- const byId = new Map(cur.map((e) => [e.id, e]))
89
- for (const e of arr) { if (!e?.id) continue; const pr = byId.get(e.id); if (!pr || (e.ts || 0) > (pr.ts || 0)) byId.set(e.id, e) }
90
- data.threads[k] = Array.from(byId.values()).sort((a, b) => (a.ts || 0) - (b.ts || 0)); trim(data.threads[k])
91
- }
92
- save(); return { mode, count: Object.keys(data.threads).length }
118
+ if (mode !== 'merge' && mode !== 'upsert') throw new Error(`unknown import mode: ${mode}`)
119
+ const now = Date.now()
120
+ const buried = core.applyTombs(data.threads, data.tombs, needMap(tombs, 'tombs'), now)
121
+ const merged = core.mergeEntries(data.threads, data.tombs, threads, { mode, max: MAX_PER_THREAD })
122
+ core.pruneTombs(data.tombs, now)
123
+ if (buried.tombsChanged || buried.changed.size || merged.size) save()
124
+ return { mode, count: Object.keys(data.threads).length, changed: [...new Set([...buried.changed, ...merged])] }
93
125
  },
94
126
  // ----- PERFIL del usuario (me): el vault es la copia AUTORITATIVA -----
95
127
  // Cada dispositivo emparejado lo empuja al editarlo y lo jala al arrancar →
@@ -182,7 +214,8 @@ export function openThreadStore (dir) {
182
214
  * dispositivo al que solo le diste «leer» no lee tus contraseñas.
183
215
  */
184
216
  export const STORE_READ_METHODS = new Set([
185
- 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet'
217
+ 'listThread', 'listThreadKeys', 'getThreadSummaries', 'getOpens', 'exportThreads', 'getStats', 'profileGet',
218
+ 'getThreadDigests', 'getThreadIndexes', 'getEntries'
186
219
  ])
187
220
 
188
221
  /**
package/src/vault.js CHANGED
@@ -936,7 +936,9 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
936
936
  // `method: 'toString'` (o cualquier miembro heredado de Object) pasaba el filtro y
937
937
  // se llamaba como si fuera del store.
938
938
  if (!d || typeof d.method !== 'string' || !Object.hasOwn(threads.methods, d.method)) {
939
- return reply(from, { type: MSG.ERROR, error: 'store: invalid method' })
939
+ // Con `code`: un aparato más nuevo que esta bóveda pide métodos que aquí no existen, y
940
+ // tiene que poder decir «actualiza la bóveda» en vez de enseñar la frase.
941
+ return reply(from, { type: MSG.ERROR, error: 'store: invalid method', code: 'store-unknown-method' })
940
942
  }
941
943
  if (!isFresh(d)) return staleReply(from)
942
944
  // EL PERFIL VA POR OTRO CAMINO, y por eso se atiende antes que nada
@@ -949,6 +951,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
949
951
  // guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
950
952
  // para lo suyo y no debe poder cambiar quién eres.
951
953
  if (PROFILE_METHODS.has(d.method)) return await handleProfile(from, p, d)
954
+ // EL ALMACÉN SOLO ENTRA CIFRADO. Lo que va por aquí —hilos de las apps, contraseñas,
955
+ // el `me` heredado— viaja por el proxio, que no cifra; en claro lo leería quien lo
956
+ // opere. Los clientes del ecosistema cifran desde @dotrino/identity 0.91.0, y hasta
957
+ // entonces uno sin la clave de contenido mandaba en claro «como antes»: aceptarlo aquí
958
+ // era dejar ese camino abierto para cualquier cliente viejo. El perfil (arriba) va
959
+ // aparte a propósito: lo privado viaja en sobres y lo público es público.
960
+ if (!d.enc) {
961
+ audit('rejected', { what: 'store', method: d.method, reason: 'unsealed' })
962
+ return reply(from, { type: MSG.ERROR, error: 'store: requests must be sealed with the profile content key', code: 'store-unsealed' })
963
+ }
952
964
  // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
953
965
  // (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
954
966
  // leyendo y guardando contenido.
@@ -968,15 +980,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
968
980
  if (!await actaAllows(from, chk, chk.scope === SCOPE.READ ? SCOPE.READ : SCOPE.STORE, 'store')) return
969
981
  try {
970
982
  // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
971
- // pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
972
- // aquí con la clave de la bóveda (que también es miembro) y la respuesta vuelve igual.
973
- let args = d.args || {}
974
- let cek = null
975
- if (d.enc) {
976
- cek = await identity.contentKey?.().catch(() => null)
977
- if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key' })
978
- args = JSON.parse(await identity.openContent(d.enc))
979
- }
983
+ // pero no ve nada de lo que el usuario guarda. Se abre aquí con la clave de la bóveda
984
+ // (que también es miembro) y la respuesta vuelve igual.
985
+ const cek = await identity.contentKey()
986
+ if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key', code: 'vault-no-content-key' })
987
+ const args = JSON.parse(await identity.openContent(d.enc))
980
988
  const result = await threads.methods[d.method](args)
981
989
  // Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
982
990
  // rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
@@ -985,11 +993,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
985
993
  if (!STORE_READ_METHODS.has(d.method)) {
986
994
  audit('store', { device: await deviceIdOf(chk.device), method: d.method })
987
995
  }
988
- if (cek) {
989
- const enc = await identity.sealContent(JSON.stringify(result ?? null))
990
- return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
991
- }
992
- reply(from, { type: MSG.STORE_RESULT, method: d.method, result })
996
+ const enc = await identity.sealContent(JSON.stringify(result ?? null))
997
+ reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
993
998
  } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
994
999
  }
995
1000