@dotrino/vaultd 0.115.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.115.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": {
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
 
package/src/vault.js CHANGED
@@ -951,6 +951,16 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
951
951
  // guardar un archivo en tu bóveda (dueño, 2026-09-03). El proxio tiene `guarda`
952
952
  // para lo suyo y no debe poder cambiar quién eres.
953
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
+ }
954
964
  // CANDADO del perfil (contraseña opcional): solo frena EDITAR el perfil heredado
955
965
  // (`profileSet`, el `me` plano). Un dispositivo enrolado puede seguir firmando,
956
966
  // leyendo y guardando contenido.
@@ -970,15 +980,11 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
970
980
  if (!await actaAllows(from, chk, chk.scope === SCOPE.READ ? SCOPE.READ : SCOPE.STORE, 'store')) return
971
981
  try {
972
982
  // CIFRADO de punta a punta con la clave de contenido del perfil: el proxy transporta
973
- // pero no ve nada de lo que el usuario guarda. Si el dispositivo mandó `enc`, se abre
974
- // aquí con la clave de la bóveda (que también es miembro) y la respuesta vuelve igual.
975
- let args = d.args || {}
976
- let cek = null
977
- if (d.enc) {
978
- cek = await identity.contentKey?.().catch(() => null)
979
- if (!cek) return reply(from, { type: MSG.ERROR, error: 'store: this vault does not hold the profile content key' })
980
- args = JSON.parse(await identity.openContent(d.enc))
981
- }
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))
982
988
  const result = await threads.methods[d.method](args)
983
989
  // Que un aparato ESCRIBA en tu bóveda queda anotado. Antes solo se auditaba el
984
990
  // rechazo, así que la bitácora contaba quién entró pero no qué hizo después.
@@ -987,11 +993,8 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
987
993
  if (!STORE_READ_METHODS.has(d.method)) {
988
994
  audit('store', { device: await deviceIdOf(chk.device), method: d.method })
989
995
  }
990
- if (cek) {
991
- const enc = await identity.sealContent(JSON.stringify(result ?? null))
992
- return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: { __enc: enc } })
993
- }
994
- 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 } })
995
998
  } catch (e) { reply(from, { type: MSG.ERROR, error: e.message }) }
996
999
  }
997
1000