@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 +7 -0
- package/package.json +3 -3
- package/protocol.js +14 -2
- package/src/agent.js +80 -22
- package/src/link.js +2 -1
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.
|
|
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.
|
|
26
|
-
"@dotrino/proxy-client": "0.
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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]
|
|
151
|
+
if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
|
|
133
152
|
}
|
|
134
153
|
}
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
}
|