@dotrino/remote-agent 0.18.2 → 0.19.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.18.2",
3
+ "version": "0.19.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",
@@ -46,12 +46,12 @@
46
46
  "p2p"
47
47
  ],
48
48
  "peerDependencies": {
49
- "@dotrino/identity": ">=0.92.0",
49
+ "@dotrino/identity": ">=0.112.0",
50
50
  "@dotrino/proxy-client": ">=0.30.0",
51
51
  "@dotrino/vault": ">=0.64.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@dotrino/identity": "0.105.0",
54
+ "@dotrino/identity": "0.112.0",
55
55
  "@dotrino/proxy-client": "0.30.0",
56
56
  "@dotrino/vault": "0.75.0"
57
57
  }
package/src/agent.js CHANGED
@@ -31,7 +31,7 @@ import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SES
31
31
  import { loadLink, saveLink, dataDir } from './link.js'
32
32
 
33
33
  /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
34
- class AgentSession {
34
+ export class AgentSession {
35
35
  constructor ({ sid, key, from, device, send, isDirect = null }) {
36
36
  this.sid = sid
37
37
  this.key = key
@@ -44,10 +44,16 @@ class AgentSession {
44
44
  }
45
45
 
46
46
  /** Envía un payload de dominio cifrado al cliente. */
47
- async send (payload) {
48
- if (this._closed) return
49
- const env = await seal(this.key, payload).catch(() => null)
50
- if (env) this._send(this.from, { type: DATA, sid: this.sid, env })
47
+ send (payload) {
48
+ if (this._closed) return Promise.resolve()
49
+ // SALEN EN EL ORDEN EN QUE SE PIDIERON. Cifrar tarda según el tamaño: sin esta cola, un
50
+ // mensaje pequeño adelantaba a uno grande pedido antes (en la terminal, lo que la consola
51
+ // pintaba llegaba antes que su pantalla inicial, y esta lo tapaba después).
52
+ const sealed = seal(this.key, payload).catch(() => null)
53
+ this._out = (this._out || Promise.resolve()).then(() => sealed).then((env) => {
54
+ if (env && !this._closed) this._send(this.from, { type: DATA, sid: this.sid, env })
55
+ })
56
+ return this._out
51
57
  }
52
58
 
53
59
  /**
@@ -79,10 +85,13 @@ class AgentSession {
79
85
 
80
86
  on (ev, cb) { this._h[ev]?.push(cb); return this }
81
87
 
82
- async _ingest (env) {
83
- let msg
84
- try { msg = await open(this.key, env) } catch { return }
85
- for (const h of this._h.message) { try { h(msg) } catch (_) {} }
88
+ /** Lo que llega se entrega en el orden en que llegó, tarde lo que tarde en descifrarse cada uno. */
89
+ _ingest (env) {
90
+ const opened = open(this.key, env).then((msg) => ({ msg }), () => null)
91
+ this._in = (this._in || Promise.resolve()).then(() => opened).then((r) => {
92
+ if (r) for (const h of this._h.message) { try { h(r.msg) } catch (_) {} }
93
+ })
94
+ return this._in
86
95
  }
87
96
 
88
97
  close () {
package/src/client.js CHANGED
@@ -115,10 +115,13 @@ export class RemoteAgentClient {
115
115
  if (token === this.agentToken) this.agentToken = null
116
116
  })
117
117
 
118
- this.client.on('message', async (_from, p) => {
118
+ this.client.on('message', (_from, p) => {
119
119
  if (!p || typeof p !== 'object') return
120
120
  if (p.type === DATA && p.sid === this.sid) {
121
- try { const m = await open(this.key, p.env); this._emit('message', m) } catch {}
121
+ // Se entregan en el orden en que llegaron: descifrar tarda según el tamaño, y sin
122
+ // esta cola un mensaje pequeño adelantaba a uno grande que llegó antes.
123
+ const opened = open(this.key, p.env).then((m) => ({ m }), () => null)
124
+ this._in = (this._in || Promise.resolve()).then(() => opened).then((r) => { if (r) this._emit('message', r.m) })
122
125
  } else if (p.type === ERROR) {
123
126
  this._onAgentError(p)
124
127
  }
@@ -214,12 +217,18 @@ export class RemoteAgentClient {
214
217
  async send (payload) {
215
218
  if (this._resuming) await this._resuming
216
219
  if (!this.key) throw Object.assign(new Error('no session with the agent'), { code: 'no-session' })
217
- const env = await seal(this.key, payload)
218
- const msg = { type: DATA, sid: this.sid, env }
219
- // Por token en cuanto se sabe: sube al canal directo, y si el token murió el pilar lo
220
- // saca por la pubkey. Por pubkey solo mientras no hay token.
221
- if (this.agentToken) this.client.sendToOrQueue(this.agentToken, msg, { peerPubkey: this.agentPubkey })
222
- else this.client.sendByPubkey(this.agentPubkey, msg)
220
+ // Salen en el orden en que se pidieron, tarde lo que tarde en cifrarse cada uno.
221
+ const sid = this.sid
222
+ const sealed = seal(this.key, payload)
223
+ const turn = (this._out || Promise.resolve()).then(() => sealed).then((env) => {
224
+ const msg = { type: DATA, sid, env }
225
+ // Por token en cuanto se sabe: sube al canal directo, y si el token murió el pilar lo
226
+ // saca por la pubkey. Por pubkey solo mientras no hay token.
227
+ if (this.agentToken) this.client.sendToOrQueue(this.agentToken, msg, { peerPubkey: this.agentPubkey })
228
+ else this.client.sendByPubkey(this.agentPubkey, msg)
229
+ })
230
+ this._out = turn.catch(() => {})
231
+ return turn
223
232
  }
224
233
 
225
234
  /** Sonda de presencia (liveness) sin abrir sesión. Devuelve true si responde. */
package/src/link.js CHANGED
@@ -20,6 +20,7 @@ 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
22
  import { adoptRecord } from './record.js'
23
+ import { verifySealerChain } from '@dotrino/identity/acta'
23
24
 
24
25
  export function dataDir (name = 'dotrino-remote-agent') {
25
26
  if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
@@ -116,7 +117,12 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
116
117
  return {
117
118
  me: { publickey: device.publickey },
118
119
  async signData (data) {
119
- return signWithDevice({ privateJwk: device.privateJwk, publickey: device.publickey, data })
120
+ const firmado = await signWithDevice({ privateJwk: device.privateJwk, publickey: device.publickey, data })
121
+ // A NOMBRE DE QUIÉN VA, igual que en el navegador: el `profileId` y la cadena de
122
+ // selladores que lo prueba (`refreshChain`). Sin cadena la firma vale ante la bóveda
123
+ // (que conoce el papel) pero no ante un extraño que use `verifySignedFor`.
124
+ const chain = Array.isArray(link.sealerChain) && link.sealerChain.length ? link.sealerChain : null
125
+ return { ...firmado, profileId: chain ? chain[0].profileId : null, chain }
120
126
  },
121
127
  async getVaultCert () { return { cert: link.cert } },
122
128
  async vaultStatus () {
@@ -192,9 +198,41 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
192
198
  }
193
199
  }
194
200
 
201
+ /**
202
+ * TRAE LA CADENA DE SELLADORES de la cuenta y la guarda en el enlace: con ella `signData`
203
+ * firma a nombre de la cuenta de forma que `verifySignedFor` lo compruebe desde el génesis.
204
+ * Se comprueba ANTES de guardarla (`verifySealerChain`): una cadena que no encadena o que es
205
+ * de otra cuenta no entra. Un agente que firma de cara a terceros la pide al arrancar y la
206
+ * refresca de vez en cuando. No lanza: devuelve qué pasó.
207
+ *
208
+ * @returns {Promise<{ ok: boolean, seq?: number, reason?: string }>}
209
+ */
210
+ export async function refreshChain (link, { dir = dataDir() } = {}) {
211
+ if (!link?.device?.privateJwk || !link?.cert || !link?.iss) return { ok: false, reason: 'invalid link' }
212
+ installNodeGlobals(dir)
213
+ try {
214
+ const res = await requestDevices({ master: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com', device: link.device, cert: link.cert })
215
+ const chain = res.sealerChain
216
+ if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'the vault sent no sealer chain (vaultd < 0.154.0?)' }
217
+ const expected = link.sealerChain?.[0]?.profileId ?? link.acta?.profileId ?? null
218
+ const v = await verifySealerChain(chain, { expectedProfileId: expected })
219
+ if (!v.ok) return { ok: false, reason: 'bad sealer chain: ' + v.reason }
220
+ const head = chain[chain.length - 1]
221
+ if (typeof link.sealerChain?.[link.sealerChain.length - 1]?.seq === 'number' && head.seq < link.sealerChain[link.sealerChain.length - 1].seq) {
222
+ return { ok: false, reason: 'older than the chain already stored' }
223
+ }
224
+ link.sealerChain = chain
225
+ saveLink(dir, link)
226
+ return { ok: true, seq: head.seq }
227
+ } catch (e) {
228
+ // Un fallo de red llega a veces sin frase: se dice el código, o al menos que no se alcanzó.
229
+ return { ok: false, reason: e?.message || e?.code || 'unreachable' }
230
+ }
231
+ }
232
+
195
233
  /** El enlace como lo esperan `ContentClient.connect({ link })` y `RemoteAgentClient`. */
196
234
  export function clientLink (link, opts) {
197
235
  return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy || 'wss://proxy.dotrino.com' }
198
236
  }
199
237
 
200
- export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink }
238
+ export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink, refreshChain }