@dotrino/identity 0.110.2 → 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 +1 -1
- package/vault/acta.js +47 -27
- package/vault/core.js +3 -3
- package/vault/remote.js +3 -1
- package/vault/vendor/proxy-client/VERSION.txt +1 -1
- package/vault/vendor/proxy-client/client.js +37 -5
- package/vault/vendor/proxy-client/index.js +1 -1
- package/vault/vendor/proxy-client/stats-log.js +61 -0
- package/vault/vendor/proxy-client/webrtc.js +50 -1
- package/vault/vendor/vault/VERSION.txt +1 -1
- package/vault/vendor/vault/enroll.js +12 -7
- package/vault/vendor/vault/index.js +4 -1
package/package.json
CHANGED
package/vault/acta.js
CHANGED
|
@@ -94,8 +94,8 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
|
|
|
94
94
|
* traspaso); el permiso dice **quién puede firmar el acta**. Admitir sigue sin ser un
|
|
95
95
|
* permiso: se admite sellando.
|
|
96
96
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
97
|
+
* El cajón de un servicio NO está en la lista: lo abre su **CN**, no un permiso. Ver
|
|
98
|
+
* `cnScope` abajo.
|
|
99
99
|
*
|
|
100
100
|
* `admin` es la rendija que se le abre a la consola remota (`dotrino-vault/docs/
|
|
101
101
|
* consola-remota.md`): deja **admitir y expulsar** miembros a distancia, y nada más.
|
|
@@ -103,7 +103,7 @@ const conCampoSellador = (acta) => Number(acta?.v) < V_SIN_CAMPO_SELLADOR
|
|
|
103
103
|
* el rol de master, que no se delega. Así un dispositivo con `admin` robado hace daño
|
|
104
104
|
* acotado y **reversible** (se le revoca), en vez de poder dejarte fuera de tu cuenta.
|
|
105
105
|
*/
|
|
106
|
-
export const CAPS = Object.freeze(['sign', 'store', 'read', '
|
|
106
|
+
export const CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'approve', 'passwords', 'passkeys', 'sealer', 'unattended', 'replica'])
|
|
107
107
|
|
|
108
108
|
/** Capacidades de un DISPOSITIVO (sin CN): acceso a todo lo del usuario. */
|
|
109
109
|
/**
|
|
@@ -158,32 +158,57 @@ export const DEVICE_CAPS = Object.freeze(['sign', 'store', 'read', 'admin', 'app
|
|
|
158
158
|
*/
|
|
159
159
|
export const PAIRED_CAPS = Object.freeze(['sign', 'store', 'read'])
|
|
160
160
|
|
|
161
|
-
/** Capacidades de un SERVICIO (con CN): solo su propio cajón de secretos. */
|
|
162
|
-
export const SERVICE_CAPS = Object.freeze(['secrets'])
|
|
163
|
-
|
|
164
161
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
162
|
+
* EL CAJÓN NO ES UN PERMISO: LO ABRE EL CN (dueño, 2026-10-13).
|
|
163
|
+
*
|
|
164
|
+
* Antes existía la capacidad `secrets`, y abrir el cajón exigía las dos cosas: llevar el
|
|
165
|
+
* `cn` de ese cajón Y tener `secrets`. La segunda no decidía nada —el alta se la ponía sola
|
|
166
|
+
* a todo miembro con `cn`— y en la pantalla de permisos salía como una casilla más al lado
|
|
167
|
+
* de las de verdad. Un servicio abre el cajón de SU nombre porque el acta dice que es ese
|
|
168
|
+
* servicio; para cortárselo se le bloquea o se le quita.
|
|
169
|
+
*
|
|
170
|
+
* Las actas ya selladas que llevan la palabra se siguen leyendo: es una capacidad que este
|
|
171
|
+
* lector no conoce, así que no cuenta (ver `checkShape`).
|
|
167
172
|
*/
|
|
168
|
-
|
|
173
|
+
|
|
174
|
+
/** Qué capacidades puede llevar un miembro: las mismas con cajón (CN) que sin él. */
|
|
175
|
+
export const allowedCaps = () => [...DEVICE_CAPS]
|
|
169
176
|
|
|
170
177
|
/** Un CN válido: minúsculas, números y guiones (igual que el namespace de secretos). */
|
|
171
178
|
export const isValidCn = (cn) => typeof cn === 'string' && /^[a-z0-9-]{1,32}$/.test(cn)
|
|
172
179
|
|
|
173
180
|
/**
|
|
174
|
-
* Scope del cert que corresponde a cada capacidad.
|
|
175
|
-
*
|
|
176
|
-
* scope de secretos «de todos».
|
|
181
|
+
* Scope del cert que corresponde a cada capacidad. El del cajón no sale de aquí: lo da el
|
|
182
|
+
* CN (`cnScope`).
|
|
177
183
|
*/
|
|
178
|
-
export function capScope (cap
|
|
184
|
+
export function capScope (cap) {
|
|
179
185
|
// SALE DE `CAP_SCOPE`, no de una cadena de `if` escrita a mano. Esto era lo segundo: la
|
|
180
186
|
// lista de arriba y esta se escribían por separado, así que un permiso nuevo entraba en
|
|
181
187
|
// una y no en la otra y se quedaba sin scope en silencio. Ya pasó con `sealer`,
|
|
182
188
|
// `unattended` y `secrets`; con `replica` se cortó aquí.
|
|
183
|
-
if (cap === 'secrets') return isValidCn(cn) ? 'vault:secrets:' + cn : null
|
|
184
189
|
return CAP_SCOPE[cap] || null
|
|
185
190
|
}
|
|
186
191
|
|
|
192
|
+
/**
|
|
193
|
+
* EL NOMBRE DE LA PROPIA BÓVEDA NO ES UN SERVICIO, Y NO TIENE CAJÓN.
|
|
194
|
+
*
|
|
195
|
+
* La llave de comunicación de la bóveda entra en el acta con este `cn` para hablar por la
|
|
196
|
+
* bóveda y no firmar por la persona. Es la que firma con el perfil CERRADO, así que un sobre
|
|
197
|
+
* dirigido a ella sería leer secretos sin abrir la bóveda. Mientras existió la capacidad
|
|
198
|
+
* `secrets` lo impedía no tenerla; ahora que el cajón lo abre el `cn`, se dice aquí y por
|
|
199
|
+
* su nombre: este `cn` no abre ninguno, tampoco uno que se llame igual.
|
|
200
|
+
*/
|
|
201
|
+
export const VAULT_CN = 'vault'
|
|
202
|
+
|
|
203
|
+
/** ¿Es el `cn` de un servicio con cajón? Válido y distinto del de la propia bóveda. */
|
|
204
|
+
const hasDrawer = (cn) => isValidCn(cn) && cn !== VAULT_CN
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* El scope del cajón de un servicio: el miembro `proxy` obtiene `vault:secrets:proxy` y nada
|
|
208
|
+
* más — no existe un scope de secretos «de todos».
|
|
209
|
+
*/
|
|
210
|
+
export const cnScope = (cn) => (hasDrawer(cn) ? 'vault:secrets:' + cn : null)
|
|
211
|
+
|
|
187
212
|
/** Compat: el mapa directo, para las capacidades de dispositivo. */
|
|
188
213
|
export const CAP_SCOPE = Object.freeze({ sign: 'vault:sign', store: 'vault:store', read: 'vault:read', admin: 'vault:admin', approve: 'vault:approve', passwords: 'vault:passwords', sealer: 'vault:sealer', replica: 'vault:replica' })
|
|
189
214
|
|
|
@@ -479,11 +504,7 @@ export function checkShape (acta) {
|
|
|
479
504
|
// El CN es la frontera: un SERVICIO solo puede abrir su propio cajón, y un
|
|
480
505
|
// DISPOSITIVO no tiene cajón que abrir. Que no se pueda escribir un acta que
|
|
481
506
|
// mezcle las dos cosas es lo que hace que el límite sea real y no una costumbre.
|
|
482
|
-
if (m.cn != null)
|
|
483
|
-
if (!isValidCn(m.cn)) return 'cn-invalido'
|
|
484
|
-
} else if (m.caps.includes('secrets')) {
|
|
485
|
-
return 'secretos-sin-cn'
|
|
486
|
-
}
|
|
507
|
+
if (m.cn != null && !isValidCn(m.cn)) return 'cn-invalido'
|
|
487
508
|
// La llave de cifrado: por ahora se valida la FORMA y solo si viene. NO se exige
|
|
488
509
|
// todavía, aunque el destino sea exigírsela a quien recibe secretos.
|
|
489
510
|
//
|
|
@@ -777,8 +798,8 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
|
|
|
777
798
|
cn,
|
|
778
799
|
// PERMISOS, no tipos (2026-08-22): el CN dice qué cajón abre; no recorta lo demás.
|
|
779
800
|
// Un miembro con CN puede llevar además las capacidades de aparato (un bot que
|
|
780
|
-
// firma como aparato del acta Y lee su cajón).
|
|
781
|
-
caps: cleanCaps(m.caps)
|
|
801
|
+
// firma como aparato del acta Y lee su cajón).
|
|
802
|
+
caps: cleanCaps(m.caps),
|
|
782
803
|
addedAt: now,
|
|
783
804
|
cert: m.cert || null,
|
|
784
805
|
// Puente con la identidad que este miembro traía de antes (ver makeContinuity).
|
|
@@ -803,8 +824,8 @@ export async function applyChanges (acta, changes, { by, now = Date.now(), sealP
|
|
|
803
824
|
const m = find(ch.pub)
|
|
804
825
|
if (!m) throw new Error('caps: that member is not in the record')
|
|
805
826
|
// El CN no cambia por aquí (quitar el cajón es sacar al miembro y volver a admitirlo,
|
|
806
|
-
// que es un gesto visible); las capacidades s
|
|
807
|
-
m.caps = cleanCaps(ch.caps)
|
|
827
|
+
// que es un gesto visible); las capacidades sí.
|
|
828
|
+
m.caps = cleanCaps(ch.caps)
|
|
808
829
|
// CONCEDER LIMPIA LA RENUNCIA. `effectiveCaps` resta lo renunciado, así que sin
|
|
809
830
|
// esto una renuncia sellada era IRREVERSIBLE: el master le devolvía el permiso en
|
|
810
831
|
// `caps` y la resta seguía dejándolo fuera, para siempre. La interfaz promete
|
|
@@ -1066,15 +1087,14 @@ export function memberCanScope (acta, pub, scope, extraRenounces = []) {
|
|
|
1066
1087
|
|
|
1067
1088
|
export function memberCanReadSecrets (acta, pub, ns, extraRenounces = []) {
|
|
1068
1089
|
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
1069
|
-
|
|
1070
|
-
return m.cn === ns && effectiveCaps(acta, pub, extraRenounces).includes('secrets')
|
|
1090
|
+
return !!m && hasDrawer(m.cn) && m.cn === ns
|
|
1071
1091
|
}
|
|
1072
1092
|
|
|
1073
1093
|
/** Los scopes de cert que le corresponden a un miembro según el acta. */
|
|
1074
1094
|
export function memberScopes (acta, pub, extraRenounces = []) {
|
|
1075
1095
|
const m = (acta?.members || []).find((x) => x.pub === pub)
|
|
1076
1096
|
if (!m) return []
|
|
1077
|
-
return effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c
|
|
1097
|
+
return [cnScope(m.cn), ...effectiveCaps(acta, pub, extraRenounces).map((c) => capScope(c))].filter(Boolean)
|
|
1078
1098
|
}
|
|
1079
1099
|
|
|
1080
1100
|
/** ¿Es un servicio (tiene CN) o un dispositivo del usuario? */
|
|
@@ -1276,6 +1296,6 @@ export default {
|
|
|
1276
1296
|
sealActa, verifyActa, checkVaultReply, applyChanges, makeRenounce, verifyRenounce,
|
|
1277
1297
|
makeContinuity, verifyContinuity,
|
|
1278
1298
|
cardBody, makeProfileCard, verifyProfileCard, canAdoptCard, sealersOf, canSeal,
|
|
1279
|
-
effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, isValidCn, canAdopt,
|
|
1299
|
+
effectiveCaps, memberCan, memberCanSign, memberCanScope, memberCanReadSecrets, memberScopes, isService, capScope, cnScope, VAULT_CN, isValidCn, canAdopt,
|
|
1280
1300
|
samePubkey, memberEncPub
|
|
1281
1301
|
}
|
package/vault/core.js
CHANGED
|
@@ -2596,9 +2596,9 @@ export async function createIdentityCore ({ kv: hostKv, peers, makeSync = null,
|
|
|
2596
2596
|
* `continuity`: si esa identidad ya existía por su cuenta, su puente firmado (F3).
|
|
2597
2597
|
*/
|
|
2598
2598
|
async admitMember ({ pub, encPub = null, label = '', cn = null, caps = null, cert = null, continuity = null } = {}) {
|
|
2599
|
-
// `cn`: si viene, este miembro es un SERVICIO
|
|
2600
|
-
//
|
|
2601
|
-
const finales = caps || (cn ? [
|
|
2599
|
+
// `cn`: si viene, este miembro es un SERVICIO: abre el cajón de secretos de ESE nombre
|
|
2600
|
+
// por llevarlo, sin permiso aparte, y no ve nada más del usuario. Sin `cn` es un dispositivo.
|
|
2601
|
+
const finales = caps || (cn ? [] : ['store', 'read'])
|
|
2602
2602
|
// Que entre al perfil incluye poder LEER lo que ya hay: se le envuelve la clave
|
|
2603
2603
|
// vigente (no hace falta rotar; rotar es para cuando alguien SALE).
|
|
2604
2604
|
//
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* `docs/pairing-protocol.md`).
|
|
31
31
|
*/
|
|
32
32
|
import { verifyDeviceSig, pubkeyId, commitCode } from '@dotrino/identity/capabilities'
|
|
33
|
-
import { verifyContinuity, canSeal, DEVICE_CAPS } from '@dotrino/identity/acta'
|
|
33
|
+
import { verifyContinuity, canSeal, DEVICE_CAPS, VAULT_CN } from '@dotrino/identity/acta'
|
|
34
34
|
|
|
35
35
|
/** Un token de emparejamiento vale 5 min. */
|
|
36
36
|
export const PAIRING_TTL_MS = 5 * 60 * 1000
|
|
@@ -169,12 +169,16 @@ export function createEnrollDesk ({
|
|
|
169
169
|
* el código, igual que hacía `caps` después.
|
|
170
170
|
*/
|
|
171
171
|
async function startPairing ({ scope = defaultScope, ttlMs = defaultTtlMs, label = '', mode = 'join', account = '', caps = null } = {}) {
|
|
172
|
+
// El nombre de la propia bóveda no es un servicio: su llave firma con el perfil cerrado
|
|
173
|
+
// y no puede abrir ningún cajón. Un servicio que entrara con ese nombre tampoco podría.
|
|
174
|
+
if (scopeToCn(scope) === VAULT_CN) throw Object.assign(new Error(`"${VAULT_CN}" is the vault's own name: a service cannot use it`), { code: 'reserved-cn' })
|
|
172
175
|
if (caps != null) {
|
|
173
176
|
if (!Array.isArray(caps)) throw Object.assign(new Error('caps must be a list'), { code: 'bad-caps' })
|
|
174
177
|
const cn = scopeToCn(scope)
|
|
175
|
-
const bad = caps.filter((c) => !
|
|
178
|
+
const bad = caps.filter((c) => !DEVICE_CAPS.includes(c))
|
|
176
179
|
if (bad.length) throw Object.assign(new Error('unknown permission(s): ' + bad.join(', ')), { code: 'bad-caps' })
|
|
177
|
-
|
|
180
|
+
// Un servicio puede entrar sin ningún permiso: su cajón lo abre el CN.
|
|
181
|
+
if (!caps.length && !cn) throw Object.assign(new Error('the pairing grants no permission'), { code: 'empty-scope' })
|
|
178
182
|
caps = [...new Set(caps)]
|
|
179
183
|
}
|
|
180
184
|
pending.clear() // uno a la vez: una sesión nueva supersede a la anterior
|
|
@@ -378,13 +382,14 @@ export function createEnrollDesk ({
|
|
|
378
382
|
throw Object.assign(new Error('this vault cannot add devices to the account record: no certificate was issued'), { code: 'admit-unavailable' })
|
|
379
383
|
}
|
|
380
384
|
// PERMISOS, no tipos (2026-08-22): las capacidades son las del scope ENTERO. Un
|
|
381
|
-
// cajón (`secrets:<ns>`)
|
|
382
|
-
// con `sign,secrets:eco` firma como
|
|
385
|
+
// cajón (`secrets:<ns>`) fija el CN, que es lo que lo abre —no hay permiso aparte
|
|
386
|
+
// (dueño, 2026-10-13)—, y no borra lo demás: un bot con `sign,secrets:eco` firma como
|
|
387
|
+
// aparato del acta Y lee solo su cajón.
|
|
383
388
|
// Si quien emparejó ELIGIÓ los permisos (`startPairing({ caps })`), entran esos y en
|
|
384
389
|
// esta misma acta; si no, los que da el scope.
|
|
385
390
|
const cn = scopeToCn(pend.scope)
|
|
386
|
-
const caps = [...new Set(
|
|
387
|
-
if (!caps.length) {
|
|
391
|
+
const caps = [...new Set(pend.caps || scopeToCaps(pend.scope))]
|
|
392
|
+
if (!caps.length && !cn) {
|
|
388
393
|
throw Object.assign(new Error('the pairing scope grants no permission: no certificate was issued'), { code: 'empty-scope' })
|
|
389
394
|
}
|
|
390
395
|
|
|
@@ -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
|
-
|
|
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).
|