@dotrino/vaultd 0.105.3 → 0.106.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.105.3",
3
+ "version": "0.106.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/replica.js CHANGED
@@ -24,9 +24,10 @@ import fs from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import os from 'node:os'
26
26
  import { WebSocketProxyClient } from '@dotrino/proxy-client'
27
- import { signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
28
- import { verifyActa, memberCan, sealKeyAt } from '@dotrino/identity/acta'
29
- import { MSG } from '../lib/src/protocol.js'
27
+ import { signWithDevice, verifyDeviceSig, verifyChain, pubkeyId } from '@dotrino/identity/capabilities'
28
+ import { verifyActa, memberCan, memberCanReadSecrets, sealKeyAt, sealersOf } from '@dotrino/identity/acta'
29
+ import { MSG, secretsScope, isValidSecretsNs } from '../lib/src/protocol.js'
30
+ import { FRESH_WINDOW_MS } from '../lib/src/enroll.js'
30
31
  import { seal } from '../lib/src/sealed.js'
31
32
  import { atRestFor } from './atrest.js'
32
33
  import { readJson, writeJson } from './paths.js'
@@ -169,17 +170,59 @@ export async function runReplica ({ dir = replicaDir(), proxyUrl, log = console.
169
170
  * quien pregunta y firmado con NUESTRA llave — que es lo que el cliente acepta cuando el
170
171
  * acta nos reconoce `replica` (`verifyResponder` de `@dotrino/vault`).
171
172
  *
172
- * Lo que NO se decide aquí, y es a propósito: si quien pide tiene derecho a ese cajón.
173
- * Lo dice el sobre, no nosotros — va cerrado a su llave, así que a quien no le toque le
174
- * llega algo que no puede abrir. Un replicador que decidiera quién lee sería un
175
- * replicador del que hay que fiarse, y la gracia es que no haga falta.
173
+ * SE COMPRUEBA QUIÉN PIDE, con lo mismo que comprueba la bóveda. Durante un día esto no
174
+ * miraba nada: bastaba con nombrar un cajón y una llave de aparato para llevarse el sobre
175
+ * y, dentro de él, EL ACTA — que es el inventario de máquinas del dueño, y que la bóveda
176
+ * no le enseña ni a un servicio de la casa. Un extraño con una llave recién hecha lo
177
+ * sacaba entero (`smoke/replica.mjs`, «a un DESCONOCIDO…»).
178
+ *
179
+ * El argumento de entonces era que el sobre va cerrado a su destinatario, así que a quien
180
+ * no le toque le llega algo que no puede abrir. Sigue siendo verdad y sigue siendo la
181
+ * segunda línea — pero no es una puerta: repartir cifrado a desconocidos regala el acta,
182
+ * deja el texto cifrado en manos de cualquiera para siempre, y no cuesta nada evitarlo.
183
+ * La petición YA viaja firmada y con papel desde `fetchSecrets`; aquí solo se miraban.
184
+ *
185
+ * Lo que sigue sin decidirse aquí, y eso sí es a propósito: NADA sobre el contenido. El
186
+ * replicador no abre, no filtra por clave y no sabe qué entrega. Comprobar quién llama no
187
+ * lo convierte en alguien de quien haya que fiarse: si mintiera diciendo que sí, el sobre
188
+ * sigue sin abrirse; si mintiera diciendo que no, es una caída, que es lo que un
189
+ * replicador puede costar.
190
+ *
191
+ * `revoked` no se le pasa a `verifyChain` porque aquí no hay registro de revocaciones —
192
+ * y no hace falta: quitar un aparato es sacarlo del acta (`docs/acta-de-perfil.md`), y de
193
+ * eso se ocupa `memberCanReadSecrets` con el acta de abajo.
176
194
  */
177
195
  async function onSecrets (from, p) {
178
196
  const ns = p?.data?.ns
179
197
  const ek = p?.data?.ek
180
- const quien = p?.data?.publickey
181
- if (typeof ns !== 'string' || !ek || !quien) return
198
+ if (!isValidSecretsNs(ns) || typeof ek !== 'string') return
182
199
  if (!store.acta) return client.send(from, { type: MSG.ERROR, error: 'replica: no record yet' })
200
+ const no = (error) => client.send(from, { type: MSG.ERROR, error })
201
+ const acta = store.acta
202
+
203
+ if (typeof p?.data?.ts !== 'number' || Math.abs(Date.now() - p.data.ts) > FRESH_WINDOW_MS) {
204
+ log('[replica] a secrets request arrived outside the freshness window: refused')
205
+ return no('replica: stale request')
206
+ }
207
+ const chk = await verifyChain({
208
+ data: p.data,
209
+ signature: p.signature,
210
+ cert: p.cert,
211
+ expectedScope: secretsScope(ns),
212
+ actaSeq: acta.seq,
213
+ sealers: sealersOf(acta)
214
+ })
215
+ if (!chk.ok) {
216
+ log(`[replica] a secrets request for "${ns}" does not check out (${chk.reason}): refused`)
217
+ return no('unauthorized: ' + chk.reason)
218
+ }
219
+ // EL ACTA MANDA, igual que en la bóveda: el papel dice qué se emitió, el acta dice qué
220
+ // vale hoy. Un aparato al que le quitaron el cajón trae un papel intacto.
221
+ if (!memberCanReadSecrets(acta, chk.device, ns)) {
222
+ log(`[replica] the record does not let ${(await pubkeyId(chk.device)).slice(0, 8)} read "${ns}": refused`)
223
+ return no(`unauthorized: cn — the record does not recognise this member as the "${ns}" service`)
224
+ }
225
+ const quien = chk.device
183
226
  const b = store.bundleFor(ns, quien)
184
227
  if (!b) {
185
228
  log(`[replica] asked for "${ns}" and has nothing stored for that device`)
package/src/vault.js CHANGED
@@ -644,10 +644,24 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
644
644
  if (d.method === 'profilePublic') {
645
645
  return reply(from, { type: MSG.STORE_RESULT, method: d.method, result: secrets.profilePublic() })
646
646
  }
647
+ const revoked = await revocationSet()
648
+ // `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
649
+ const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
650
+ if (!chk.ok) return denyChain(from, chk, p, 'profile')
651
+ if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
652
+
647
653
  // A QUIÉN HAY QUE ENVOLVERLE. Lo pide el aparato ANTES de sellar, porque sellar lo hace
648
654
  // él: envolver solo necesita públicas, así que puede hacerlo un navegador sin que
649
655
  // ninguna privada ande suelta. Sale de aquí y no de una lista suya para que conceder
650
656
  // `firma` a un aparato nuevo baste — sin que nadie tenga que acordarse de nada.
657
+ //
658
+ // VA DETRÁS DEL CONTROL, y estuvo delante. Que solo haga falta material PÚBLICO para
659
+ // envolver no significa que la lista sea pública: lo que devuelve es el INVENTARIO
660
+ // —cuántas máquinas tienes, sus llaves, y la de recuperación— y eso la bóveda no se lo
661
+ // enseña ni a un servicio de la casa (`modelo-de-amenazas.md` §2.5). Cualquiera que
662
+ // supiera la llave de la cuenta, que es pública, lo sacaba entero preguntando. Y no
663
+ // costaba nada evitarlo: la consola YA manda su firma y su papel en esta misma llamada
664
+ // (`requestStore` de `@dotrino/identity`), solo que aquí no se miraban.
651
665
  if (d.method === 'profileRecipients') {
652
666
  const members = await recipientsOf(PROFILE_OWNER)
653
667
  return reply(from, {
@@ -657,12 +671,6 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
657
671
  })
658
672
  }
659
673
 
660
- const revoked = await revocationSet()
661
- // `vault:sign` — hablar por ti. No `vault:store`, que es guardar cosas.
662
- const chk = await verifyChain({ data: d, signature: p.signature, cert: p.cert, expectedScope: SCOPE.SIGN, ...(await contextoActa()), revoked })
663
- if (!chk.ok) return denyChain(from, chk, p, 'profile')
664
- if (!await actaAllows(from, chk, SCOPE.SIGN, 'profile')) return
665
-
666
674
  try {
667
675
  if (d.method === 'profileBundle') {
668
676
  const b = secrets.profileBundleFor(chk.device)
@@ -1344,6 +1352,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1344
1352
  async function handleAdminEvent (payload) {
1345
1353
  const acta = payload?.acta
1346
1354
  if (!acta || typeof acta !== 'object') return
1355
+ // ESTE MENSAJE NO LO FIRMA NADIE, y no hace falta: el acta se autentica sola —va sellada
1356
+ // y `canAdopt` comprueba que la selló quien la MÍA reconoce—. Pero eso solo funciona si
1357
+ // hay una mía contra la que comparar: sin acta previa, `canAdopt` acepta cualquiera
1358
+ // («sin-acta-previa»), y este es el único mostrador donde una llega de un desconocido.
1359
+ // Anunciar no es cómo se entra en una cuenta —para eso está el enrolamiento—, así que
1360
+ // sin acta propia no se adopta: se dice y se para.
1361
+ const propia = (await identity.profileActa?.().catch(() => null))?.acta || null
1362
+ if (!propia) return log('[vault] a record was announced but this vault has none of its own to compare it with: ignored')
1347
1363
  try {
1348
1364
  const r = await identity.adoptActa?.(acta)
1349
1365
  if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)