@dotrino/remote-agent 0.5.4 → 0.6.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/package.json +3 -3
- package/src/agent.js +65 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -45,12 +45,12 @@
|
|
|
45
45
|
"p2p"
|
|
46
46
|
],
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@dotrino/identity": ">=0.
|
|
48
|
+
"@dotrino/identity": ">=0.73.0",
|
|
49
49
|
"@dotrino/proxy-client": ">=0.10.1",
|
|
50
50
|
"@dotrino/vault": ">=0.30.0"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@dotrino/identity": "0.
|
|
53
|
+
"@dotrino/identity": "0.73.0",
|
|
54
54
|
"@dotrino/proxy-client": "0.13.1",
|
|
55
55
|
"@dotrino/vault": "0.35.0"
|
|
56
56
|
}
|
package/src/agent.js
CHANGED
|
@@ -22,9 +22,10 @@
|
|
|
22
22
|
import fs from 'node:fs'
|
|
23
23
|
import path from 'node:path'
|
|
24
24
|
import { verifyChain, signWithDevice, verifyDeviceSig, verifyDelegation, pubkeyId } from '@dotrino/identity/capabilities'
|
|
25
|
+
import { sealersOf, memberCanSign } from '@dotrino/identity/acta'
|
|
25
26
|
import { installNodeGlobals } from '../node-globals.js'
|
|
26
27
|
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
|
|
28
|
+
import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, REVOKE_REFRESH_MS } from '../protocol.js'
|
|
28
29
|
import { loadLink, saveLink, dataDir } from './link.js'
|
|
29
30
|
|
|
30
31
|
/** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
|
|
@@ -154,48 +155,76 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
154
155
|
try {
|
|
155
156
|
const res = await vaultRpc(VMSG.DEVICES, VMSG.DEVICES_RESULT, { op: 'devices', publickey: myPub, ts: Date.now() })
|
|
156
157
|
revokedSet = new Set((res.revoked || []).map((r) => r.nonce || r))
|
|
158
|
+
// El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
|
|
159
|
+
// mismo tic si el dueño nos cambió los permisos.
|
|
160
|
+
anotarActa(res.acta)
|
|
157
161
|
} catch (e) {
|
|
158
162
|
if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
|
|
159
163
|
}
|
|
160
164
|
}
|
|
161
165
|
|
|
162
166
|
/**
|
|
163
|
-
* RENOVACIÓN del cert (`vault.renew`).
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
* el agente ya está hablando con la bóveda ahí, y si está caída o el proxy no va, el
|
|
167
|
-
* siguiente tic reintenta — con días de margen antes de que el cert muera.
|
|
167
|
+
* RENOVACIÓN del cert (`vault.renew`). YA NO ES UNA TAREA DE CALENDARIO: el papel no
|
|
168
|
+
* vence, lleva el `seq` del acta con el que se emitió. Se pide uno nuevo cuando el ACTA
|
|
169
|
+
* dice algo distinto de lo que lleva escrito — o sea, cuando el dueño cambió permisos.
|
|
168
170
|
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
171
|
+
* Antes se pedía al acercarse la caducidad, y eso obligaba a que la bóveda pudiera firmar
|
|
172
|
+
* sola cada mes; era el último motivo por el que la maestra tenía que estar disponible sin
|
|
173
|
+
* nadie delante. Ahora renovar ocurre justo cuando ya hay una selladora abierta, porque
|
|
174
|
+
* cambiar el acta ES tenerla abierta.
|
|
175
|
+
*
|
|
176
|
+
* El cert nuevo se VERIFICA antes de guardarlo, contra el ACTA que viene con él: quien lo
|
|
177
|
+
* firmó tiene que ser selladora de ESTE perfil. Ya no se compara con una llave fija, para
|
|
178
|
+
* que una segunda bóveda pueda emitir; lo que se fija es el perfil.
|
|
172
179
|
*/
|
|
173
180
|
async function renewCertIfNeeded () {
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
// ¿Nos quedamos atrás? Lo sabemos por el acta que la bóveda manda con cada respuesta.
|
|
182
|
+
const visto = link.actaSeq
|
|
183
|
+
if (typeof visto !== 'number' || typeof link.cert?.seq !== 'number' || visto <= link.cert.seq) return
|
|
176
184
|
try {
|
|
177
185
|
const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
|
|
178
186
|
const cert = res?.cert
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
187
|
+
const acta = res?.acta
|
|
188
|
+
if (!cert || cert.sub !== myPub) throw new Error('the vault returned a cert that is not for this machine')
|
|
189
|
+
if (!acta) throw new Error('the vault did not send its record: cannot check who signed this cert')
|
|
190
|
+
if (acta.profileId !== master) throw new Error('the record is from a profile other than the pinned one')
|
|
191
|
+
const v = await verifyDelegation({
|
|
192
|
+
cert, expectedSub: myPub, expectedScope: SIGN_SCOPE,
|
|
193
|
+
actaSeq: acta.seq, sealers: sealersOf(acta)
|
|
194
|
+
})
|
|
183
195
|
if (!v.ok) throw new Error(v.reason)
|
|
184
196
|
link.cert = cert
|
|
197
|
+
link.actaSeq = acta.seq
|
|
185
198
|
saveLink(dir, link)
|
|
186
|
-
audit('cert-renew', {
|
|
187
|
-
if (!opts.quiet) console.log(`[remote-agent] cert renewed
|
|
199
|
+
audit('cert-renew', { seq: cert.seq })
|
|
200
|
+
if (!opts.quiet) console.log(`[remote-agent] cert renewed against record #${cert.seq}`)
|
|
188
201
|
} catch (e) {
|
|
189
|
-
// No es fatal: el
|
|
190
|
-
// reintenta. Se avisa igual
|
|
191
|
-
//
|
|
192
|
-
if (!opts.quiet)
|
|
193
|
-
const days = Math.max(0, Math.round((exp - Date.now()) / 86400000))
|
|
194
|
-
console.error(`[remote-agent] could not renew the cert (${days} day(s) left):`, e.message)
|
|
195
|
-
}
|
|
202
|
+
// No es fatal: el papel de antes sigue sirviendo para todo lo que el acta le siga
|
|
203
|
+
// permitiendo, y el próximo tic reintenta. Se avisa igual: si esto falla, lo que el
|
|
204
|
+
// dueño acaba de conceder no llega.
|
|
205
|
+
if (!opts.quiet) console.error('[remote-agent] could not renew the cert:', e.message)
|
|
196
206
|
}
|
|
197
207
|
}
|
|
198
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Guarda el acta que manda la bóveda. Hace dos cosas, y las dos hacen falta:
|
|
211
|
+
* · dispara la renovación cuando su `seq` pasa al del papel que tenemos;
|
|
212
|
+
* · es CON LO QUE SE JUZGA a quien nos habla — quien firma tiene que ser selladora de
|
|
213
|
+
* este perfil, y eso solo lo dice el acta. Por eso se PERSISTE: si viviera en memoria,
|
|
214
|
+
* al reiniciar el agente no podría atender a nadie hasta el primer tic.
|
|
215
|
+
*/
|
|
216
|
+
function anotarActa (acta) {
|
|
217
|
+
if (typeof acta?.seq !== 'number' || link.acta?.seq === acta.seq) return
|
|
218
|
+
link.acta = acta
|
|
219
|
+
link.actaSeq = acta.seq
|
|
220
|
+
try { saveLink(dir, link) } catch (_) {}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** El acta con la que se juzga un papel. Sin ella no se atiende a nadie. */
|
|
224
|
+
const contextoActa = () => (link.acta
|
|
225
|
+
? { actaSeq: link.acta.seq, sealers: sealersOf(link.acta) }
|
|
226
|
+
: { actaSeq: null, sealers: null })
|
|
227
|
+
|
|
199
228
|
const vaultTick = async () => { await refreshRevocations(); await renewCertIfNeeded() }
|
|
200
229
|
vaultTick()
|
|
201
230
|
const revTimer = setInterval(vaultTick, REVOKE_REFRESH_MS); revTimer.unref?.()
|
|
@@ -217,8 +246,19 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
217
246
|
if (typeof data.ts !== 'number' || Math.abs(Date.now() - data.ts) > 5 * 60 * 1000) {
|
|
218
247
|
return send(from, { type: ERROR, error: 'handshake vencido (posible replay, o el reloj del dispositivo está desfasado)' })
|
|
219
248
|
}
|
|
220
|
-
|
|
249
|
+
// Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
|
|
250
|
+
// venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
|
|
251
|
+
// qué decidir, así que no se decide que sí.
|
|
252
|
+
const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...contextoActa(), revoked: revokedSet })
|
|
221
253
|
if (!chk.ok) return send(from, { type: ERROR, error: 'no autorizado: ' + chk.reason })
|
|
254
|
+
// CERT ∩ ACTA. `verifyChain` comprueba QUIÉN EMITIÓ el papel; que el APARATO siga siendo
|
|
255
|
+
// miembro y pueda firmar lo dice el acta, y hay que preguntárselo aparte. Sin esto, un
|
|
256
|
+
// papel bien firmado por una selladora abría sesión aunque el acta no nombrara a ese
|
|
257
|
+
// aparato — o ya no lo nombrara. Bajo el modelo viejo daba igual, porque el papel ERA la
|
|
258
|
+
// autoridad y caducaba; con el papel atado al acta, el acta tiene que decidir.
|
|
259
|
+
if (!memberCanSign(link.acta, chk.device)) {
|
|
260
|
+
return send(from, { type: ERROR, error: 'no autorizado: acta — ese aparato no firma por este perfil' })
|
|
261
|
+
}
|
|
222
262
|
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake inválido' })
|
|
223
263
|
|
|
224
264
|
const eph = await makeEphemeral()
|