@dotrino/remote-agent 0.2.1 → 0.3.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/README.md CHANGED
@@ -12,6 +12,13 @@ Provee lo común — y **solo** eso:
12
12
  - **Handshake anti-MITM** con `verifyChain` + anti-replay (firmado por el vault).
13
13
  - **Emparejamiento SAS** del agente contra el vault (código que no viaja).
14
14
  - **Revocación** con auto-borrado del enlace al ser revocada la máquina.
15
+ - **Renovación automática del cert** (`vault.renew`): el cert dura 30 días y el agente
16
+ pide uno fresco cuando le quedan menos de 7, en el mismo tic que el refresco de
17
+ revocados. Sin esto, una máquina que nunca dejó de ser tuya se caía sola al mes y
18
+ había que re-emparejarla a mano. El cert nuevo se verifica antes de guardarlo (misma
19
+ maestra, misma sub-clave, más vida); si la bóveda está apagada, el siguiente tic
20
+ reintenta, con días de margen. Un cert **ya vencido** no se renueva: ahí sí toca
21
+ re-emparejar.
15
22
  - **Wiring de proxy + identity** (`identify` firmado, transporte por `proxy.dotrino.com`).
16
23
  - **Autodescubrimiento** de agentes por label (`listVaultDevices` + filtro).
17
24
  - **Auditoría** (`sessions.log` JSONL).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/remote-agent",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -22,8 +22,8 @@
22
22
  "test": "node -e \"import('@dotrino/remote-agent').then(()=>console.log('ok'))\""
23
23
  },
24
24
  "dependencies": {
25
- "@dotrino/identity": "0.23.0",
26
- "@dotrino/proxy-client": "0.6.4",
25
+ "@dotrino/identity": "0.33.0",
26
+ "@dotrino/proxy-client": "0.8.0",
27
27
  "ws": "8.21.0"
28
28
  },
29
29
  "engines": {
package/protocol.js CHANGED
@@ -19,11 +19,14 @@ export const PING = 'ra.ping'
19
19
  export const PONG = 'ra.pong'
20
20
  export const ERROR = 'ra.error'
21
21
 
22
- /** Mensajes del vault (refresco de dispositivos / revocación). */
22
+ /** Mensajes del vault (refresco de dispositivos / revocación / renovación). */
23
23
  export const VMSG = {
24
24
  DEVICES: 'vault.devices',
25
25
  DEVICES_RESULT: 'vault.devices.result',
26
- REVOKED: 'vault.revoked'
26
+ REVOKED: 'vault.revoked',
27
+ RENEW: 'vault.renew',
28
+ RENEWED: 'vault.renewed',
29
+ ERROR: 'vault.error'
27
30
  }
28
31
 
29
32
  /** Scope del cert de dispositivo que autoriza a hablar con el agente. */
@@ -32,3 +35,12 @@ export const SIGN_SCOPE = 'vault:sign'
32
35
  /** Vigencia de una sesión inactiva y refresco de la lista de revocados. */
33
36
  export const SESSION_TTL_MS = 30 * 60 * 1000
34
37
  export const REVOKE_REFRESH_MS = 5 * 60 * 1000
38
+
39
+ /**
40
+ * RENOVACIÓN del cert de esta máquina. El cert dura 30 días; se pide uno fresco
41
+ * cuando le quedan menos de 7. El margen es amplio a propósito: un agente puede
42
+ * pasar días con el proxy caído o la bóveda apagada, y debe tener muchas ventanas
43
+ * de reintento antes de que el cert venza. Un cert VENCIDO ya no se renueva —ahí
44
+ * toca re-emparejar a mano—, que es exactamente lo que esto viene a evitar.
45
+ */
46
+ export const RENEW_BEFORE_MS = 7 * 24 * 60 * 60 * 1000
package/src/agent.js CHANGED
@@ -21,11 +21,11 @@
21
21
  */
22
22
  import fs from 'node:fs'
23
23
  import path from 'node:path'
24
- import { verifyChain, signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
24
+ import { verifyChain, signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from '@dotrino/identity/capabilities'
25
25
  import { installNodeGlobals } from '../node-globals.js'
26
26
  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 } from '../protocol.js'
28
- import { loadLink, dataDir } from './link.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 { loadLink, saveLink, dataDir } from './link.js'
29
29
 
30
30
  /** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
31
31
  class AgentSession {
@@ -73,6 +73,7 @@ class AgentSession {
73
73
  * @param {()=>void} [opts.onRevoked] se llamó al auto-borrarse por revocación.
74
74
  * @param {()=>void} [opts.onReady] agente listo y escuchando.
75
75
  * @param {boolean} [opts.quiet] sin logs.
76
+ * @param {object} [opts.client] transporte ya conectado (SOLO pruebas).
76
77
  */
77
78
  export async function startRemoteAgent (opts = {}) {
78
79
  const dir = opts.dir || dataDir()
@@ -86,13 +87,18 @@ export async function startRemoteAgent (opts = {}) {
86
87
 
87
88
  installNodeGlobals(dir)
88
89
 
89
- const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
90
90
  const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || 'wss://proxy.dotrino.com'
91
- const client = getWebSocketProxyClient({
92
- url: proxyUrl, enableWebRTC: false, autoReconnect: true,
93
- maxReconnectAttempts: 100000, reconnectDelay: 4000
94
- })
95
- await client.connect()
91
+ // `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
92
+ // levanta el del ecosistema — no hay otro transporte.
93
+ const client = opts.client || await (async () => {
94
+ const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
95
+ const c = getWebSocketProxyClient({
96
+ url: proxyUrl, enableWebRTC: false, autoReconnect: true,
97
+ maxReconnectAttempts: 100000, reconnectDelay: 4000
98
+ })
99
+ await c.connect()
100
+ return c
101
+ })()
96
102
 
97
103
  // Identificarse bajo la pubkey de ESTA máquina (firmado con su D).
98
104
  const identify = async () => {
@@ -113,27 +119,79 @@ export async function startRemoteAgent (opts = {}) {
113
119
 
114
120
  const send = (to, obj) => { try { client.send(to, obj) } catch (e) { if (!opts.quiet) console.error('[remote-agent] send:', e.message) } }
115
121
 
122
+ /**
123
+ * Petición firmada a la bóveda por el proxy, dirigida a su pubkey maestra. Es el
124
+ * mismo sobre para todo (`data` firmado con la D + el cert de esta máquina): lo
125
+ * comparten el refresco de revocaciones y la renovación del cert.
126
+ */
127
+ async function vaultRpc (sendType, okType, data, timeoutMs = 15000) {
128
+ const { signature } = await signWithDevice({ privateJwk: link.device.privateJwk, data })
129
+ return new Promise((resolve, reject) => {
130
+ // El temporizador se LIMPIA al contestar (y no retiene el bucle de eventos):
131
+ // sin eso, cada consulta al vault dejaba 15 s de espera pendiente y un proceso
132
+ // corto que solo quería preguntar algo se quedaba colgado hasta que vencía.
133
+ const done = (fn, v) => { off(); clearTimeout(timer); fn(v) }
134
+ const off = client.on('message', (_f, p) => {
135
+ if (p?.type === okType) done(resolve, p)
136
+ else if (p?.type === VMSG.ERROR) done(reject, new Error(p.error))
137
+ })
138
+ const timer = setTimeout(() => done(reject, new Error('timeout')), timeoutMs)
139
+ timer.unref?.()
140
+ client.sendByPubkey(master, { type: sendType, data, signature, cert: link.cert })
141
+ })
142
+ }
143
+
116
144
  // --- Revocación: refrescar la lista del vault por el proxy (best-effort) ---
117
145
  let revokedSet = new Set()
118
146
  async function refreshRevocations () {
119
147
  try {
120
- const data = { op: 'devices', publickey: myPub, ts: Date.now() }
121
- const { signature } = await signWithDevice({ privateJwk: link.device.privateJwk, data })
122
- const res = await new Promise((resolve, reject) => {
123
- const off = client.on('message', (_f, p) => {
124
- if (p?.type === VMSG.DEVICES_RESULT) { off(); resolve(p) }
125
- else if (p?.type === 'vault.error') { off(); reject(new Error(p.error)) }
126
- })
127
- setTimeout(() => { off(); reject(new Error('timeout')) }, 15000)
128
- client.sendByPubkey(master, { type: VMSG.DEVICES, data, signature, cert: link.cert })
129
- })
148
+ const res = await vaultRpc(VMSG.DEVICES, VMSG.DEVICES_RESULT, { op: 'devices', publickey: myPub, ts: Date.now() })
130
149
  revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
131
150
  } catch (e) {
132
- if (!opts.quiet) console.error('[remote-agent] no pude refrescar revocaciones (uso la cache):', e.message)
151
+ if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
133
152
  }
134
153
  }
135
- refreshRevocations()
136
- const revTimer = setInterval(refreshRevocations, REVOKE_REFRESH_MS); revTimer.unref?.()
154
+
155
+ /**
156
+ * RENOVACIÓN del cert (`vault.renew`). Sin esto, el cert vence a los 30 días y hay
157
+ * que re-emparejar a mano una máquina que nunca dejó de ser tuya. Se pide cuando le
158
+ * quedan menos de `RENEW_BEFORE_MS`, en el mismo tic que el refresco de revocados:
159
+ * el agente ya está hablando con la bóveda ahí, y si está caída o el proxy no va, el
160
+ * siguiente tic reintenta — con días de margen antes de que el cert muera.
161
+ *
162
+ * El cert nuevo se VERIFICA antes de guardarlo (misma maestra, misma sub-clave y más
163
+ * vida que el que tenemos): una respuesta cualquiera del proxy no puede sustituirle
164
+ * la credencial al agente.
165
+ */
166
+ async function renewCertIfNeeded () {
167
+ const exp = link.cert?.exp
168
+ if (typeof exp !== 'number' || exp - Date.now() > RENEW_BEFORE_MS) return
169
+ try {
170
+ const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
171
+ const cert = res?.cert
172
+ if (!cert || cert.sub !== myPub || cert.iss !== master || !(cert.exp > exp)) {
173
+ throw new Error('the vault returned a cert that is not for this machine')
174
+ }
175
+ const v = await verifyDelegation({ cert, expectedSub: myPub, expectedScope: SIGN_SCOPE })
176
+ if (!v.ok) throw new Error(v.reason)
177
+ link.cert = cert
178
+ saveLink(dir, link)
179
+ audit('cert-renew', { exp: cert.exp })
180
+ if (!opts.quiet) console.log(`[remote-agent] cert renewed until ${new Date(cert.exp).toISOString().slice(0, 10)}`)
181
+ } catch (e) {
182
+ // No es fatal: el cert viejo sigue sirviendo hasta que venza y el próximo tic
183
+ // reintenta. Se avisa igual, porque si esto falla durante días la máquina se cae
184
+ // sola y hay que re-emparejarla.
185
+ if (!opts.quiet) {
186
+ const days = Math.max(0, Math.round((exp - Date.now()) / 86400000))
187
+ console.error(`[remote-agent] could not renew the cert (${days} day(s) left):`, e.message)
188
+ }
189
+ }
190
+ }
191
+
192
+ const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
193
+ vaultTick()
194
+ const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
137
195
 
138
196
  // sid -> AgentSession
139
197
  const sessions = new Map()
package/src/link.js CHANGED
@@ -41,7 +41,8 @@ export function loadLink (dir = dataDir()) {
41
41
  try { return JSON.parse(fs.readFileSync(linkPath(dir), 'utf8')) } catch { return null }
42
42
  }
43
43
 
44
- function saveLink (dir, link) {
44
+ /** Persiste el enlace (0600). Exportada porque la RENOVACIÓN del cert lo reescribe. */
45
+ export function saveLink (dir, link) {
45
46
  fs.mkdirSync(dir, { recursive: true })
46
47
  fs.writeFileSync(linkPath(dir), JSON.stringify(link, null, 2), { mode: 0o600 })
47
48
  }