@dotrino/remote-agent 0.22.0 → 0.22.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 +1 -1
- package/src/agent.js +20 -20
- package/src/client.js +5 -5
- package/src/link.js +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.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",
|
package/src/agent.js
CHANGED
|
@@ -130,7 +130,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
130
130
|
const dir = opts.dir || dataDir()
|
|
131
131
|
const link = opts.link || loadLink(dir)
|
|
132
132
|
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) {
|
|
133
|
-
throw new Error('
|
|
133
|
+
throw new Error('this machine is not linked: enroll your agent first')
|
|
134
134
|
}
|
|
135
135
|
const master = link.iss
|
|
136
136
|
const myPub = link.device.publickey
|
|
@@ -214,7 +214,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
214
214
|
if (Array.isArray(res.blocked)) takeBlocked(res.blocked)
|
|
215
215
|
// El acta viaja con la lista. Apuntar su `seq` es lo que dispara la renovación en el
|
|
216
216
|
// mismo tic si el dueño nos cambió los permisos.
|
|
217
|
-
await
|
|
217
|
+
await recordActa(res.acta)
|
|
218
218
|
} catch (e) {
|
|
219
219
|
if (!opts.quiet) console.error('[remote-agent] could not refresh revocations (using cache):', e.message)
|
|
220
220
|
}
|
|
@@ -270,10 +270,10 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
270
270
|
// máquina. Sin esta rama la migración no terminaba nunca — el disparador comparaba
|
|
271
271
|
// `seq` y un papel viejo no tiene, así que nunca saltaba.
|
|
272
272
|
// · o el acta que hemos visto va por delante del papel: el dueño cambió permisos.
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const
|
|
276
|
-
if (!
|
|
273
|
+
const legacy = typeof link.cert?.seq !== 'number'
|
|
274
|
+
const seenSeq = link.actaSeq
|
|
275
|
+
const behind = typeof seenSeq === 'number' && typeof link.cert?.seq === 'number' && seenSeq > link.cert.seq
|
|
276
|
+
if (!legacy && !behind) return
|
|
277
277
|
try {
|
|
278
278
|
const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
|
|
279
279
|
const cert = res?.cert
|
|
@@ -308,7 +308,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
308
308
|
* mandar un acta inventada y nombrarse miembro. Ahora el acta tiene que encadenar desde la
|
|
309
309
|
* que ya se dio por buena, o venir firmada por la bóveda con la que se enroló.
|
|
310
310
|
*/
|
|
311
|
-
async function
|
|
311
|
+
async function recordActa (acta) {
|
|
312
312
|
if (typeof acta?.seq !== 'number') return
|
|
313
313
|
const r = await adoptRecord({ link, candidate: acta, me: myPub })
|
|
314
314
|
if (!r.adopt) {
|
|
@@ -328,9 +328,9 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
328
328
|
* El acta con la que se juzga un papel. Sin ella no se atiende a nadie — y un acta que
|
|
329
329
|
* nunca se comprobó (la que guardó una versión anterior) cuenta como no tenerla.
|
|
330
330
|
*/
|
|
331
|
-
const
|
|
332
|
-
const
|
|
333
|
-
const acta =
|
|
331
|
+
const trustedActa = () => (link.actaTrusted === true && link.acta ? link.acta : null)
|
|
332
|
+
const actaContext = () => {
|
|
333
|
+
const acta = trustedActa()
|
|
334
334
|
return acta ? { actaSeq: acta.seq, sealers: sealersOf(acta) } : { actaSeq: null, sealers: null }
|
|
335
335
|
}
|
|
336
336
|
|
|
@@ -349,11 +349,11 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
349
349
|
|
|
350
350
|
async function handleHandshake (from, p) {
|
|
351
351
|
const { data, signature, cert } = p
|
|
352
|
-
if (!data || !signature || !cert) return send(from, { type: ERROR, error: 'handshake
|
|
352
|
+
if (!data || !signature || !cert) return send(from, { type: ERROR, error: 'incomplete handshake' })
|
|
353
353
|
// FRESCURA anti-replay: el HS debe ser reciente (±5 min); sin esto un relay
|
|
354
354
|
// malicioso podía reproducir handshakes viejos (sesiones huérfanas / DoS).
|
|
355
355
|
if (typeof data.ts !== 'number' || Math.abs(Date.now() - data.ts) > 5 * 60 * 1000) {
|
|
356
|
-
return send(from, { type: ERROR, error: 'handshake
|
|
356
|
+
return send(from, { type: ERROR, error: 'stale handshake (possible replay, or the device clock is off)' })
|
|
357
357
|
}
|
|
358
358
|
// Manda el acta, no una llave fija: con varias selladoras el papel de un peer puede
|
|
359
359
|
// venir firmado por otra bóveda del mismo perfil. Sin acta no se atiende — no hay con
|
|
@@ -365,13 +365,13 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
365
365
|
// sola — y solo si el acta avanzó. Con freno (`catchUp`): cualquiera puede mandar saludos, y
|
|
366
366
|
// no por eso se le pregunta a la bóveda cada vez.
|
|
367
367
|
const judge = async () => {
|
|
368
|
-
const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...
|
|
368
|
+
const chk = await verifyChain({ data, signature, cert, expectedScope: SIGN_SCOPE, ...actaContext(), revoked: revokedSet })
|
|
369
369
|
if (!chk.ok) return { error: 'no autorizado: ' + chk.reason }
|
|
370
370
|
return { chk }
|
|
371
371
|
}
|
|
372
372
|
let verdict = await judge()
|
|
373
373
|
const known = (v) => {
|
|
374
|
-
const acta =
|
|
374
|
+
const acta = trustedActa()
|
|
375
375
|
return !!acta && (acta.members || []).some((m) => m?.pub === v.chk.device) && memberCan(acta, v.chk.device, 'sign')
|
|
376
376
|
}
|
|
377
377
|
if ((verdict.error || !known(verdict)) && await catchUp()) verdict = await judge()
|
|
@@ -391,7 +391,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
391
391
|
// abrir sesión con otro agente. El bot social se pasó así el 1 de septiembre, con un
|
|
392
392
|
// node de contenido encendido a su lado que le contestaba «ese aparato no firma por este
|
|
393
393
|
// perfil». Se le pide lo que corresponde: que el acta lo nombre y le reconozca `sign`.
|
|
394
|
-
const acta =
|
|
394
|
+
const acta = trustedActa()
|
|
395
395
|
const isMember = (acta?.members || []).some((m) => m?.pub === chk.device)
|
|
396
396
|
if (!acta || !isMember || !memberCan(acta, chk.device, 'sign')) {
|
|
397
397
|
return send(from, { type: ERROR, error: 'no autorizado: acta — este perfil no reconoce a ese aparato' })
|
|
@@ -402,7 +402,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
402
402
|
audit('session-refused', { reason: 'blocked', device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
|
|
403
403
|
return send(from, { type: ERROR, code: 'blocked', error: 'no autorizado: bloqueado — un aprobador bloqueó este aparato; se desbloquea en la bóveda' })
|
|
404
404
|
}
|
|
405
|
-
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'handshake
|
|
405
|
+
if (data.op !== HS || typeof data.eph !== 'string') return send(from, { type: ERROR, error: 'invalid handshake' })
|
|
406
406
|
|
|
407
407
|
const eph = await makeEphemeral()
|
|
408
408
|
const sid = [...crypto.getRandomValues(new Uint8Array(16))].map((x) => x.toString(16).padStart(2, '0')).join('')
|
|
@@ -419,7 +419,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
419
419
|
sessions.set(sid, session)
|
|
420
420
|
audit('session-open', { sid: sid.slice(0, 8), device: (await pubkeyId(chk.device)).slice(0, 8).toUpperCase() })
|
|
421
421
|
send(from, { type: ACK, sid, ack, signature: ackSig, cert: link.cert })
|
|
422
|
-
if (!opts.quiet) console.log(`[remote-agent]
|
|
422
|
+
if (!opts.quiet) console.log(`[remote-agent] session ${sid.slice(0, 8)} authorized (device ${chk.device?.slice?.(0, 8) || '?'})`)
|
|
423
423
|
if (opts.onSession) { try { opts.onSession(session) } catch (e) { if (!opts.quiet) console.error('[remote-agent] onSession:', e.message) } }
|
|
424
424
|
}
|
|
425
425
|
|
|
@@ -428,11 +428,11 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
428
428
|
// Con `code` y el `sid`: así el cliente sabe que es SU sesión la que se perdió (el agente
|
|
429
429
|
// se reinició, o venció) y vuelve a saludar solo, en vez de quedarse con un error. Se
|
|
430
430
|
// comprueba por el código, no por la frase.
|
|
431
|
-
if (!s) return send(from, { type: ERROR, code: 'unknown-session', sid: p.sid, error: '
|
|
431
|
+
if (!s) return send(from, { type: ERROR, code: 'unknown-session', sid: p.sid, error: 'unknown or expired session' })
|
|
432
432
|
s._exp = Date.now() + SESSION_TTL_MS
|
|
433
433
|
s.from = from
|
|
434
434
|
if (s._gone) { clearTimeout(s._gone); s._gone = null }
|
|
435
|
-
s._ingest(p.env).catch(() => send(from, { type: ERROR, error: '
|
|
435
|
+
s._ingest(p.env).catch(() => send(from, { type: ERROR, error: 'invalid envelope' }))
|
|
436
436
|
}
|
|
437
437
|
|
|
438
438
|
// LA CONEXIÓN DEL CLIENTE SE FUE: su sesión no se queda 30 minutos. El proxio avisa de que ese
|
|
@@ -479,7 +479,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
479
479
|
if (typeof b.exp === 'number' && Date.now() > b.exp) return
|
|
480
480
|
if (!await verifyDeviceSig({ publickey: master, data: b, signature: p.signature })) return
|
|
481
481
|
audit('revoked', { nonce: b.nonce })
|
|
482
|
-
if (!opts.quiet) console.log('\n[remote-agent]
|
|
482
|
+
if (!opts.quiet) console.log('\n[remote-agent] your vault REVOKED this machine → deleting the link and exiting.')
|
|
483
483
|
try { fs.rmSync(path.join(dir, 'link.json'), { force: true }) } catch (_) {}
|
|
484
484
|
stop()
|
|
485
485
|
if (opts.onRevoked) { try { opts.onRevoked() } catch (_) {} }
|
package/src/client.js
CHANGED
|
@@ -92,7 +92,7 @@ export class RemoteAgentClient {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
async connect () {
|
|
95
|
-
if (!this.agentPubkey) throw new Error('
|
|
95
|
+
if (!this.agentPubkey) throw new Error('the target agent address is missing')
|
|
96
96
|
const { WebSocketProxyClient } = await import('@dotrino/proxy-client')
|
|
97
97
|
// SIEMPRE EL CAMINO MÁS DIRECTO: WebRTC encendido. Estuvo en `false` desde el 0.1.0
|
|
98
98
|
// porque entonces Node no tenía con qué; se quedó así cuando ya lo tuvo, y dos equipos
|
|
@@ -179,7 +179,7 @@ export class RemoteAgentClient {
|
|
|
179
179
|
if (p.type === ACK && p.ack && p.ack.ceph === eph.pub) { off(); if (from) this._directPeers.add(from); resolve({ ...p, from }) }
|
|
180
180
|
else if (p.type === ERROR) { off(); reject(new Error(p.error)) }
|
|
181
181
|
})
|
|
182
|
-
setTimeout(() => { off(); reject(new Error('
|
|
182
|
+
setTimeout(() => { off(); reject(new Error('the agent did not reply (is it running there?)')) }, 20000)
|
|
183
183
|
})
|
|
184
184
|
this.client.sendByPubkey(this.agentPubkey, { type: HS, data, signature, cert })
|
|
185
185
|
const res = await acked
|
|
@@ -205,9 +205,9 @@ export class RemoteAgentClient {
|
|
|
205
205
|
data: res.ack, signature: res.signature, cert: res.cert, trustedIssuer: this.link.iss,
|
|
206
206
|
actaSeq: acta?.seq ?? null, sealers: acta ? sealersOf(acta) : null
|
|
207
207
|
})
|
|
208
|
-
if (!chk.ok) throw new Error('
|
|
209
|
-
if (res.ack.machine !== this.agentPubkey) throw new Error('
|
|
210
|
-
if (res.ack.ceph !== eph.pub || res.ack.sid !== res.sid) throw new Error('ack
|
|
208
|
+
if (!chk.ok) throw new Error('the agent is not certified by your vault: ' + chk.reason)
|
|
209
|
+
if (res.ack.machine !== this.agentPubkey) throw new Error('the ack came from another agent')
|
|
210
|
+
if (res.ack.ceph !== eph.pub || res.ack.sid !== res.sid) throw new Error('the ack does not match this handshake')
|
|
211
211
|
|
|
212
212
|
this.sid = res.sid
|
|
213
213
|
this.key = await deriveKey(eph.privateKey, res.ack.seph, res.sid)
|
package/src/link.js
CHANGED
|
@@ -117,12 +117,12 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
|
|
|
117
117
|
return {
|
|
118
118
|
me: { publickey: device.publickey },
|
|
119
119
|
async signData (data) {
|
|
120
|
-
const
|
|
120
|
+
const signed = await signWithDevice({ privateJwk: device.privateJwk, publickey: device.publickey, data })
|
|
121
121
|
// A NOMBRE DE QUIÉN VA, igual que en el navegador: el `profileId` y la cadena de
|
|
122
122
|
// selladores que lo prueba (`refreshChain`). Sin cadena la firma vale ante la bóveda
|
|
123
123
|
// (que conoce el papel) pero no ante un extraño que use `verifySignedFor`.
|
|
124
124
|
const chain = Array.isArray(link.sealerChain) && link.sealerChain.length ? link.sealerChain : null
|
|
125
|
-
return { ...
|
|
125
|
+
return { ...signed, profileId: chain ? chain[0].profileId : null, chain }
|
|
126
126
|
},
|
|
127
127
|
async getVaultCert () { return { cert: link.cert } },
|
|
128
128
|
async vaultStatus () {
|
|
@@ -167,12 +167,12 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
|
|
|
167
167
|
// EL PAPEL DEL MODELO VIEJO SE CAMBIA SÍ O SÍ. Lleva `exp` y no `seq`; se acepta por el
|
|
168
168
|
// repliegue de migración pero muere en su fecha, y con él la máquina. Los demás se
|
|
169
169
|
// renuevan cuando el acta va por delante — nunca por calendario, que el papel no vence.
|
|
170
|
-
const
|
|
171
|
-
if (
|
|
170
|
+
const legacy = typeof link.cert.seq !== 'number'
|
|
171
|
+
if (legacy && typeof link.cert.exp === 'number' && link.cert.exp <= Date.now()) {
|
|
172
172
|
return { renewed: false, reason: 'expired: enroll again' }
|
|
173
173
|
}
|
|
174
|
-
const
|
|
175
|
-
if (!force && !
|
|
174
|
+
const behind = typeof link.actaSeq === 'number' && typeof link.cert.seq === 'number' && link.actaSeq > link.cert.seq
|
|
175
|
+
if (!force && !legacy && !behind) return { renewed: false, seq: link.cert.seq, reason: 'not due' }
|
|
176
176
|
installNodeGlobals(dir)
|
|
177
177
|
try {
|
|
178
178
|
// `requestRenew` ya JUZGA lo que devuelve (`checkVaultReply` del pilar: acta bien
|