@dotrino/remote-agent 0.1.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/README.md +112 -0
- package/e2e.js +75 -0
- package/index.js +16 -0
- package/node-globals.js +40 -0
- package/package.json +39 -0
- package/protocol.js +34 -0
- package/src/agent.js +227 -0
- package/src/client.js +152 -0
- package/src/discover.js +33 -0
- package/src/link.js +113 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# @dotrino/remote-agent
|
|
2
|
+
|
|
3
|
+
Middleware compartido del ecosistema Dotrino para **apps de agente remoto cifrado
|
|
4
|
+
punto a punto**. Lo consumen [`dotrino-terminal`](https://github.com/imdotrino/dotrino-terminal)
|
|
5
|
+
(PTY remoto) y [`dotrino-ia`](https://github.com/imdotrino/dotrino-ia) (chat con
|
|
6
|
+
agentes de IA), y cualquier app futura que necesite hablar con un proceso que corre
|
|
7
|
+
en la máquina del usuario.
|
|
8
|
+
|
|
9
|
+
Provee lo común — y **solo** eso:
|
|
10
|
+
|
|
11
|
+
- **Canal cifrado E2E por sesión** (ECDH P-256 → HKDF → AES-256-GCM, isomórfico WebCrypto).
|
|
12
|
+
- **Handshake anti-MITM** con `verifyChain` + anti-replay (firmado por el vault).
|
|
13
|
+
- **Emparejamiento SAS** del agente contra el vault (código que no viaja).
|
|
14
|
+
- **Revocación** con auto-borrado del enlace al ser revocada la máquina.
|
|
15
|
+
- **Wiring de proxy + identity** (`identify` firmado, transporte por `proxy.dotrino.com`).
|
|
16
|
+
- **Autodescubrimiento** de agentes por label (`listVaultDevices` + filtro).
|
|
17
|
+
- **Auditoría** (`sessions.log` JSONL).
|
|
18
|
+
|
|
19
|
+
**No sabe nada de PTY ni de IA.** Cada app define sus payloads de dominio
|
|
20
|
+
(terminal pasa `{type:'cmd',...}`, ia pasa `{type:'msg',...}`) y su renderer.
|
|
21
|
+
|
|
22
|
+
## Entry points (subpath exports)
|
|
23
|
+
|
|
24
|
+
| Import | Entorno | Qué da |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `@dotrino/remote-agent` | isomórfico | `e2e` (`makeEphemeral`/`deriveKey`/`seal`/`open`) + constantes de protocolo |
|
|
27
|
+
| `@dotrino/remote-agent/agent` | Node | `startRemoteAgent(opts)` |
|
|
28
|
+
| `@dotrino/remote-agent/client` | navegador | `RemoteAgentClient` |
|
|
29
|
+
| `@dotrino/remote-agent/link` | Node | `enroll`, `loadLink`, `dataDir`, `parseQr` |
|
|
30
|
+
| `@dotrino/remote-agent/discover` | navegador | `listAgentsByLabel` |
|
|
31
|
+
|
|
32
|
+
## Uso
|
|
33
|
+
|
|
34
|
+
### Agente (Node, en tu PC)
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { startRemoteAgent } from '@dotrino/remote-agent/agent'
|
|
38
|
+
|
|
39
|
+
const ra = await startRemoteAgent({
|
|
40
|
+
label: 'ia-agent',
|
|
41
|
+
proxyUrl: 'wss://proxy.dotrino.com', // opcional
|
|
42
|
+
dir: '/home/yo/.local/share/dotrino-ia-agent', // opcional
|
|
43
|
+
quiet: false,
|
|
44
|
+
onSession (session) {
|
|
45
|
+
// sesión cifrada con un dispositivo cliente. session.device = su pubkey.
|
|
46
|
+
session.on('message', async (msg) => {
|
|
47
|
+
// msg es un payload de dominio: la app define la forma
|
|
48
|
+
if (msg.type === 'msg') {
|
|
49
|
+
const reply = await runYourDriver(msg.text) // Claude / OpenCode / ...
|
|
50
|
+
session.send({ type: 'done', text: reply })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
session.on('close', () => {})
|
|
54
|
+
},
|
|
55
|
+
onRevoked () { console.log('me revocaron') }
|
|
56
|
+
})
|
|
57
|
+
// ra.machine, ra.machineId, ra.master, ra.close()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Enrolar la máquina (una vez, con el QR del vault):
|
|
61
|
+
|
|
62
|
+
```js
|
|
63
|
+
import { enroll } from '@dotrino/remote-agent/link'
|
|
64
|
+
const link = await enroll({
|
|
65
|
+
qr, label: 'ia-agent',
|
|
66
|
+
onChallenge: ({ deviceId, code }) => console.log(`Tipeá en el vault: ${code}`)
|
|
67
|
+
})
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Cliente (navegador, la PWA)
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import { RemoteAgentClient } from '@dotrino/remote-agent/client'
|
|
74
|
+
import { listAgentsByLabel } from '@dotrino/remote-agent/discover'
|
|
75
|
+
|
|
76
|
+
const agentes = await listAgentsByLabel(id, 'ia-agent') // tarjetas en la UI
|
|
77
|
+
const chat = new RemoteAgentClient(link, { agentPubkey: agentes[0].sub })
|
|
78
|
+
await chat.connect() // handshake E2E
|
|
79
|
+
chat.on('message', (payload) => render(payload))
|
|
80
|
+
chat.on('error', (e) => console.warn(e))
|
|
81
|
+
chat.send({ type: 'msg', text: 'arreglá el bug en auth.js' })
|
|
82
|
+
|
|
83
|
+
const online = await chat.ping() // liveness sin abrir sesión
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `link` (la PWA lo obtiene del vault)
|
|
87
|
+
|
|
88
|
+
Ver `dotrino-terminal/src/vault.js` (`getLink` / `getSelfLink`) como referencia: es
|
|
89
|
+
el enlace del dispositivo (cert `P ← maestra`, `iss`, `proxy`), que se pasa al
|
|
90
|
+
`RemoteAgentClient`. La terminal ya lo resuelve; cada app puede copiar ese patrón o
|
|
91
|
+
extraerlo luego a este paquete.
|
|
92
|
+
|
|
93
|
+
## Seguridad
|
|
94
|
+
|
|
95
|
+
- **Autorización = estar vinculado al vault.** El agente solo atiende dispositivos
|
|
96
|
+
cuya cadena certifica la maestra pineada (`verifyChain` con `trustedIssuer`).
|
|
97
|
+
- **Emparejamiento con código SAS** que no viaja (anti aprobación a ciegas / vault impostor).
|
|
98
|
+
- **Anti-replay** en el handshake (`ts` ±5 min).
|
|
99
|
+
- **Auto-borrado** del `link.json` al recibir un `revoke` firmado por la maestra.
|
|
100
|
+
- **Auditoría** local de cada sesión abierta.
|
|
101
|
+
- El relay de transporte (`proxy.dotrino.com`) **solo ve bytes cifrados**.
|
|
102
|
+
|
|
103
|
+
## Dependencias
|
|
104
|
+
|
|
105
|
+
- `@dotrino/identity` (capabilities: `verifyChain`, `signWithDevice`, `verifyDeviceSig`,
|
|
106
|
+
`pubkeyId`, `makeDeviceKey`, `verifyDelegation`, `makePairingCode`)
|
|
107
|
+
- `@dotrino/proxy-client` (`WebSocketProxyClient`)
|
|
108
|
+
- `ws` (shim de `WebSocket` en Node, lado agente)
|
|
109
|
+
|
|
110
|
+
## Licencia
|
|
111
|
+
|
|
112
|
+
MIT.
|
package/e2e.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2e.js — canal cifrado punto a punto entre el dispositivo enlazado y un agente
|
|
3
|
+
* remoto del ecosistema Dotrino. Isomórfico: usa WebCrypto
|
|
4
|
+
* (`globalThis.crypto.subtle`), disponible tanto en el navegador (PWA) como en
|
|
5
|
+
* Node ≥ 18.
|
|
6
|
+
*
|
|
7
|
+
* NO reimplementa la identidad ni la firma del ecosistema: la AUTORIZACIÓN la da
|
|
8
|
+
* el vault (cert de dispositivo verificado con `@dotrino/identity/capabilities`
|
|
9
|
+
* → cadena hasta la maestra). Este módulo solo levanta un secreto de sesión
|
|
10
|
+
* efímero (ECDH P-256 → HKDF → AES-256-GCM) para que el relay de transporte
|
|
11
|
+
* (`proxy.dotrino.com`) vea únicamente texto cifrado. La identidad de las dos
|
|
12
|
+
* puntas ya la garantizan las firmas del vault que envuelven las claves efímeras.
|
|
13
|
+
*
|
|
14
|
+
* Fuente única para `@dotrino/remote-agent` (consumido por terminal, ia y futuras
|
|
15
|
+
* apps de agente remoto).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const subtle = () => globalThis.crypto.subtle
|
|
19
|
+
|
|
20
|
+
const b64 = {
|
|
21
|
+
enc (buf) {
|
|
22
|
+
const b = new Uint8Array(buf)
|
|
23
|
+
let s = ''
|
|
24
|
+
for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i])
|
|
25
|
+
return btoa(s)
|
|
26
|
+
},
|
|
27
|
+
dec (str) {
|
|
28
|
+
const bin = atob(str)
|
|
29
|
+
const out = new Uint8Array(bin.length)
|
|
30
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
|
|
31
|
+
return out
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Genera un par ECDH P-256 efímero. Devuelve la pub en raw (base64) + privada. */
|
|
36
|
+
export async function makeEphemeral () {
|
|
37
|
+
const kp = await subtle().generateKey({ name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits'])
|
|
38
|
+
const raw = await subtle().exportKey('raw', kp.publicKey)
|
|
39
|
+
return { privateKey: kp.privateKey, pub: b64.enc(raw) }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deriva la clave AES-256-GCM de la sesión a partir de la privada propia y la
|
|
44
|
+
* pub efímera (base64 raw) del otro extremo. `salt` liga la clave a la sesión.
|
|
45
|
+
*/
|
|
46
|
+
export async function deriveKey (privateKey, otherPubB64, salt) {
|
|
47
|
+
const otherPub = await subtle().importKey(
|
|
48
|
+
'raw', b64.dec(otherPubB64), { name: 'ECDH', namedCurve: 'P-256' }, false, []
|
|
49
|
+
)
|
|
50
|
+
const bits = await subtle().deriveBits({ name: 'ECDH', public: otherPub }, privateKey, 256)
|
|
51
|
+
const hkdfKey = await subtle().importKey('raw', bits, 'HKDF', false, ['deriveKey'])
|
|
52
|
+
return subtle().deriveKey(
|
|
53
|
+
{ name: 'HKDF', hash: 'SHA-256', salt: new TextEncoder().encode(salt), info: new TextEncoder().encode('dotrino-remote-agent-e2e') },
|
|
54
|
+
hkdfKey,
|
|
55
|
+
{ name: 'AES-GCM', length: 256 },
|
|
56
|
+
false,
|
|
57
|
+
['encrypt', 'decrypt']
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Cifra un objeto JSON → sobre { iv, ct } (ambos base64). */
|
|
62
|
+
export async function seal (key, obj) {
|
|
63
|
+
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
64
|
+
const pt = new TextEncoder().encode(JSON.stringify(obj))
|
|
65
|
+
const ct = await subtle().encrypt({ name: 'AES-GCM', iv }, key, pt)
|
|
66
|
+
return { iv: b64.enc(iv), ct: b64.enc(ct) }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Descifra un sobre { iv, ct } → objeto JSON. Lanza si el tag GCM no valida. */
|
|
70
|
+
export async function open (key, env) {
|
|
71
|
+
const pt = await subtle().decrypt({ name: 'AES-GCM', iv: b64.dec(env.iv) }, key, b64.dec(env.ct))
|
|
72
|
+
return JSON.parse(new TextDecoder().decode(pt))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { b64 }
|
package/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @dotrino/remote-agent — middleware compartido para apps de agente remoto
|
|
3
|
+
* cifrado punto a punto del ecosistema Dotrino.
|
|
4
|
+
*
|
|
5
|
+
* Entry points (subpath exports en package.json):
|
|
6
|
+
* '@dotrino/remote-agent' → e2e + constantes de protocolo (isomórfico)
|
|
7
|
+
* '@dotrino/remote-agent/agent' → startRemoteAgent() (Node, lado del agente)
|
|
8
|
+
* '@dotrino/remote-agent/client' → RemoteAgentClient (navegador, lado PWA)
|
|
9
|
+
* '@dotrino/remote-agent/link' → enroll() emparejamiento SAS (Node)
|
|
10
|
+
* '@dotrino/remote-agent/discover' → listAgentsByLabel() (navegador)
|
|
11
|
+
*
|
|
12
|
+
* El paquete NO sabe nada de PTY ni de IA: provee canal cifrado por sesión,
|
|
13
|
+
* despacho de payloads de dominio, emparejamiento con vault y revocación.
|
|
14
|
+
*/
|
|
15
|
+
export * from './e2e.js'
|
|
16
|
+
export * from './protocol.js'
|
package/node-globals.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instala los globals del navegador que los paquetes del ecosistema esperan
|
|
3
|
+
* cuando corren headless en Node: `WebSocket` (paquete `ws`) y un `localStorage`
|
|
4
|
+
* respaldado en archivo (lo usa `@dotrino/proxy-client` para su keypair de
|
|
5
|
+
* transporte estable). Mismo patrón que `dotrino-bots/src/core/node-globals.js`
|
|
6
|
+
* — NO se reimplementa nada, solo se inyecta el entorno.
|
|
7
|
+
*
|
|
8
|
+
* Solo para uso del lado del AGENTE (Node). No importar desde entrypoints de
|
|
9
|
+
* navegador.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import WebSocket from 'ws'
|
|
14
|
+
|
|
15
|
+
/** localStorage síncrono respaldado por un archivo JSON (solo lo que usan los paquetes). */
|
|
16
|
+
export function fileLocalStorage (filePath) {
|
|
17
|
+
let data = {}
|
|
18
|
+
try { if (fs.existsSync(filePath)) data = JSON.parse(fs.readFileSync(filePath, 'utf8')) || {} } catch (_) {}
|
|
19
|
+
const flush = () => {
|
|
20
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
21
|
+
fs.writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 })
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
getItem: (k) => (k in data ? data[k] : null),
|
|
25
|
+
setItem: (k, v) => { data[k] = String(v); flush() },
|
|
26
|
+
removeItem: (k) => { delete data[k]; flush() },
|
|
27
|
+
clear: () => { data = {}; flush() },
|
|
28
|
+
key: (i) => Object.keys(data)[i] ?? null,
|
|
29
|
+
get length () { return Object.keys(data).length }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let _installed = false
|
|
34
|
+
export function installNodeGlobals (dir) {
|
|
35
|
+
if (_installed) return
|
|
36
|
+
if (typeof globalThis.WebSocket === 'undefined') globalThis.WebSocket = WebSocket
|
|
37
|
+
// Node ≥22 expone un `localStorage` no funcional sin flag → forzamos el shim.
|
|
38
|
+
globalThis.localStorage = fileLocalStorage(path.join(dir, 'transport.json'))
|
|
39
|
+
_installed = true
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dotrino/remote-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./agent": "./src/agent.js",
|
|
10
|
+
"./client": "./src/client.js",
|
|
11
|
+
"./link": "./src/link.js",
|
|
12
|
+
"./discover": "./src/discover.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"e2e.js",
|
|
17
|
+
"protocol.js",
|
|
18
|
+
"node-globals.js",
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node -e \"import('@dotrino/remote-agent').then(()=>console.log('ok'))\""
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@dotrino/identity": "0.17.0",
|
|
26
|
+
"@dotrino/proxy-client": "0.6.4",
|
|
27
|
+
"ws": "8.21.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+ssh://git@github.com/imdotrino/dotrino-remote-agent.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/imdotrino/dotrino-remote-agent",
|
|
38
|
+
"keywords": ["dotrino", "remote", "agent", "e2e", "vault", "proxy", "p2p"]
|
|
39
|
+
}
|
package/protocol.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* protocol.js — constantes del protocolo de transporte de `@dotrino/remote-agent`.
|
|
3
|
+
*
|
|
4
|
+
* El protocolo tiene dos capas:
|
|
5
|
+
* 1. Envelope de transporte (visible al relay/proxy): `{ type, sid, env?, ... }`.
|
|
6
|
+
* Usa los tipos `ra.*` de aquí abajo. El proxy solo ve el `type` + ciphertext.
|
|
7
|
+
* 2. Payload de dominio (dentro de `env`, cifrado AES-256-GCM): un objeto
|
|
8
|
+
* arbitrario definido por CADA app (terminal envía `{type:'cmd',...}`,
|
|
9
|
+
* ia envía `{type:'msg',...}`). El paquete no impone su forma.
|
|
10
|
+
*
|
|
11
|
+
* El handshake (HS/ACK) y la liveness (PING/PONG) son comunes; el despacho de
|
|
12
|
+
* payloads de dominio viaja por el tipo `DATA` genérico.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const HS = 'ra.hs'
|
|
16
|
+
export const ACK = 'ra.hs.ack'
|
|
17
|
+
export const DATA = 'ra.data' // payload de dominio cifrado (cualquier dirección)
|
|
18
|
+
export const PING = 'ra.ping'
|
|
19
|
+
export const PONG = 'ra.pong'
|
|
20
|
+
export const ERROR = 'ra.error'
|
|
21
|
+
|
|
22
|
+
/** Mensajes del vault (refresco de dispositivos / revocación). */
|
|
23
|
+
export const VMSG = {
|
|
24
|
+
DEVICES: 'vault.devices',
|
|
25
|
+
DEVICES_RESULT: 'vault.devices.result',
|
|
26
|
+
REVOKED: 'vault.revoked'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Scope del cert de dispositivo que autoriza a hablar con el agente. */
|
|
30
|
+
export const SIGN_SCOPE = 'vault:sign'
|
|
31
|
+
|
|
32
|
+
/** Vigencia de una sesión inactiva y refresco de la lista de revocados. */
|
|
33
|
+
export const SESSION_TTL_MS = 30 * 60 * 1000
|
|
34
|
+
export const REVOKE_REFRESH_MS = 5 * 60 * 1000
|
package/src/agent.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agent.js — lado del AGENTE (Node) de `@dotrino/remote-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Abre sesiones cifradas punto a punto SOLO para dispositivos enlazados al mismo
|
|
5
|
+
* vault. Puede correr en CUALQUIER máquina: se enrola con el vault como un
|
|
6
|
+
* dispositivo más (ver link.js), así que NO necesita la maestra — solo su propia
|
|
7
|
+
* sub-clave `D` + `cert` y la pública maestra pineada (`iss`).
|
|
8
|
+
*
|
|
9
|
+
* Autorización = el vault. Cada `ra.hs` llega firmado por la `D` del cliente con su
|
|
10
|
+
* `cert`; el agente verifica la cadena `D_cliente ← maestra` (misma maestra que la
|
|
11
|
+
* suya) con `verifyChain`. Ambas puntas son peers certificados por el mismo vault;
|
|
12
|
+
* ninguna tiene la clave maestra. El ack lo firma el agente con SU `D` y adjunta su
|
|
13
|
+
* `cert`, para que el cliente compruebe que habla con un agente que el vault certificó
|
|
14
|
+
* (anti-MITM del relay).
|
|
15
|
+
*
|
|
16
|
+
* Transporte = el proxy (`@dotrino/proxy-client`): el agente se identifica bajo SU
|
|
17
|
+
* pubkey y el cliente lo direcciona por ella. Revocación: refresca la lista del vault
|
|
18
|
+
* por el proxy; si está offline, usa la última cacheada + el TTL del cert acota el
|
|
19
|
+
* riesgo. Los payloads de dominio viajan cifrados E2E (../e2e.js) dentro del tipo
|
|
20
|
+
* `DATA` genérico — el agente NO sabe nada de PTY ni de IA.
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import path from 'node:path'
|
|
24
|
+
import { verifyChain, signWithDevice, verifyDeviceSig, pubkeyId } from '@dotrino/identity/capabilities'
|
|
25
|
+
import { installNodeGlobals } from '../node-globals.js'
|
|
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'
|
|
29
|
+
|
|
30
|
+
/** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
|
|
31
|
+
class AgentSession {
|
|
32
|
+
constructor ({ sid, key, from, device, send }) {
|
|
33
|
+
this.sid = sid
|
|
34
|
+
this.key = key
|
|
35
|
+
this.from = from // token del cliente en el proxy
|
|
36
|
+
this.device = device // pubkey del dispositivo cliente
|
|
37
|
+
this._send = send // (to, obj) => void
|
|
38
|
+
this._h = { message: [], close: [] }
|
|
39
|
+
this._closed = false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Envía un payload de dominio cifrado al cliente. */
|
|
43
|
+
async send (payload) {
|
|
44
|
+
if (this._closed) return
|
|
45
|
+
const env = await seal(this.key, payload).catch(() => null)
|
|
46
|
+
if (env) this._send(this.from, { type: DATA, sid: this.sid, env })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
on (ev, cb) { this._h[ev]?.push(cb); return this }
|
|
50
|
+
|
|
51
|
+
async _ingest (env) {
|
|
52
|
+
let msg
|
|
53
|
+
try { msg = await open(this.key, env) } catch { return }
|
|
54
|
+
for (const h of this._h.message) { try { h(msg) } catch (_) {} }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
close () {
|
|
58
|
+
if (this._closed) return
|
|
59
|
+
this._closed = true
|
|
60
|
+
for (const h of this._h.close) { try { h() } catch (_) {} }
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Arranca el agente. Devuelve `{ machine, machineId, master, close }`.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} opts
|
|
68
|
+
* @param {string} [opts.label] label del dispositivo (para logging; el real vive en link.json).
|
|
69
|
+
* @param {string} [opts.proxyUrl] default wss://proxy.dotrino.com.
|
|
70
|
+
* @param {string} [opts.dir] dir del enlace (default dataDir()).
|
|
71
|
+
* @param {object} [opts.link] enlace ya cargado (override de dir).
|
|
72
|
+
* @param {(session:AgentSession)=>void} [opts.onSession] cada handshake exitoso abre una sesión.
|
|
73
|
+
* @param {()=>void} [opts.onRevoked] se llamó al auto-borrarse por revocación.
|
|
74
|
+
* @param {()=>void} [opts.onReady] agente listo y escuchando.
|
|
75
|
+
* @param {boolean} [opts.quiet] sin logs.
|
|
76
|
+
*/
|
|
77
|
+
export async function startRemoteAgent (opts = {}) {
|
|
78
|
+
const dir = opts.dir || dataDir()
|
|
79
|
+
const link = opts.link || loadLink(dir)
|
|
80
|
+
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
|
|
81
|
+
throw new Error('esta máquina no está enlazada. Ejecuta primero el enrolamiento (enroll) de tu agente.')
|
|
82
|
+
}
|
|
83
|
+
const master = link.iss
|
|
84
|
+
const myPub = link.device.publickey
|
|
85
|
+
const myId = (await pubkeyId(myPub)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
86
|
+
|
|
87
|
+
installNodeGlobals(dir)
|
|
88
|
+
|
|
89
|
+
const { getWebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
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()
|
|
96
|
+
|
|
97
|
+
// Identificarse bajo la pubkey de ESTA máquina (firmado con su D).
|
|
98
|
+
const identify = async () => {
|
|
99
|
+
if (!client.token) return
|
|
100
|
+
const data = { op: 'identify', publickey: myPub, token: client.token, ts: Date.now() }
|
|
101
|
+
const { signature } = await signWithDevice({ privateJwk: link.device.privateJwk, data })
|
|
102
|
+
await client.identify({ data, signature })
|
|
103
|
+
}
|
|
104
|
+
await identify()
|
|
105
|
+
client.on('token', () => { identify().catch(() => {}) })
|
|
106
|
+
|
|
107
|
+
// Bitácora persistente (sessions.log, JSONL): qué dispositivo abrió sesión y cuándo
|
|
108
|
+
// — auditoría local si un dispositivo tuyo cae en malas manos.
|
|
109
|
+
const sessionsLog = path.join(dir, 'sessions.log')
|
|
110
|
+
const audit = (op, info = {}) => {
|
|
111
|
+
try { fs.appendFileSync(sessionsLog, JSON.stringify({ ts: Date.now(), op, ...info }) + '\n') } catch (_) {}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const send = (to, obj) => { try { client.send(to, obj) } catch (e) { if (!opts.quiet) console.error('[remote-agent] send:', e.message) } }
|
|
115
|
+
|
|
116
|
+
// --- Revocación: refrescar la lista del vault por el proxy (best-effort) ---
|
|
117
|
+
let revokedSet = new Set()
|
|
118
|
+
async function refreshRevocations () {
|
|
119
|
+
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
|
+
})
|
|
130
|
+
revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (!opts.quiet) console.error('[remote-agent] no pude refrescar revocaciones (uso la cache):', e.message)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
refreshRevocations()
|
|
136
|
+
const revTimer = setInterval(refreshRevocations, REVOKE_REFRESH_MS); revTimer.unref?.()
|
|
137
|
+
|
|
138
|
+
// sid -> AgentSession
|
|
139
|
+
const sessions = new Map()
|
|
140
|
+
const sweeper = setInterval(() => {
|
|
141
|
+
const now = Date.now()
|
|
142
|
+
for (const [sid, s] of sessions) {
|
|
143
|
+
if (now > s._exp) { s.close(); sessions.delete(sid); audit('session-expire', { sid: sid.slice(0, 8) }) }
|
|
144
|
+
}
|
|
145
|
+
}, 60 * 1000); sweeper.unref?.()
|
|
146
|
+
|
|
147
|
+
async function handleHandshake (from, p) {
|
|
148
|
+
const { data, signature, cert } = p
|
|
149
|
+
if (!data || !signature || !cert) return send(from, { type: ERROR, error: 'handshake incompleto' })
|
|
150
|
+
// FRESCURA anti-replay: el HS debe ser reciente (±5 min); sin esto un relay
|
|
151
|
+
// malicioso podía reproducir handshakes viejos (sesiones huérfanas / DoS).
|
|
152
|
+
if (typeof data.ts !== 'number' || Math.abs(Date.now() - data.ts) > 5 * 60 * 1000) {
|
|
153
|
+
return send(from, { type: ERROR, error: 'handshake vencido (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
154
|
+
}
|
|
155
|
+
const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, trustedIssuer: master, revoked: revokedSet })
|
|
156
|
+
if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
|
|
157
|
+
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
|
|
158
|
+
|
|
159
|
+
const eph = await makeEphemeral()
|
|
160
|
+
const sid = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
161
|
+
const key = await deriveKey(eph.privateKey, data.eph, sid)
|
|
162
|
+
// Ack firmado con la D de ESTA máquina + su cert: el cliente verifica la cadena
|
|
163
|
+
// a la maestra y que la pub efímera viene de un agente certificado. `publickey`
|
|
164
|
+
// va DENTRO del dato firmado (verifyChain lo exige: firma contra data.publickey
|
|
165
|
+
// y cert.sub === data.publickey).
|
|
166
|
+
const ack = { op: ACK, sid, seph: eph.pub, ceph: data.eph, machine: myPub, publickey: myPub, ts: Date.now() }
|
|
167
|
+
const { signature: ackSig } = await signWithDevice({ privateJwk: link.device.privateJwk, data: ack })
|
|
168
|
+
|
|
169
|
+
const session = new AgentSession({ sid, key, from, device: chk.device, send })
|
|
170
|
+
session._exp = Date.now() + SESSION_TTL_MS
|
|
171
|
+
sessions.set(sid, session)
|
|
172
|
+
audit('session-open', { sid: sid.slice(0, 8), device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
|
|
173
|
+
send(from, { type: ACK, sid, ack, signature: ackSig, cert: link.cert })
|
|
174
|
+
if (!opts.quiet) console.log(`[remote-agent] sesión ${sid.slice(0, 8)} autorizada (device ${chk.device?.slice?.(0, 8) || '?'})`)
|
|
175
|
+
if (opts.onSession) { try { opts.onSession(session) } catch (e) { if (!opts.quiet) console.error('[remote-agent] onSession:', e.message) } }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleData (from, p) {
|
|
179
|
+
const s = sessions.get(p.sid)
|
|
180
|
+
if (!s) return send(from, { type: ERROR, error: 'sesión desconocida o expirada' })
|
|
181
|
+
s._exp = Date.now() + SESSION_TTL_MS
|
|
182
|
+
s.from = from
|
|
183
|
+
s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'sobre inválido' }))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const stop = () => {
|
|
187
|
+
clearInterval(revTimer); clearInterval(sweeper)
|
|
188
|
+
for (const s of sessions.values()) s.close()
|
|
189
|
+
try { client.close() } catch {}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// AUTO-BORRADO al ser REVOCADA: la bóveda envía un REVOKED FIRMADO por la maestra
|
|
193
|
+
// dirigido a ESTA máquina (al revocar, o cuando reaparece). Solo nos borramos si la
|
|
194
|
+
// firma valida y el body es para nuestra pubkey → un proxy/peer malicioso NO puede
|
|
195
|
+
// borrarnos con un mensaje falso (cierra el wipe-DoS). Borra el enlace (link.json) y
|
|
196
|
+
// detiene el agente.
|
|
197
|
+
async function handleRevoked (p) {
|
|
198
|
+
const b = p?.body
|
|
199
|
+
if (!b || b.op !== 'revoke' || b.sub !== myPub) return
|
|
200
|
+
if (typeof b.exp === 'number' && Date.now() > b.exp) return
|
|
201
|
+
if (!await verifyDeviceSig({ publickey: master, data: b, signature: p.signature })) return
|
|
202
|
+
audit('revoked', { nonce: b.nonce })
|
|
203
|
+
if (!opts.quiet) console.log('\n[remote-agent] tu bóveda REVOCÓ esta máquina → borro el enlace y salgo.')
|
|
204
|
+
try { fs.rmSync(path.join(dir, 'link.json'), { force: true }) } catch (_) {}
|
|
205
|
+
stop()
|
|
206
|
+
if (opts.onRevoked) { try { opts.onRevoked() } catch (_) {} }
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
client.on('message', (from, payload) => {
|
|
210
|
+
if (!payload || typeof payload !== 'object') return
|
|
211
|
+
if (payload.type === HS) handleHandshake(from, payload).catch((e) => send(from, { type: ERROR, error: e.message }))
|
|
212
|
+
else if (payload.type === DATA) handleData(from, payload)
|
|
213
|
+
// Sonda de presencia (liveness): el cliente hace ping y respondemos pong con el
|
|
214
|
+
// mismo nonce → así la app sabe que el agente está online (sin abrir sesión).
|
|
215
|
+
else if (payload.type === PING) send(from, { type: PONG, n: payload.n })
|
|
216
|
+
else if (payload.type === VMSG.REVOKED) handleRevoked(payload).catch(() => {})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
if (!opts.quiet) {
|
|
220
|
+
console.log(`[remote-agent] agente activo · máquina ${myId} · vault ${(await pubkeyId(master)).slice(0, 16)} · proxy ${proxyUrl}`)
|
|
221
|
+
}
|
|
222
|
+
if (opts.onReady) { try { opts.onReady({ machine: myPub, machineId: myId, master }) } catch (_) {} }
|
|
223
|
+
|
|
224
|
+
return { machine: myPub, machineId: myId, master, close: stop }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export default { startRemoteAgent }
|
package/src/client.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.js — lado del CLIENTE (navegador/PWA) de `@dotrino/remote-agent`.
|
|
3
|
+
*
|
|
4
|
+
* Habla con un agente remoto por el proxy del ecosistema (`@dotrino/proxy-client`).
|
|
5
|
+
* El agente es otro dispositivo enrolado en el MISMO vault: lo direccionamos por SU
|
|
6
|
+
* pubkey (`agentPubkey`) y verificamos que su `cert` encadena a la maestra que este
|
|
7
|
+
* dispositivo vio al enlazar. Ambas puntas son peers certificados por el vault;
|
|
8
|
+
* ninguna tiene la clave maestra.
|
|
9
|
+
*
|
|
10
|
+
* La firma de este lado la hace el PILAR de identidad (`id.signData`, dentro del
|
|
11
|
+
* iframe id.dotrino.com): la clave del dispositivo es la identidad del navegador y
|
|
12
|
+
* su cert (`P ← maestra`) viene del emparejamiento estándar del ecosistema
|
|
13
|
+
* (profile.dotrino.com/#vault). Nada de claves privadas en la app.
|
|
14
|
+
*
|
|
15
|
+
* Handshake: firmamos la autorización → el agente responde un ack firmado con SU
|
|
16
|
+
* `D` + su `cert` → verificamos la cadena a la maestra pineada (anti-MITM del
|
|
17
|
+
* relay) → levantamos el canal cifrado (ECDH → AES-GCM).
|
|
18
|
+
*
|
|
19
|
+
* Una vez conectado, `send(payload)` envía un objeto de dominio arbitrario (la app
|
|
20
|
+
* define la forma: terminal pasa `{type:'cmd',...}`, ia pasa `{type:'msg',...}`) y
|
|
21
|
+
* `on('message', cb)` recibe los payloads del agente, ya descifrados.
|
|
22
|
+
*/
|
|
23
|
+
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
24
|
+
import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
|
|
25
|
+
import { HS, ACK, DATA, PING, PONG, ERROR } from '../protocol.js'
|
|
26
|
+
|
|
27
|
+
export class RemoteAgentClient {
|
|
28
|
+
/**
|
|
29
|
+
* @param {{ id:object, cert:object, iss:string, proxy?:string, mode?:string }} link
|
|
30
|
+
* enlace del vault (modo vault) o de @dotrino/vault (modo self: el dispositivo
|
|
31
|
+
* es su propio vault; `cert` es el self-cert `P ← P` e `iss` es la propia P).
|
|
32
|
+
* @param {object} opts
|
|
33
|
+
* @param {string} opts.agentPubkey dirección (pubkey) del agente destino.
|
|
34
|
+
* @param {string} [opts.proxyUrl] override del proxy del enlace.
|
|
35
|
+
*/
|
|
36
|
+
constructor (link, { agentPubkey, proxyUrl } = {}) {
|
|
37
|
+
this.link = link // { id, cert, iss, proxy, mode? }
|
|
38
|
+
this.agentPubkey = agentPubkey // pubkey del agente destino
|
|
39
|
+
this.proxyUrl = proxyUrl || link.proxy || 'wss://proxy.dotrino.com'
|
|
40
|
+
this.client = null
|
|
41
|
+
this.key = null
|
|
42
|
+
this.sid = null
|
|
43
|
+
this._h = { message: [], error: [] }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Suscribe a eventos. Devuelve un `off()` para desuscribir. Eventos: message, error. */
|
|
47
|
+
on (ev, cb) {
|
|
48
|
+
if (!this._h[ev]) this._h[ev] = []
|
|
49
|
+
this._h[ev].push(cb)
|
|
50
|
+
return () => { this._h[ev] = this._h[ev].filter((f) => f !== cb) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_emit (ev, ...args) { for (const h of (this._h[ev] || [])) { try { h(...args) } catch (_) {} } }
|
|
54
|
+
|
|
55
|
+
async _identify () {
|
|
56
|
+
// En modo self NO identificamos esta conexión como P: el proxy enruta por token
|
|
57
|
+
// las respuestas del agente (no por pubkey), y así no recibimos el fan-out de
|
|
58
|
+
// mensajes dirigidos a P (que atiende el listener de enrolamiento). La
|
|
59
|
+
// autorización la da el vault/self-cert que va firmado en el handshake.
|
|
60
|
+
if (this.link.mode === 'self') return
|
|
61
|
+
if (!this.client.token) return
|
|
62
|
+
// Patrón estándar del ecosistema (messenger): identify firmado por id.signData
|
|
63
|
+
// + cert del vault → el proxy enruta también lo dirigido a la maestra.
|
|
64
|
+
const { id, cert } = this.link
|
|
65
|
+
const publickey = id.me?.publickey
|
|
66
|
+
if (!publickey) return
|
|
67
|
+
const data = { op: 'identify', publickey, token: this.client.token, ts: Date.now() }
|
|
68
|
+
const { signature } = await id.signData(data)
|
|
69
|
+
await this.client.identify({ data, signature, cert })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async connect () {
|
|
73
|
+
if (!this.agentPubkey) throw new Error('falta la dirección del agente destino')
|
|
74
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
75
|
+
this.client = new WebSocketProxyClient({ url: this.proxyUrl, enableWebRTC: false, autoReconnect: true })
|
|
76
|
+
await this.client.connect()
|
|
77
|
+
await this._identify()
|
|
78
|
+
if (this.link.mode !== 'self') {
|
|
79
|
+
this.client.on('token', () => { this._identify().catch(() => {}) })
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
this.client.on('message', async (_from, p) => {
|
|
83
|
+
if (!p || typeof p !== 'object') return
|
|
84
|
+
if (p.type === DATA && p.sid === this.sid) {
|
|
85
|
+
try { const m = await open(this.key, p.env); this._emit('message', m) } catch {}
|
|
86
|
+
} else if (p.type === ERROR) {
|
|
87
|
+
this._emit('error', new Error(p.error))
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const eph = await makeEphemeral()
|
|
92
|
+
// El self-cert P←P del modo self puede vencerse (24 h): refrescarlo si hace falta.
|
|
93
|
+
let cert = this.link.cert
|
|
94
|
+
if (this.link.mode === 'self' && this.link.getSelfCert) {
|
|
95
|
+
cert = await this.link.getSelfCert()
|
|
96
|
+
}
|
|
97
|
+
// `publickey` va DENTRO del dato firmado: verifyChain verifica la firma contra
|
|
98
|
+
// data.publickey y exige cert.sub === data.publickey.
|
|
99
|
+
const data = { op: HS, eph: eph.pub, publickey: this.link.id.me?.publickey, ts: Date.now() }
|
|
100
|
+
const { signature } = await this.link.id.signData(data)
|
|
101
|
+
|
|
102
|
+
// El ACK se correlaciona por la clave efímera PROPIA (ceph === eph.pub): así
|
|
103
|
+
// varias sesiones simultáneas (varios RemoteAgentClient sobre la misma pubkey)
|
|
104
|
+
// no se roban el ACK de la otra.
|
|
105
|
+
const acked = new Promise((resolve, reject) => {
|
|
106
|
+
const off = this.client.on('message', (_from, p) => {
|
|
107
|
+
if (!p || typeof p !== 'object') return
|
|
108
|
+
if (p.type === ACK && p.ack && p.ack.ceph === eph.pub) { off(); resolve(p) }
|
|
109
|
+
else if (p.type === ERROR) { off(); reject(new Error(p.error)) }
|
|
110
|
+
})
|
|
111
|
+
setTimeout(() => { off(); reject(new Error('el agente no respondió (¿está corriendo allí?)')) }, 20000)
|
|
112
|
+
})
|
|
113
|
+
this.client.sendByPubkey(this.agentPubkey, { type: HS, data, signature, cert })
|
|
114
|
+
const res = await acked
|
|
115
|
+
|
|
116
|
+
// El ack debe: (1) encadenar a NUESTRA maestra, (2) estar firmado por el agente
|
|
117
|
+
// que apuntamos, (3) atar nuestra pub efímera y el sid.
|
|
118
|
+
const chk = await verifyChain({ data: res.ack, signature: res.signature, cert: res.cert, trustedIssuer: this.link.iss })
|
|
119
|
+
if (!chk.ok) throw new Error('el agente no está certificado por tu vault: ' + chk.reason)
|
|
120
|
+
if (res.ack.machine !== this.agentPubkey) throw new Error('el ack vino de otro agente')
|
|
121
|
+
if (res.ack.ceph !== eph.pub || res.ack.sid !== res.sid) throw new Error('ack no corresponde a este handshake')
|
|
122
|
+
|
|
123
|
+
this.sid = res.sid
|
|
124
|
+
this.key = await deriveKey(eph.privateKey, res.ack.seph, res.sid)
|
|
125
|
+
return this
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Envía un payload de dominio cifrado al agente. */
|
|
129
|
+
async send (payload) {
|
|
130
|
+
const env = await seal(this.key, payload)
|
|
131
|
+
this.client.sendByPubkey(this.agentPubkey, { type: DATA, sid: this.sid, env })
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Sonda de presencia (liveness) sin abrir sesión. Devuelve true si responde. */
|
|
135
|
+
async ping ({ timeoutMs = 4000 } = {}) {
|
|
136
|
+
const n = crypto.getRandomValues(new Uint8Array(8)).join('')
|
|
137
|
+
const got = new Promise((resolve) => {
|
|
138
|
+
const off = this.client.on('message', (_f, p) => {
|
|
139
|
+
if (p?.type === PONG && p.n === n) { off(); resolve(true) }
|
|
140
|
+
})
|
|
141
|
+
setTimeout(() => { off(); resolve(false) }, timeoutMs)
|
|
142
|
+
})
|
|
143
|
+
this.client.sendByPubkey(this.agentPubkey, { type: PING, n })
|
|
144
|
+
return got
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async close () {
|
|
148
|
+
try { this.client?.close() } catch {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default { RemoteAgentClient }
|
package/src/discover.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* discover.js — autodescubrimiento de agentes vinculados al vault (lado cliente).
|
|
3
|
+
*
|
|
4
|
+
* Tus agentes = los dispositivos enrolados en TU vault (`vault.devices` trae la
|
|
5
|
+
* pubkey `sub` de cada uno = su dirección en el proxy). Sin pegar nada: filtrás por
|
|
6
|
+
* label y abrís una sesión con el que quieras.
|
|
7
|
+
*
|
|
8
|
+
* Si pasás `label` (p. ej. `'ia-agent'`), lista solo los dispositivos con ESE label
|
|
9
|
+
* exacto. Si NO pasás `label`, mantiene el comportamiento legacy de terminal: lista
|
|
10
|
+
* cualquier dispositivo que tenga un label y no sea `'cli'` (los navegadores
|
|
11
|
+
* enrolados quedan con label `'cli'` y no atienden agentes).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {object} id instancia de Identity (del vault) ya conectada.
|
|
16
|
+
* @param {string} [label] filtrar por label exacto (p. ej. 'ia-agent'). Omitir = todos los no-cli.
|
|
17
|
+
* @returns {Promise<Array<{sub:string,label:string,deviceId:string,exp?:number}>>}
|
|
18
|
+
* dedup por pubkey (renovaciones/re-emparejes → el cert más nuevo).
|
|
19
|
+
*/
|
|
20
|
+
export async function listAgentsByLabel (id, label) {
|
|
21
|
+
const { devices } = await id.listVaultDevices()
|
|
22
|
+
const mine = id.me?.publickey
|
|
23
|
+
const now = Date.now()
|
|
24
|
+
const bySub = new Map()
|
|
25
|
+
for (const d of devices || []) {
|
|
26
|
+
if (!d.sub || d.sub === mine || (d.exp && d.exp <= now)) continue
|
|
27
|
+
if (label ? d.label !== label : (!d.label || d.label === 'cli')) continue
|
|
28
|
+
if (!bySub.has(d.sub) || (d.exp || 0) > (bySub.get(d.sub).exp || 0)) bySub.set(d.sub, d)
|
|
29
|
+
}
|
|
30
|
+
return [...bySub.values()]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export default { listAgentsByLabel }
|
package/src/link.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* link.js — enrola ESTA máquina como un dispositivo del vault (mismo flujo
|
|
3
|
+
* endurecido que usa el navegador / `dotrino-vault/src/client.js#enroll`). Es lo
|
|
4
|
+
* que vuelve al agente INDEPENDIENTE de la máquina del vault: guarda su propia
|
|
5
|
+
* sub-clave `D` + `cert` (cadena `D ← maestra`); la maestra NUNCA vive aquí, solo
|
|
6
|
+
* su pública pineada (`iss`).
|
|
7
|
+
*
|
|
8
|
+
* Persistencia en un directorio propio (por defecto
|
|
9
|
+
* `~/.local/share/<name>`, override `DOTRINO_REMOTE_AGENT_DIR` o `dir` explícito):
|
|
10
|
+
* NO comparte carpeta con el vault, así el agente puede correr en otro host.
|
|
11
|
+
*
|
|
12
|
+
* El `label` (p. ej. `terminal-agent`, `ia-agent`) viaja en el cert y lo usa el
|
|
13
|
+
* cliente para FILTRAR qué dispositivos son agentes del tipo que busca
|
|
14
|
+
* (listAgentsByLabel).
|
|
15
|
+
*/
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import {
|
|
20
|
+
makeDeviceKey, signWithDevice, verifyDelegation, makePairingCode, pubkeyId
|
|
21
|
+
} from '@dotrino/identity/capabilities'
|
|
22
|
+
import { installNodeGlobals } from '../node-globals.js'
|
|
23
|
+
|
|
24
|
+
const MSG = {
|
|
25
|
+
ENROLL: 'vault.enroll',
|
|
26
|
+
ENROLL_CHALLENGE: 'vault.enroll.challenge',
|
|
27
|
+
ENROLLED: 'vault.enrolled',
|
|
28
|
+
ERROR: 'vault.error'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Directorio de datos del agente. `name` define el subdir (p. ej. 'dotrino-ia-agent'). */
|
|
32
|
+
export function dataDir (name = 'dotrino-remote-agent') {
|
|
33
|
+
if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
|
|
34
|
+
const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
|
|
35
|
+
return path.join(base, name)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const linkPath = (dir) => path.join(dir, 'link.json')
|
|
39
|
+
|
|
40
|
+
export function loadLink (dir = dataDir()) {
|
|
41
|
+
try { return JSON.parse(fs.readFileSync(linkPath(dir), 'utf8')) } catch { return null }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function saveLink (dir, link) {
|
|
45
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
46
|
+
fs.writeFileSync(linkPath(dir), JSON.stringify(link, null, 2), { mode: 0o600 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function parseQr (text) {
|
|
50
|
+
const qr = JSON.parse(String(text).trim())
|
|
51
|
+
if (!qr?.iss || !qr?.proxy || !qr?.token || !qr?.sn) throw new Error('QR inválido (v2): faltan iss/proxy/token/sn')
|
|
52
|
+
return qr
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function freshClient (proxyUrl, dir) {
|
|
56
|
+
installNodeGlobals(dir)
|
|
57
|
+
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
58
|
+
const client = new WebSocketProxyClient({ url: proxyUrl, enableWebRTC: false, autoReconnect: false })
|
|
59
|
+
await client.connect()
|
|
60
|
+
return client
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Enrola esta máquina contra el vault del QR. Devuelve y persiste
|
|
65
|
+
* `{ device, cert, iss, proxy, label, at }`.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} args
|
|
68
|
+
* @param {object} args.qr QR parseado del vault ({ iss, proxy, token, sn }).
|
|
69
|
+
* @param {string} [args.label='remote-agent'] label del dispositivo (lo filtra el cliente).
|
|
70
|
+
* @param {string} [args.dir=dataDir()] dónde persistir el enlace.
|
|
71
|
+
* @param {(c:{deviceId:string,code:string})=>void} [args.onChallenge] mostrar el CÓDIGO para
|
|
72
|
+
* que un humano lo TIPEE en la bóveda al aprobar (el código NO viaja de aquí).
|
|
73
|
+
* @param {number} [args.timeoutMs=180000]
|
|
74
|
+
*/
|
|
75
|
+
export async function enroll ({ qr, label = 'remote-agent', dir = dataDir(), onChallenge, timeoutMs = 180000 } = {}) {
|
|
76
|
+
const client = await freshClient(qr.proxy, dir)
|
|
77
|
+
try {
|
|
78
|
+
const device = await makeDeviceKey({ label })
|
|
79
|
+
const deviceId = (await pubkeyId(device.publickey)).slice(0, 8).toUpperCase().replace(/(.{4})(.{4})/, '$1-$2')
|
|
80
|
+
// Código de emparejamiento ALEATORIO (un secreto, NO derivable de valores públicos):
|
|
81
|
+
// lo mostramos y NO lo enviamos. La bóveda lo aprende solo cuando un humano lo tipea
|
|
82
|
+
// y nos lo ECHA de vuelta → así aprobar exige TENER esta máquina (de aquí sale el código).
|
|
83
|
+
const code = makePairingCode()
|
|
84
|
+
const data = { op: 'enroll', dpub: device.publickey, token: qr.token, sn: qr.sn, label, ts: Date.now() }
|
|
85
|
+
const { signature } = await signWithDevice({ privateJwk: device.privateJwk, data })
|
|
86
|
+
|
|
87
|
+
const done = new Promise((resolve, reject) => {
|
|
88
|
+
const off = client.on('message', (_from, p) => {
|
|
89
|
+
if (!p || typeof p !== 'object') return
|
|
90
|
+
if (p.type === MSG.ENROLL_CHALLENGE) onChallenge?.({ deviceId, code })
|
|
91
|
+
// Aceptamos SOLO si el código echado coincide con el que generamos (anti
|
|
92
|
+
// aprobación-a-ciegas / bóveda impostora). Un eco distinto se IGNORA y seguimos
|
|
93
|
+
// esperando la aprobación correcta (o el timeout).
|
|
94
|
+
else if (p.type === MSG.ENROLLED) { if (String(p.code || '').trim() === code) { cleanup(); resolve(p) } }
|
|
95
|
+
else if (p.type === MSG.ERROR) { cleanup(); reject(new Error(p.error)) }
|
|
96
|
+
})
|
|
97
|
+
const t = setTimeout(() => { cleanup(); reject(new Error('timeout esperando la aprobación en el vault')) }, timeoutMs)
|
|
98
|
+
const cleanup = () => { off(); clearTimeout(t) }
|
|
99
|
+
})
|
|
100
|
+
client.sendByPubkey(qr.iss, { type: MSG.ENROLL, data, signature })
|
|
101
|
+
const res = await done
|
|
102
|
+
|
|
103
|
+
const v = await verifyDelegation({ cert: res.cert, expectedSub: device.publickey })
|
|
104
|
+
if (!v.ok) throw new Error('cert inválido: ' + v.reason)
|
|
105
|
+
if (res.cert.iss !== qr.iss) throw new Error('cert firmado por otra maestra (posible proxy malicioso)')
|
|
106
|
+
|
|
107
|
+
const link = { device, cert: res.cert, iss: qr.iss, proxy: qr.proxy, label, at: Date.now() }
|
|
108
|
+
saveLink(dir, link)
|
|
109
|
+
return link
|
|
110
|
+
} finally { client.close() }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default { dataDir, loadLink, parseQr, enroll }
|