@dotrino/vaultd 0.105.3 → 0.106.1

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.1",
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)
@@ -1322,7 +1330,30 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1322
1330
  sock.on('error', cerrar)
1323
1331
  })
1324
1332
  server.on('error', (e) => log('[vault] the local desk could not open (' + e.message + '): services will use the proxy'))
1325
- try { fs.mkdirSync(socketDir(), { recursive: true, mode: 0o700 }) } catch (_) {}
1333
+ // EL DIRECTORIO ES EL CONTROL, no el modo del socket.
1334
+ //
1335
+ // `mkdir` con `mode` NO corrige un directorio que ya existe, y sin `XDG_RUNTIME_DIR`
1336
+ // —un servicio de sistema, un contenedor— esto cae en `/tmp`, donde cualquiera puede
1337
+ // haberlo creado antes con permiso de escritura para todos. Quien sea dueño del
1338
+ // directorio puede borrar el socket y poner el suyo: los servicios de esta máquina
1339
+ // acabarían hablando con él. No leería nada (la respuesta va firmada por una llave que
1340
+ // nombra el acta y el cliente la comprueba), pero los deja sin arrancar.
1341
+ //
1342
+ // Y el `chmod 0600` de abajo llega DESPUÉS de `listen`, así que por sí solo deja una
1343
+ // rendija. Lo que la cierra de verdad es que el directorio sea nuestro y solo nuestro.
1344
+ const dir = socketDir()
1345
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) } catch (_) {}
1346
+ try {
1347
+ const st = fs.statSync(dir)
1348
+ const ajeno = typeof process.getuid === 'function' && st.uid !== process.getuid()
1349
+ const abierto = (st.mode & 0o077) !== 0
1350
+ if (ajeno || abierto) {
1351
+ return log(`[vault] the local desk stays closed: ${dir} is ${ajeno ? 'owned by another user' : 'writable by others'} ` +
1352
+ '— services on this machine will use the proxy instead')
1353
+ }
1354
+ } catch (e) {
1355
+ return log('[vault] the local desk stays closed: could not check ' + dir + ' (' + e.message + ')')
1356
+ }
1326
1357
  try { fs.rmSync(socketPath, { force: true }) } catch (_) {}
1327
1358
  server.listen(socketPath, () => {
1328
1359
  try { fs.chmodSync(socketPath, 0o600) } catch (_) {}
@@ -1344,6 +1375,14 @@ export async function startVault ({ dir = dataDir(), proxyUrl, log = console.log
1344
1375
  async function handleAdminEvent (payload) {
1345
1376
  const acta = payload?.acta
1346
1377
  if (!acta || typeof acta !== 'object') return
1378
+ // ESTE MENSAJE NO LO FIRMA NADIE, y no hace falta: el acta se autentica sola —va sellada
1379
+ // y `canAdopt` comprueba que la selló quien la MÍA reconoce—. Pero eso solo funciona si
1380
+ // hay una mía contra la que comparar: sin acta previa, `canAdopt` acepta cualquiera
1381
+ // («sin-acta-previa»), y este es el único mostrador donde una llega de un desconocido.
1382
+ // Anunciar no es cómo se entra en una cuenta —para eso está el enrolamiento—, así que
1383
+ // sin acta propia no se adopta: se dice y se para.
1384
+ const propia = (await identity.profileActa?.().catch(() => null))?.acta || null
1385
+ if (!propia) return log('[vault] a record was announced but this vault has none of its own to compare it with: ignored')
1347
1386
  try {
1348
1387
  const r = await identity.adoptActa?.(acta)
1349
1388
  if (r?.adopted) log(`[vault] adopted record #${acta.seq} announced by another vault (${r.reason})`)