@dotrino/remote-agent 0.12.0 → 0.13.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/remote-agent",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
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",
@@ -51,8 +51,8 @@
51
51
  "@dotrino/vault": ">=0.64.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@dotrino/identity": "0.92.0",
55
- "@dotrino/proxy-client": "0.18.2",
56
- "@dotrino/vault": "0.64.0"
54
+ "@dotrino/identity": "0.105.0",
55
+ "@dotrino/proxy-client": "0.25.0",
56
+ "@dotrino/vault": "0.75.0"
57
57
  }
58
58
  }
package/src/agent.js CHANGED
@@ -23,6 +23,7 @@ import fs from 'node:fs'
23
23
  import path from 'node:path'
24
24
  import { verifyChain, signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
25
25
  import { sealersOf, memberCan, checkVaultReply } from '@dotrino/identity/acta'
26
+ import { adoptRecord } from './record.js'
26
27
  import { installNodeGlobals } from '../node-globals.js'
27
28
  import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
28
29
  import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
@@ -157,7 +158,7 @@ export async function startRemoteAgent (opts = {}) {
157
158
  revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
158
159
  // El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
159
160
  // mismo tic si el dueño nos cambió los permisos.
160
- anotarActa(res.acta)
161
+ await anotarActa(res.acta)
161
162
  } catch (e) {
162
163
  if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
163
164
  }
@@ -210,23 +211,43 @@ export async function startRemoteAgent (opts = {}) {
210
211
  }
211
212
 
212
213
  /**
213
- * Guarda el acta que manda la bóveda. Hace dos cosas, y las dos hacen falta:
214
+ * Guarda el acta que manda la bóveda, SOLO SI SE PUEDE COMPROBAR (`record.js`). Hace dos
215
+ * cosas, y las dos hacen falta:
214
216
  * · dispara la renovación cuando su `seq` pasa al del papel que tenemos;
215
217
  * · es CON LO QUE SE JUZGA a quien nos habla — quien firma tiene que ser selladora de
216
218
  * este perfil, y eso solo lo dice el acta. Por eso se PERSISTE: si viviera en memoria,
217
219
  * al reiniciar el agente no podría atender a nadie hasta el primer tic.
220
+ *
221
+ * Antes se guardaba lo que llegara, de quien llegara: la respuesta de la bóveda no trae
222
+ * remitente comprobable, así que cualquiera que conociera la llave de esta máquina podía
223
+ * mandar un acta inventada y nombrarse miembro. Ahora el acta tiene que encadenar desde la
224
+ * que ya se dio por buena, o venir firmada por la bóveda con la que se enroló.
218
225
  */
219
- function anotarActa (acta) {
220
- if (typeof acta?.seq !== 'number' || link.acta?.seq === acta.seq) return
226
+ async function anotarActa (acta) {
227
+ if (typeof acta?.seq !== 'number') return
228
+ const r = await adoptRecord({ link, candidate: acta, me: myPub })
229
+ if (!r.adopt) {
230
+ if (r.reason !== 'misma-acta') {
231
+ audit('record-rejected', { seq: acta.seq, reason: r.reason })
232
+ if (!opts.quiet) console.error(`[remote-agent] record #${acta.seq} rejected (${r.reason}); keeping the one already trusted`)
233
+ }
234
+ return
235
+ }
221
236
  link.acta = acta
222
237
  link.actaSeq = acta.seq
238
+ link.actaTrusted = true
223
239
  try { saveLink(dir, link) } catch (_) {}
224
240
  }
225
241
 
226
- /** El acta con la que se juzga un papel. Sin ella no se atiende a nadie. */
227
- const contextoActa = () => (link.acta
228
- ? { actaSeq: link.acta.seq, sealers: sealersOf(link.acta) }
229
- : { actaSeq: null, sealers: null })
242
+ /**
243
+ * El acta con la que se juzga un papel. Sin ella no se atiende a nadie — y un acta que
244
+ * nunca se comprobó (la que guardó una versión anterior) cuenta como no tenerla.
245
+ */
246
+ const actaDeConfianza = () => (link.actaTrusted === true && link.acta ? link.acta : null)
247
+ const contextoActa = () => {
248
+ const acta = actaDeConfianza()
249
+ return acta ? { actaSeq: acta.seq, sealers: sealersOf(acta) } : { actaSeq: null, sealers: null }
250
+ }
230
251
 
231
252
  const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
232
253
  vaultTick()
@@ -268,8 +289,9 @@ export async function startRemoteAgent (opts = {}) {
268
289
  // abrir sesión con otro agente. El bot social se pasó así el 1 de septiembre, con un
269
290
  // node de contenido encendido a su lado que le contestaba «ese aparato no firma por este
270
291
  // perfil». Se le pide lo que corresponde: que el acta lo nombre y le reconozca `sign`.
271
- const isMember = (link.acta?.members || []).some((m) => m?.pub === chk.device)
272
- if (!isMember || !memberCan(link.acta, chk.device, 'sign')) {
292
+ const acta = actaDeConfianza()
293
+ const isMember = (acta?.members || []).some((m) => m?.pub === chk.device)
294
+ if (!acta || !isMember || !memberCan(acta, chk.device, 'sign')) {
273
295
  return send(from, { type: ERROR, error: 'no autorizado: acta — este perfil no reconoce a ese aparato' })
274
296
  }
275
297
  if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
package/src/client.js CHANGED
@@ -169,7 +169,8 @@ export class RemoteAgentClient {
169
169
  })
170
170
  setTimeout(() => { off(); resolve(false) }, timeoutMs)
171
171
  })
172
- this.client.sendByPubkey(this.agentPubkey, { type: PING, n })
172
+ // Efímero, como `probeAgents`: si no está, no se encola ni se le timbra.
173
+ this.client.sendByPubkey(this.agentPubkey, { type: PING, n }, { ephemeral: true })
173
174
  return got
174
175
  }
175
176
 
package/src/discover.js CHANGED
@@ -73,7 +73,11 @@ export function probeAgents (client, subs, { timeoutMs = 3000 } = {}) {
73
73
  for (const sub of subs) {
74
74
  const n = [...crypto.getRandomValues(new Uint8Array(8))].map((x) => x.toString(16).padStart(2, '0')).join('')
75
75
  bySub.set(n, sub)
76
- try { client.sendByPubkey(sub, { type: PING, n }) } catch (_) {}
76
+ // EFÍMERO: una pregunta de «¿estás ahí?» solo vale en vivo. Sin esto el proxio la
77
+ // ENCOLABA para quien no estaba conectado y le tocaba el timbre: la app preguntaba a
78
+ // todos los aparatos de la cuenta y el teléfono sonaba cada minuto sin ningún pedido
79
+ // detrás (2026-09-30). Quien no contesta ahora, no está; eso es la respuesta.
80
+ try { client.sendByPubkey(sub, { type: PING, n }, { ephemeral: true }) } catch (_) {}
77
81
  }
78
82
  setTimeout(() => { try { off() } catch (_) {} resolve(found) }, timeoutMs)
79
83
  })
package/src/link.js CHANGED
@@ -19,6 +19,7 @@ import os from 'node:os'
19
19
  import { signWithDevice, pubkeyId } from '@dotrino/identity/capabilities'
20
20
  import { requestDevices, requestRenew } from '@dotrino/identity/vault/remote.js'
21
21
  import { installNodeGlobals } from '../node-globals.js'
22
+ import { adoptRecord } from './record.js'
22
23
 
23
24
  export function dataDir (name = 'dotrino-remote-agent') {
24
25
  if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
@@ -177,8 +178,13 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
177
178
  // esto no se persiste la máquina se queda usando uno revocado y fuera para siempre. Le
178
179
  // pasó al registro de selladores en la migración, y por eso está dicho aquí.
179
180
  link.cert = cert
180
- link.acta = acta
181
181
  link.actaSeq = acta.seq
182
+ // El acta que viaja con el papel se guarda SOLO si se puede comprobar (`record.js`): es
183
+ // la que decide quién abre sesión con este agente. El papel sí se guarda siempre.
184
+ if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
185
+ link.acta = acta
186
+ link.actaTrusted = true
187
+ }
182
188
  saveLink(dir, link)
183
189
  return { renewed: true, seq: cert.seq }
184
190
  } catch (e) {
package/src/record.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * record.js — ¿se puede dar por buena el acta que acaba de llegar?
3
+ *
4
+ * El acta decide quién abre sesión con el agente, así que no se guarda ninguna que no se
5
+ * pueda comprobar. Hay dos casos y ninguno más:
6
+ *
7
+ * · YA HAY una de confianza → la nueva tiene que encadenar desde ella (`canAdopt` del
8
+ * pilar: misma cuenta, bien firmada, sellada por quien la anterior nombra selladora, y
9
+ * nunca hacia atrás).
10
+ * · TODAVÍA NO (recién enrolado, o el acta la guardó una versión que no comprobaba nada)
11
+ * → solo vale una firmada por LA BÓVEDA CON LA QUE SE ENROLÓ (`link.iss`, la única llave
12
+ * que esta máquina tiene fijada), que nombre a esta máquina y no sea anterior a su papel.
13
+ * Si la última acta de la cuenta la selló otra selladora, no hay con qué comprobarla
14
+ * desde aquí: no se adopta, y toca enrolar de nuevo.
15
+ *
16
+ * Sin dependencias de Node: lo usa el agente y se prueba solo.
17
+ */
18
+ import { canAdopt, verifyActa, samePubkey } from '@dotrino/identity/acta'
19
+
20
+ /**
21
+ * @param {{ link: any, candidate: any, me: string }} args `me` = la llave pública de esta máquina.
22
+ * @returns {Promise<{ adopt: boolean, reason: string }>}
23
+ */
24
+ export async function adoptRecord ({ link, candidate, me }) {
25
+ if (!candidate || typeof candidate.seq !== 'number') return { adopt: false, reason: 'shape' }
26
+ if (link?.actaTrusted === true && link.acta) return canAdopt({ candidate, current: link.acta })
27
+
28
+ if (typeof link?.iss !== 'string') return { adopt: false, reason: 'no-pinned-vault' }
29
+ const v = await verifyActa({ acta: candidate })
30
+ if (!v.ok) return { adopt: false, reason: v.reason }
31
+ if (!samePubkey(candidate.sealedBy, link.iss)) return { adopt: false, reason: 'not-sealed-by-pinned-vault' }
32
+ if (!(candidate.members || []).some((m) => samePubkey(m?.pub, me))) return { adopt: false, reason: 'not-a-member' }
33
+ if (typeof link.cert?.seq === 'number' && candidate.seq < link.cert.seq) return { adopt: false, reason: 'older-than-cert' }
34
+ return { adopt: true, reason: 'sealed-by-pinned-vault' }
35
+ }
36
+
37
+ export default { adoptRecord }