@dotrino/remote-agent 0.22.3 → 0.22.4
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/protocol.js +3 -0
- package/src/agent.js +6 -16
- package/src/browser-link.js +3 -1
- package/src/client.js +2 -2
- package/src/link.js +47 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotrino/remote-agent",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.4",
|
|
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/protocol.js
CHANGED
|
@@ -33,6 +33,9 @@ export const VMSG = {
|
|
|
33
33
|
ERROR: 'vault.error'
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** El proxio por defecto, cuando el enlace no trae uno. Única definición del paquete. */
|
|
37
|
+
export const DEFAULT_PROXY = 'wss://proxy.dotrino.com'
|
|
38
|
+
|
|
36
39
|
/** Scope del cert de dispositivo que autoriza a hablar con el agente. */
|
|
37
40
|
export const SIGN_SCOPE = 'vault:sign'
|
|
38
41
|
|
package/src/agent.js
CHANGED
|
@@ -27,8 +27,8 @@ import { adoptRecord } from './record.js'
|
|
|
27
27
|
import { installNodeGlobals } from '../node-globals.js'
|
|
28
28
|
import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
|
|
29
29
|
import { makeCatchUp } from './catch-up.js'
|
|
30
|
-
import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SESSION_GONE_MS, REVOKE_REFRESH_MS } from '../protocol.js'
|
|
31
|
-
import { loadLink, saveLink, dataDir } from './link.js'
|
|
30
|
+
import { HS, ACK, DATA, PING, PONG, ERROR, VMSG, SIGN_SCOPE, SESSION_TTL_MS, SESSION_GONE_MS, REVOKE_REFRESH_MS, DEFAULT_PROXY } from '../protocol.js'
|
|
31
|
+
import { loadLink, saveLink, dataDir, renewalReason, storeRenewedCert } from './link.js'
|
|
32
32
|
|
|
33
33
|
/** Sesión cifrada con un dispositivo cliente. La app recibe esto en `onSession`. */
|
|
34
34
|
export class AgentSession {
|
|
@@ -142,7 +142,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
142
142
|
const sessions = new Map()
|
|
143
143
|
const hasSessionFrom = (token) => { for (const s of sessions.values()) if (s.from === token) return true; return false }
|
|
144
144
|
|
|
145
|
-
const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy ||
|
|
145
|
+
const proxyUrl = opts.proxyUrl || process.env.PROXY_URL || link.proxy || DEFAULT_PROXY
|
|
146
146
|
// `client` inyectado: solo para las pruebas (transporte de mentira). En producción se
|
|
147
147
|
// levanta el del ecosistema — no hay otro transporte.
|
|
148
148
|
const client = opts.client || await (async () => {
|
|
@@ -264,16 +264,8 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
264
264
|
* que una segunda bóveda pueda emitir; lo que se fija es el perfil.
|
|
265
265
|
*/
|
|
266
266
|
async function renewCertIfNeeded () {
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
// o sí: se acepta por el repliegue de migración, pero muere en su fecha y con él la
|
|
270
|
-
// máquina. Sin esta rama la migración no terminaba nunca — el disparador comparaba
|
|
271
|
-
// `seq` y un papel viejo no tiene, así que nunca saltaba.
|
|
272
|
-
// · o el acta que hemos visto va por delante del papel: el dueño cambió permisos.
|
|
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
|
|
267
|
+
// La regla de cuándo toca (papel del modelo viejo, o acta por delante) es UNA, en link.js.
|
|
268
|
+
if (!renewalReason(link)) return
|
|
277
269
|
try {
|
|
278
270
|
const res = await vaultRpc(VMSG.RENEW, VMSG.RENEWED, { op: 'renew', publickey: myPub, ts: Date.now() })
|
|
279
271
|
const cert = res?.cert
|
|
@@ -282,9 +274,7 @@ export async function startRemoteAgent (opts = {}) {
|
|
|
282
274
|
// `acta.profileId === master`, que con una segunda bóveda nunca es verdad.
|
|
283
275
|
const chk = await checkVaultReply({ acta, cert, vault: master, sub: myPub, scope: SIGN_SCOPE })
|
|
284
276
|
if (!chk.ok) throw new Error('invalid renewed cert: ' + chk.reason)
|
|
285
|
-
link
|
|
286
|
-
link.actaSeq = acta.seq
|
|
287
|
-
saveLink(dir, link)
|
|
277
|
+
await storeRenewedCert({ dir, link, cert, acta })
|
|
288
278
|
audit('cert-renew', { seq: cert.seq })
|
|
289
279
|
if (!opts.quiet) console.log(`[remote-agent] cert renewed against record #${cert.seq}`)
|
|
290
280
|
} catch (e) {
|
package/src/browser-link.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { DEFAULT_PROXY } from '../protocol.js'
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* El enlace de un NAVEGADOR a su bóveda, sacado del pilar de identidad (iframe
|
|
3
5
|
* id.dotrino.com): es lo que necesita `RemoteAgentClient` para hablar con un agente.
|
|
@@ -23,7 +25,7 @@ export async function linkFromIdentity (id) {
|
|
|
23
25
|
id,
|
|
24
26
|
cert: v.cert,
|
|
25
27
|
iss: status.master,
|
|
26
|
-
proxy: status.proxy ||
|
|
28
|
+
proxy: status.proxy || DEFAULT_PROXY,
|
|
27
29
|
deviceId: status.deviceId,
|
|
28
30
|
scope: status.scope || []
|
|
29
31
|
}
|
package/src/client.js
CHANGED
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
import { verifyChain } from '@dotrino/identity/capabilities'
|
|
24
24
|
import { sealersOf } from '@dotrino/identity/acta'
|
|
25
25
|
import { makeEphemeral, deriveKey, seal, open } from '../e2e.js'
|
|
26
|
-
import { HS, ACK, DATA, PING, PONG, ERROR } from '../protocol.js'
|
|
26
|
+
import { HS, ACK, DATA, PING, PONG, ERROR, DEFAULT_PROXY } from '../protocol.js'
|
|
27
27
|
|
|
28
28
|
export class RemoteAgentClient {
|
|
29
29
|
/**
|
|
@@ -37,7 +37,7 @@ export class RemoteAgentClient {
|
|
|
37
37
|
constructor (link, { agentPubkey, proxyUrl } = {}) {
|
|
38
38
|
this.link = link // { id, cert, iss, proxy, mode? }
|
|
39
39
|
this.agentPubkey = agentPubkey // pubkey del agente destino
|
|
40
|
-
this.proxyUrl = proxyUrl || link.proxy ||
|
|
40
|
+
this.proxyUrl = proxyUrl || link.proxy || DEFAULT_PROXY
|
|
41
41
|
this.client = null
|
|
42
42
|
this.key = null
|
|
43
43
|
this.sid = null
|
package/src/link.js
CHANGED
|
@@ -21,6 +21,7 @@ import { requestDevices, requestRenew } from '@dotrino/identity/vault/remote.js'
|
|
|
21
21
|
import { installNodeGlobals } from '../node-globals.js'
|
|
22
22
|
import { adoptRecord } from './record.js'
|
|
23
23
|
import { verifySealerChain } from '@dotrino/identity/acta'
|
|
24
|
+
import { DEFAULT_PROXY, RENEW_BEFORE_MS } from '../protocol.js'
|
|
24
25
|
|
|
25
26
|
export function dataDir (name = 'dotrino-remote-agent') {
|
|
26
27
|
if (process.env.DOTRINO_REMOTE_AGENT_DIR) return process.env.DOTRINO_REMOTE_AGENT_DIR
|
|
@@ -113,7 +114,7 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
|
|
|
113
114
|
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) throw new Error('invalid link: device/cert/iss required')
|
|
114
115
|
installNodeGlobals(dir)
|
|
115
116
|
const device = link.device
|
|
116
|
-
const proxy = link.proxy ||
|
|
117
|
+
const proxy = link.proxy || DEFAULT_PROXY
|
|
117
118
|
return {
|
|
118
119
|
me: { publickey: device.publickey },
|
|
119
120
|
async signData (data) {
|
|
@@ -146,8 +147,43 @@ export function identityFromLink (link, { dir = dataDir() } = {}) {
|
|
|
146
147
|
}
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
|
|
150
|
-
export
|
|
150
|
+
// Se reexporta por compatibilidad del subpath '/link'; la definición vive en protocol.js.
|
|
151
|
+
export { RENEW_BEFORE_MS }
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* POR QUÉ TOCA PEDIR PAPEL NUEVO, o `null` si no toca. Es la única regla de renovación:
|
|
155
|
+
* la usan `renewLink` (agentes que corren una vez) y el tic de `startRemoteAgent`.
|
|
156
|
+
* · `'legacy'`: el papel es del MODELO VIEJO (lleva `exp` y no `seq`). Se cambia sí o sí:
|
|
157
|
+
* se acepta por el repliegue de migración, pero muere en su fecha y con él la máquina.
|
|
158
|
+
* · `'behind'`: el acta que hemos visto va por delante del papel (el dueño cambió permisos).
|
|
159
|
+
* Nunca por calendario: el papel no vence.
|
|
160
|
+
*
|
|
161
|
+
* @param {object} link
|
|
162
|
+
* @returns {'legacy'|'behind'|null}
|
|
163
|
+
*/
|
|
164
|
+
export function renewalReason (link) {
|
|
165
|
+
if (typeof link?.cert?.seq !== 'number') return 'legacy'
|
|
166
|
+
if (typeof link.actaSeq === 'number' && link.actaSeq > link.cert.seq) return 'behind'
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* GUARDA un papel renovado (ya comprobado por quien lo pidió) y lo persiste. SE GUARDA ANTES
|
|
172
|
+
* DE DAR NADA POR BUENO: emitir un papel RETIRA el anterior, así que si esto no se persiste la
|
|
173
|
+
* máquina se queda usando uno revocado y fuera para siempre.
|
|
174
|
+
*
|
|
175
|
+
* El acta que viaja con el papel se adopta SOLO si se puede comprobar (`record.js`): es la que
|
|
176
|
+
* decide quién abre sesión con este agente. El papel sí se guarda siempre.
|
|
177
|
+
*/
|
|
178
|
+
export async function storeRenewedCert ({ dir, link, cert, acta }) {
|
|
179
|
+
link.cert = cert
|
|
180
|
+
link.actaSeq = acta.seq
|
|
181
|
+
if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
|
|
182
|
+
link.acta = acta
|
|
183
|
+
link.actaTrusted = true
|
|
184
|
+
}
|
|
185
|
+
saveLink(dir, link)
|
|
186
|
+
}
|
|
151
187
|
|
|
152
188
|
/**
|
|
153
189
|
* RENUEVA el cert del enlace si vence pronto y lo persiste. Es lo que hace el agente
|
|
@@ -167,31 +203,22 @@ export async function renewLink (link, { dir = dataDir(), force = false } = {})
|
|
|
167
203
|
// EL PAPEL DEL MODELO VIEJO SE CAMBIA SÍ O SÍ. Lleva `exp` y no `seq`; se acepta por el
|
|
168
204
|
// repliegue de migración pero muere en su fecha, y con él la máquina. Los demás se
|
|
169
205
|
// renuevan cuando el acta va por delante — nunca por calendario, que el papel no vence.
|
|
170
|
-
const
|
|
171
|
-
if (legacy && typeof link.cert.exp === 'number' && link.cert.exp <= Date.now()) {
|
|
206
|
+
const why = renewalReason(link)
|
|
207
|
+
if (why === 'legacy' && typeof link.cert.exp === 'number' && link.cert.exp <= Date.now()) {
|
|
172
208
|
return { renewed: false, reason: 'expired: enroll again' }
|
|
173
209
|
}
|
|
174
|
-
|
|
175
|
-
if (!force && !legacy && !behind) return { renewed: false, seq: link.cert.seq, reason: 'not due' }
|
|
210
|
+
if (!force && !why) return { renewed: false, seq: link.cert.seq, reason: 'not due' }
|
|
176
211
|
installNodeGlobals(dir)
|
|
177
212
|
try {
|
|
178
213
|
// `requestRenew` ya JUZGA lo que devuelve (`checkVaultReply` del pilar: acta bien
|
|
179
214
|
// firmada, esta bóveda puede sellarla, el papel lo firmó ella y es para esta llave) y
|
|
180
215
|
// lanza si no cuadra. Aquí se comparaba `acta.profileId` con `link.iss`, que solo vale
|
|
181
216
|
// en una cuenta que nació en esa bóveda: con una segunda bóveda no renovaba nunca.
|
|
182
|
-
const { cert, acta } = await requestRenew({ master: link.iss, proxy: link.proxy ||
|
|
217
|
+
const { cert, acta } = await requestRenew({ master: link.iss, proxy: link.proxy || DEFAULT_PROXY, device: link.device, cert: link.cert })
|
|
183
218
|
// SE GUARDA ANTES DE DAR NADA POR BUENO. Emitir un papel RETIRA el anterior, así que si
|
|
184
219
|
// esto no se persiste la máquina se queda usando uno revocado y fuera para siempre. Le
|
|
185
220
|
// pasó al registro de selladores en la migración, y por eso está dicho aquí.
|
|
186
|
-
link
|
|
187
|
-
link.actaSeq = acta.seq
|
|
188
|
-
// El acta que viaja con el papel se guarda SOLO si se puede comprobar (`record.js`): es
|
|
189
|
-
// la que decide quién abre sesión con este agente. El papel sí se guarda siempre.
|
|
190
|
-
if ((await adoptRecord({ link, candidate: acta, me: link.device.publickey })).adopt) {
|
|
191
|
-
link.acta = acta
|
|
192
|
-
link.actaTrusted = true
|
|
193
|
-
}
|
|
194
|
-
saveLink(dir, link)
|
|
221
|
+
await storeRenewedCert({ dir, link, cert, acta })
|
|
195
222
|
return { renewed: true, seq: cert.seq }
|
|
196
223
|
} catch (e) {
|
|
197
224
|
return { renewed: false, seq: link.cert.seq ?? null, reason: e.message }
|
|
@@ -211,7 +238,7 @@ export async function refreshChain (link, { dir = dataDir() } = {}) {
|
|
|
211
238
|
if (!link?.device?.privateJwk || !link?.cert || !link?.iss) return { ok: false, reason: 'invalid link' }
|
|
212
239
|
installNodeGlobals(dir)
|
|
213
240
|
try {
|
|
214
|
-
const res = await requestDevices({ master: link.iss, proxy: link.proxy ||
|
|
241
|
+
const res = await requestDevices({ master: link.iss, proxy: link.proxy || DEFAULT_PROXY, device: link.device, cert: link.cert })
|
|
215
242
|
const chain = res.sealerChain
|
|
216
243
|
if (!Array.isArray(chain) || !chain.length) return { ok: false, reason: 'the vault sent no sealer chain (vaultd < 0.154.0?)' }
|
|
217
244
|
const expected = link.sealerChain?.[0]?.profileId ?? link.acta?.profileId ?? null
|
|
@@ -232,7 +259,7 @@ export async function refreshChain (link, { dir = dataDir() } = {}) {
|
|
|
232
259
|
|
|
233
260
|
/** El enlace como lo esperan `ContentClient.connect({ link })` y `RemoteAgentClient`. */
|
|
234
261
|
export function clientLink (link, opts) {
|
|
235
|
-
return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy ||
|
|
262
|
+
return { id: identityFromLink(link, opts), cert: link.cert, iss: link.iss, proxy: link.proxy || DEFAULT_PROXY }
|
|
236
263
|
}
|
|
237
264
|
|
|
238
|
-
export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink, refreshChain }
|
|
265
|
+
export default { dataDir, loadLink, saveLink, parseQr, enroll, identityFromLink, clientLink, renewLink, renewalReason, storeRenewedCert, refreshChain }
|