@dotrino/identity 0.116.0 → 0.117.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/identity",
3
- "version": "0.116.0",
3
+ "version": "0.117.1",
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/content.js CHANGED
@@ -133,6 +133,33 @@ export async function encryptWithCek ({ cek, gen, plaintext }) {
133
133
  return { gen, iv: b64(iv), ct: b64(ct) }
134
134
  }
135
135
 
136
+ /**
137
+ * Cifra BYTES con una clave simétrica, y devuelve bytes: `{ iv, ct }` como `Uint8Array`.
138
+ *
139
+ * Es la hermana binaria de `encryptWithCek`, para lo que no tiene por qué pasar por texto:
140
+ * un archivo de 5 MB cifrado y en base64 ocupa 6,7 MB, y si además iba dentro de un JSON en
141
+ * base64, 8,9 MB. Así viaja con lo que ocupa. Lo usa el transporte para los mensajes con
142
+ * archivos: el archivo se cifra con una CEK nueva y solo la CEK pasa por el sellado de
143
+ * siempre (`wrapForMember`, o la bóveda).
144
+ *
145
+ * @param {{ cek: string, bytes: Uint8Array }} o
146
+ * @returns {Promise<{ iv: Uint8Array, ct: Uint8Array }>}
147
+ */
148
+ export async function encryptBytesWithCek ({ cek, bytes }) {
149
+ if (!(bytes instanceof Uint8Array)) throw new Error('encryptBytesWithCek: bytes must be a Uint8Array')
150
+ const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['encrypt'])
151
+ const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
152
+ const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, k, bytes))
153
+ return { iv, ct }
154
+ }
155
+
156
+ /** El inverso de `encryptBytesWithCek`. Lanza si los bytes no son de esa clave o se tocaron. */
157
+ export async function decryptBytesWithCek ({ cek, iv, ct }) {
158
+ if (!(iv instanceof Uint8Array) || !(ct instanceof Uint8Array)) throw new Error('decryptBytesWithCek: iv and ct must be Uint8Array')
159
+ const k = await subtle.importKey('raw', fromB64(cek), { name: 'AES-GCM' }, false, ['decrypt'])
160
+ return new Uint8Array(await subtle.decrypt({ name: 'AES-GCM', iv }, k, ct))
161
+ }
162
+
136
163
  /** Cierra con la LLAVE DE LA CUENTA (generación `gen`): el sobre lleva la marca. */
137
164
  export async function sealAccount ({ cek, gen, plaintext }) {
138
165
  if (!Number.isInteger(gen) || gen < 1) throw new Error('sealAccount: the account key generation starts at 1')
@@ -243,5 +270,5 @@ export async function resealStale (value, { gen, reseal, now = Date.now() }) {
243
270
 
244
271
  export default {
245
272
  makeContentKey, wrapForMember, openWrap, makeGeneration, myContentKey,
246
- encryptWithCek, sealAccount, decryptWithCek, decryptWithKeyring, CEK_ENVELOPE, isCekEnvelope, isLegacyCekEnvelope, LEGACY_UNMARKED_UNTIL, resealStale
273
+ encryptWithCek, encryptBytesWithCek, decryptBytesWithCek, sealAccount, decryptWithCek, decryptWithKeyring, CEK_ENVELOPE, isCekEnvelope, isLegacyCekEnvelope, LEGACY_UNMARKED_UNTIL, resealStale
247
274
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.32.2 (dotrino-proxy-client/src/{canonical,client,encpub,index,sealing,signature,stats-log,stats,webrtc}.js).
1
+ Copia vendorizada de @dotrino/proxy-client@0.35.0 (dotrino-proxy-client/src/{binpack,canonical,client,encpub,index,parts,sealing,signature,stats-log,stats,webrtc}.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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * BYTES QUE VIAJAN COMO BYTES (0.35.0).
3
+ *
4
+ * Hasta aquí un archivo viajaba dos veces en base64: la app lo metía en base64 en el JSON
5
+ * del mensaje (×1,33), el sellado cifraba ese JSON y lo volvía a pasar a base64 (×1,33), y
6
+ * el resultado iba como texto. Medido: 1,78 veces el tamaño del archivo en el cable.
7
+ *
8
+ * Ahora una app puede poner `Uint8Array` dentro de lo que manda, y el pilar lo lleva tal
9
+ * cual: lo empaqueta (`pack`), lo cifra como bytes y lo manda en una trama BINARIA, al
10
+ * proxio y al canal directo. Lo que no lleva bytes sale exactamente como antes, en texto:
11
+ * así las piezas que no saben de esto (las apps nativas, las versiones anteriores) siguen
12
+ * hablando con todos para lo de siempre.
13
+ *
14
+ * Dos formatos, y los dos son de este archivo para que no haya una segunda copia:
15
+ *
16
+ * · EL PAQUETE (`pack`/`unpack`): un objeto con bytes dentro.
17
+ * [0xB1][u32 largo del JSON][JSON][bytes, uno detrás de otro]
18
+ * Cada `Uint8Array` del objeto queda en el JSON como `{"__dotrino_bin":[desde,largo]}`.
19
+ *
20
+ * · LA TRAMA (`encodeFrame`/`decodeFrame`): lo que va por el WebSocket del proxio.
21
+ * [0x01][u32 largo de la cabecera][cabecera JSON][cuerpo]
22
+ * La cabecera es la trama de texto de siempre sin `message`; el cuerpo es el `message`.
23
+ * El proxio lee la cabecera para enrutar y no mira el cuerpo, que va sellado.
24
+ */
25
+
26
+ export const BIN_TAG = '__dotrino_bin'
27
+ const PACK_MAGIC = 0xB1
28
+ const FRAME_VERSION = 0x01
29
+
30
+ const enc = new TextEncoder()
31
+ const dec = new TextDecoder()
32
+
33
+ function withCode (message, code) {
34
+ return Object.assign(new Error(message), { code })
35
+ }
36
+
37
+ /** Bytes, sin importar de qué vista vengan (`Buffer`, `ArrayBuffer`, `Uint8Array`). */
38
+ export function asBytes (v) {
39
+ if (v instanceof Uint8Array) return v
40
+ if (v instanceof ArrayBuffer) return new Uint8Array(v)
41
+ if (ArrayBuffer.isView(v)) return new Uint8Array(v.buffer, v.byteOffset, v.byteLength)
42
+ return null
43
+ }
44
+
45
+ /** ¿Lleva bytes este valor, a cualquier profundidad? Es lo que decide texto o binario. */
46
+ export function hasBinary (v) {
47
+ if (v instanceof Uint8Array || v instanceof ArrayBuffer) return true
48
+ if (Array.isArray(v)) return v.some(hasBinary)
49
+ if (v && typeof v === 'object') {
50
+ for (const k in v) if (hasBinary(v[k])) return true
51
+ }
52
+ return false
53
+ }
54
+
55
+ /** Un objeto con bytes dentro → un solo `Uint8Array`. */
56
+ export function pack (obj) {
57
+ const blobs = []
58
+ let off = 0
59
+ const json = JSON.stringify(obj, function (k, v) {
60
+ // `this[k]` es el valor antes de `toJSON`: un Buffer de Node se convertiría en
61
+ // `{ type: 'Buffer', data: [...] }` y aquí lo queremos como bytes.
62
+ const b = asBytes(this[k])
63
+ if (!b) return v
64
+ blobs.push(b)
65
+ const ref = { [BIN_TAG]: [off, b.byteLength] }
66
+ off += b.byteLength
67
+ return ref
68
+ })
69
+ const head = enc.encode(json)
70
+ const out = new Uint8Array(5 + head.length + off)
71
+ out[0] = PACK_MAGIC
72
+ new DataView(out.buffer).setUint32(1, head.length)
73
+ out.set(head, 5)
74
+ let p = 5 + head.length
75
+ for (const b of blobs) { out.set(b, p); p += b.byteLength }
76
+ return out
77
+ }
78
+
79
+ export const isPacked = (b) => b instanceof Uint8Array && b.length >= 5 && b[0] === PACK_MAGIC
80
+
81
+ /**
82
+ * Un paquete → el objeto, con sus bytes como `Uint8Array` (vistas sobre `bytes`, sin copiar).
83
+ * Lanza con `code: 'bad-pack'` si no es un paquete o una referencia se sale de él.
84
+ */
85
+ export function unpack (bytes) {
86
+ const b = asBytes(bytes)
87
+ if (!isPacked(b)) throw withCode('not a packed message', 'bad-pack')
88
+ const len = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(1)
89
+ if (5 + len > b.length) throw withCode('packed message is shorter than its header says', 'bad-pack')
90
+ const area = b.subarray(5 + len)
91
+ let obj
92
+ try { obj = JSON.parse(dec.decode(b.subarray(5, 5 + len))) } catch (_) { throw withCode('packed message header is not JSON', 'bad-pack') }
93
+ const revive = (v) => {
94
+ if (Array.isArray(v)) return v.map(revive)
95
+ if (!v || typeof v !== 'object') return v
96
+ const ref = v[BIN_TAG]
97
+ if (ref !== undefined) {
98
+ if (!Array.isArray(ref) || ref.length !== 2 || !Number.isInteger(ref[0]) || !Number.isInteger(ref[1]) ||
99
+ ref[0] < 0 || ref[1] < 0 || ref[0] + ref[1] > area.length) {
100
+ throw withCode('packed message points outside itself', 'bad-pack')
101
+ }
102
+ return area.subarray(ref[0], ref[0] + ref[1])
103
+ }
104
+ const out = {}
105
+ for (const k of Object.keys(v)) out[k] = revive(v[k])
106
+ return out
107
+ }
108
+ return revive(obj)
109
+ }
110
+
111
+ /** Une varios trozos de bytes en uno. */
112
+ export function concatBytes (list) {
113
+ const out = new Uint8Array(list.reduce((a, b) => a + b.byteLength, 0))
114
+ let p = 0
115
+ for (const b of list) { out.set(b, p); p += b.byteLength }
116
+ return out
117
+ }
118
+
119
+ /** La trama binaria del proxio: la cabecera de texto de siempre, y el `message` en crudo. */
120
+ export function encodeFrame (header, body) {
121
+ const head = enc.encode(JSON.stringify(header))
122
+ const out = new Uint8Array(5 + head.length + body.byteLength)
123
+ out[0] = FRAME_VERSION
124
+ new DataView(out.buffer).setUint32(1, head.length)
125
+ out.set(head, 5)
126
+ out.set(body, 5 + head.length)
127
+ return out
128
+ }
129
+
130
+ /** El inverso: `{ header, body }`. Lanza con `code: 'bad-frame'` si no es una trama válida. */
131
+ export function decodeFrame (data) {
132
+ const b = asBytes(data)
133
+ if (!b || b.length < 5 || b[0] !== FRAME_VERSION) throw withCode('not a binary frame of a known version', 'bad-frame')
134
+ const len = new DataView(b.buffer, b.byteOffset, b.byteLength).getUint32(1)
135
+ if (5 + len > b.length) throw withCode('binary frame is shorter than its header says', 'bad-frame')
136
+ let header
137
+ try { header = JSON.parse(dec.decode(b.subarray(5, 5 + len))) } catch (_) { throw withCode('binary frame header is not JSON', 'bad-frame') }
138
+ if (!header || typeof header !== 'object' || Array.isArray(header)) throw withCode('binary frame header is not an object', 'bad-frame')
139
+ return { header, body: b.subarray(5 + len) }
140
+ }
@@ -1,8 +1,10 @@
1
1
  import { buildSignedChannel, getPublicKeyJwk, signData, samePubkey } from './signature.js'
2
- import { seal, open, isSealed } from './sealing.js'
2
+ import { seal, open, isSealed, isBinSealed, BIN_SEALED, makeMessageKey, encryptBytes, decryptBytes } from './sealing.js'
3
3
  import { buildEncPubStatement, readEncPubStatement, isEncPub } from './encpub.js'
4
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
+ import { splitParts, splitBinParts, isPart, Reassembler, DIRECT_PART_CHARS, PROXY_PART_CHARS, QUEUE_MAX_CHARS, RELAY_MAX_CHARS, DIRECT_PART_BYTES, PROXY_PART_BYTES, QUEUE_MAX_BYTES, RELAY_MAX_BYTES } from './parts.js'
7
+ import { pack, unpack, hasBinary, encodeFrame, decodeFrame } from './binpack.js'
6
8
 
7
9
  /**
8
10
  * Error con un `code` estable.
@@ -186,7 +188,15 @@ export class WebSocketProxyClient {
186
188
  this._handlers = new Map()
187
189
  this._pending = new Map() // messageId -> { resolve, reject, timer }
188
190
  this._nextId = 1
189
- this._tokenWatch = new Map() // messageId -> { token, sobre, peerPubkey, timer }
191
+ this._tokenWatch = new Map() // messageId -> { token, sobre, peerPubkey, timer, queueFallback }
192
+ // Mensajes grandes que llegan en partes (parts.js). Uno que se queda a medias se DICE:
193
+ // con él se pierde el mensaje entero, y eso no puede pasar callado.
194
+ this._parts = new Reassembler({
195
+ onExpire: (info) => this._emit('error', { type: 'parts_incomplete', code: 'parts-incomplete', ...info })
196
+ })
197
+ // Las CEK de los mensajes con bytes ya abiertas, por su sobre: todas las partes de un
198
+ // mensaje traen la misma, y abrirla es una vuelta a la bóveda. Pocas: un mensaje vivo.
199
+ this._msgKeys = new Map()
190
200
  this._traffic = new TrafficStats()
191
201
 
192
202
  this._rtc = this.enableWebRTC ? new WebRTCManager({
@@ -335,7 +345,7 @@ export class WebSocketProxyClient {
335
345
  'unsealed')
336
346
  }
337
347
  const tokens = Array.isArray(to) ? to : [to]
338
- const messageStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
348
+ const messageStr = this._wire(payload)
339
349
  if (!this._rtc) {
340
350
  this._sendRaw({ to: tokens, message: messageStr })
341
351
  return
@@ -501,10 +511,23 @@ export class WebSocketProxyClient {
501
511
  */
502
512
  async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
503
513
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
514
+ // Por pubkey el mensaje puede quedarse ESPERANDO en el proxio, y ahí se deja como mucho
515
+ // 8 MB de archivo (parts.js). Lo que pasa de eso no se manda: se dice, y la app decide.
516
+ const m = this._measure(payload)
517
+ const queueMax = m.bin ? QUEUE_MAX_BYTES : QUEUE_MAX_CHARS
518
+ if (m.len > queueMax) {
519
+ throw errorCon(`sendSealed: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than can wait in the proxy (${queueMax}); send it by token while both ends are connected`, 'too-big-for-queue')
520
+ }
521
+ // Por pubkey todo va por el proxio: si no admite bytes, se dice antes de mandar nada.
522
+ if (m.bin) this._needBinaryProxy()
523
+ const partSize = m.bin ? PROXY_PART_BYTES : PROXY_PART_CHARS
524
+ const pieces = m.len <= partSize ? [m.bin ? m.whole : payload]
525
+ : m.bin ? splitBinParts(m.whole, partSize) : splitParts(m.whole, partSize)
504
526
  // Con la llave puesta a mano se respeta tal cual: quien la pasa está diciendo que ya
505
527
  // sabe de quién es (se emparejaron), y un sobre vale para todos los destinatarios.
506
528
  if (peerEncPub) {
507
- this._sendByPubkeyRaw(list, await this._seal(payload, peerEncPub, list.length === 1 ? list[0] : undefined), opts)
529
+ const sealPiece = await this._sealerFor(m.bin, peerEncPub, list.length === 1 ? list[0] : undefined)
530
+ for (const piece of pieces) this._sendByPubkeyRaw(list, await sealPiece(piece), opts)
508
531
  return
509
532
  }
510
533
  // Sin ella, se averigua. UNA ENVOLTURA POR DESTINATARIO: cada uno tiene su llave, así
@@ -515,7 +538,8 @@ export class WebSocketProxyClient {
515
538
  // sin forma de saber cuál mitad.
516
539
  const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
517
540
  for (const [pk, encPub] of llaves) {
518
- this._sendByPubkeyRaw([pk], await this._seal(payload, encPub, pk), opts)
541
+ const sealPiece = await this._sealerFor(m.bin, encPub, pk)
542
+ for (const piece of pieces) this._sendByPubkeyRaw([pk], await sealPiece(piece), opts)
519
543
  }
520
544
  }
521
545
 
@@ -547,10 +571,75 @@ export class WebSocketProxyClient {
547
571
  peerEncPub = await this.encPubOf(peerPubkey)
548
572
  }
549
573
  if (!peerPubkey && tokens.length === 1) peerPubkey = this.pubkeyOfToken(tokens[0]) || undefined
550
- const sobre = await this._seal(payload, peerEncPub, peerPubkey)
551
- if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], sobre, peerPubkey); return }
552
- // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
553
- this.send(tokens, sobre)
574
+ // Por token los dos están conectados y NO HAY TOPE (parts.js): lo que no cabe en un
575
+ // mensaje del camino que toca sale en partes. El tamaño de parte lo decide el camino
576
+ // de ahora: el canal directo admite mucho menos por mensaje que el proxio.
577
+ const m = this._measure(payload)
578
+ const direct = tokens.length === 1 && !!this._rtc?.isOpen(tokens[0])
579
+ const size = m.bin ? (direct ? DIRECT_PART_BYTES : PROXY_PART_BYTES) : (direct ? DIRECT_PART_CHARS : PROXY_PART_CHARS)
580
+ const relayMax = m.bin ? RELAY_MAX_BYTES : RELAY_MAX_CHARS
581
+ const queueMax = m.bin ? QUEUE_MAX_BYTES : QUEUE_MAX_CHARS
582
+ if (m.bin && !direct) this._needBinaryProxy()
583
+ const sealPiece = await this._sealerFor(m.bin, peerEncPub, peerPubkey)
584
+ if (m.len <= size) {
585
+ const sobre = await sealPiece(m.bin ? m.whole : payload)
586
+ if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], sobre, peerPubkey); return }
587
+ // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
588
+ this.send(tokens, sobre)
589
+ return
590
+ }
591
+ // Por el proxio en vivo hay tope (parts.js): lo que pasa de él solo sale por canal
592
+ // directo. Se pide el canal para que el siguiente intento lo encuentre abierto.
593
+ if (!direct && m.len > relayMax) {
594
+ this._upgradeDirect(tokens)
595
+ throw errorCon(`sendSealedTo: ${m.len} ${m.bin ? 'bytes' : 'chars'} is more than goes through the proxy live (${relayMax}); it needs a direct channel`, 'too-big-for-relay')
596
+ }
597
+ // Si el token muere a mitad, lo que quede se iría a la cola por pubkey; solo se permite
598
+ // si el mensaje entero cabe ahí. Si no, se dice (`too-big-for-queue`), no se deja.
599
+ const queueFallback = m.len <= queueMax
600
+ // Y si el canal directo se cae a mitad, el resto va por el proxio en vivo solo si el
601
+ // mensaje entero cabe por ahí; si no, se para y se dice (`direct-lost`).
602
+ const relayFallback = m.len <= relayMax
603
+ for (const piece of (m.bin ? splitBinParts(m.whole, size) : splitParts(m.whole, size))) {
604
+ const sobre = await sealPiece(piece)
605
+ if (tokens.length === 1 && peerPubkey) await this._sendPiece(tokens[0], sobre, peerPubkey, queueFallback, relayFallback)
606
+ else this.send(tokens, sobre)
607
+ }
608
+ }
609
+
610
+ /**
611
+ * POR DÓNDE LE LLEGA AHORA un mensaje a este token: `socket` (la otra punta en esta
612
+ * máquina), `direct` (WebRTC sin nadie en medio), `turn` (WebRTC por el relevo, que no puede
613
+ * leer lo que pasa), `proxy` (en vivo por el proxio) u `offline` (no hay conexión: por
614
+ * pubkey quedaría esperando en el proxio). Cada camino tiene su tope (parts.js), y para eso
615
+ * existe esto: que la app lo enseñe antes de que el usuario intente algo que no cabe.
616
+ * `webrtc` es un canal directo abierto del que todavía no se sabe si va por TURN.
617
+ * @param {string|null} token
618
+ * @returns {Promise<'socket'|'direct'|'turn'|'webrtc'|'proxy'|'offline'>}
619
+ */
620
+ async routeTo (token) {
621
+ if (!this._connected) return 'offline'
622
+ if (!token) return 'offline'
623
+ if (this._rtc?.isOpen(token)) {
624
+ const peer = this._rtc.peers.get(token)
625
+ try { return (await this._rtc.probeRoute(peer)) || 'webrtc' } catch (_) { return 'webrtc' }
626
+ }
627
+ return 'proxy'
628
+ }
629
+
630
+ /**
631
+ * Una parte por token: por el canal directo esperando a que vacíe su búfer (sin eso, un
632
+ * archivo grande lo desborda y el navegador cierra el canal), o por el proxio en vivo con
633
+ * una pausa corta entre partes, para no chocar con su límite de mensajes por segundo.
634
+ */
635
+ async _sendPiece (token, sobre, peerPubkey, queueFallback, relayFallback = true) {
636
+ if (this._rtc && await this._rtc.sendDirect(token, this._wire(sobre))) return
637
+ if (!relayFallback) {
638
+ throw errorCon('the direct channel closed in the middle of a message too big for the proxy', 'direct-lost')
639
+ }
640
+ if (sobre instanceof Uint8Array) this._needBinaryProxy()
641
+ this._sendToTokenOrQueue(token, sobre, peerPubkey, { queueFallback, skipDirect: true })
642
+ await new Promise((r) => setTimeout(r, 20))
554
643
  }
555
644
 
556
645
  /**
@@ -585,16 +674,16 @@ export class WebSocketProxyClient {
585
674
  * `token_gone` para que la app deje de usar ese token. No bloquea a nadie: se manda ya y
586
675
  * el arreglo, si hace falta, llega detrás.
587
676
  */
588
- _sendToTokenOrQueue (token, sobre, peerPubkey) {
677
+ _sendToTokenOrQueue (token, sobre, peerPubkey, { queueFallback = true, skipDirect = false } = {}) {
589
678
  if (this.requireSealed && !this._isSealed(sobre)) {
590
679
  throw errorCon('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
591
680
  }
592
- const messageStr = JSON.stringify(sobre)
593
- if (this._rtc && this._rtc.trySend(token, messageStr)) return
681
+ const messageStr = this._wire(sobre)
682
+ if (!skipDirect && this._rtc && this._rtc.trySend(token, messageStr)) return
594
683
  const id = `msg_${this._nextId++}`
595
684
  const timer = setTimeout(() => this._tokenWatch.delete(id), 15000)
596
685
  timer.unref?.()
597
- this._tokenWatch.set(id, { token, sobre, peerPubkey, timer })
686
+ this._tokenWatch.set(id, { token, sobre, peerPubkey, timer, queueFallback })
598
687
  try {
599
688
  this._sendRaw({ to: [token], message: messageStr, id })
600
689
  } catch (e) {
@@ -613,6 +702,11 @@ export class WebSocketProxyClient {
613
702
  this._tokenWatch.delete(data.id)
614
703
  if (!Array.isArray(data.failed) || !data.failed.includes(w.token)) return
615
704
  this._emit('token_gone', w.token, w.peerPubkey)
705
+ // Una parte de un mensaje que no cabe en la cola no se deja en el proxio (parts.js).
706
+ if (!w.queueFallback) {
707
+ this._emit('error', { type: 'client', error: 'the peer left in the middle of a message too big to wait in the proxy', code: 'too-big-for-queue', peerPubkey: w.peerPubkey })
708
+ return
709
+ }
616
710
  try { this._sendByPubkeyRaw([w.peerPubkey], w.sobre) } catch (e) { this._emit('error', { type: 'client', error: e.message, code: e.code || null }) }
617
711
  }
618
712
 
@@ -683,6 +777,62 @@ export class WebSocketProxyClient {
683
777
  : seal(payload, peerEncPub)
684
778
  }
685
779
 
780
+ /**
781
+ * Quién sella las piezas de UN mensaje hacia UN destinatario. Con bytes (sealing.js): una
782
+ * CEK para el mensaje entero, sellada una vez con el sellado de siempre, y cada pieza
783
+ * cifrada como bytes con ella. Sin bytes, el sobre de texto de siempre por pieza.
784
+ */
785
+ async _sealerFor (bin, peerEncPub, peerPubkey) {
786
+ if (!bin) return (piece) => this._seal(piece, peerEncPub, peerPubkey)
787
+ const cek = await makeMessageKey()
788
+ const k = await this._seal({ cek }, peerEncPub, peerPubkey)
789
+ return async (bytes) => {
790
+ const { iv, ct } = await encryptBytes(cek, bytes)
791
+ return pack({ bv: BIN_SEALED, k, iv, ct })
792
+ }
793
+ }
794
+
795
+ /** Lo que mide un mensaje para sus topes: su paquete si lleva bytes, su JSON si no. */
796
+ _measure (payload) {
797
+ if (hasBinary(payload)) {
798
+ const whole = pack(payload)
799
+ return { bin: true, whole, len: whole.byteLength }
800
+ }
801
+ const whole = JSON.stringify(payload)
802
+ return { bin: false, whole, len: whole.length }
803
+ }
804
+
805
+ /** Cómo va por el cable: los bytes tal cual, el texto tal cual, y un objeto en JSON (o empaquetado, si lleva bytes). */
806
+ _wire (payload) {
807
+ if (typeof payload === 'string' || payload instanceof Uint8Array) return payload
808
+ if (hasBinary(payload)) return pack(payload)
809
+ return JSON.stringify(payload)
810
+ }
811
+
812
+ /** Un mensaje con bytes por el proxio necesita uno que admita tramas binarias (websocket-proxy ≥ 1.6.0). */
813
+ _needBinaryProxy () {
814
+ if (!Array.isArray(this.caps) || !this.caps.includes('binary')) {
815
+ throw errorCon('this proxy does not take binary frames (it needs websocket-proxy >= 1.6.0)', 'no-binary-support')
816
+ }
817
+ }
818
+
819
+ /** La CEK de un mensaje con bytes, abriendo su sobre una vez por mensaje. */
820
+ _openMessageKey (k, meta) {
821
+ const id = JSON.stringify(k)
822
+ let p = this._msgKeys.get(id)
823
+ if (!p) {
824
+ p = (async () => {
825
+ const o = this.sealing ? await this.sealing.open(k, meta) : await open(k, this.myEncPrivateKey)
826
+ if (!o || typeof o.cek !== 'string') throw errorCon('the sealed key of a message has no key inside', 'bad-pack')
827
+ return o.cek
828
+ })()
829
+ this._msgKeys.set(id, p)
830
+ if (this._msgKeys.size > 64) this._msgKeys.delete(this._msgKeys.keys().next().value)
831
+ p.catch(() => this._msgKeys.delete(id))
832
+ }
833
+ return p
834
+ }
835
+
686
836
  // ---------- llaves de cifrado ajenas ----------
687
837
 
688
838
  /**
@@ -779,6 +929,11 @@ export class WebSocketProxyClient {
779
929
  }
780
930
 
781
931
  _isSealed (msg) {
932
+ if (msg instanceof Uint8Array) {
933
+ let env
934
+ try { env = unpack(msg) } catch (_) { return false }
935
+ return isBinSealed(env) && this._isSealed(env.k)
936
+ }
782
937
  return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
783
938
  }
784
939
 
@@ -801,6 +956,33 @@ export class WebSocketProxyClient {
801
956
  * into the app.
802
957
  */
803
958
  async _deliver (from, payload, meta) {
959
+ // BYTES (binpack.js): un paquete. Si es un sobre binario se abre aquí; si no, es un
960
+ // objeto con bytes sin sellar, y decide `requireSealed` igual que con el texto.
961
+ if (payload instanceof Uint8Array) {
962
+ let env
963
+ try { env = unpack(payload) } catch (e) {
964
+ this._emit('error', { type: 'bad_pack', from, code: e.code || 'bad-pack', error: e })
965
+ return
966
+ }
967
+ if (isBinSealed(env)) {
968
+ if (!this.sealing && !this.myEncPrivateKey) {
969
+ this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
970
+ return
971
+ }
972
+ let opened
973
+ try {
974
+ if (!this._isSealed(env.k)) throw errorCon('the key of a sealed message is not sealed', 'bad-pack')
975
+ opened = unpack(await decryptBytes(await this._openMessageKey(env.k, meta), env))
976
+ } catch (e) {
977
+ this._emit('error', { type: 'undecipherable', from, error: e })
978
+ return
979
+ }
980
+ const senderEncPub = this.sealing?.senderOf?.(env.k) || null
981
+ this._handOpened(from, opened, { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) })
982
+ return
983
+ }
984
+ payload = env
985
+ }
804
986
  if (this._isSealed(payload)) {
805
987
  if (!this.sealing && !this.myEncPrivateKey) {
806
988
  this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
@@ -813,7 +995,7 @@ export class WebSocketProxyClient {
813
995
  // Quién selló (la llave de cifrado), si el sobre lo dice: es lo que autentica al
814
996
  // remitente, porque solo quien tiene esa privada pudo armarlo.
815
997
  const senderEncPub = this.sealing?.senderOf?.(payload) || null
816
- this._emit('message', from, opened, { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) })
998
+ this._handOpened(from, opened, { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) })
817
999
  } catch (e) {
818
1000
  // Sealed to somebody else, or tampered with. Staying quiet is the point.
819
1001
  this._emit('error', { type: 'undecipherable', from, error: e })
@@ -828,11 +1010,24 @@ export class WebSocketProxyClient {
828
1010
  this._emit('message', from, payload, { ...meta, sealed: false })
829
1011
  }
830
1012
 
1013
+ /** Lo ya abierto a la app: entero, o a rearmar si es una parte (parts.js). */
1014
+ _handOpened (from, opened, fullMeta) {
1015
+ if (isPart(opened)) {
1016
+ // Se agrupa por quien selló: solo esa llave pudo armar cada parte.
1017
+ let whole
1018
+ try { whole = this._parts.add(fullMeta.senderEncPub || fullMeta.fromPubkey || from || '', opened) }
1019
+ catch (e) { this._emit('error', { type: 'bad_part', from, code: e.code || 'bad-part', error: e }); return }
1020
+ if (whole !== null) this._emit('message', from, whole, { ...fullMeta, parts: opened.__dotrino_part.n })
1021
+ return
1022
+ }
1023
+ this._emit('message', from, opened, fullMeta)
1024
+ }
1025
+
831
1026
  _sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
832
1027
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
833
1028
  const msg = {
834
1029
  to_publickey: list,
835
- message: typeof payload === 'string' ? payload : JSON.stringify(payload)
1030
+ message: this._wire(payload)
836
1031
  }
837
1032
  if (opts.ephemeral) msg.ephemeral = true
838
1033
  if (opts.quiet) msg.quiet = true
@@ -1258,6 +1453,9 @@ export class WebSocketProxyClient {
1258
1453
  try { prev.close() } catch (_) {}
1259
1454
  }
1260
1455
  const ws = new WebSocket(this.url)
1456
+ // Las tramas binarias (binpack.js) llegan como ArrayBuffer: un Blob obligaría a leerlas
1457
+ // de forma asíncrona y desordenaría lo que llega detrás.
1458
+ try { ws.binaryType = 'arraybuffer' } catch (_) {}
1261
1459
  this.ws = ws
1262
1460
  const openTimer = this.connectTimeout > 0 ? setTimeout(() => this._onConnectTimeout(ws), this.connectTimeout) : null
1263
1461
  openTimer?.unref?.()
@@ -1395,9 +1593,20 @@ export class WebSocketProxyClient {
1395
1593
 
1396
1594
  _handleFrame (raw) {
1397
1595
  let data
1398
- try { data = JSON.parse(raw) } catch (e) {
1399
- this._emit('error', { type: 'parse_error', error: e })
1400
- return
1596
+ if (typeof raw === 'string') {
1597
+ try { data = JSON.parse(raw) } catch (e) {
1598
+ this._emit('error', { type: 'parse_error', error: e })
1599
+ return
1600
+ }
1601
+ } else {
1602
+ // Trama binaria: la cabecera de siempre y el `message` en crudo.
1603
+ try {
1604
+ const { header, body } = decodeFrame(raw)
1605
+ data = { ...header, message: body }
1606
+ } catch (e) {
1607
+ this._emit('error', { type: 'parse_error', code: e.code || 'bad-frame', error: e })
1608
+ return
1609
+ }
1401
1610
  }
1402
1611
  const { type } = data
1403
1612
  switch (type) {
@@ -1530,6 +1739,14 @@ export class WebSocketProxyClient {
1530
1739
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1531
1740
  throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
1532
1741
  }
1742
+ if (frame.message instanceof Uint8Array) {
1743
+ this._needBinaryProxy()
1744
+ const { message, ...header } = frame
1745
+ const bytes = encodeFrame(header, message)
1746
+ this.ws.send(bytes)
1747
+ this._countOut(frame, bytes)
1748
+ return
1749
+ }
1533
1750
  const texto = JSON.stringify(frame)
1534
1751
  this.ws.send(texto)
1535
1752
  this._countOut(frame, texto)
@@ -1538,7 +1755,7 @@ export class WebSocketProxyClient {
1538
1755
  /** Cuenta un frame que sale por el proxio, y a quién iba. */
1539
1756
  _countOut (frame, texto) {
1540
1757
  this._traffic.frame('out', utf8Length(texto))
1541
- if (typeof frame.message !== 'string') return
1758
+ if (typeof frame.message !== 'string' && !(frame.message instanceof Uint8Array)) return
1542
1759
  const bytes = utf8Length(frame.message)
1543
1760
  for (const token of Array.isArray(frame.to) ? frame.to : []) {
1544
1761
  this._traffic.peer('out', 'proxy', { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes)
@@ -11,6 +11,8 @@ export {
11
11
  } from './encpub.js'
12
12
 
13
13
  export { listTransports, routeOf } from './stats.js'
14
+ export { PART_TAG, DIRECT_PART_CHARS, PROXY_PART_CHARS, QUEUE_MAX_CHARS, RELAY_MAX_CHARS, DIRECT_PART_BYTES, PROXY_PART_BYTES, QUEUE_MAX_BYTES, RELAY_MAX_BYTES } from './parts.js'
15
+ export { pack, unpack, hasBinary, encodeFrame, decodeFrame } from './binpack.js'
14
16
  export { logStats, formatStats, netReport, splitPeers } from './stats-log.js'
15
17
 
16
18
  import { WebSocketProxyClient } from './client.js'
@@ -0,0 +1,157 @@
1
+ /**
2
+ * MENSAJES GRANDES, EN PARTES (0.33.0).
3
+ *
4
+ * Un mensaje dirigido tiene techo en cada camino: el canal directo (WebRTC) rechaza lo que
5
+ * pasa de 64 KiB hacia libwebrtc y la trama del proxio es de 1 MB. Antes, lo que no cabía
6
+ * en el canal directo se iba callado por el proxio (y llegaba desordenado), y lo que no
7
+ * cabía en la trama no salía. Ahora el pilar parte el mensaje y la otra punta lo rearma:
8
+ * la app manda y recibe un objeto entero, como siempre.
9
+ *
10
+ * La regla del dueño (2026-10-15), que es lo que decide los topes:
11
+ * - por el canal directo (WebRTC, también por TURN) NO HAY TOPE;
12
+ * - por el proxio EN VIVO (los dos conectados, sin canal directo), como mucho 25 MB de
13
+ * archivo: cada parte cruza el VPS dos veces, y uno de 25 MB ya son ~90 MB de tráfico.
14
+ * Más que eso lanza `too-big-for-relay`, y sale cuando haya canal directo;
15
+ * - lo que se queda ESPERANDO en el proxio (por pubkey, el otro desconectado) es como
16
+ * mucho 8 MB de archivo: una foto de teléfono pesa 2-5 MB, y con 1 MB (lo primero que se
17
+ * pensó) casi ninguna habría podido esperar. Lo que pasa de ahí no se deja en el proxio:
18
+ * se lanza `too-big-for-queue`.
19
+ *
20
+ * Cada parte va SELLADA por su cuenta (el pilar sella cada una como cualquier mensaje), así
21
+ * que el proxio no ve ni el contenido ni cuántas partes son de qué. El rearmado agrupa por la
22
+ * llave que selló: nadie más puede colar una parte en el mensaje de otro.
23
+ */
24
+
25
+ import { pack, unpack, concatBytes } from './binpack.js'
26
+
27
+ export const PART_TAG = '__dotrino_part'
28
+
29
+ /** Parte por el canal directo: por debajo de los 64 KiB que acepta libwebrtc, ya sellada. */
30
+ export const DIRECT_PART_CHARS = 40 * 1024
31
+ /** Parte por el proxio: sellada (×1,37) cabe holgada en la trama de 1 MB. */
32
+ export const PROXY_PART_CHARS = 512 * 1024
33
+ /**
34
+ * Lo más que se deja esperando en el proxio. Se mide sobre el JSON del mensaje: 8 MB de
35
+ * archivo en base64 ocupan ~10,7 MB, y se deja margen para lo que va alrededor.
36
+ */
37
+ export const QUEUE_MAX_CHARS = 11 * 1024 * 1024
38
+ /** Lo más que pasa por el proxio EN VIVO: 25 MB de archivo son ~33,4 MB en base64. */
39
+ export const RELAY_MAX_CHARS = 34 * 1024 * 1024
40
+
41
+ /**
42
+ * LOS MISMOS TOPES PARA UN MENSAJE CON BYTES (0.35.0), medidos sobre su paquete (binpack.js),
43
+ * que ocupa lo que el archivo más unos pocos bytes: 8 MB y 25 MB de archivo, con 64 KB de
44
+ * margen para el texto que va alrededor (nombre, tipo, pie de foto).
45
+ */
46
+ export const QUEUE_MAX_BYTES = 8 * 1024 * 1024 + 64 * 1024
47
+ export const RELAY_MAX_BYTES = 25 * 1024 * 1024 + 64 * 1024
48
+ /**
49
+ * Parte binaria por el canal directo: bajo los 64 KiB de libwebrtc, con sitio para el sobre
50
+ * de la llave, que la bóveda envuelve para cada aparato del destinatario.
51
+ */
52
+ export const DIRECT_PART_BYTES = 48 * 1024
53
+ /** Parte binaria por el proxio: cabe en su trama de 1 MB con la cabecera y el sobre. */
54
+ export const PROXY_PART_BYTES = 768 * 1024
55
+
56
+ /** Rearmado: lo que se acepta guardar a medias, y cuánto se espera a la parte siguiente. */
57
+ const RECV_MAX_CHARS = 512 * 1024 * 1024
58
+ const RECV_IDLE_MS = 5 * 60 * 1000
59
+ const MAX_PARTS = 1 << 20
60
+
61
+ /** El id de un mensaje en partes: al azar, para que dos mensajes no se mezclen. */
62
+ export function partId () {
63
+ const b = new Uint8Array(12)
64
+ globalThis.crypto.getRandomValues(b)
65
+ return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('')
66
+ }
67
+
68
+ /** Las partes (ya como objetos para sellar) de un JSON de `json.length` > `size`. */
69
+ export function splitParts (json, size) {
70
+ const n = Math.ceil(json.length / size)
71
+ const id = partId()
72
+ const out = []
73
+ for (let i = 0; i < n; i++) out.push({ [PART_TAG]: { id, i, n }, d: json.slice(i * size, (i + 1) * size) })
74
+ return out
75
+ }
76
+
77
+ /**
78
+ * Las partes de un paquete de `bytes.length` > `size`, cada una ya empaquetada (lista para
79
+ * cifrar como bytes). El rearmado une los trozos y desempaqueta el resultado.
80
+ */
81
+ export function splitBinParts (bytes, size) {
82
+ const n = Math.ceil(bytes.byteLength / size)
83
+ const id = partId()
84
+ const out = []
85
+ for (let i = 0; i < n; i++) out.push(pack({ [PART_TAG]: { id, i, n }, d: bytes.subarray(i * size, (i + 1) * size) }))
86
+ return out
87
+ }
88
+
89
+ export const isPart = (o) => !!(o && typeof o === 'object' && o[PART_TAG])
90
+
91
+ /**
92
+ * Rearma los mensajes. `add` devuelve el mensaje entero cuando llega la última parte, `null`
93
+ * mientras falten, y lanza con `code` si la parte no vale (se dice: una parte mala no puede
94
+ * desaparecer en silencio, porque el mensaje entero se pierde con ella).
95
+ */
96
+ export class Reassembler {
97
+ constructor ({ onExpire = () => {} } = {}) {
98
+ this._open = new Map()
99
+ this._chars = 0
100
+ this._onExpire = onExpire
101
+ }
102
+
103
+ /** @param {string} sender quién selló (agrupa) @param {any} part */
104
+ add (sender, part) {
105
+ const p = part[PART_TAG]
106
+ const d = part.d
107
+ if (!p || typeof p.id !== 'string' || p.id.length > 64 ||
108
+ !Number.isInteger(p.n) || !Number.isInteger(p.i) || p.n < 2 || p.n > MAX_PARTS ||
109
+ p.i < 0 || p.i >= p.n || (typeof d !== 'string' && !(d instanceof Uint8Array))) {
110
+ throw Object.assign(new Error('malformed message part'), { code: 'bad-part' })
111
+ }
112
+ const key = `${sender}|${p.id}`
113
+ let a = this._open.get(key)
114
+ if (!a) {
115
+ a = { n: p.n, bin: d instanceof Uint8Array, got: new Array(p.n), count: 0, chars: 0, timer: null }
116
+ this._open.set(key, a)
117
+ }
118
+ if (a.n !== p.n || a.bin !== (d instanceof Uint8Array)) {
119
+ this._drop(key)
120
+ throw Object.assign(new Error('message parts disagree on their count or kind'), { code: 'bad-part' })
121
+ }
122
+ if (a.got[p.i] === undefined) {
123
+ if (this._chars + d.length > RECV_MAX_CHARS) {
124
+ this._drop(key)
125
+ throw Object.assign(new Error('too much of a message in parts is pending'), { code: 'parts-too-big' })
126
+ }
127
+ // Una copia: `d` es una vista sobre la trama entera, y guardarla retendría la trama.
128
+ a.got[p.i] = a.bin ? d.slice() : d
129
+ a.count++
130
+ a.chars += d.length
131
+ this._chars += d.length
132
+ }
133
+ if (a.timer) clearTimeout(a.timer)
134
+ if (a.count === a.n) {
135
+ this._drop(key)
136
+ try { return a.bin ? unpack(concatBytes(a.got)) : JSON.parse(a.got.join('')) }
137
+ catch (_) { throw Object.assign(new Error('a message in parts did not join into a message'), { code: 'bad-part' }) }
138
+ }
139
+ a.timer = setTimeout(() => {
140
+ const missing = a.n - a.count
141
+ this._drop(key)
142
+ this._onExpire({ id: p.id, parts: a.n, missing })
143
+ }, RECV_IDLE_MS)
144
+ a.timer.unref?.()
145
+ return null
146
+ }
147
+
148
+ _drop (key) {
149
+ const a = this._open.get(key)
150
+ if (!a) return
151
+ if (a.timer) clearTimeout(a.timer)
152
+ this._chars -= a.chars
153
+ this._open.delete(key)
154
+ }
155
+
156
+ clear () { for (const k of [...this._open.keys()]) this._drop(k) }
157
+ }
@@ -164,3 +164,45 @@ export function identitySealing (identity, { app = 'dotrino' } = {}) {
164
164
  isSealed: (m) => !!m && m.app === app && !!m.sealed,
165
165
  }
166
166
  }
167
+
168
+ /**
169
+ * SELLAR BYTES (0.35.0). Un archivo no pasa por el sellado de texto —que lo volvería a
170
+ * base64—: se cifra con una CEK nueva (`encryptBytesWithCek` de `@dotrino/identity/content`,
171
+ * AES-GCM) y lo que se sella con el sellado de siempre es SOLO la CEK. Así la bóveda del
172
+ * navegador ve una llave de 44 caracteres y no los megas, y no hay cripto nueva: la misma
173
+ * envoltura de siempre más el AES-GCM del pilar de identidad.
174
+ *
175
+ * El sobre binario es un paquete (`binpack.js`) con `{ bv: 1, k, iv, ct }`:
176
+ * · `k` el sobre de siempre (del sellado propio o de la bóveda) con `{ cek }` dentro
177
+ * · `iv`, `ct` los bytes cifrados, como bytes
178
+ *
179
+ * Una CEK vale para todas las partes de un mensaje (cada parte con su `iv`, que es lo que
180
+ * AES-GCM exige), así que la bóveda sella una vez por mensaje y no una por parte.
181
+ */
182
+ export const BIN_SEALED = 1
183
+
184
+ export function isBinSealed (env) {
185
+ return !!env && env.bv === BIN_SEALED && !!env.k && typeof env.k === 'object' &&
186
+ env.iv instanceof Uint8Array && env.ct instanceof Uint8Array
187
+ }
188
+
189
+ /** Una CEK nueva para un mensaje con bytes. */
190
+ export async function makeMessageKey () {
191
+ const { makeContentKey } = await crypto_()
192
+ if (typeof makeContentKey !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
193
+ return makeContentKey()
194
+ }
195
+
196
+ /** Cifra `bytes` con la CEK y devuelve los campos del sobre (`iv`, `ct`). */
197
+ export async function encryptBytes (cek, bytes) {
198
+ const { encryptBytesWithCek } = await crypto_()
199
+ if (typeof encryptBytesWithCek !== 'function') throw errorCon('sealing bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
200
+ return encryptBytesWithCek({ cek, bytes })
201
+ }
202
+
203
+ /** Descifra el `ct` de un sobre binario con su CEK ya abierta. */
204
+ export async function decryptBytes (cek, env) {
205
+ const { decryptBytesWithCek } = await crypto_()
206
+ if (typeof decryptBytesWithCek !== 'function') throw errorCon('opening bytes needs @dotrino/identity >= 0.117.0', 'no-bytes-sealing')
207
+ return decryptBytesWithCek({ cek, iv: env.iv, ct: env.ct })
208
+ }
@@ -192,6 +192,29 @@ export class WebRTCManager {
192
192
  return false
193
193
  }
194
194
 
195
+ /**
196
+ * Mandar por el canal directo ESPERANDO a que su búfer baje. Para las partes de un
197
+ * mensaje grande: soltarlas todas de golpe desborda el búfer del canal, el navegador lo
198
+ * cierra y el resto se pierde. Devuelve false si el canal no está (o se cerró), y quien
199
+ * llama sigue por el proxio.
200
+ */
201
+ async sendDirect (to, payloadString, highWater = 1024 * 1024) {
202
+ const peer = this.peers.get(to)
203
+ const dc = peer && peer.dc
204
+ if (!dc || dc.readyState !== 'open') return false
205
+ while (dc.bufferedAmount > highWater) {
206
+ await new Promise((r) => setTimeout(r, 15))
207
+ if (dc.readyState !== 'open') return false
208
+ }
209
+ try {
210
+ dc.send(payloadString)
211
+ this.count?.('out', to, utf8Length(payloadString), peer.route || 'webrtc')
212
+ return true
213
+ } catch (_) {
214
+ return false
215
+ }
216
+ }
217
+
195
218
  /** Optional: explicitly preconnect to a peer. */
196
219
  connect (to) {
197
220
  const peer = this._ensurePeer(to)
@@ -328,6 +351,8 @@ export class WebRTCManager {
328
351
 
329
352
  _attachDC (peer, dc) {
330
353
  peer.dc = dc
354
+ // Los bytes como ArrayBuffer: un Blob se lee de forma asíncrona y desordenaría las partes.
355
+ try { dc.binaryType = 'arraybuffer' } catch (_) {}
331
356
  dc.onopen = () => {
332
357
  // Por dónde salió: directo o por TURN. Por detrás, sin retrasar a nadie.
333
358
  this.probeRoute(peer).catch(() => {})
@@ -344,10 +369,13 @@ export class WebRTCManager {
344
369
  }
345
370
  dc.onmessage = (ev) => {
346
371
  let parsed = null
347
- const raw = ev.data
372
+ let raw = ev.data
348
373
  this.count?.('in', peer.remote, utf8Length(raw), peer.route || 'webrtc')
349
374
  if (typeof raw === 'string') {
350
375
  try { parsed = JSON.parse(raw) } catch (_) { parsed = null }
376
+ } else if (raw instanceof ArrayBuffer) {
377
+ // Un mensaje con bytes (binpack.js). En Node llega ya como Buffer, que es un Uint8Array.
378
+ raw = new Uint8Array(raw)
351
379
  }
352
380
  this.deliverMessage(peer.remote, parsed ?? raw, { raw, timestamp: Date.now(), via: 'webrtc' })
353
381
  }
@@ -1,3 +1,3 @@
1
- Copia vendorizada de @dotrino/roadmap@0.4.13 (dotrino-roadmap/src/{index,manifests}.js).
1
+ Copia vendorizada de @dotrino/roadmap@0.4.17 (dotrino-roadmap/src/{index,manifests}.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
  El registro de versiones rotas que viaja con cada versión. Importa @dotrino/compat/ranges.
@@ -31,7 +31,7 @@ export const MANIFESTS = Object.freeze({
31
31
  "identity": {
32
32
  "repo": "imdotrino/dotrino-identity",
33
33
  "npm": "@dotrino/identity",
34
- "current": "0.115.0",
34
+ "current": "0.116.0",
35
35
  "protocol": 1,
36
36
  "requires": {}
37
37
  },
@@ -47,13 +47,13 @@ export const MANIFESTS = Object.freeze({
47
47
  },
48
48
  "messenger": {
49
49
  "repo": "imdotrino/dotrino-messenger",
50
- "current": "0.5.2",
50
+ "current": "0.7.0",
51
51
  "protocol": 2,
52
52
  "requires": {
53
53
  "identity": ">=0.108.0",
54
- "proxy-client": ">=0.28.0",
54
+ "proxy-client": ">=0.34.0",
55
55
  "store": ">=0.12.3",
56
- "proxy": "1.1.0+"
56
+ "proxy": "1.5.2+"
57
57
  }
58
58
  },
59
59
  "messenger-android": {
@@ -107,18 +107,19 @@ export const MANIFESTS = Object.freeze({
107
107
  },
108
108
  "proxy": {
109
109
  "repo": "imdotrino/dotrino-proxy",
110
- "current": "1.4.9",
110
+ "current": "1.5.2",
111
111
  "protocol": 2,
112
112
  "requires": {
113
- "vault": ">=0.79.0",
114
- "identity": ">=0.99.0",
115
- "proxy-client": ">=0.23.1"
113
+ "vault": ">=0.86.0",
114
+ "identity": ">=0.116.0",
115
+ "proxy-client": ">=0.23.1",
116
+ "content": "0.5.3+"
116
117
  }
117
118
  },
118
119
  "proxy-client": {
119
120
  "repo": "imdotrino/dotrino-proxy-client",
120
121
  "npm": "@dotrino/proxy-client",
121
- "current": "0.32.2",
122
+ "current": "0.34.0",
122
123
  "protocol": 2,
123
124
  "requires": {
124
125
  "identity": ">=0.53.0",
@@ -128,11 +129,11 @@ export const MANIFESTS = Object.freeze({
128
129
  "remote-agent": {
129
130
  "repo": "imdotrino/dotrino-remote-agent",
130
131
  "npm": "@dotrino/remote-agent",
131
- "current": "0.19.0",
132
+ "current": "0.19.1",
132
133
  "protocol": 1,
133
134
  "requires": {
134
- "identity": ">=0.112.0",
135
- "vault": ">=0.64.0",
135
+ "identity": ">=0.115.0",
136
+ "vault": ">=0.85.0",
136
137
  "proxy-client": ">=0.30.0",
137
138
  "vaultd": ">=0.154.0"
138
139
  }
@@ -140,12 +141,12 @@ export const MANIFESTS = Object.freeze({
140
141
  "terminal-agent": {
141
142
  "repo": "imdotrino/dotrino-terminal",
142
143
  "npm": "@dotrino/terminal-agent",
143
- "current": "0.47.1",
144
+ "current": "0.47.4",
144
145
  "protocol": 2,
145
146
  "requires": {
146
147
  "remote-agent": ">=0.18.3",
147
- "identity": ">=0.92.0",
148
- "vault": ">=0.79.0",
148
+ "identity": ">=0.116.0",
149
+ "vault": ">=0.86.0",
149
150
  "proxy-client": ">=0.31.1",
150
151
  "vaultd": ">=0.142.0"
151
152
  }
@@ -168,7 +169,7 @@ export const MANIFESTS = Object.freeze({
168
169
  },
169
170
  "terminal-desktop": {
170
171
  "repo": "imdotrino/dotrino-terminal",
171
- "current": "0.2.72",
172
+ "current": "0.2.74",
172
173
  "protocol": 1,
173
174
  "requires": {
174
175
  "terminal-agent": ">=0.37.0"
@@ -189,31 +190,31 @@ export const MANIFESTS = Object.freeze({
189
190
  "content": {
190
191
  "repo": "imdotrino/dotrino-content",
191
192
  "npm": "@dotrino/content",
192
- "current": "0.5.1",
193
+ "current": "0.5.3",
193
194
  "protocol": 1,
194
195
  "requires": {
195
196
  "remote-agent": ">=0.13.0",
196
- "identity": ">=0.92.0",
197
- "vault": ">=0.79.0",
197
+ "identity": ">=0.116.0",
198
+ "vault": ">=0.86.0",
198
199
  "proxy-client": ">=0.23.1"
199
200
  }
200
201
  },
201
202
  "sealers": {
202
203
  "repo": "imdotrino/dotrino-sealers",
203
204
  "npm": "@dotrino/sealers",
204
- "current": "0.9.1",
205
+ "current": "0.9.2",
205
206
  "protocol": 1,
206
207
  "requires": {
207
208
  "remote-agent": ">=0.13.0",
208
- "identity": ">=0.92.0",
209
- "vault": ">=0.75.0",
209
+ "identity": ">=0.116.0",
210
+ "vault": ">=0.86.0",
210
211
  "proxy-client": ">=0.23.1"
211
212
  }
212
213
  },
213
214
  "roadmap": {
214
215
  "repo": "imdotrino/dotrino-roadmap",
215
216
  "npm": "@dotrino/roadmap",
216
- "current": "0.4.8",
217
+ "current": "0.4.14",
217
218
  "protocol": 1,
218
219
  "requires": {
219
220
  "compat": "0.4.0+"
@@ -249,28 +250,32 @@ export const MANIFESTS = Object.freeze({
249
250
  "vault": {
250
251
  "repo": "imdotrino/dotrino-vault",
251
252
  "npm": "@dotrino/vault",
252
- "current": "0.85.0",
253
+ "current": "0.86.0",
253
254
  "protocol": 1,
254
255
  "requires": {
255
256
  "vaultd": ">=0.105.0",
256
257
  "proxy-client": "0.24.0+",
257
- "identity": ">=0.115.0",
258
- "opaque": ">=0.1.0"
258
+ "identity": ">=0.116.0",
259
+ "opaque": ">=0.1.0",
260
+ "compat": ">=0.4.0",
261
+ "roadmap": ">=0.4.13",
262
+ "passmanager": ">=0.17.0",
263
+ "store": ">=0.11.0"
259
264
  }
260
265
  },
261
266
  "vaultd": {
262
267
  "repo": "imdotrino/dotrino-vault",
263
268
  "npm": "@dotrino/vaultd",
264
- "current": "0.155.0",
269
+ "current": "0.157.0",
265
270
  "protocol": 1,
266
271
  "requires": {
267
- "identity": ">=0.115.0",
272
+ "identity": ">=0.116.0",
268
273
  "proxy-client": ">=0.30.1",
269
274
  "store": ">=0.11.0",
270
275
  "passmanager": ">=0.17.0",
271
276
  "compat": ">=0.4.0",
272
277
  "opaque": ">=0.1.0",
273
- "roadmap": ">=0.3.19"
278
+ "roadmap": ">=0.4.13"
274
279
  }
275
280
  },
276
281
  "verifier": {
@@ -312,12 +317,12 @@ export const MANIFESTS = Object.freeze({
312
317
  },
313
318
  "passmanager-extension": {
314
319
  "repo": "imdotrino/dotrino-passmanager",
315
- "current": "0.48.4",
320
+ "current": "0.49.1",
316
321
  "protocol": 1,
317
322
  "requires": {
318
- "identity": ">=0.109.0",
319
- "proxy-client": ">=0.30.4",
320
- "vault": ">=0.82.1",
323
+ "identity": ">=0.116.0",
324
+ "proxy-client": ">=0.32.2",
325
+ "vault": ">=0.86.0",
321
326
  "proxy": "1.1.0+"
322
327
  }
323
328
  },
@@ -333,11 +338,11 @@ export const MANIFESTS = Object.freeze({
333
338
  },
334
339
  "social-bot": {
335
340
  "repo": "imdotrino/dotrino-social-bot",
336
- "current": "0.2.0",
341
+ "current": "0.2.1",
337
342
  "protocol": 1,
338
343
  "requires": {
339
- "vault": ">=0.75.0",
340
- "identity": ">=0.92.0",
344
+ "vault": ">=0.86.0",
345
+ "identity": ">=0.116.0",
341
346
  "proxy-client": ">=0.23.1",
342
347
  "remote-agent": ">=0.13.0"
343
348
  }
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/vault@0.86.0 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.js).
1
+ Copia vendorizada de @dotrino/vault@0.86.1 (dotrino-vault/lib/src/{admin,b64,browser,core/approvals,core/blocked,core/browserDisk,core/commKey,core/disk,core/libVersion,core/machines,core/notices,core/notifiable,core/passwords,core/sealKey,core/sealer,core/sealers,core/secretsStore,core/sha256,core/store,core/subacta,core/threadStore,core/transport,core/vault,enroll,index,loginClient,passwordLogins,procContext,protocol,revocation,sealed}.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
  browser.js es la bóveda de esta pestaña: el MISMO núcleo que el binario (core/), con
4
4
  el disco en IndexedDB. Importa @dotrino/{compat,roadmap,passmanager,store/core} y