@dotrino/remote-agent 0.5.4 → 0.6.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.
Files changed (2) hide show
  1. package/package.json +3 -3
  2. package/src/agent.js +65 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/remote-agent",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "Middleware compartido del ecosistema Dotrino para apps de agente remoto cifrado punto a punto (handshake E2E, emparejamiento SAS con vault, revocación, descubrimiento). Lo consumen dotrino-terminal (PTY) y dotrino-ia (chat con IA).",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -45,12 +45,12 @@
45
45
  "p2p"
46
46
  ],
47
47
  "peerDependencies": {
48
- "@dotrino/identity": ">=0.56.0",
48
+ "@dotrino/identity": ">=0.73.0",
49
49
  "@dotrino/proxy-client": ">=0.10.1",
50
50
  "@dotrino/vault": ">=0.30.0"
51
51
  },
52
52
  "devDependencies": {
53
- "@dotrino/identity": "0.62.0",
53
+ "@dotrino/identity": "0.73.0",
54
54
  "@dotrino/proxy-client": "0.13.1",
55
55
  "@dotrino/vault": "0.35.0"
56
56
  }
package/src/agent.js CHANGED
@@ -22,9 +22,10 @@
22
22
  import fs from 'node:fs'
23
23
  import path from 'node:path'
24
24
  import { verifyChain, signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from '@dotrino/identity/capabilities'
25
+ import { sealersOf, memberCanSign } from '@dotrino/identity/acta'
25
26
  import { installNodeGlobals } from '../node-globals.js'
26
27
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
27
- import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS, RENEW_BEFORE_MS } from '../protocol.js'
28
+ import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
28
29
  import { loadLink, saveLink, dataDir } from './link.js'
29
30
 
30
31
  /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
@@ -154,48 +155,76 @@ export async function startRemoteAgent (opts = {}) {
154
155
  try {
155
156
  const res = await vaultRpc(VMSG.DEVICES, VMSG.DEVICES_RESULT, { op: 'devices', publickey: myPub, ts: Date.now() })
156
157
  revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
158
+ // El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
159
+ // mismo tic si el dueño nos cambió los permisos.
160
+ anotarActa(res.acta)
157
161
  } catch (e) {
158
162
  if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
159
163
  }
160
164
  }
161
165
 
162
166
  /**
163
- * RENOVACIÓN del cert (`vault.renew`). Sin esto, el cert vence a los 30 días y hay
164
- * que re-emparejar a mano una máquina que nunca dejó de ser tuya. Se pide cuando le
165
- * quedan menos de `RENEW_BEFORE_MS`, en el mismo tic que el refresco de revocados:
166
- * el agente ya está hablando con la bóveda ahí, y si está caída o el proxy no va, el
167
- * siguiente tic reintenta — con días de margen antes de que el cert muera.
167
+ * RENOVACIÓN del cert (`vault.renew`). YA NO ES UNA TAREA DE CALENDARIO: el papel no
168
+ * vence, lleva el `seq` del acta con el que se emitió. Se pide uno nuevo cuando el ACTA
169
+ * dice algo distinto de lo que lleva escrito — o sea, cuando el dueño cambió permisos.
168
170
  *
169
- * El cert nuevo se VERIFICA antes de guardarlo (misma maestra, misma sub-clave y más
170
- * vida que el que tenemos): una respuesta cualquiera del proxy no puede sustituirle
171
- * la credencial al agente.
171
+ * Antes se pedía al acercarse la caducidad, y eso obligaba a que la bóveda pudiera firmar
172
+ * sola cada mes; era el último motivo por el que la maestra tenía que estar disponible sin
173
+ * nadie delante. Ahora renovar ocurre justo cuando ya hay una selladora abierta, porque
174
+ * cambiar el acta ES tenerla abierta.
175
+ *
176
+ * El cert nuevo se VERIFICA antes de guardarlo, contra el ACTA que viene con él: quien lo
177
+ * firmó tiene que ser selladora de ESTE perfil. Ya no se compara con una llave fija, para
178
+ * que una segunda bóveda pueda emitir; lo que se fija es el perfil.
172
179
  */
173
180
  async function renewCertIfNeeded () {
174
- const exp = link.cert?.exp
175
- if (typeof exp !== 'number' || exp - Date.now() > RENEW_BEFORE_MS) return
181
+ // ¿Nos quedamos atrás? Lo sabemos por el acta que la bóveda manda con cada respuesta.
182
+ const visto = link.actaSeq
183
+ if (typeof visto !== 'number' || typeof link.cert?.seq !== 'number' || visto <= link.cert.seq) return
176
184
  try {
177
185
  const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
178
186
  const cert = res?.cert
179
- if (!cert || cert.sub !== myPub || cert.iss !== master || !(cert.exp > exp)) {
180
- throw new Error('the vault returned a cert that is not for this machine')
181
- }
182
- const v = await verifyDelegation({ cert, expectedSub: myPub, expectedScope: SIGN_SCOPE })
187
+ const acta = res?.acta
188
+ if (!cert || cert.sub !== myPub) throw new Error('the vault returned a cert that is not for this machine')
189
+ if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
190
+ if (acta.profileId !== master) throw new Error('the record is from a profile other than the pinned one')
191
+ const v = await verifyDelegation({
192
+ cert, expectedSub: myPub, expectedScope: SIGN_SCOPE,
193
+ actaSeq: acta.seq, sealers: sealersOf(acta)
194
+ })
183
195
  if (!v.ok) throw new Error(v.reason)
184
196
  link.cert = cert
197
+ link.actaSeq = acta.seq
185
198
  saveLink(dir, link)
186
- audit('cert-renew', { exp: cert.exp })
187
- if (!opts.quiet) console.log(`[remote-agent] cert renewed until ${new Date(cert.exp).toISOString().slice(0, 10)}`)
199
+ audit('cert-renew', { seq: cert.seq })
200
+ if (!opts.quiet) console.log(`[remote-agent] cert renewed against record #${cert.seq}`)
188
201
  } catch (e) {
189
- // No es fatal: el cert viejo sigue sirviendo hasta que venza y el próximo tic
190
- // reintenta. Se avisa igual, porque si esto falla durante días la máquina se cae
191
- // sola y hay que re-emparejarla.
192
- if (!opts.quiet) {
193
- const days = Math.max(0, Math.round((exp - Date.now()) / 86400000))
194
- console.error(`[remote-agent] could not renew the cert (${days} day(s) left):`, e.message)
195
- }
202
+ // No es fatal: el papel de antes sigue sirviendo para todo lo que el acta le siga
203
+ // permitiendo, y el próximo tic reintenta. Se avisa igual: si esto falla, lo que el
204
+ // dueño acaba de conceder no llega.
205
+ if (!opts.quiet) console.error('[remote-agent] could not renew the cert:', e.message)
196
206
  }
197
207
  }
198
208
 
209
+ /**
210
+ * Guarda el acta que manda la bóveda. Hace dos cosas, y las dos hacen falta:
211
+ * · dispara la renovación cuando su `seq` pasa al del papel que tenemos;
212
+ * · es CON LO QUE SE JUZGA a quien nos habla — quien firma tiene que ser selladora de
213
+ * este perfil, y eso solo lo dice el acta. Por eso se PERSISTE: si viviera en memoria,
214
+ * al reiniciar el agente no podría atender a nadie hasta el primer tic.
215
+ */
216
+ function anotarActa (acta) {
217
+ if (typeof acta?.seq !== 'number' || link.acta?.seq === acta.seq) return
218
+ link.acta = acta
219
+ link.actaSeq = acta.seq
220
+ try { saveLink(dir, link) } catch (_) {}
221
+ }
222
+
223
+ /** El acta con la que se juzga un papel. Sin ella no se atiende a nadie. */
224
+ const contextoActa = () => (link.acta
225
+ ? { actaSeq: link.acta.seq, sealers: sealersOf(link.acta) }
226
+ : { actaSeq: null, sealers: null })
227
+
199
228
  const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
200
229
  vaultTick()
201
230
  const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
@@ -217,8 +246,19 @@ export async function startRemoteAgent (opts = {}) {
217
246
  if (typeof data.ts !== 'number' || Math.abs(Date.now() - data.ts) > 5 * 60 * 1000) {
218
247
  return send(from, { type: ERROR, error: 'handshake vencido (posible replay, o el reloj del dispositivo está desfasado)' })
219
248
  }
220
- const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, trustedIssuer: master, revoked: revokedSet })
249
+ // Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
250
+ // venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
251
+ // qué decidir, así que no se decide que sí.
252
+ const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
221
253
  if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
254
+ // CERT ∩ ACTA. `verifyChain` comprueba QUIÉN EMITIÓ el papel; que el APARATO siga siendo
255
+ // miembro y pueda firmar lo dice el acta, y hay que preguntárselo aparte. Sin esto, un
256
+ // papel bien firmado por una selladora abría sesión aunque el acta no nombrara a ese
257
+ // aparato — o ya no lo nombrara. Bajo el modelo viejo daba igual, porque el papel ERA la
258
+ // autoridad y caducaba; con el papel atado al acta, el acta tiene que decidir.
259
+ if (!memberCanSign(link.acta, chk.device)) {
260
+ return send(from, { type: ERROR, error: 'no autorizado: acta — ese aparato no firma por este perfil' })
261
+ }
222
262
  if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
223
263
 
224
264
  const eph = await makeEphemeral()