@dotrino/identity 0.111.0 → 0.112.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.111.0",
3
+ "version": "0.112.0",
4
4
  "description": "Identidad y rating de usuarios compartidos entre apps de Dotrino (vault iframe + postMessage)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/vault/remote.js CHANGED
@@ -374,7 +374,9 @@ export async function requestStore ({ master, proxy, device, cert, method, args,
374
374
  export async function requestDevices ({ master, proxy, device, cert, sinceSeq, onRevoked } = {}) {
375
375
  const data = typeof sinceSeq === 'number' ? { op: 'devices', sinceSeq } : { op: 'devices' }
376
376
  const res = await vaultRpc({ master, proxy, device, cert, onRevoked, sendType: 'vault.devices', okType: 'vault.devices.result', data })
377
- return { devices: res.devices || [], revoked: res.revoked || [], acta: res.acta || null, chain: res.chain || null }
377
+ // `sealerChain`: [génesis, …cambios de sellador…, actual], lo que una firma de este aparato
378
+ // tiene que llevar para que `verifySignedFor` la ate a la cuenta (vaultd ≥ 0.154.0).
379
+ return { devices: res.devices || [], revoked: res.revoked || [], acta: res.acta || null, chain: res.chain || null, sealerChain: res.sealerChain || null }
378
380
  }
379
381
 
380
382
  /**
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.30.4 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats,stats-log}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.32.0 (dotrino-proxy-client/src/{index,client,signature,canonical,sealing,encpub,webrtc,stats,stats-log}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  sealing.js resuelve @dotrino/identity/content de forma PEREZOSA (= ../../content.js
4
4
  por el import map): solo se carga si de verdad se sella algo.
@@ -1,7 +1,7 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData, samePubkey } from './signature.js'
2
2
  import { seal, open, isSealed } from './sealing.js'
3
3
  import { buildEncPubStatement, readEncPubStatement, isEncPub } from './encpub.js'
4
- import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection } from './webrtc.js'
4
+ import { WebRTCManager, RTC_TAG, DEFAULT_ICE_SERVERS, loadNodePeerConnection, resolvePeerConnection, pinTurnToIPv4 } from './webrtc.js'
5
5
  import { TrafficStats, utf8Length, registerTransport, unregisterTransport } from './stats.js'
6
6
 
7
7
  /**
@@ -1002,9 +1002,35 @@ export class WebSocketProxyClient {
1002
1002
  clearTimeout(this._turnTimer)
1003
1003
  this._turnTimer = null
1004
1004
  }
1005
- const res = await this.getTurnCredentials({ publicKey, sign })
1006
- if (!res.enabled || !res.iceServers) return false
1007
- this._rtc.setIceServers([...res.iceServers, ...DEFAULT_ICE_SERVERS])
1005
+ let res
1006
+ try {
1007
+ res = await this.getTurnCredentials({ publicKey, sign })
1008
+ } catch (e) {
1009
+ if (e?.status !== 429) throw e
1010
+ // 429: EL PROXIO DICE «AHORA NO» (tope diario de tráfico, cuota por hora, o no puede
1011
+ // leer el consumo). No es un fallo: se sigue por el proxio, que es el escalón que
1012
+ // siempre funciona. Se quita el relevo que hubiera (sus credenciales ya no se
1013
+ // renuevan), se dice por un evento y en `stats()`, y se vuelve a pedir cuando el
1014
+ // proxio dice que se puede — si no, un aparato que pasó por un día con el tope
1015
+ // alcanzado se quedaría sin relevo hasta su siguiente reconexión.
1016
+ this._rtc.setIceServers(DEFAULT_ICE_SERVERS)
1017
+ const retryAfterMs = Math.max(60000, Number(e.retryAfterMs) || 0)
1018
+ this._turn = { active: false, code: e.code || null, retryAt: Date.now() + retryAfterMs }
1019
+ this._emit('turn_unavailable', { code: e.code || null, retryAfterMs })
1020
+ this._turnTimer = setTimeout(() => {
1021
+ this._turnTimer = null
1022
+ this.enableTurn({ publicKey, sign }).catch(() => {})
1023
+ }, retryAfterMs)
1024
+ return false
1025
+ }
1026
+ if (!res.enabled || !res.iceServers) {
1027
+ this._turn = { active: false, code: 'turn-disabled', retryAt: null }
1028
+ return false
1029
+ }
1030
+ this._turn = { active: true, code: null, expiresAt: res.expiresAt || null }
1031
+ // En Node el relevo va con su dirección IPv4 ya escrita (ver `pinTurnToIPv4`).
1032
+ const turnServers = await pinTurnToIPv4(res.iceServers)
1033
+ this._rtc.setIceServers([...turnServers, ...DEFAULT_ICE_SERVERS])
1008
1034
  // Renovar con margen antes de expirar (mínimo 30 s entre renovaciones)
1009
1035
  const refreshIn = Math.max(30000, (res.expiresAt || 0) - Date.now() - 60000)
1010
1036
  this._turnTimer = setTimeout(() => {
@@ -1523,6 +1549,8 @@ export class WebSocketProxyClient {
1523
1549
  publickey: this.myPublickey,
1524
1550
  connected: this._connected,
1525
1551
  webrtc: !!this._rtc,
1552
+ // El relevo: null si nunca se pidió; si no, activo o por qué no (`code`, `retryAt`).
1553
+ turn: this._turn ? { ...this._turn } : null,
1526
1554
  since: t.since,
1527
1555
  proxy: { ...t.proxy },
1528
1556
  peers
@@ -1566,7 +1594,11 @@ export class WebSocketProxyClient {
1566
1594
  this._pending.delete(id)
1567
1595
  // Con su `code`: quien llama distingue los casos por él, no por la frase (que el proxio
1568
1596
  // puede cambiar o traducir). Antes se perdía aquí y solo quedaba comparar el texto.
1569
- entry.reject(errorCon(data.error || 'Server error', data.code || null))
1597
+ const e = errorCon(data.error || 'Server error', data.code || null)
1598
+ // «Ahora no» (429) y cuánto esperar, cuando el proxio lo dice.
1599
+ if (data.status) e.status = data.status
1600
+ if (data.retry_after_ms) e.retryAfterMs = data.retry_after_ms
1601
+ entry.reject(e)
1570
1602
  }
1571
1603
 
1572
1604
  _emit (event, ...args) {
@@ -11,7 +11,7 @@ export {
11
11
  } from './encpub.js'
12
12
 
13
13
  export { listTransports, routeOf } from './stats.js'
14
- export { logStats, formatStats } from './stats-log.js'
14
+ export { logStats, formatStats, netReport, splitPeers } from './stats-log.js'
15
15
 
16
16
  import { WebSocketProxyClient } from './client.js'
17
17
 
@@ -54,6 +54,7 @@ export function formatStats (s, opts = {}) {
54
54
  ` ws.in=${s.proxy.bytesIn} ws.out=${s.proxy.bytesOut}` +
55
55
  ` ${pairs('in', sum(s.peers, 'bytesIn'))} ${pairs('out', sum(s.peers, 'bytesOut'))}` +
56
56
  ` peers=${talking.length} routes=${routeList}` +
57
+ (s.turn ? ` turn=${s.turn.active ? 'on' : `off(${s.turn.code || '?'})`}` : '') +
57
58
  (silent.length ? ` unanswered=${silent.length} unanswered.out=${silent.reduce((n, p) => n + PATHS.reduce((m, k) => m + p.bytesOut[k], 0), 0)}` : '') +
58
59
  (unknown.length ? ` unknown=${unknown.length} unknown.in=${unknown.reduce((n, p) => n + PATHS.reduce((m, k) => m + p.bytesIn[k], 0), 0)}` : '')
59
60
  ]
@@ -105,3 +106,63 @@ export function logStats (opts = {}) {
105
106
  stop: async () => { clearInterval(timer); await flush() }
106
107
  }
107
108
  }
109
+
110
+ /** Bytes para el informe de texto: `B`, `KB` con un decimal, `MB` con dos. */
111
+ const rBytes = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(2)} MB`)
112
+ const rPaths = (o) => {
113
+ const parts = PATHS.filter((k) => (o?.[k] || 0) > 0).map((k) => `${k} ${rBytes(o[k])}`)
114
+ return parts.length ? parts.join(', ') : '0 B'
115
+ }
116
+
117
+ /**
118
+ * Los que contestaron algo (`talking`) y los que solo recibieron (`silent`, resumidos): a un
119
+ * aparato apagado al que se le mandó un ping no se le llama conexión. `unknown`: quien nos
120
+ * escribió, nunca recibió respuesta y no sabemos su llave. Tampoco es una conexión.
121
+ *
122
+ * @param {Array<object>} peers `TransportStats.peers`
123
+ */
124
+ export function splitPeers (peers) {
125
+ const total = (o) => PATHS.reduce((n, k) => n + (o?.[k] || 0), 0)
126
+ const talking = []
127
+ const silent = { count: 0, bytesOut: 0, msgsOut: 0 }
128
+ const unknown = { count: 0, bytesIn: 0, msgsIn: 0 }
129
+ for (const p of peers) {
130
+ if (!((p.msgsIn || 0) > 0)) { silent.count++; silent.bytesOut += total(p.bytesOut); silent.msgsOut += p.msgsOut || 0 } else if (!p.pubkey && !((p.msgsOut || 0) > 0)) { unknown.count++; unknown.bytesIn += total(p.bytesIn); unknown.msgsIn += p.msgsIn || 0 } else talking.push(p)
131
+ }
132
+ return { talking, silent, unknown }
133
+ }
134
+
135
+ /**
136
+ * EL INFORME DE RED COMO TEXTO, para que lo lea una persona: las mismas líneas que el modal de
137
+ * red del topbar y que las apps nativas (`NetworkStats.report`). A diferencia de `formatStats`
138
+ * (que va a un log en disco y por eso calla la llave), este se enseña en pantalla a quien lo
139
+ * pidió, y dice con quién es cada conexión.
140
+ *
141
+ * @param {Array<object>} list lo que devolvió `client.stats()` de cada cliente (o `{ url, error }`)
142
+ * @param {{ label?: (pubkey: string) => string | Promise<string>, now?: Date }} [opts]
143
+ * `label`: cómo se nombra un aparato a partir de su llave (su id `AB12-CD34`, su nombre).
144
+ * Sin él, o si falla, sale `?`: nunca un trozo de la llave.
145
+ * @returns {Promise<string>}
146
+ */
147
+ export async function netReport (list, opts = {}) {
148
+ const now = opts.now || new Date()
149
+ let out = `Dotrino network stats · ${now.toISOString().replace(/\.\d{3}Z$/, 'Z')}\n`
150
+ if (!list.length) out += '(no transports)\n'
151
+ for (const s of list) {
152
+ if (s.error) { out += `Proxy ${s.url} | error: ${s.error}\n`; continue }
153
+ const hhmm = new Date(s.since).toTimeString().slice(0, 8)
154
+ out += `Proxy ${s.url}${s.app ? ` | app=${s.app}` : ''}${s.node ? ` | node=${s.node}` : ''} | ${s.connected ? 'connected' : 'disconnected'} | since ${hhmm}\n`
155
+ out += ` proxy total: in ${rBytes(s.proxy.bytesIn)} / out ${rBytes(s.proxy.bytesOut)} (frames ${s.proxy.framesIn ?? '?'}/${s.proxy.framesOut ?? '?'})\n`
156
+ const { talking, silent, unknown } = splitPeers(s.peers)
157
+ out += ` peers: ${talking.length}\n`
158
+ for (const p of talking) {
159
+ let who = '?'
160
+ if (p.pubkey && opts.label) { try { who = (await opts.label(p.pubkey)) || '?' } catch (_) { who = '?' } }
161
+ const tok = p.token ? ` (token ${p.token})` : ''
162
+ out += ` - ${who}${tok} | route=${p.route} | in: ${rPaths(p.bytesIn)} | out: ${rPaths(p.bytesOut)} | ${(p.msgsIn || 0) + (p.msgsOut || 0)} msgs\n`
163
+ }
164
+ if (silent.count) out += ` no answer: ${silent.count} (out ${rBytes(silent.bytesOut)}, ${silent.msgsOut} msgs)\n`
165
+ if (unknown.count) out += ` unknown senders: ${unknown.count} (in ${rBytes(unknown.bytesIn)}, ${unknown.msgsIn} msgs)\n`
166
+ }
167
+ return out
168
+ }
@@ -6,7 +6,7 @@
6
6
  * `send()` payloads tagged with `_rtc`.
7
7
  * - Once the DataChannel is open, payloads from `client.send(token, ...)`
8
8
  * travel P2P; before that (or on failure) they fall back to the proxy.
9
- * - STUN-only (no TURN). Symmetric NATs will simply stay on the proxy.
9
+ * - STUN by default; `client.enableTurn()` adds the proxy's temporary TURN servers.
10
10
  *
11
11
  * Glare resolution: the peer with the lexicographically smaller token is
12
12
  * the "polite" one (rolls back on collision).
@@ -76,6 +76,55 @@ export async function loadNodePeerConnection () {
76
76
  return _PC
77
77
  }
78
78
 
79
+ /**
80
+ * EL RELEVO, FIJADO A IPv4 — solo fuera del navegador.
81
+ *
82
+ * La implementación de Node (`@dotrino/webrtc`, o sea werift) abre el socket UDP hacia el
83
+ * relevo en IPv4 y nada más, pero resuelve el nombre sin pedir familia. En una máquina con
84
+ * las dos, el nombre sale en IPv6: el pedido se manda a una dirección que ese socket no
85
+ * alcanza, se reintenta siete veces (6,4 s medidos) y solo entonces se cae a TCP. Y como la
86
+ * oferta espera a que termine de reunir candidatos, CADA negociación arrancaba 6,4 s tarde
87
+ * por punta — una sesión más corta que eso no salía nunca del proxio.
88
+ *
89
+ * Con la dirección ya escrita no hay nada que resolver: 30 ms, y el relevo va por UDP.
90
+ *
91
+ * Solo las `turn:`. Una `turns:` necesita el nombre para comprobar el certificado, y a un
92
+ * `stun:` no le pasa esto. Se envuelve desde aquí y no se toca la librería, que es una poda
93
+ * sin cambios de lógica (README de `dotrino-webrtc`).
94
+ *
95
+ * Si el nombre no tiene dirección IPv4, se deja como vino: esa máquina no tiene por dónde
96
+ * llegar en UDP, y con el nombre la librería todavía llega por TCP.
97
+ *
98
+ * @param {any[]} iceServers
99
+ * @param {{ native?: boolean, lookup?: (host: string) => Promise<string> }} [opts]
100
+ * @returns {Promise<any[]>}
101
+ */
102
+ export async function pinTurnToIPv4 (iceServers, opts = {}) {
103
+ const native = opts.native ?? (typeof globalThis.RTCPeerConnection === 'function')
104
+ if (native || !Array.isArray(iceServers)) return iceServers
105
+ let lookup = opts.lookup
106
+ if (!lookup) {
107
+ // El nombre va en una variable para que ningún empaquetador lo siga: esto lo importan
108
+ // las apps de navegador, donde `node:dns` no existe (y donde no se llega hasta aquí).
109
+ const mod = 'node:dns'
110
+ const dns = await import(mod)
111
+ lookup = async (host) => (await dns.promises.lookup(host, { family: 4 })).address
112
+ }
113
+ const cache = new Map()
114
+ const pin = async (url) => {
115
+ const m = /^(turn:)([^:?[\]]+)((?::\d+)?(?:\?.*)?)$/i.exec(String(url).trim())
116
+ if (!m || /^[\d.]+$/.test(m[2])) return url
117
+ if (!cache.has(m[2])) cache.set(m[2], lookup(m[2]).catch(() => null))
118
+ const ip = await cache.get(m[2])
119
+ return ip ? m[1] + ip + m[3] : url
120
+ }
121
+ return Promise.all(iceServers.map(async (s) => {
122
+ if (!s || s.urls == null) return s
123
+ const urls = Array.isArray(s.urls) ? await Promise.all(s.urls.map(pin)) : await pin(s.urls)
124
+ return { ...s, urls }
125
+ }))
126
+ }
127
+
79
128
  export class WebRTCManager {
80
129
  /**
81
130
  * @param {object} opts
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.83.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
1
+ Copia vendorizada de @dotrino/vault@0.84.0 (dotrino-vault/lib/src/{index,enroll,protocol,passwordLogins,loginClient,b64}.js).
2
2
  NO se edita a mano: la escribe `node vendor.mjs` y la vigila test/vendor-up-to-date.test.mjs.
3
3
  index.js importa ./enroll.js y ./protocol.js (relativos, van en esta misma copia),
4
4
  @dotrino/identity/{capabilities,acta} (= ../../{capabilities,acta}.js) y
@@ -217,7 +217,10 @@ export async function startDeviceVault (identity, { proxyUrl, client: injectedCl
217
217
  deviceId: x.sub ? await deviceIdOf(x.sub) : null, sub: x.sub || null,
218
218
  label: x.label || '', scope: x.scope, seq: x.seq, nonce: x.nonce
219
219
  })))
220
- send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r) })
220
+ // La cadena de selladores va con la lista (como en el daemon): es lo que un aparato de
221
+ // Node necesita para firmar a nombre de la cuenta de forma que un extraño lo compruebe.
222
+ const sealerChain = await identity.sealerChain?.().catch(() => null) || null
223
+ send(from, { type: MSG.DEVICES_RESULT, devices, revoked: (revoked || []).map((r) => r.nonce || r), sealerChain })
221
224
  // ¿el que consulta es una máquina revocada que reapareció? → re-emite el REVOKED firmado.
222
225
  // Se busca en `revokedCerts`: desde que `issued` solo trae lo vigente, los retirados
223
226
  // ya no están ahí y este aviso dejaba de dispararse (se caía en silencio).