@dotrino/identity 0.117.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.117.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",
@@ -1,4 +1,4 @@
1
- Copia vendorizada de @dotrino/proxy-client@0.34.0 (dotrino-proxy-client/src/{canonical,client,encpub,index,parts,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,9 +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, isPart, Reassembler, DIRECT_PART_CHARS, PROXY_PART_CHARS, QUEUE_MAX_CHARS, RELAY_MAX_CHARS } from './parts.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'
7
8
 
8
9
  /**
9
10
  * Error con un `code` estable.
@@ -193,6 +194,9 @@ export class WebSocketProxyClient {
193
194
  this._parts = new Reassembler({
194
195
  onExpire: (info) => this._emit('error', { type: 'parts_incomplete', code: 'parts-incomplete', ...info })
195
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()
196
200
  this._traffic = new TrafficStats()
197
201
 
198
202
  this._rtc = this.enableWebRTC ? new WebRTCManager({
@@ -341,7 +345,7 @@ export class WebSocketProxyClient {
341
345
  'unsealed')
342
346
  }
343
347
  const tokens = Array.isArray(to) ? to : [to]
344
- const messageStr = typeof payload === 'string' ? payload : JSON.stringify(payload)
348
+ const messageStr = this._wire(payload)
345
349
  if (!this._rtc) {
346
350
  this._sendRaw({ to: tokens, message: messageStr })
347
351
  return
@@ -508,18 +512,22 @@ export class WebSocketProxyClient {
508
512
  async sendSealed (toPubkeys, payload, { peerEncPub, ...opts } = {}) {
509
513
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
510
514
  // Por pubkey el mensaje puede quedarse ESPERANDO en el proxio, y ahí se deja como mucho
511
- // 1 MB (parts.js). Lo que pasa de eso no se manda: se dice, y la app decide.
512
- const json = JSON.stringify(payload)
513
- if (json.length > QUEUE_MAX_CHARS) {
514
- throw errorCon(`sendSealed: ${json.length} chars is more than can wait in the proxy (${QUEUE_MAX_CHARS}); send it by token while both ends are connected`, 'too-big-for-queue')
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')
515
520
  }
516
- const pieces = json.length > PROXY_PART_CHARS ? splitParts(json, PROXY_PART_CHARS) : [payload]
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)
517
526
  // Con la llave puesta a mano se respeta tal cual: quien la pasa está diciendo que ya
518
527
  // sabe de quién es (se emparejaron), y un sobre vale para todos los destinatarios.
519
528
  if (peerEncPub) {
520
- for (const piece of pieces) {
521
- this._sendByPubkeyRaw(list, await this._seal(piece, peerEncPub, list.length === 1 ? list[0] : undefined), opts)
522
- }
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)
523
531
  return
524
532
  }
525
533
  // Sin ella, se averigua. UNA ENVOLTURA POR DESTINATARIO: cada uno tiene su llave, así
@@ -530,7 +538,8 @@ export class WebSocketProxyClient {
530
538
  // sin forma de saber cuál mitad.
531
539
  const llaves = await Promise.all(list.map(async (pk) => [pk, await this.encPubOf(pk)]))
532
540
  for (const [pk, encPub] of llaves) {
533
- for (const piece of pieces) this._sendByPubkeyRaw([pk], await this._seal(piece, 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)
534
543
  }
535
544
  }
536
545
 
@@ -565,11 +574,15 @@ export class WebSocketProxyClient {
565
574
  // Por token los dos están conectados y NO HAY TOPE (parts.js): lo que no cabe en un
566
575
  // mensaje del camino que toca sale en partes. El tamaño de parte lo decide el camino
567
576
  // de ahora: el canal directo admite mucho menos por mensaje que el proxio.
568
- const json = JSON.stringify(payload)
577
+ const m = this._measure(payload)
569
578
  const direct = tokens.length === 1 && !!this._rtc?.isOpen(tokens[0])
570
- const size = direct ? DIRECT_PART_CHARS : PROXY_PART_CHARS
571
- if (json.length <= size) {
572
- const sobre = await this._seal(payload, peerEncPub, peerPubkey)
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)
573
586
  if (tokens.length === 1 && peerPubkey) { this._sendToTokenOrQueue(tokens[0], sobre, peerPubkey); return }
574
587
  // Por `send`, no por `_sendRaw`: así sigue prefiriendo el canal directo si lo hay.
575
588
  this.send(tokens, sobre)
@@ -577,18 +590,18 @@ export class WebSocketProxyClient {
577
590
  }
578
591
  // Por el proxio en vivo hay tope (parts.js): lo que pasa de él solo sale por canal
579
592
  // directo. Se pide el canal para que el siguiente intento lo encuentre abierto.
580
- if (!direct && json.length > RELAY_MAX_CHARS) {
593
+ if (!direct && m.len > relayMax) {
581
594
  this._upgradeDirect(tokens)
582
- throw errorCon(`sendSealedTo: ${json.length} chars is more than goes through the proxy live (${RELAY_MAX_CHARS}); it needs a direct channel`, 'too-big-for-relay')
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')
583
596
  }
584
597
  // Si el token muere a mitad, lo que quede se iría a la cola por pubkey; solo se permite
585
598
  // si el mensaje entero cabe ahí. Si no, se dice (`too-big-for-queue`), no se deja.
586
- const queueFallback = json.length <= QUEUE_MAX_CHARS
599
+ const queueFallback = m.len <= queueMax
587
600
  // Y si el canal directo se cae a mitad, el resto va por el proxio en vivo solo si el
588
601
  // mensaje entero cabe por ahí; si no, se para y se dice (`direct-lost`).
589
- const relayFallback = json.length <= RELAY_MAX_CHARS
590
- for (const piece of splitParts(json, size)) {
591
- const sobre = await this._seal(piece, peerEncPub, peerPubkey)
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)
592
605
  if (tokens.length === 1 && peerPubkey) await this._sendPiece(tokens[0], sobre, peerPubkey, queueFallback, relayFallback)
593
606
  else this.send(tokens, sobre)
594
607
  }
@@ -620,10 +633,11 @@ export class WebSocketProxyClient {
620
633
  * una pausa corta entre partes, para no chocar con su límite de mensajes por segundo.
621
634
  */
622
635
  async _sendPiece (token, sobre, peerPubkey, queueFallback, relayFallback = true) {
623
- if (this._rtc && await this._rtc.sendDirect(token, JSON.stringify(sobre))) return
636
+ if (this._rtc && await this._rtc.sendDirect(token, this._wire(sobre))) return
624
637
  if (!relayFallback) {
625
638
  throw errorCon('the direct channel closed in the middle of a message too big for the proxy', 'direct-lost')
626
639
  }
640
+ if (sobre instanceof Uint8Array) this._needBinaryProxy()
627
641
  this._sendToTokenOrQueue(token, sobre, peerPubkey, { queueFallback, skipDirect: true })
628
642
  await new Promise((r) => setTimeout(r, 20))
629
643
  }
@@ -664,7 +678,7 @@ export class WebSocketProxyClient {
664
678
  if (this.requireSealed && !this._isSealed(sobre)) {
665
679
  throw errorCon('requireSealed: refusing to send a directed message in the clear — use sendSealedTo()', 'unsealed')
666
680
  }
667
- const messageStr = JSON.stringify(sobre)
681
+ const messageStr = this._wire(sobre)
668
682
  if (!skipDirect && this._rtc && this._rtc.trySend(token, messageStr)) return
669
683
  const id = `msg_${this._nextId++}`
670
684
  const timer = setTimeout(() => this._tokenWatch.delete(id), 15000)
@@ -763,6 +777,62 @@ export class WebSocketProxyClient {
763
777
  : seal(payload, peerEncPub)
764
778
  }
765
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
+
766
836
  // ---------- llaves de cifrado ajenas ----------
767
837
 
768
838
  /**
@@ -859,6 +929,11 @@ export class WebSocketProxyClient {
859
929
  }
860
930
 
861
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
+ }
862
937
  return this.sealing ? this.sealing.isSealed(msg) : isSealed(msg)
863
938
  }
864
939
 
@@ -881,6 +956,33 @@ export class WebSocketProxyClient {
881
956
  * into the app.
882
957
  */
883
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
+ }
884
986
  if (this._isSealed(payload)) {
885
987
  if (!this.sealing && !this.myEncPrivateKey) {
886
988
  this._emit('error', { type: 'unsealed', reason: 'no_encryption_key', from })
@@ -893,16 +995,7 @@ export class WebSocketProxyClient {
893
995
  // Quién selló (la llave de cifrado), si el sobre lo dice: es lo que autentica al
894
996
  // remitente, porque solo quien tiene esa privada pudo armarlo.
895
997
  const senderEncPub = this.sealing?.senderOf?.(payload) || null
896
- const fullMeta = { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) }
897
- if (isPart(opened)) {
898
- // Se agrupa por quien selló: solo esa llave pudo armar cada parte.
899
- let whole
900
- try { whole = this._parts.add(senderEncPub || meta.fromPubkey || from || '', opened) }
901
- catch (e) { this._emit('error', { type: 'bad_part', from, code: e.code || 'bad-part', error: e }); return }
902
- if (whole !== null) this._emit('message', from, whole, { ...fullMeta, parts: opened.__dotrino_part.n })
903
- return
904
- }
905
- this._emit('message', from, opened, fullMeta)
998
+ this._handOpened(from, opened, { ...meta, sealed: true, ...(senderEncPub ? { senderEncPub } : {}) })
906
999
  } catch (e) {
907
1000
  // Sealed to somebody else, or tampered with. Staying quiet is the point.
908
1001
  this._emit('error', { type: 'undecipherable', from, error: e })
@@ -917,11 +1010,24 @@ export class WebSocketProxyClient {
917
1010
  this._emit('message', from, payload, { ...meta, sealed: false })
918
1011
  }
919
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
+
920
1026
  _sendByPubkeyRaw (toPubkeys, payload, opts = {}) {
921
1027
  const list = Array.isArray(toPubkeys) ? toPubkeys : [toPubkeys]
922
1028
  const msg = {
923
1029
  to_publickey: list,
924
- message: typeof payload === 'string' ? payload : JSON.stringify(payload)
1030
+ message: this._wire(payload)
925
1031
  }
926
1032
  if (opts.ephemeral) msg.ephemeral = true
927
1033
  if (opts.quiet) msg.quiet = true
@@ -1347,6 +1453,9 @@ export class WebSocketProxyClient {
1347
1453
  try { prev.close() } catch (_) {}
1348
1454
  }
1349
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 (_) {}
1350
1459
  this.ws = ws
1351
1460
  const openTimer = this.connectTimeout > 0 ? setTimeout(() => this._onConnectTimeout(ws), this.connectTimeout) : null
1352
1461
  openTimer?.unref?.()
@@ -1484,9 +1593,20 @@ export class WebSocketProxyClient {
1484
1593
 
1485
1594
  _handleFrame (raw) {
1486
1595
  let data
1487
- try { data = JSON.parse(raw) } catch (e) {
1488
- this._emit('error', { type: 'parse_error', error: e })
1489
- 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
+ }
1490
1610
  }
1491
1611
  const { type } = data
1492
1612
  switch (type) {
@@ -1619,6 +1739,14 @@ export class WebSocketProxyClient {
1619
1739
  if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
1620
1740
  throw errorCon('WebSocket not connected', 'NOT_CONNECTED')
1621
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
+ }
1622
1750
  const texto = JSON.stringify(frame)
1623
1751
  this.ws.send(texto)
1624
1752
  this._countOut(frame, texto)
@@ -1627,7 +1755,7 @@ export class WebSocketProxyClient {
1627
1755
  /** Cuenta un frame que sale por el proxio, y a quién iba. */
1628
1756
  _countOut (frame, texto) {
1629
1757
  this._traffic.frame('out', utf8Length(texto))
1630
- if (typeof frame.message !== 'string') return
1758
+ if (typeof frame.message !== 'string' && !(frame.message instanceof Uint8Array)) return
1631
1759
  const bytes = utf8Length(frame.message)
1632
1760
  for (const token of Array.isArray(frame.to) ? frame.to : []) {
1633
1761
  this._traffic.peer('out', 'proxy', { token, pubkey: this._tokenPubkeys.get(token) || null }, bytes)
@@ -11,7 +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 } from './parts.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'
15
16
  export { logStats, formatStats, netReport, splitPeers } from './stats-log.js'
16
17
 
17
18
  import { WebSocketProxyClient } from './client.js'
@@ -22,6 +22,8 @@
22
22
  * llave que selló: nadie más puede colar una parte en el mensaje de otro.
23
23
  */
24
24
 
25
+ import { pack, unpack, concatBytes } from './binpack.js'
26
+
25
27
  export const PART_TAG = '__dotrino_part'
26
28
 
27
29
  /** Parte por el canal directo: por debajo de los 64 KiB que acepta libwebrtc, ya sellada. */
@@ -36,6 +38,21 @@ export const QUEUE_MAX_CHARS = 11 * 1024 * 1024
36
38
  /** Lo más que pasa por el proxio EN VIVO: 25 MB de archivo son ~33,4 MB en base64. */
37
39
  export const RELAY_MAX_CHARS = 34 * 1024 * 1024
38
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
+
39
56
  /** Rearmado: lo que se acepta guardar a medias, y cuánto se espera a la parte siguiente. */
40
57
  const RECV_MAX_CHARS = 512 * 1024 * 1024
41
58
  const RECV_IDLE_MS = 5 * 60 * 1000
@@ -57,6 +74,18 @@ export function splitParts (json, size) {
57
74
  return out
58
75
  }
59
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
+
60
89
  export const isPart = (o) => !!(o && typeof o === 'object' && o[PART_TAG])
61
90
 
62
91
  /**
@@ -77,25 +106,26 @@ export class Reassembler {
77
106
  const d = part.d
78
107
  if (!p || typeof p.id !== 'string' || p.id.length > 64 ||
79
108
  !Number.isInteger(p.n) || !Number.isInteger(p.i) || p.n < 2 || p.n > MAX_PARTS ||
80
- p.i < 0 || p.i >= p.n || typeof d !== 'string') {
109
+ p.i < 0 || p.i >= p.n || (typeof d !== 'string' && !(d instanceof Uint8Array))) {
81
110
  throw Object.assign(new Error('malformed message part'), { code: 'bad-part' })
82
111
  }
83
112
  const key = `${sender}|${p.id}`
84
113
  let a = this._open.get(key)
85
114
  if (!a) {
86
- a = { n: p.n, got: new Array(p.n), count: 0, chars: 0, timer: null }
115
+ a = { n: p.n, bin: d instanceof Uint8Array, got: new Array(p.n), count: 0, chars: 0, timer: null }
87
116
  this._open.set(key, a)
88
117
  }
89
- if (a.n !== p.n) {
118
+ if (a.n !== p.n || a.bin !== (d instanceof Uint8Array)) {
90
119
  this._drop(key)
91
- throw Object.assign(new Error('message parts disagree on their count'), { code: 'bad-part' })
120
+ throw Object.assign(new Error('message parts disagree on their count or kind'), { code: 'bad-part' })
92
121
  }
93
122
  if (a.got[p.i] === undefined) {
94
123
  if (this._chars + d.length > RECV_MAX_CHARS) {
95
124
  this._drop(key)
96
125
  throw Object.assign(new Error('too much of a message in parts is pending'), { code: 'parts-too-big' })
97
126
  }
98
- a.got[p.i] = d
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
99
129
  a.count++
100
130
  a.chars += d.length
101
131
  this._chars += d.length
@@ -103,8 +133,8 @@ export class Reassembler {
103
133
  if (a.timer) clearTimeout(a.timer)
104
134
  if (a.count === a.n) {
105
135
  this._drop(key)
106
- try { return JSON.parse(a.got.join('')) }
107
- catch (_) { throw Object.assign(new Error('a message in parts did not join into JSON'), { code: 'bad-part' }) }
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' }) }
108
138
  }
109
139
  a.timer = setTimeout(() => {
110
140
  const missing = a.n - a.count
@@ -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
+ }
@@ -351,6 +351,8 @@ export class WebRTCManager {
351
351
 
352
352
  _attachDC (peer, dc) {
353
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 (_) {}
354
356
  dc.onopen = () => {
355
357
  // Por dónde salió: directo o por TURN. Por detrás, sin retrasar a nadie.
356
358
  this.probeRoute(peer).catch(() => {})
@@ -367,10 +369,13 @@ export class WebRTCManager {
367
369
  }
368
370
  dc.onmessage = (ev) => {
369
371
  let parsed = null
370
- const raw = ev.data
372
+ let raw = ev.data
371
373
  this.count?.('in', peer.remote, utf8Length(raw), peer.route || 'webrtc')
372
374
  if (typeof raw === 'string') {
373
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)
374
379
  }
375
380
  this.deliverMessage(peer.remote, parsed ?? raw, { raw, timestamp: Date.now(), via: 'webrtc' })
376
381
  }
@@ -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