feat(descriptor): SBQ2 connection descriptor format; release v5.8.0
The SB1 invitation runs 2000-2400 characters and needs QR version 38-40, past the point where a single code is scannable, so the app falls back to an animated multi-frame QR. SBQ2 is a fixed binary layout carrying only what brings up DTLS -- ICE credentials, certificate fingerprint, candidates -- with the SDP rebuilt from a template by a strict serializer. Measured on real Chrome and Firefox SDP across four network profiles: 98-149 bytes, QR version 6-8. Key material is meant to move to the DataChannel, bound by a commitment in the descriptor. That half does not exist yet, so nothing calls this module: the format is landed for review and freeze, not wired into the connection path. doc/DESCRIPTOR-SBQ2.md records the gate on phase 3. The decoder is a parser of hostile input: fixed offsets, explicit lengths, deny-by-default on reserved values and unknown TLV extension types, trailing bytes rejected, ICE credentials alphabet-checked so a CRLF cannot reach the serializer. No compression -- DEFLATE adds bytes on this payload, and dropping it removes the decompression-bomb surface with it. Candidate pruning keeps coverage before count: one candidate per (family, type, transport) survives before any surplus, so an IPv6-only or UDP-blocked path cannot be pruned away by a v4-first sort. Tests cover round-trip against captured Chrome and Firefox SDP, IPv6 and NAT64 addresses, ICE-TCP candidates, the TLV area, clock skew, one-shot binding and SAS transcript coverage.
This commit is contained in:
@@ -1,5 +1,42 @@
|
||||
# Changelog
|
||||
|
||||
## v5.8.0 — A connection descriptor that fits in a small QR code
|
||||
|
||||
No change to how messages are protected, and no change to how a connection is
|
||||
established. This release adds the wire format for a much smaller invitation and
|
||||
the code that reads and writes it; nothing in the application calls it yet.
|
||||
|
||||
### Added
|
||||
|
||||
- `src/network/descriptor/sbq2.js` — version 2 of the connection descriptor. The
|
||||
current `SB1:` payload runs 2000–2400 characters and needs QR version 38–40, at
|
||||
which point the app has to fall back to an animated multi-frame code. Measured
|
||||
on real Chrome and Firefox SDP across four network profiles, SBQ2 is **98–149
|
||||
bytes**, which is **QR version 6–8** — a single, instantly scannable image.
|
||||
|
||||
The saving comes from sending only what is needed to bring up DTLS (ICE
|
||||
credentials, certificate fingerprint, candidates) and templating the SDP rather
|
||||
than shipping it verbatim. Key material is intended to move to the DataChannel,
|
||||
bound to the descriptor by a commitment; **that half is not implemented**, which
|
||||
is why the format is not yet in the connection path. See
|
||||
`doc/DESCRIPTOR-SBQ2.md` for the layout, the security argument and the migration
|
||||
gate.
|
||||
|
||||
The decoder is written as a parser of hostile input: fixed offsets, explicit
|
||||
lengths, deny-by-default on every reserved value and on unknown extension types,
|
||||
trailing bytes rejected, and ICE credentials alphabet-checked so a CRLF cannot
|
||||
reach the SDP serializer. Compression is deliberately absent — on this payload
|
||||
DEFLATE adds bytes, and removing it removes the decompression-bomb surface too.
|
||||
|
||||
- `doc/DESCRIPTOR-SBQ2.md`, and `tests/descriptor-sbq2.test.mjs` covering
|
||||
round-trip against real Chrome and Firefox SDP, IPv6 and NAT64 addresses,
|
||||
ICE-TCP candidates, candidate-coverage pruning, the TLV extension area, clock
|
||||
skew, one-shot binding and the SAS transcript.
|
||||
|
||||
- `tests/fixtures/sdp-chrome.json` and `tests/fixtures/sdp-firefox.json` —
|
||||
SDP captured from real browsers rather than written by hand.
|
||||
|
||||
|
||||
## v5.7.2 — Documentation, and a version that keeps itself honest
|
||||
|
||||
No changes to the protocol or to how messages are protected.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
No accounts. No servers storing your messages. No installation required.
|
||||
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](#install-as-an-app)
|
||||
[](#security-model)
|
||||
[](#forward-secrecy)
|
||||
|
||||
Vendored
+1
-1
@@ -19984,7 +19984,7 @@ var SecureMasterKeyManager = class {
|
||||
var import_NotificationIntegration = __toESM(require_NotificationIntegration());
|
||||
|
||||
// package.json
|
||||
var version = "5.7.2";
|
||||
var version = "5.8.0";
|
||||
|
||||
// src/components/ui/Header.jsx
|
||||
var APP_VERSION = `v${version}`;
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -7,7 +7,7 @@ this document describes.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Release | v5.7.2 |
|
||||
| Release | v5.8.0 |
|
||||
| Protocol version | 4.1 |
|
||||
| Ratchet wire version | 1 |
|
||||
|
||||
@@ -214,5 +214,5 @@ worse than one that reports nothing.
|
||||
|
||||
## Scope
|
||||
|
||||
This describes the browser implementation as it stands in v5.7.2. It is not a
|
||||
This describes the browser implementation as it stands in v5.8.0. It is not a
|
||||
substitute for independent cryptographic review.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
# SBQ2 — connection descriptor v2
|
||||
|
||||
The descriptor is the blob a user carries from one device to the other by hand:
|
||||
a QR code, a deep link, or a paste into another messenger. There is no signalling
|
||||
server, so this is the only channel that exists before the peers can talk.
|
||||
|
||||
SBQ2 replaces the `SB1:bin:` format (CBOR + zlib + base64url of the whole offer
|
||||
package). Measured on real Chrome and Firefox SDP, a descriptor went from
|
||||
2000–2400 characters to **98–149 bytes**, and the QR from version 38–40 down to
|
||||
**version 6–8** at error-correction level M.
|
||||
|
||||
**Status: specified and implemented, not wired into the connection path.** See
|
||||
[Migration](#migration) for the gate on phase 3.
|
||||
|
||||
---
|
||||
|
||||
## 1. What travels where
|
||||
|
||||
Out of band (this descriptor): ICE credentials, the DTLS certificate
|
||||
fingerprint, the candidate list, an expiry, and a 16-byte commitment.
|
||||
|
||||
In band (over the DataChannel, once DTLS is up): identity key, ECDH key,
|
||||
signatures — everything that used to make the descriptor large.
|
||||
|
||||
The fingerprint is what makes that split safe. It arrives over the channel the
|
||||
user already trusts, and DTLS completes only with the holder of the matching
|
||||
private key, so the transport is authenticated to whoever showed the code before
|
||||
any key material moves. The commitment makes substitution of that material fail
|
||||
closed automatically rather than relying on the human comparison, and the SAS
|
||||
covers a transcript containing both descriptors verbatim and both in-band blobs.
|
||||
|
||||
---
|
||||
|
||||
## 2. Wire layout
|
||||
|
||||
All integers big-endian. Offsets are for the offer; an answer inserts its
|
||||
8-byte binding tag at offset 5 and everything after shifts by 8.
|
||||
|
||||
```
|
||||
off len field
|
||||
0 1 version = 0x02 mismatch is an error, never a reparse
|
||||
1 1 flags
|
||||
2 3 expiry u24, minutes since 2024-01-01T00:00:00Z
|
||||
5 [8] binding_tag ANSWERS ONLY
|
||||
.. 32 dtls_fingerprint SHA-256 of the certificate, raw
|
||||
.. 1 ufrag_len 4..64
|
||||
.. L1 ufrag ASCII, RFC 8839 ice-char alphabet
|
||||
.. 1 pwd_len 22..64
|
||||
.. L2 pwd ASCII, RFC 8839 ice-char alphabet
|
||||
.. 1 candidate_count 0..8
|
||||
.. .. candidates
|
||||
.. [16] commitment if flags bit 6
|
||||
.. [1] ext_len if flags bit 7
|
||||
.. .. TLV records if flags bit 7
|
||||
```
|
||||
|
||||
### flags
|
||||
|
||||
| bits | meaning |
|
||||
|---|---|
|
||||
| 0–1 | type: 0 offer, 1 answer. 2 and 3 are reserved → **reject** |
|
||||
| 2–3 | DTLS setup role: 0 actpass, 1 active, 2 passive. 3 reserved → **reject** |
|
||||
| 4–5 | max-message-size: 0 = 262144, 1 = 1073741823, 2 = 65536, 3 = explicit, in extension `0x01` |
|
||||
| 6 | a commitment follows the candidates |
|
||||
| 7 | a TLV extension area follows |
|
||||
|
||||
Every bit is allocated. Future fields go in the TLV area, which is itself
|
||||
deny-by-default; there is deliberately no spare "ignore me" bit.
|
||||
|
||||
### candidate
|
||||
|
||||
```
|
||||
1 byte kind << 4 | tcptype
|
||||
n bytes address (v4 = 4, v6 = 16, mDNS UUID = 16)
|
||||
2 bytes port
|
||||
```
|
||||
|
||||
`kind`: 0 host-v4, 1 host-mDNS, 2 srflx-v4, 3 relay-v4, 4 host-v6, 5 srflx-v6,
|
||||
6 relay-v6. 7–15 reserved → **reject**.
|
||||
`tcptype`: 0 udp, 1 tcp/passive, 2 tcp/active, 3 tcp/so. 4–15 → **reject**.
|
||||
|
||||
Foundation and priority are **not** transmitted. Priority only orders
|
||||
connectivity checks, and each peer computes its own local priorities anyway; the
|
||||
serializer re-derives RFC 8445 §5.1.2.1 values with `localPref = 65535 - index`,
|
||||
so the sender's ordering intent survives at zero cost. Foundations are grouped by
|
||||
kind and transport, satisfying both halves of §5.1.1.3. `raddr`/`rport` are
|
||||
diagnostics that ICE does not consume, and `generation`/`network-cost` are Chrome
|
||||
extensions.
|
||||
|
||||
### TLV extension area
|
||||
|
||||
```
|
||||
ext_len u8, 1..255, must be consumed exactly
|
||||
record: type u8, len u8, value[len]
|
||||
```
|
||||
|
||||
Records must appear in **ascending type order with no duplicates**, so every
|
||||
descriptor has exactly one valid spelling. An unknown type is a hard error.
|
||||
|
||||
| type | len | value |
|
||||
|---|---|---|
|
||||
| `0x01` | 4 | max-message-size, u32, 1024..2^31-1, and not equal to a value the flags already encode |
|
||||
|
||||
---
|
||||
|
||||
## 3. Sizes measured
|
||||
|
||||
Real SDP, identical ICE configuration on both peers, each peer gathering in its
|
||||
own browser process. QR versions are byte mode at level M.
|
||||
|
||||
| browser | profile | offer | QR | answer | QR |
|
||||
|---|---|---|---|---|---|
|
||||
| Chrome | host_only | 103 B | v6 | 111 B | v7 |
|
||||
| Chrome | stun | 110 B | v7 | 118 B | v7 |
|
||||
| Chrome | turn_all | 124 B | v8 | 132 B | v8 |
|
||||
| Chrome | turn_relay_only | 98 B | v6 | 106 B | v6 |
|
||||
| Firefox | host_only | 134 B | v8 | 142 B | v8 |
|
||||
| Firefox | stun | 141 B | v8 | 149 B | v8 |
|
||||
| Firefox | turn_all | 136 B | v8 | 144 B | v8 |
|
||||
| Firefox | turn_relay_only | 110 B | v7 | 118 B | v7 |
|
||||
|
||||
Firefox descriptors run ~12 bytes larger because its ICE credentials are longer
|
||||
(8-char ufrag and 32-char pwd against Chrome's 4 and 24).
|
||||
|
||||
Transport: **raw bytes in QR byte mode**. base45 buys nothing (198 alphanumeric
|
||||
characters = 1089 bits against 1056 bits raw for the same 132-byte payload), and
|
||||
base64url costs a QR version. For text channels the form is `SB2:` + base64url,
|
||||
whose alphabet survives messenger auto-formatting; the decoder strips whitespace
|
||||
so a wrapped paste still works. **DEFLATE is not used** — on this payload it adds
|
||||
2 to 11 bytes, and dropping it removes the decompression-bomb surface with it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Candidate pruning: coverage before count
|
||||
|
||||
A count limit is the wrong policy. Sorted v4-first it can evict the only usable
|
||||
candidate on an IPv6-only network, which is a normal mode on several mobile
|
||||
carriers, and one that ignores transport can evict the TCP candidate that exists
|
||||
precisely for networks where UDP is blocked.
|
||||
|
||||
The rule, in order:
|
||||
|
||||
1. **Coverage.** Every `(address family, candidate type, transport)` combination
|
||||
present in the input keeps its highest-priority representative. Families are
|
||||
`v4`, `v6` and `mdns` — mDNS is its own family because it resolves only on
|
||||
the sender's link, covering a case neither of the others does. Coverage is
|
||||
never cut, not even to stay inside the byte budget: a QR one version larger
|
||||
costs less than a connection that cannot be made.
|
||||
2. **Surplus,** by the sender's own priority, until either `MAX_CANDIDATES` (8)
|
||||
or `SURPLUS_CANDIDATE_BYTES` (48) runs out, with relays capped at 2.
|
||||
|
||||
Candidates a peer cannot dial — ICE-TCP `active` and `so`, which are
|
||||
outbound-only sockets on the discard port — are excluded from coverage and
|
||||
compete only for surplus. Firefox advertises an `active` host candidate on every
|
||||
connection; at 19 bytes for an mDNS address it must not hold a coverage slot it
|
||||
cannot use.
|
||||
|
||||
The 48-byte surplus budget is derived, not chosen: the largest answer head
|
||||
measured is Firefox's at 104 bytes, and QR version 8 at level M holds 152, so
|
||||
152 − 104 = 48.
|
||||
|
||||
The relay cap of 2 applies only to surplus. A TURN server offering udp/tcp/tls
|
||||
hands out one allocation per transport and they all resolve to the same relayed
|
||||
address, so the third adds no reachability; two survive in case one allocation's
|
||||
binding dies.
|
||||
|
||||
---
|
||||
|
||||
## 5. Freshness, uniqueness, one-shot
|
||||
|
||||
**Expiry** is absolute, at minute granularity, with a ±2-minute skew allowance.
|
||||
Two minutes is sized against the failure it exists for: an NTP-synced device is
|
||||
within milliseconds and an unsynced modern device drifts seconds per day, so two
|
||||
minutes swallows every ordinary case while still refusing a grossly wrong clock
|
||||
(manually set, or reset by a dead battery) — a device that cannot be given a
|
||||
meaningful freshness guarantee should be told so, and the error message names the
|
||||
clock as the likely cause. The cost is a replay window of 12 minutes instead of
|
||||
10.
|
||||
|
||||
**The offer carries no nonce.** It does not need one: `ice-pwd` is in the hashed
|
||||
bytes, RFC 8839 §5.4 requires it to contain at least 128 bits of randomness, and
|
||||
every browser regenerates it per peer connection and per ICE restart. A separate
|
||||
8-byte random field would have been 8 bytes restating entropy already present.
|
||||
|
||||
**The answer carries an 8-byte binding tag**, `SHA-256("sbq2/bind\0" ||
|
||||
offer_bytes)[0..8]`. The offerer keeps the tag of the offer it is currently
|
||||
showing and refuses anything else, which is both the answer's replay defence and
|
||||
what makes each offer exactly one-shot — without any stored state between
|
||||
sessions.
|
||||
|
||||
> **Limitation, on the record:** 64 bits is not a standalone integrity primitive.
|
||||
> The tag is a duplicate-detection device whose security comes from the SAS
|
||||
> transcript, which covers both descriptors in full. Nothing may be built on this
|
||||
> tag alone. If a future change needs one, widen the field rather than lean on it.
|
||||
|
||||
---
|
||||
|
||||
## 6. SAS
|
||||
|
||||
```
|
||||
transcript = "sbq2/sas/v1\0"
|
||||
|| len32(offer_bytes) || offer_bytes
|
||||
|| len32(answer_bytes) || answer_bytes
|
||||
|| len32(offer_blob) || offer_blob
|
||||
|| len32(answer_blob) || answer_blob
|
||||
|
||||
SAS = HKDF-SHA256(IKM = ECDH shared secret,
|
||||
salt = SHA-256(transcript),
|
||||
info = "sbq2-sas-v1") -> 64 bits -> 7 digits
|
||||
```
|
||||
|
||||
The transcript covers both descriptors byte for byte — version, flags, expiry,
|
||||
binding tag, fingerprints, ICE credentials, every candidate, the commitment and
|
||||
the whole extension area — plus both in-band blobs. Lengths are prefixed so no
|
||||
field boundary can be shifted to produce a colliding transcript.
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration
|
||||
|
||||
The two formats separate without heuristics: an SBQ2 QR starts with byte `0x02`,
|
||||
an SB1 payload starts with ASCII `S` (`0x53`); in text, the prefixes are `SB2:`
|
||||
and `SB1:bin:` / `SB1:gz:`.
|
||||
|
||||
| phase | change |
|
||||
|---|---|
|
||||
| 1 (done) | Codec and tests in the tree. Nothing in the connection path changes. |
|
||||
| 2 | Receiver accepts both. Try SBQ2 first, fall back to the SB1 parser. Sender still emits `SB1:`. |
|
||||
| 3 | Sender switches to SBQ2 behind a flag. **Gated — see below.** |
|
||||
| 4 | `SB1:` emission removed; SB1 parsing kept one more release, then deleted along with `cose-qr.js`, `inflateBounded`, and the animated multi-frame QR path in `app.jsx`. |
|
||||
|
||||
### Phase 3 is blocked on the in-band key exchange
|
||||
|
||||
**The security argument in §1 describes a protocol that does not exist in the
|
||||
code yet.** Today `EnhancedSecureWebRTCManager` still ships the key material
|
||||
inside the descriptor, still computes the SAS in `_computeSAS` from the DTLS
|
||||
fingerprints alone, and still sends `authProof`. Shrinking the descriptor without
|
||||
that delivery would remove the key material from the QR with nothing carrying it
|
||||
instead.
|
||||
|
||||
Phase 3 must not be enabled until a separate delivery lands:
|
||||
|
||||
- a key-exchange phase after the DataChannel opens,
|
||||
- verification of the commitment **before** any use of the blob,
|
||||
- `_computeSAS` replaced by the transcript SAS above,
|
||||
- the session salt derived from the transcript instead of transmitted,
|
||||
- `authProof` replaced by a signature over the transcript,
|
||||
- interlock with the Double Ratchet start,
|
||||
|
||||
each with its own tests. Phases 1 and 2 are safe to ship without it, because
|
||||
neither changes what is sent.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decoder rules
|
||||
|
||||
The decoder parses fully attacker-controlled input.
|
||||
|
||||
- Payload ceiling (512 B) checked before any structure is walked.
|
||||
- Version compared first; a mismatch throws.
|
||||
- Reserved values (descriptor type 2–3, setup role 3, candidate kind 7–15,
|
||||
tcptype 4–15) are refused, never coerced.
|
||||
- Unknown TLV types are refused. Records must be ascending and unique, and a TLV
|
||||
restating a value the flags already encode is refused as non-canonical.
|
||||
- Flags and extension area must agree in both directions: `mms = 3` without
|
||||
extension `0x01` is an error, and extension `0x01` without `mms = 3` is too.
|
||||
- ufrag and pwd are range-checked and alphabet-checked; any byte outside
|
||||
printable ASCII fails before the value is used, so a CR or LF cannot reach the
|
||||
serializer and inject an SDP line.
|
||||
- Trailing bytes after the structure are an error. A decoder that tolerated them
|
||||
would let a second reading of the same QR slip past whatever hashed the
|
||||
canonical form.
|
||||
- Base64url input must be canonical: non-zero padding bits are refused, so a
|
||||
descriptor has exactly one textual spelling.
|
||||
|
||||
---
|
||||
|
||||
## 9. Provenance of the numbers
|
||||
|
||||
Everything above was measured, not estimated.
|
||||
|
||||
- Chrome fixtures: `tests/fixtures/sdp-chrome.json`, captured over CDP from a
|
||||
real Chrome across four network profiles, both peers configured identically,
|
||||
each gathering in isolation.
|
||||
- Firefox fixtures: `tests/fixtures/sdp-firefox.json`, Firefox 153 over
|
||||
Marionette, same method.
|
||||
- Round-trip, rejection, coverage, TLV, skew and transcript tests:
|
||||
`tests/descriptor-sbq2.test.mjs`.
|
||||
|
||||
The original brief's payload (CBOR 2391 B, 991 B of SDP) was an **offer** — it
|
||||
carries `sl`, `si`, `vc` and `ac`, and has no `ap` block. An earlier draft of the
|
||||
analysis matched it against a STUN-profile answer on the strength of the post-
|
||||
zlib and post-base64 sizes; those agreed by coincidence while the CBOR sizes
|
||||
differ by 135 bytes. Conclusions were unaffected, but the attribution was wrong.
|
||||
+22
-22
@@ -24,7 +24,7 @@
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="./manifest.json">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1785988424571">
|
||||
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786031423211">
|
||||
|
||||
<!-- PWA Meta Tags -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -90,7 +90,7 @@
|
||||
<link rel="apple-touch-startup-image" media="screen and (device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)" href="./logo/splash/splash_screens/8.3__iPad_Mini_portrait.png">
|
||||
|
||||
<!-- Apple Touch Icons -->
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1785988424571">
|
||||
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786031423211">
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="./logo/icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.png">
|
||||
@@ -99,7 +99,7 @@
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="./logo/icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="./logo/icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1785988424571">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786031423211">
|
||||
|
||||
<!-- Microsoft Tiles -->
|
||||
<meta name="msapplication-TileColor" content="#ff6b35">
|
||||
@@ -183,7 +183,7 @@
|
||||
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
|
||||
both execute in document order after parsing, so React still runs before the
|
||||
app modules below, but the parser / first paint is no longer blocked. -->
|
||||
<script defer src="config/ice-servers.js?v=1785988424571"></script>
|
||||
<script defer src="config/ice-servers.js?v=1786031423211"></script>
|
||||
<script defer src="libs/react/react.production.min.js"></script>
|
||||
<script defer src="libs/react-dom/react-dom.production.min.js"></script>
|
||||
<!-- Prism syntax highlighting (vendored, offline). Tokenizes code as TEXT only —
|
||||
@@ -191,8 +191,8 @@
|
||||
Its CSS is loaded async via load-async-css.js (not paint-critical). -->
|
||||
<script defer src="libs/prism/prism.js"></script>
|
||||
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1785988424571">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1785988424571">
|
||||
<link rel="stylesheet" href="assets/tailwind.css?v=1786031423211">
|
||||
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786031423211">
|
||||
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI
|
||||
icons; fa-regular/fa-brands are loaded on demand by their CSS (rarely on the
|
||||
first screen). Inter latin 400/700 cover body text and headings/buttons. -->
|
||||
@@ -200,31 +200,31 @@
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/assets/fonts/inter/files/inter-latin-700.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/assets/fonts/inter/inter.css">
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1785988424571">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1785988424571">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1785988424571">
|
||||
<link rel="stylesheet" href="src/styles/main.css?v=1786031423211">
|
||||
<link rel="stylesheet" href="src/styles/animations.css?v=1786031423211">
|
||||
<link rel="stylesheet" href="src/styles/components.css?v=1786031423211">
|
||||
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
|
||||
<script defer src="src/scripts/load-async-css.js?v=1785988424571"></script>
|
||||
<script defer src="src/scripts/load-async-css.js?v=1786031423211"></script>
|
||||
<noscript>
|
||||
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="libs/prism/prism.css">
|
||||
</noscript>
|
||||
<script defer src="src/scripts/fa-check.js?v=1785988424571"></script>
|
||||
<script defer src="src/scripts/fa-check.js?v=1786031423211"></script>
|
||||
<!-- Update Manager - система принудительного обновления -->
|
||||
<script defer src="src/utils/updateManager.js?v=1785988424571"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1785988424571"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1785988424571"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1785988424571"></script>
|
||||
<script defer src="src/utils/updateManager.js?v=1786031423211"></script>
|
||||
<script type="module" src="src/components/UpdateChecker.jsx?v=1786031423211"></script>
|
||||
<script type="module" src="dist/qr-local.js?v=1786031423211"></script>
|
||||
<script type="module" src="src/components/QRScanner.js?v=1786031423211"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="dist/app-boot.js?v=1785988424571"></script>
|
||||
<script type="module" src="dist/app.js?v=1785988424571"></script>
|
||||
<script type="module" src="dist/app-boot.js?v=1786031423211"></script>
|
||||
<script type="module" src="dist/app.js?v=1786031423211"></script>
|
||||
|
||||
<script defer src="src/scripts/pwa-register.js?v=1785988424571"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1785988424571" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1785988424571" type="module"></script>
|
||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1785988424571"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1785988424571">
|
||||
<script defer src="src/scripts/pwa-register.js?v=1786031423211"></script>
|
||||
<script src="./src/pwa/install-prompt.js?v=1786031423211" type="module"></script>
|
||||
<script src="./src/pwa/pwa-manager.js?v=1786031423211" type="module"></script>
|
||||
<script defer src="./src/scripts/pwa-offline-test.js?v=1786031423211"></script>
|
||||
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786031423211">
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"version": "1785988424571",
|
||||
"buildVersion": "1785988424571",
|
||||
"appVersion": "5.7.2",
|
||||
"buildTime": "2026-08-06T03:53:44.611Z",
|
||||
"buildId": "1785988424571-27279ae",
|
||||
"gitHash": "27279ae",
|
||||
"version": "1786031423211",
|
||||
"buildVersion": "1786031423211",
|
||||
"appVersion": "5.8.0",
|
||||
"buildTime": "2026-08-06T15:50:23.257Z",
|
||||
"buildId": "1786031423211-3212138",
|
||||
"gitHash": "3212138",
|
||||
"generated": true,
|
||||
"generatedAt": "2026-08-06T03:53:44.612Z"
|
||||
"generatedAt": "2026-08-06T15:50:23.258Z"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "securebit-chat",
|
||||
"version": "5.7.2",
|
||||
"version": "5.8.0",
|
||||
"description": "Secure P2P Communication Application with End-to-End Encryption",
|
||||
"main": "index.html",
|
||||
"scripts": {
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "npm run build && python -m http.server 8000",
|
||||
"watch": "npx tailwindcss -i src/styles/tw-input.css -o assets/tailwind.css --watch",
|
||||
"serve": "npx http-server -p 8000",
|
||||
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs"
|
||||
"test": "node tests/sas-verification.test.mjs && node tests/verification-gate.test.mjs && node tests/inbound-frame-authentication.test.mjs && node tests/control-frame-authorization.test.mjs && node tests/security-level-shape.test.mjs && node tests/desktop-download-links.test.mjs && node tests/file-transfer-consent.test.mjs && node tests/incoming-message-sanitization.test.mjs && node tests/outgoing-message-integrity.test.mjs && node tests/secure-chat-features.test.mjs && node tests/notification-meta-forwarding.test.mjs && node tests/notification-ephemeral-privacy.test.mjs && node tests/key-derivation-compat.test.mjs && node tests/key-exchange-e2e.test.mjs && node tests/file-type-allowlist.test.mjs && node tests/voice-auto-accept.test.mjs && node tests/legacy-offer-purge.test.mjs && node tests/webrtc-privacy-mode.test.mjs && node tests/indexeddb-metadata-encryption.test.mjs && node tests/disconnect-cleanup.test.mjs && node tests/timer-lifecycle.test.mjs && node tests/file-transfer-cleanup.test.mjs && node tests/file-transfer-ui-cleanup.test.mjs && node tests/file-transfer-callback-propagation.test.mjs && node tests/debug-window-hooks.test.mjs && node tests/inbound-message-rate-limit.test.mjs && node tests/file-transfer-chunk-rate-limit.test.mjs && node tests/ice-servers-validation.test.mjs && node tests/sessions-reducer.test.mjs && node tests/webrtc-sdp.test.mjs && node tests/webrtc-video.test.mjs && node tests/webrtc-adaptation.test.mjs && node tests/session-recovery.test.mjs && node tests/qr-zip-bomb.test.mjs && node tests/ice-gathering-patience.test.mjs && node tests/version-consistency.test.mjs && node tests/double-ratchet.test.mjs && node tests/ratchet-integration.test.mjs && node tests/descriptor-sbq2.test.mjs"
|
||||
},
|
||||
"keywords": [
|
||||
"p2p",
|
||||
|
||||
@@ -0,0 +1,824 @@
|
||||
// SBQ2 — connection descriptor v2.
|
||||
//
|
||||
// The out-of-band descriptor (QR / link / paste) carries ONLY what is needed to
|
||||
// bring up the DTLS association: ICE credentials, the DTLS certificate
|
||||
// fingerprint, and the candidate list. Every byte of key material — identity
|
||||
// key, ECDH key, signatures — travels in-band over the DataChannel that the
|
||||
// fingerprint already authenticates, and is bound to the descriptor by a
|
||||
// commitment carried here.
|
||||
//
|
||||
// Why that is safe, in one paragraph: the fingerprint is transferred over the
|
||||
// out-of-band channel the user already trusts (they are looking at the QR), and
|
||||
// DTLS will only complete with the holder of the matching private key. So the
|
||||
// channel is authenticated to whoever showed the code before a single byte of
|
||||
// key material moves. The commitment makes substitution of that material fail
|
||||
// closed automatically instead of relying on the human SAS comparison, and the
|
||||
// SAS itself is computed over a transcript that covers both descriptors
|
||||
// verbatim and both in-band blobs — so nothing that travelled out of band can
|
||||
// be altered without changing the digits the users read to each other.
|
||||
//
|
||||
// NOTE: the in-band half of that protocol is not implemented yet. This module
|
||||
// is the wire format only; see doc/descriptor-sbq2.md for the migration gate.
|
||||
//
|
||||
// This module is pure: no DOM, no crypto beyond an injected digest, no network.
|
||||
// It is the parser for fully attacker-controlled input, so every length, range
|
||||
// and alphabet is checked before the value is used, and the SDP is rebuilt by a
|
||||
// strict serializer from validated primitives — never by concatenating a string
|
||||
// that came off the wire.
|
||||
|
||||
export const SBQ2_VERSION = 0x02;
|
||||
|
||||
// Hard limits applied BEFORE any structural parsing.
|
||||
export const LIMITS = Object.freeze({
|
||||
MAX_PAYLOAD_BYTES: 512, // ~3.5x the largest descriptor we have ever measured
|
||||
MAX_CANDIDATES: 8,
|
||||
MIN_UFRAG: 4, // RFC 8839: ice-ufrag is 4..256 chars
|
||||
MAX_UFRAG: 64,
|
||||
MIN_PWD: 22, // RFC 8839: ice-pwd is 22..256 chars, >=128 bits of randomness
|
||||
MAX_PWD: 64,
|
||||
FINGERPRINT_BYTES: 32, // SHA-256
|
||||
COMMITMENT_BYTES: 16, // 128-bit second-preimage resistance
|
||||
BINDING_BYTES: 8,
|
||||
MAX_LIFETIME_MINUTES: 60,
|
||||
MAX_EXT_BYTES: 255,
|
||||
|
||||
// Byte budget for candidates admitted BEYOND the coverage set (coverage
|
||||
// itself is never cut — see pruneCandidates). Derived from the acceptance
|
||||
// target rather than picked: the largest answer head we have measured is
|
||||
// Firefox's, at 104 bytes (version+flags+expiry+tag+fingerprint+8-char
|
||||
// ufrag+32-char pwd+count+commitment), and QR version 8 at level M holds
|
||||
// 152 bytes in byte mode. 152 - 104 = 48.
|
||||
SURPLUS_CANDIDATE_BYTES: 48,
|
||||
|
||||
// Clock-skew allowance, applied in both directions on the expiry check.
|
||||
//
|
||||
// Two minutes is chosen against the failure it exists for: a receiver whose
|
||||
// clock is off. An NTP-synced device is within milliseconds, and an
|
||||
// unsynced modern device drifts on the order of seconds per day, so two
|
||||
// minutes swallows every ordinary case. It does NOT swallow a grossly wrong
|
||||
// clock (manually set, or reset to the epoch by a dead battery) — that is
|
||||
// deliberate, because such a device cannot be given a meaningful freshness
|
||||
// guarantee and should be told so. The cost is that the replay window grows
|
||||
// from the nominal 10 minutes to 12; keeping the tolerance well under the
|
||||
// lifetime is what bounds that.
|
||||
CLOCK_SKEW_MS: 120_000,
|
||||
});
|
||||
|
||||
// Expiry is stored as minutes since 2024-01-01T00:00:00Z in 24 bits, which runs
|
||||
// out in 2055. Minute granularity is far finer than any descriptor lifetime, and
|
||||
// uint16 was tried first and rejected: 65535 minutes is only 45 days of range.
|
||||
const EPOCH_MS = Date.UTC(2024, 0, 1);
|
||||
const MAX_EXPIRY_UNITS = 0xffffff;
|
||||
|
||||
export const TYPE = Object.freeze({ OFFER: 0, ANSWER: 1 });
|
||||
const SETUP = Object.freeze(['actpass', 'active', 'passive']);
|
||||
|
||||
// max-message-size, packed into two flag bits. Index 3 means "an explicit value
|
||||
// is carried in extension 0x01"; RFC 8841 makes 64 KiB the default when the
|
||||
// attribute is absent, which is why absent and 65536 share an encoding.
|
||||
const MMS_ENUM = Object.freeze([262144, 1073741823, 65536, null]);
|
||||
const MMS_EXPLICIT = 3;
|
||||
|
||||
// Extension types. Unknown types are rejected, never skipped — see decodeExt.
|
||||
export const EXT = Object.freeze({ MAX_MESSAGE_SIZE: 0x01 });
|
||||
|
||||
// Candidate kinds. Values are wire constants — never renumber, only append.
|
||||
const KIND = Object.freeze({
|
||||
HOST_V4: 0, HOST_MDNS: 1, SRFLX_V4: 2, RELAY_V4: 3,
|
||||
HOST_V6: 4, SRFLX_V6: 5, RELAY_V6: 6,
|
||||
});
|
||||
const KIND_ADDR_LEN = Object.freeze({ 0: 4, 1: 16, 2: 4, 3: 4, 4: 16, 5: 16, 6: 16 });
|
||||
const KIND_TYPE = Object.freeze({ 0: 'host', 1: 'host', 2: 'srflx', 3: 'relay', 4: 'host', 5: 'srflx', 6: 'relay' });
|
||||
// Address family for the coverage rule. mDNS is its own family: it resolves
|
||||
// only on the sender's link, so it covers a case neither v4 nor v6 does.
|
||||
const KIND_FAMILY = Object.freeze({ 0: 'v4', 1: 'mdns', 2: 'v4', 3: 'v4', 4: 'v6', 5: 'v6', 6: 'v6' });
|
||||
const TCPTYPE = Object.freeze([null, 'passive', 'active', 'so']);
|
||||
|
||||
// RFC 8445 §5.1.2.2 type preferences. We do not reproduce the sender's original
|
||||
// priority values: ICE priority only orders connectivity checks, and both peers
|
||||
// compute their own local priorities anyway. Re-deriving them from the type and
|
||||
// the candidate's position in the list preserves the sender's ordering intent
|
||||
// while costing zero bytes on the wire.
|
||||
const TYPE_PREF = Object.freeze({ host: 126, srflx: 100, relay: 0 });
|
||||
|
||||
// RFC 8839 ice-char = ALPHA / DIGIT / "+" / "/"
|
||||
const ICE_CHAR = /^[A-Za-z0-9+/]+$/;
|
||||
|
||||
class DescriptorError extends Error {
|
||||
constructor(message, code = 'malformed') { super(message); this.name = 'DescriptorError'; this.code = code; }
|
||||
}
|
||||
const fail = (msg, code) => { throw new DescriptorError(msg, code); };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDP -> structured fields
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const UUID_RE = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})\.local$/i;
|
||||
const IPV4_RE = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
|
||||
function parseIpv4(s) {
|
||||
const m = IPV4_RE.exec(s);
|
||||
if (!m) return null;
|
||||
const out = new Uint8Array(4);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const v = Number(m[i + 1]);
|
||||
if (!Number.isInteger(v) || v < 0 || v > 255) return null;
|
||||
out[i] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseIpv6(s) {
|
||||
// Accept the plain hextet forms (with "::" compression) and the dotted-quad
|
||||
// tail used by IPv4-mapped and NAT64 addresses (64:ff9b::203.0.113.7).
|
||||
if (!/^[0-9a-fA-F:.]+$/.test(s) || s.length > 45) return null;
|
||||
let text = s;
|
||||
let tail4 = null;
|
||||
const lastColon = text.lastIndexOf(':');
|
||||
if (text.includes('.')) {
|
||||
tail4 = parseIpv4(text.slice(lastColon + 1));
|
||||
if (!tail4) return null;
|
||||
text = text.slice(0, lastColon + 1) + '0:0';
|
||||
}
|
||||
const halves = text.split('::');
|
||||
if (halves.length > 2) return null;
|
||||
const toWords = (part) => (part === '' ? [] : part.split(':').map((h) => (
|
||||
h.length === 0 || h.length > 4 ? NaN : parseInt(h, 16)
|
||||
)));
|
||||
const head = toWords(halves[0]);
|
||||
const tail = halves.length === 2 ? toWords(halves[1]) : [];
|
||||
if ([...head, ...tail].some((w) => !Number.isInteger(w) || w < 0 || w > 0xffff)) return null;
|
||||
let words;
|
||||
if (halves.length === 2) {
|
||||
const gap = 8 - head.length - tail.length;
|
||||
if (gap < 1) return null;
|
||||
words = [...head, ...new Array(gap).fill(0), ...tail];
|
||||
} else {
|
||||
words = head;
|
||||
}
|
||||
if (words.length !== 8) return null;
|
||||
const out = new Uint8Array(16);
|
||||
words.forEach((w, i) => { out[i * 2] = w >> 8; out[i * 2 + 1] = w & 0xff; });
|
||||
if (tail4) out.set(tail4, 12);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseMdns(s) {
|
||||
const m = UUID_RE.exec(s);
|
||||
if (!m) return null;
|
||||
const hex = (m[1] + m[2] + m[3] + m[4] + m[5]).toLowerCase();
|
||||
const out = new Uint8Array(16);
|
||||
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
|
||||
return out;
|
||||
}
|
||||
|
||||
function sdpLines(sdp) {
|
||||
if (typeof sdp !== 'string') fail('SDP must be a string');
|
||||
if (sdp.length > 64 * 1024) fail('SDP is too large');
|
||||
return sdp.split(/\r\n|\n/).filter((l) => l.length > 0);
|
||||
}
|
||||
|
||||
function attr(lines, name) {
|
||||
const prefix = `a=${name}:`;
|
||||
for (const l of lines) if (l.startsWith(prefix)) return l.slice(prefix.length).trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the fields SBQ2 carries from a browser-generated SDP.
|
||||
* Anything not represented in the template is dropped here, deliberately.
|
||||
*
|
||||
* Candidates keep their original `priority` so pruneCandidates can rank by the
|
||||
* sender's own judgement; the value is never encoded.
|
||||
*/
|
||||
export function parseSdp(sdp) {
|
||||
const lines = sdpLines(sdp);
|
||||
|
||||
const ufrag = attr(lines, 'ice-ufrag');
|
||||
const pwd = attr(lines, 'ice-pwd');
|
||||
if (!ufrag || !pwd) fail('SDP is missing ICE credentials');
|
||||
|
||||
const fpLine = attr(lines, 'fingerprint');
|
||||
if (!fpLine) fail('SDP is missing a DTLS fingerprint');
|
||||
const [hashAlg, fpHex] = fpLine.split(/\s+/);
|
||||
if (!hashAlg || hashAlg.toLowerCase() !== 'sha-256') {
|
||||
fail(`unsupported DTLS fingerprint algorithm: ${String(hashAlg).slice(0, 16)}`);
|
||||
}
|
||||
const fpBytes = String(fpHex).split(':');
|
||||
if (fpBytes.length !== LIMITS.FINGERPRINT_BYTES) fail('DTLS fingerprint has the wrong length');
|
||||
const fingerprint = new Uint8Array(LIMITS.FINGERPRINT_BYTES);
|
||||
fpBytes.forEach((b, i) => {
|
||||
if (!/^[0-9a-fA-F]{2}$/.test(b)) fail('DTLS fingerprint is not hex');
|
||||
fingerprint[i] = parseInt(b, 16);
|
||||
});
|
||||
|
||||
const setupStr = attr(lines, 'setup') || 'actpass';
|
||||
const setup = SETUP.indexOf(setupStr);
|
||||
if (setup < 0) fail(`unsupported DTLS setup role: ${setupStr.slice(0, 16)}`);
|
||||
|
||||
// RFC 8841: an absent a=max-message-size means 64 KiB.
|
||||
const mmsStr = attr(lines, 'max-message-size');
|
||||
const maxMessageSize = mmsStr === null ? 65536 : Number(mmsStr);
|
||||
if (!Number.isInteger(maxMessageSize) || maxMessageSize < 0) fail('invalid a=max-message-size');
|
||||
|
||||
const candidates = [];
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('a=candidate:')) continue;
|
||||
const p = line.slice('a=candidate:'.length).split(/\s+/);
|
||||
// foundation component transport priority addr port "typ" type ...
|
||||
if (p.length < 8 || p[6] !== 'typ') continue;
|
||||
if (p[1] !== '1') continue; // component 1 only (BUNDLE, no RTCP)
|
||||
const transport = p[2].toLowerCase();
|
||||
const priority = Number(p[3]);
|
||||
const addr = p[4];
|
||||
const port = Number(p[5]);
|
||||
const ctype = p[7];
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) continue;
|
||||
|
||||
let tcptype = 0;
|
||||
if (transport === 'tcp') {
|
||||
const idx = p.indexOf('tcptype');
|
||||
const t = idx >= 0 ? TCPTYPE.indexOf(p[idx + 1]) : -1;
|
||||
if (t <= 0) continue; // unusable TCP candidate
|
||||
tcptype = t;
|
||||
} else if (transport !== 'udp') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let kind = null; let bytes = null;
|
||||
const mdns = parseMdns(addr);
|
||||
if (mdns && ctype === 'host') { kind = KIND.HOST_MDNS; bytes = mdns; }
|
||||
else {
|
||||
const v4 = parseIpv4(addr);
|
||||
const v6 = v4 ? null : parseIpv6(addr);
|
||||
const raw = v4 || v6;
|
||||
if (!raw) continue;
|
||||
if (ctype === 'host') kind = v4 ? KIND.HOST_V4 : KIND.HOST_V6;
|
||||
else if (ctype === 'srflx' || ctype === 'prflx') kind = v4 ? KIND.SRFLX_V4 : KIND.SRFLX_V6;
|
||||
else if (ctype === 'relay') kind = v4 ? KIND.RELAY_V4 : KIND.RELAY_V6;
|
||||
else continue;
|
||||
bytes = raw;
|
||||
}
|
||||
candidates.push({
|
||||
kind, tcptype, addr: bytes, port,
|
||||
priority: Number.isFinite(priority) ? priority : 0,
|
||||
});
|
||||
}
|
||||
|
||||
return { ufrag, pwd, fingerprint, setup, maxMessageSize, candidates };
|
||||
}
|
||||
|
||||
/** Bytes a candidate occupies on the wire: kind byte + address + port. */
|
||||
export function candidateSize(c) { return 1 + KIND_ADDR_LEN[c.kind] + 2; }
|
||||
|
||||
/**
|
||||
* True if a peer can actually connect TO this candidate.
|
||||
*
|
||||
* An ICE-TCP candidate with `tcptype active` is an outbound-only socket on the
|
||||
* discard port; it pairs solely with a remote `passive` candidate and offers the
|
||||
* peer no address to reach. Firefox advertises one on every connection. It is
|
||||
* therefore surplus, never coverage — otherwise it would claim a coverage slot
|
||||
* that buys no reachability, at 19 bytes for an mDNS address.
|
||||
*/
|
||||
const isConnectable = (c) => c.tcptype !== 2 && c.tcptype !== 3;
|
||||
|
||||
/**
|
||||
* Trim a candidate list to what actually buys connectivity, under a byte budget.
|
||||
*
|
||||
* The rule is coverage first, count second. Every (address family, candidate
|
||||
* type, transport) combination present in the input keeps at least one
|
||||
* representative before any second candidate is admitted. That ordering is the
|
||||
* whole point: a pure count limit sorted v4-first can evict the only working
|
||||
* candidate on an IPv6-only network, which is now a normal mode on several
|
||||
* mobile carriers, and a count limit that ignores transport can evict the TCP
|
||||
* candidate that exists precisely for networks where UDP is blocked.
|
||||
*
|
||||
* Only after coverage is satisfied is the remaining budget filled by the
|
||||
* sender's own priority, with relays capped — a TURN server offering udp/tcp/tls
|
||||
* hands out one allocation per transport, and they all resolve to the same
|
||||
* relayed address, so the third one adds nothing the first does not already
|
||||
* provide. Two are kept in case one allocation's binding dies.
|
||||
*
|
||||
* If coverage alone overruns the budget, coverage wins and the descriptor grows.
|
||||
* A QR one version larger is cheaper than a connection that cannot be made.
|
||||
*/
|
||||
export function pruneCandidates(candidates, {
|
||||
maxCandidates = LIMITS.MAX_CANDIDATES,
|
||||
maxBytes = LIMITS.SURPLUS_CANDIDATE_BYTES,
|
||||
keepMdns = true,
|
||||
maxRelays = 2,
|
||||
} = {}) {
|
||||
const pool = candidates.filter((c) => keepMdns || c.kind !== KIND.HOST_MDNS);
|
||||
|
||||
// Exact duplicates first — same kind, transport, address and port.
|
||||
const uniq = [];
|
||||
const seen = new Set();
|
||||
for (const c of pool) {
|
||||
const key = `${c.kind}:${c.tcptype}:${Array.from(c.addr).join('.')}:${c.port}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
uniq.push(c);
|
||||
}
|
||||
|
||||
const byPriority = (a, b) => (b.priority || 0) - (a.priority || 0);
|
||||
const groupKey = (c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}/${c.tcptype === 0 ? 'udp' : 'tcp'}`;
|
||||
|
||||
// Pass 1 — one representative per (family, type, transport) group, best
|
||||
// priority first. Only candidates a peer can dial count for coverage.
|
||||
const groups = new Map();
|
||||
for (const c of [...uniq].sort(byPriority)) {
|
||||
if (!isConnectable(c)) continue;
|
||||
const g = groupKey(c);
|
||||
if (!groups.has(g)) groups.set(g, []);
|
||||
groups.get(g).push(c);
|
||||
}
|
||||
const chosen = [];
|
||||
const taken = new Set();
|
||||
let bytes = 0;
|
||||
let relays = 0;
|
||||
const admit = (c) => {
|
||||
chosen.push(c);
|
||||
taken.add(c);
|
||||
bytes += candidateSize(c);
|
||||
if (KIND_TYPE[c.kind] === 'relay') relays++;
|
||||
};
|
||||
for (const list of groups.values()) admit(list[0]);
|
||||
|
||||
// Pass 2 — fill what is left by priority, honouring both budgets.
|
||||
for (const c of [...uniq].sort(byPriority)) {
|
||||
if (taken.has(c)) continue;
|
||||
if (chosen.length >= maxCandidates) break;
|
||||
if (bytes + candidateSize(c) > maxBytes) continue;
|
||||
if (KIND_TYPE[c.kind] === 'relay' && relays >= maxRelays) continue;
|
||||
admit(c);
|
||||
}
|
||||
|
||||
// Emit in descending priority so the sender's ordering intent survives into
|
||||
// the localPref the serializer reconstructs.
|
||||
return chosen.sort(byPriority);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Writer {
|
||||
constructor() { this.b = []; }
|
||||
u8(v) { this.b.push(v & 0xff); }
|
||||
u16(v) { this.b.push((v >> 8) & 0xff, v & 0xff); }
|
||||
u24(v) { this.b.push((v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff); }
|
||||
u32(v) { this.b.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff); }
|
||||
bytes(a) { for (const x of a) this.b.push(x & 0xff); }
|
||||
ascii(s) { for (let i = 0; i < s.length; i++) this.b.push(s.charCodeAt(i) & 0xff); }
|
||||
done() { return Uint8Array.from(this.b); }
|
||||
}
|
||||
|
||||
function buildExtensions(maxMessageSize) {
|
||||
// Guard before the lookup: MMS_ENUM's slot 3 is the "explicit" marker and
|
||||
// holds null, so a null or undefined size would otherwise index straight
|
||||
// onto it and promise an extension record that never gets written.
|
||||
if (!Number.isInteger(maxMessageSize) || maxMessageSize < 1024 || maxMessageSize > 0x7fffffff) {
|
||||
fail('max-message-size must be an integer between 1024 and 2^31-1');
|
||||
}
|
||||
// Records are emitted in ascending type order so a descriptor has exactly
|
||||
// one valid spelling; the decoder enforces the same ordering.
|
||||
const records = [];
|
||||
let mmsIndex = MMS_ENUM.indexOf(maxMessageSize);
|
||||
if (mmsIndex < 0) {
|
||||
mmsIndex = MMS_EXPLICIT;
|
||||
const w = new Writer();
|
||||
w.u32(maxMessageSize);
|
||||
records.push({ type: EXT.MAX_MESSAGE_SIZE, value: w.done() });
|
||||
}
|
||||
return { mmsIndex, records };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} d
|
||||
* @param {number} d.type TYPE.OFFER | TYPE.ANSWER
|
||||
* @param {Uint8Array} [d.bindingTag] answers only: 8-byte tag of the offer
|
||||
* @param {number} d.expiresAtMs absolute expiry, ms since epoch
|
||||
* @param {object} d.sdpFields output of parseSdp (already pruned)
|
||||
* @param {Uint8Array|null} d.commitment 16-byte commitment to the in-band blob
|
||||
*/
|
||||
export function encodeDescriptor(d) {
|
||||
const { type, bindingTag: tag = null, expiresAtMs, sdpFields, commitment = null } = d;
|
||||
if (type !== TYPE.OFFER && type !== TYPE.ANSWER) fail('invalid descriptor type');
|
||||
if (type === TYPE.ANSWER) {
|
||||
if (!(tag instanceof Uint8Array) || tag.length !== LIMITS.BINDING_BYTES) fail('answer needs an 8-byte binding tag');
|
||||
} else if (tag !== null) {
|
||||
fail('offers do not carry a binding tag');
|
||||
}
|
||||
if (commitment !== null && (!(commitment instanceof Uint8Array) || commitment.length !== LIMITS.COMMITMENT_BYTES)) {
|
||||
fail('commitment must be 16 bytes');
|
||||
}
|
||||
|
||||
const { ufrag, pwd, fingerprint, setup, maxMessageSize, candidates } = sdpFields;
|
||||
if (ufrag.length < LIMITS.MIN_UFRAG || ufrag.length > LIMITS.MAX_UFRAG || !ICE_CHAR.test(ufrag)) fail('invalid ice-ufrag');
|
||||
if (pwd.length < LIMITS.MIN_PWD || pwd.length > LIMITS.MAX_PWD || !ICE_CHAR.test(pwd)) fail('invalid ice-pwd');
|
||||
if (candidates.length > LIMITS.MAX_CANDIDATES) fail('too many candidates');
|
||||
|
||||
const minutes = Math.ceil((expiresAtMs - EPOCH_MS) / 60000);
|
||||
if (!Number.isInteger(minutes) || minutes < 0 || minutes > MAX_EXPIRY_UNITS) fail('expiry out of range');
|
||||
|
||||
const { mmsIndex, records } = buildExtensions(maxMessageSize);
|
||||
|
||||
const ext = new Writer();
|
||||
for (const r of records) {
|
||||
if (r.value.length > 255) fail('extension value is too long');
|
||||
ext.u8(r.type); ext.u8(r.value.length); ext.bytes(r.value);
|
||||
}
|
||||
const extBytes = ext.done();
|
||||
if (extBytes.length > LIMITS.MAX_EXT_BYTES) fail('extension area is too long');
|
||||
|
||||
const flags = (type & 0x03)
|
||||
| ((setup & 0x03) << 2)
|
||||
| ((mmsIndex & 0x03) << 4)
|
||||
| (commitment ? 0x40 : 0)
|
||||
| (extBytes.length ? 0x80 : 0);
|
||||
|
||||
const w = new Writer();
|
||||
w.u8(SBQ2_VERSION);
|
||||
w.u8(flags);
|
||||
w.u24(minutes);
|
||||
if (type === TYPE.ANSWER) w.bytes(tag);
|
||||
w.bytes(fingerprint);
|
||||
w.u8(ufrag.length); w.ascii(ufrag);
|
||||
w.u8(pwd.length); w.ascii(pwd);
|
||||
w.u8(candidates.length);
|
||||
for (const c of candidates) {
|
||||
w.u8(((c.kind & 0x0f) << 4) | (c.tcptype & 0x0f));
|
||||
w.bytes(c.addr);
|
||||
w.u16(c.port);
|
||||
}
|
||||
if (commitment) w.bytes(commitment);
|
||||
if (extBytes.length) { w.u8(extBytes.length); w.bytes(extBytes); }
|
||||
|
||||
const out = w.done();
|
||||
if (out.length > LIMITS.MAX_PAYLOAD_BYTES) fail('descriptor exceeds the payload limit');
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class Reader {
|
||||
constructor(buf) { this.buf = buf; this.i = 0; }
|
||||
need(n) { if (this.i + n > this.buf.length) fail('descriptor is truncated'); }
|
||||
u8() { this.need(1); return this.buf[this.i++]; }
|
||||
u16() { this.need(2); const v = (this.buf[this.i] << 8) | this.buf[this.i + 1]; this.i += 2; return v; }
|
||||
u24() { this.need(3); const v = (this.buf[this.i] << 16) | (this.buf[this.i + 1] << 8) | this.buf[this.i + 2]; this.i += 3; return v; }
|
||||
u32() { this.need(4); const v = ((this.buf[this.i] << 24) >>> 0) + (this.buf[this.i + 1] << 16) + (this.buf[this.i + 2] << 8) + this.buf[this.i + 3]; this.i += 4; return v >>> 0; }
|
||||
bytes(n) { this.need(n); return this.buf.slice(this.i, this.i += n); }
|
||||
ascii(n) {
|
||||
this.need(n);
|
||||
let s = '';
|
||||
for (let k = 0; k < n; k++) {
|
||||
const c = this.buf[this.i + k];
|
||||
if (c < 0x20 || c > 0x7e) fail('non-printable byte in a text field');
|
||||
s += String.fromCharCode(c);
|
||||
}
|
||||
this.i += n;
|
||||
return s;
|
||||
}
|
||||
get rest() { return this.buf.length - this.i; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the TLV extension area.
|
||||
*
|
||||
* An unrecognised type is a hard error, not a skip. That is deliberate: a
|
||||
* decoder that ignores what it does not understand turns the extension area
|
||||
* into a downgrade channel, because an attacker can append a record that one
|
||||
* side acts on and the other silently drops, and the two ends then disagree
|
||||
* about the session while both believe they validated it. Deny-by-default costs
|
||||
* forward compatibility, and that cost is paid on purpose — a new extension
|
||||
* type ships together with a version bump that both ends can gate on, never
|
||||
* silently to a population that will half-ignore it.
|
||||
*/
|
||||
function decodeExt(buf) {
|
||||
const r = new Reader(buf);
|
||||
const out = new Map();
|
||||
let lastType = -1;
|
||||
while (r.rest > 0) {
|
||||
const type = r.u8();
|
||||
const len = r.u8();
|
||||
const value = r.bytes(len);
|
||||
if (type <= lastType) fail('extension records must be in ascending type order without duplicates');
|
||||
lastType = type;
|
||||
switch (type) {
|
||||
case EXT.MAX_MESSAGE_SIZE: {
|
||||
if (len !== 4) fail('extension 0x01 must be 4 bytes');
|
||||
const v = new Reader(value).u32();
|
||||
if (v < 1024 || v > 0x7fffffff) fail('extension 0x01 value is out of range');
|
||||
if (MMS_ENUM.includes(v)) fail('extension 0x01 duplicates a value the flags already encode');
|
||||
out.set(type, v);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
fail(`unknown extension type 0x${type.toString(16).padStart(2, '0')}`, 'unknown_extension');
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an SBQ2 descriptor. Throws DescriptorError on anything malformed —
|
||||
* there is no partial or best-effort result.
|
||||
*
|
||||
* @param {Uint8Array} buf
|
||||
* @param {object} [opts]
|
||||
* @param {number} [opts.nowMs] clock to check the expiry against
|
||||
*/
|
||||
export function decodeDescriptor(buf, { nowMs = Date.now() } = {}) {
|
||||
if (!(buf instanceof Uint8Array)) fail('descriptor must be a Uint8Array');
|
||||
if (buf.length === 0) fail('descriptor is empty');
|
||||
if (buf.length > LIMITS.MAX_PAYLOAD_BYTES) fail('descriptor exceeds the payload limit');
|
||||
|
||||
const r = new Reader(buf);
|
||||
|
||||
// Version first, and a mismatch is an error — never an attempt to parse a
|
||||
// different shape. This is what makes downgrade to the old scheme impossible
|
||||
// rather than merely unlikely.
|
||||
const version = r.u8();
|
||||
if (version !== SBQ2_VERSION) fail(`unsupported descriptor version 0x${version.toString(16)}`, 'version');
|
||||
|
||||
const flags = r.u8();
|
||||
const type = flags & 0x03;
|
||||
if (type !== TYPE.OFFER && type !== TYPE.ANSWER) fail('reserved descriptor type');
|
||||
const setup = (flags >> 2) & 0x03;
|
||||
if (setup > 2) fail('reserved DTLS setup role');
|
||||
const mmsIndex = (flags >> 4) & 0x03;
|
||||
const hasCommitment = (flags & 0x40) !== 0;
|
||||
const hasExt = (flags & 0x80) !== 0;
|
||||
|
||||
const minutes = r.u24();
|
||||
const expiresAtMs = EPOCH_MS + minutes * 60000;
|
||||
if (nowMs - LIMITS.CLOCK_SKEW_MS > expiresAtMs) {
|
||||
const lateMin = Math.round((nowMs - expiresAtMs) / 60000);
|
||||
fail(
|
||||
`this code expired ${lateMin} minute(s) ago. If it was just created, ` +
|
||||
`this device's clock or time zone is probably wrong — check the date and time settings.`,
|
||||
'expired',
|
||||
);
|
||||
}
|
||||
if (expiresAtMs - nowMs > (LIMITS.MAX_LIFETIME_MINUTES * 60000) + LIMITS.CLOCK_SKEW_MS) {
|
||||
fail('descriptor lifetime is implausibly long', 'lifetime');
|
||||
}
|
||||
|
||||
const bindingTag = type === TYPE.ANSWER ? r.bytes(LIMITS.BINDING_BYTES) : null;
|
||||
const fingerprint = r.bytes(LIMITS.FINGERPRINT_BYTES);
|
||||
|
||||
const ufragLen = r.u8();
|
||||
if (ufragLen < LIMITS.MIN_UFRAG || ufragLen > LIMITS.MAX_UFRAG) fail('ice-ufrag length out of range');
|
||||
const ufrag = r.ascii(ufragLen);
|
||||
if (!ICE_CHAR.test(ufrag)) fail('ice-ufrag contains characters outside the ICE alphabet');
|
||||
|
||||
const pwdLen = r.u8();
|
||||
if (pwdLen < LIMITS.MIN_PWD || pwdLen > LIMITS.MAX_PWD) fail('ice-pwd length out of range');
|
||||
const pwd = r.ascii(pwdLen);
|
||||
if (!ICE_CHAR.test(pwd)) fail('ice-pwd contains characters outside the ICE alphabet');
|
||||
|
||||
const count = r.u8();
|
||||
if (count > LIMITS.MAX_CANDIDATES) fail('too many candidates');
|
||||
const candidates = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tagByte = r.u8();
|
||||
const kind = (tagByte >> 4) & 0x0f;
|
||||
const tcptype = tagByte & 0x0f;
|
||||
const addrLen = KIND_ADDR_LEN[kind];
|
||||
if (addrLen === undefined) fail(`reserved candidate kind ${kind}`);
|
||||
if (tcptype >= TCPTYPE.length) fail('reserved TCP candidate type');
|
||||
const addr = r.bytes(addrLen);
|
||||
const port = r.u16();
|
||||
if (port < 1) fail('candidate port must be non-zero');
|
||||
candidates.push({ kind, tcptype, addr, port });
|
||||
}
|
||||
|
||||
let commitment = null;
|
||||
if (hasCommitment) commitment = r.bytes(LIMITS.COMMITMENT_BYTES);
|
||||
|
||||
let extensions = new Map();
|
||||
if (hasExt) {
|
||||
const extLen = r.u8();
|
||||
if (extLen === 0) fail('extension area is flagged but empty');
|
||||
extensions = decodeExt(r.bytes(extLen));
|
||||
}
|
||||
|
||||
// Trailing bytes are a malformed descriptor, not something to ignore: a
|
||||
// decoder that tolerates them lets an attacker smuggle a second reading of
|
||||
// the same QR past whatever hashed the canonical form.
|
||||
if (r.rest !== 0) fail(`${r.rest} trailing byte(s) after the descriptor`);
|
||||
|
||||
let maxMessageSize;
|
||||
if (mmsIndex === MMS_EXPLICIT) {
|
||||
if (!extensions.has(EXT.MAX_MESSAGE_SIZE)) fail('flags promise an explicit max-message-size but no extension carries it');
|
||||
maxMessageSize = extensions.get(EXT.MAX_MESSAGE_SIZE);
|
||||
} else {
|
||||
if (extensions.has(EXT.MAX_MESSAGE_SIZE)) fail('extension 0x01 present but the flags do not select it');
|
||||
maxMessageSize = MMS_ENUM[mmsIndex];
|
||||
}
|
||||
|
||||
return {
|
||||
version, type, setup, maxMessageSize, expiresAtMs,
|
||||
bindingTag, fingerprint, ufrag, pwd, candidates, commitment, extensions,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// strict SDP serializer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const hex2 = (b) => b.toString(16).padStart(2, '0');
|
||||
|
||||
function renderAddr(kind, addr) {
|
||||
switch (kind) {
|
||||
case KIND.HOST_MDNS: {
|
||||
const h = Array.from(addr, hex2).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}.local`;
|
||||
}
|
||||
case KIND.HOST_V4: case KIND.SRFLX_V4: case KIND.RELAY_V4:
|
||||
return `${addr[0]}.${addr[1]}.${addr[2]}.${addr[3]}`;
|
||||
default: {
|
||||
const words = [];
|
||||
for (let i = 0; i < 16; i += 2) words.push(((addr[i] << 8) | addr[i + 1]).toString(16));
|
||||
return words.join(':');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an SDP from a decoded descriptor.
|
||||
*
|
||||
* Every value written here is either a template constant or a primitive that
|
||||
* decodeDescriptor already range-checked and that is re-rendered from bytes
|
||||
* (addresses from integers, the mDNS name from 16 raw bytes). The only strings
|
||||
* echoed through are ufrag and pwd, and both are constrained to the ICE
|
||||
* alphabet, so neither can carry a CRLF and inject an SDP line.
|
||||
*/
|
||||
export function serializeSdp(desc, { sessionId = '1' } = {}) {
|
||||
const isOffer = desc.type === TYPE.OFFER;
|
||||
const lines = [
|
||||
'v=0',
|
||||
`o=- ${sessionId} 2 IN IP4 127.0.0.1`,
|
||||
's=-',
|
||||
't=0 0',
|
||||
'a=group:BUNDLE 0',
|
||||
'a=msid-semantic: WMS',
|
||||
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
||||
'c=IN IP4 0.0.0.0',
|
||||
'a=ice-ufrag:' + desc.ufrag,
|
||||
'a=ice-pwd:' + desc.pwd,
|
||||
// Deliberately NOT `a=ice-options:trickle`. A descriptor is a complete,
|
||||
// one-shot candidate set — there is no signalling channel to trickle
|
||||
// over, so advertising trickle promises candidates that can never
|
||||
// arrive and leaves the peer's ICE agent waiting for them.
|
||||
'a=fingerprint:sha-256 ' + Array.from(desc.fingerprint, (b) => hex2(b).toUpperCase()).join(':'),
|
||||
'a=setup:' + SETUP[desc.setup],
|
||||
'a=mid:0',
|
||||
'a=sctp-port:5000',
|
||||
'a=max-message-size:' + desc.maxMessageSize,
|
||||
];
|
||||
|
||||
// Candidate lines go after the ICE credentials; order within the m-section
|
||||
// is not significant to any implementation, but keeping them grouped
|
||||
// matches what every browser emits.
|
||||
const candLines = desc.candidates.map((c, i) => {
|
||||
const ctype = KIND_TYPE[c.kind];
|
||||
const transport = c.tcptype === 0 ? 'udp' : 'tcp';
|
||||
// RFC 8445 §5.1.2.1. localPref descends with list position so the
|
||||
// sender's ordering survives, and component is always 1.
|
||||
const localPref = Math.max(0, 65535 - i);
|
||||
const priority = TYPE_PREF[ctype] * 16777216 + localPref * 256 + 255;
|
||||
// Foundation must be equal for candidates sharing type+base+transport
|
||||
// and different otherwise (RFC 8445 §5.1.1.3); grouping by kind and
|
||||
// transport satisfies both halves of that.
|
||||
const foundation = String(c.kind * 4 + c.tcptype + 1);
|
||||
let line = `a=candidate:${foundation} 1 ${transport} ${priority} ${renderAddr(c.kind, c.addr)} ${c.port} typ ${ctype}`;
|
||||
if (transport === 'tcp') line += ` tcptype ${TCPTYPE[c.tcptype]}`;
|
||||
return line;
|
||||
});
|
||||
// `a=end-of-candidates` states explicitly that the set is complete
|
||||
// (RFC 8838 §14), so the peer stops waiting for more and can start failing
|
||||
// pairs promptly instead of sitting in checking until a timeout.
|
||||
candLines.push('a=end-of-candidates');
|
||||
const at = lines.indexOf('c=IN IP4 0.0.0.0') + 1;
|
||||
lines.splice(at, 0, ...candLines);
|
||||
|
||||
return { type: isOffer ? 'offer' : 'answer', sdp: lines.join('\r\n') + '\r\n' };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// binding + transcript
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function concat(...parts) {
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let o = 0;
|
||||
for (const p of parts) { out.set(p, o); o += p.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 8-byte tag an answer carries so the offerer can confirm it answers THIS offer.
|
||||
* This is the answer's replay defence: the offerer keeps the tag of the offer it
|
||||
* is currently showing and rejects any answer that does not match it, which also
|
||||
* makes each offer exactly one-shot.
|
||||
*
|
||||
* The offer needs no nonce of its own. It already carries ice-pwd, which RFC
|
||||
* 8839 §5.4 requires to contain at least 128 bits of randomness and which every
|
||||
* browser regenerates per peer connection and per ICE restart; hashing the whole
|
||||
* descriptor therefore hashes that entropy. See doc/descriptor-sbq2.md.
|
||||
*
|
||||
* LIMITATION, on purpose: 64 bits is not a standalone integrity primitive. It is
|
||||
* a duplicate-detection tag whose security comes from the SAS transcript, which
|
||||
* covers both descriptors in full. Do not build anything on this tag alone.
|
||||
*/
|
||||
export async function bindingTag(digest, offerBytes) {
|
||||
const h = await digest(concat(enc.encode('sbq2/bind\0'), offerBytes));
|
||||
return h.slice(0, LIMITS.BINDING_BYTES);
|
||||
}
|
||||
|
||||
/** 16-byte commitment to the in-band key blob. */
|
||||
export async function commitBlob(digest, blobBytes) {
|
||||
const h = await digest(concat(enc.encode('sbq2/blob\0'), blobBytes));
|
||||
return h.slice(0, LIMITS.COMMITMENT_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SAS transcript.
|
||||
*
|
||||
* It covers both descriptors *verbatim* — every byte that travelled out of
|
||||
* band, including the version byte, the flags and the whole extension area —
|
||||
* and both in-band blobs. So there is nothing an attacker can change anywhere in
|
||||
* the handshake, in either direction, that does not change the digits the two
|
||||
* users read to each other. Lengths are prefixed so no field boundary can be
|
||||
* shifted.
|
||||
*/
|
||||
export function sasTranscript(offerBytes, answerBytes, offerBlob, answerBlob) {
|
||||
const lp = (b) => {
|
||||
const n = new Uint8Array(4);
|
||||
new DataView(n.buffer).setUint32(0, b.length);
|
||||
return concat(n, b);
|
||||
};
|
||||
return concat(
|
||||
enc.encode('sbq2/sas/v1\0'),
|
||||
lp(offerBytes), lp(answerBytes), lp(offerBlob), lp(answerBlob),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// transport encodings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
|
||||
|
||||
export function toBase64Url(bytes) {
|
||||
let out = '';
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
|
||||
out += B64URL[a >> 2];
|
||||
out += B64URL[((a & 3) << 4) | ((b ?? 0) >> 4)];
|
||||
if (b === undefined) break;
|
||||
out += B64URL[((b & 15) << 2) | ((c ?? 0) >> 6)];
|
||||
if (c === undefined) break;
|
||||
out += B64URL[c & 63];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function fromBase64Url(text) {
|
||||
if (typeof text !== 'string') fail('payload must be a string');
|
||||
// Messengers wrap long lines; strip whitespace before validating, then
|
||||
// require the alphabet exactly.
|
||||
const s = text.replace(/\s+/g, '');
|
||||
if (s.length > Math.ceil(LIMITS.MAX_PAYLOAD_BYTES * 4 / 3) + 4) fail('payload is too long');
|
||||
if (!/^[A-Za-z0-9_-]*$/.test(s)) fail('payload contains characters outside base64url');
|
||||
if (s.length % 4 === 1) fail('payload has an impossible length');
|
||||
const out = new Uint8Array(Math.floor(s.length * 3 / 4));
|
||||
let o = 0, acc = 0, bits = 0;
|
||||
for (const ch of s) {
|
||||
acc = (acc << 6) | B64URL.indexOf(ch);
|
||||
bits += 6;
|
||||
if (bits >= 8) { bits -= 8; out[o++] = (acc >> bits) & 0xff; }
|
||||
}
|
||||
if (acc & ((1 << bits) - 1)) fail('payload has non-zero padding bits');
|
||||
return out.subarray(0, o);
|
||||
}
|
||||
|
||||
export const TEXT_PREFIX = 'SB2:';
|
||||
|
||||
export function encodeText(bytes) { return TEXT_PREFIX + toBase64Url(bytes); }
|
||||
|
||||
export function decodeText(text) {
|
||||
if (typeof text !== 'string') fail('payload must be a string');
|
||||
const t = text.trim();
|
||||
if (!t.startsWith(TEXT_PREFIX)) fail('not an SB2 descriptor');
|
||||
return fromBase64Url(t.slice(TEXT_PREFIX.length));
|
||||
}
|
||||
|
||||
export { DescriptorError, KIND, KIND_FAMILY, KIND_TYPE };
|
||||
@@ -11,7 +11,7 @@ let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
|
||||
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
|
||||
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
|
||||
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
|
||||
const SW_BUILD_VERSION = '1785988424571';
|
||||
const SW_BUILD_VERSION = '1786031423211';
|
||||
|
||||
// Load version from meta.json on install
|
||||
async function getAppVersion() {
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
// SBQ2 connection descriptor: round-trip on real browser SDP, and rejection of
|
||||
// the whole class of malformed input a scanner can be handed.
|
||||
//
|
||||
// The Chrome fixtures in tests/fixtures/sdp-chrome.json were captured from a
|
||||
// real Chrome over four network profiles (host-only, STUN, STUN+TURN, and
|
||||
// relay-only) with an identical ICE configuration on both peers, each in its own
|
||||
// renderer so neither side's gathering could truncate the other's. The Firefox
|
||||
// fixtures in sdp-firefox.json came from a real Firefox 153 the same way.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { webcrypto as crypto } from 'node:crypto';
|
||||
|
||||
const {
|
||||
parseSdp, pruneCandidates, candidateSize, encodeDescriptor, decodeDescriptor, serializeSdp,
|
||||
bindingTag, commitBlob, sasTranscript,
|
||||
toBase64Url, fromBase64Url, encodeText, decodeText,
|
||||
TYPE, LIMITS, EXT, SBQ2_VERSION, DescriptorError, KIND, KIND_FAMILY, KIND_TYPE,
|
||||
} = await import('../src/network/descriptor/sbq2.js');
|
||||
|
||||
const load = (name) => {
|
||||
const url = new URL(`./fixtures/${name}`, import.meta.url);
|
||||
return existsSync(url) ? JSON.parse(readFileSync(url)) : null;
|
||||
};
|
||||
const chrome = load('sdp-chrome.json');
|
||||
const firefox = load('sdp-firefox.json');
|
||||
|
||||
const digest = async (b) => new Uint8Array(await crypto.subtle.digest('SHA-256', b));
|
||||
const rnd = (n) => crypto.getRandomValues(new Uint8Array(n));
|
||||
const EXPIRY = () => Date.now() + 10 * 60 * 1000;
|
||||
|
||||
async function build(sdp, type, over = {}) {
|
||||
const raw = parseSdp(sdp);
|
||||
const isAnswer = type === TYPE.ANSWER;
|
||||
return encodeDescriptor({
|
||||
type,
|
||||
expiresAtMs: EXPIRY(),
|
||||
sdpFields: { ...raw, candidates: pruneCandidates(raw.candidates) },
|
||||
commitment: rnd(LIMITS.COMMITMENT_BYTES),
|
||||
...(isAnswer ? { bindingTag: rnd(LIMITS.BINDING_BYTES) } : {}),
|
||||
...over,
|
||||
});
|
||||
}
|
||||
|
||||
const rejects = (fn, match, label) => {
|
||||
assert.throws(fn, (e) => {
|
||||
assert.ok(e instanceof DescriptorError, `${label}: wrong error type ${e.name}: ${e.message}`);
|
||||
assert.match(e.message, match, `${label}: unexpected message "${e.message}"`);
|
||||
return true;
|
||||
}, label);
|
||||
};
|
||||
|
||||
// Offset of ufrag_len, which differs by type because only answers carry the tag.
|
||||
const ufragLenOffset = (type) => 1 + 1 + 3 + (type === TYPE.ANSWER ? LIMITS.BINDING_BYTES : 0) + LIMITS.FINGERPRINT_BYTES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// round-trip against real browser SDP
|
||||
// ---------------------------------------------------------------------------
|
||||
for (const [browser, fixtures] of [['chrome', chrome], ['firefox', firefox]]) {
|
||||
if (!fixtures) continue;
|
||||
for (const [profile, pair] of Object.entries(fixtures)) {
|
||||
for (const kind of ['offer', 'answer']) {
|
||||
if (!pair[kind]) continue;
|
||||
const tag = `${browser}/${profile}/${kind}`;
|
||||
const type = kind === 'offer' ? TYPE.OFFER : TYPE.ANSWER;
|
||||
const original = parseSdp(pair[kind]);
|
||||
const bytes = await build(pair[kind], type);
|
||||
const desc = decodeDescriptor(bytes);
|
||||
|
||||
assert.equal(desc.version, SBQ2_VERSION, `${tag} version`);
|
||||
assert.equal(desc.type, type, `${tag} type`);
|
||||
assert.equal(desc.ufrag, original.ufrag, `${tag} ufrag`);
|
||||
assert.equal(desc.pwd, original.pwd, `${tag} pwd`);
|
||||
assert.deepEqual(desc.fingerprint, original.fingerprint, `${tag} fingerprint`);
|
||||
assert.equal(desc.setup, original.setup, `${tag} setup`);
|
||||
assert.equal(desc.maxMessageSize, original.maxMessageSize, `${tag} max-message-size`);
|
||||
assert.equal(desc.bindingTag === null, type === TYPE.OFFER, `${tag} binding tag presence`);
|
||||
|
||||
// The rebuilt SDP must re-parse to exactly the fields we encoded —
|
||||
// this is what guarantees the template is lossless for everything
|
||||
// the descriptor claims to carry.
|
||||
const rebuilt = serializeSdp(desc);
|
||||
assert.ok(rebuilt.sdp.endsWith('\r\n'), `${tag} CRLF terminated`);
|
||||
const reparsed = parseSdp(rebuilt.sdp);
|
||||
assert.equal(reparsed.ufrag, original.ufrag);
|
||||
assert.equal(reparsed.pwd, original.pwd);
|
||||
assert.deepEqual(reparsed.fingerprint, original.fingerprint);
|
||||
assert.equal(reparsed.setup, original.setup);
|
||||
assert.equal(reparsed.maxMessageSize, original.maxMessageSize);
|
||||
assert.deepEqual(
|
||||
reparsed.candidates.map((c) => [c.kind, c.tcptype, [...c.addr], c.port]),
|
||||
desc.candidates.map((c) => [c.kind, c.tcptype, [...c.addr], c.port]),
|
||||
`${tag} candidates survive the template`,
|
||||
);
|
||||
|
||||
// Acceptance criteria: offer <= QR v10, answer <= QR v8 at level M.
|
||||
const cap = kind === 'offer' ? 213 : 152;
|
||||
assert.ok(bytes.length <= cap, `${tag} is ${bytes.length} B, over the ${cap} B budget`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// candidate pruning: coverage before count
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const cand = (kind, addr, port, priority, tcptype = 0) => ({ kind, tcptype, addr: Uint8Array.from(addr), port, priority });
|
||||
const v4 = (a, b, c, d) => [a, b, c, d];
|
||||
const v6 = (last) => [0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, last];
|
||||
|
||||
// A pure count limit sorted v4-first would evict every v6 candidate here.
|
||||
const dual = [
|
||||
cand(KIND.HOST_V4, v4(192, 168, 1, 5), 1001, 2113937151),
|
||||
cand(KIND.SRFLX_V4, v4(203, 0, 113, 1), 1002, 1677729535),
|
||||
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1003, 50340351),
|
||||
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1004, 16785663),
|
||||
cand(KIND.RELAY_V4, v4(144, 172, 96, 126), 1005, 8191),
|
||||
cand(KIND.HOST_MDNS, new Array(16).fill(7), 1006, 2113937150),
|
||||
cand(KIND.HOST_V6, v6(1), 1007, 2113939199),
|
||||
cand(KIND.SRFLX_V6, v6(2), 1008, 1677731583),
|
||||
cand(KIND.RELAY_V6, v6(3), 1009, 50341375),
|
||||
];
|
||||
const kept = pruneCandidates(dual);
|
||||
const groups = new Set(kept.map((c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}`));
|
||||
for (const g of ['v4/host', 'v4/srflx', 'v4/relay', 'v6/host', 'v6/srflx', 'v6/relay', 'mdns/host']) {
|
||||
assert.ok(groups.has(g), `dual-stack pruning must keep a ${g} candidate`);
|
||||
}
|
||||
assert.ok(kept.length <= LIMITS.MAX_CANDIDATES, 'count limit still respected');
|
||||
|
||||
// IPv6-only: nothing may be dropped for being v6.
|
||||
const v6only = [
|
||||
cand(KIND.HOST_V6, v6(1), 2001, 2113939199),
|
||||
cand(KIND.SRFLX_V6, v6(2), 2002, 1677731583),
|
||||
cand(KIND.RELAY_V6, v6(3), 2003, 50341375),
|
||||
cand(KIND.RELAY_V6, v6(4), 2004, 16786687),
|
||||
];
|
||||
const keptV6 = pruneCandidates(v6only);
|
||||
assert.equal(new Set(keptV6.map((c) => KIND_TYPE[c.kind])).size, 3, 'host/srflx/relay all survive on v6-only');
|
||||
assert.ok(keptV6.every((c) => KIND_FAMILY[c.kind] === 'v6'));
|
||||
|
||||
// NAT64: the synthesised v6 prefix carries a dotted-quad tail.
|
||||
const nat64 = parseSdp([
|
||||
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
||||
'a=candidate:1 1 udp 1677729535 64:ff9b::203.0.113.7 40000 typ srflx',
|
||||
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
|
||||
'a=fingerprint:sha-256 ' + new Array(32).fill('AA').join(':'), 'a=setup:actpass',
|
||||
].join('\r\n'));
|
||||
assert.equal(nat64.candidates.length, 1, 'NAT64 address parses');
|
||||
assert.deepEqual([...nat64.candidates[0].addr].slice(12), [203, 0, 113, 7], 'dotted-quad tail preserved');
|
||||
assert.equal(KIND_FAMILY[nat64.candidates[0].kind], 'v6');
|
||||
|
||||
// Coverage wins over the byte budget: a connection that can be made is
|
||||
// worth more than a smaller QR.
|
||||
const tight = pruneCandidates(dual, { maxBytes: 10 });
|
||||
assert.ok(new Set(tight.map((c) => `${KIND_FAMILY[c.kind]}/${KIND_TYPE[c.kind]}`)).size === 7,
|
||||
'coverage is not sacrificed to the byte budget');
|
||||
assert.equal(tight.length, 7, 'but nothing beyond coverage is admitted under a tight budget');
|
||||
|
||||
// Relay cap applies only to the surplus, never to coverage.
|
||||
const manyRelays = pruneCandidates(dual, { maxRelays: 1 });
|
||||
assert.equal(manyRelays.filter((c) => KIND_TYPE[c.kind] === 'relay').length, 2,
|
||||
'one v4 relay + one v6 relay: both are coverage, so the cap does not evict either');
|
||||
|
||||
// Exact duplicates collapse.
|
||||
const dupes = [cand(KIND.SRFLX_V4, v4(1, 2, 3, 4), 5, 100), cand(KIND.SRFLX_V4, v4(1, 2, 3, 4), 5, 100)];
|
||||
assert.equal(pruneCandidates(dupes).length, 1);
|
||||
|
||||
assert.equal(candidateSize({ kind: KIND.SRFLX_V4 }), 7);
|
||||
assert.equal(candidateSize({ kind: KIND.HOST_MDNS }), 19);
|
||||
assert.equal(candidateSize({ kind: KIND.RELAY_V6 }), 19);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPv6 round-trip through the wire format
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const sdp = [
|
||||
'v=0', 'o=- 1 2 IN IP4 127.0.0.1', 's=-', 't=0 0',
|
||||
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', 'c=IN IP4 0.0.0.0',
|
||||
'a=candidate:1 1 udp 2113939199 2001:db8::1 40000 typ host',
|
||||
'a=candidate:2 1 udp 1677731583 2001:0db8:0000:0000:0000:ff00:0042:8329 40001 typ srflx',
|
||||
'a=candidate:3 1 udp 50341375 64:ff9b::198.51.100.9 40002 typ relay',
|
||||
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
|
||||
'a=fingerprint:sha-256 ' + Array.from({ length: 32 }, (_, i) => i.toString(16).padStart(2, '0').toUpperCase()).join(':'),
|
||||
'a=setup:active', 'a=mid:0', 'a=sctp-port:5000',
|
||||
].join('\r\n') + '\r\n';
|
||||
const desc = decodeDescriptor(await build(sdp, TYPE.ANSWER));
|
||||
const re = parseSdp(serializeSdp(desc).sdp);
|
||||
assert.equal(re.candidates.length, 3);
|
||||
assert.deepEqual([...re.candidates[0].addr].slice(0, 4), [0x20, 0x01, 0x0d, 0xb8], 'IPv6 :: expansion round-trips');
|
||||
assert.deepEqual([...re.candidates[2].addr].slice(12), [198, 51, 100, 9], 'NAT64 tail round-trips');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TCP candidates
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const sdp = [
|
||||
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
||||
'a=candidate:1 1 tcp 1518280447 192.168.1.9 9 typ host tcptype active',
|
||||
'a=candidate:2 1 tcp 1518149375 192.168.1.9 50000 typ host tcptype passive',
|
||||
'a=candidate:3 1 tcp 1509957375 203.0.113.4 50001 typ srflx raddr 192.168.1.9 rport 50000',
|
||||
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
|
||||
'a=fingerprint:sha-256 ' + new Array(32).fill('BB').join(':'), 'a=setup:actpass',
|
||||
].join('\r\n') + '\r\n';
|
||||
const parsed = parseSdp(sdp);
|
||||
// The srflx line has no tcptype, which makes it unusable and it is dropped.
|
||||
assert.equal(parsed.candidates.length, 2, 'TCP candidates without tcptype are dropped');
|
||||
assert.deepEqual(parsed.candidates.map((c) => c.tcptype).sort(), [1, 2]);
|
||||
|
||||
const desc = decodeDescriptor(await build(sdp, TYPE.OFFER));
|
||||
const out = serializeSdp(desc).sdp;
|
||||
assert.match(out, /tcptype passive/, 'passive tcptype re-emitted');
|
||||
assert.match(out, /tcptype active/, 'active tcptype re-emitted');
|
||||
const re = parseSdp(out);
|
||||
assert.deepEqual(re.candidates.map((c) => c.tcptype).sort(), [1, 2], 'tcptype survives the round trip');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TLV extension area
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const base = [
|
||||
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
||||
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
|
||||
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
|
||||
'a=fingerprint:sha-256 ' + new Array(32).fill('CC').join(':'), 'a=setup:actpass',
|
||||
];
|
||||
const withMms = (v) => (base.concat(v === null ? [] : [`a=max-message-size:${v}`]).join('\r\n') + '\r\n');
|
||||
|
||||
// The three well-known values stay in the flags: zero extension bytes.
|
||||
const flagged = await build(withMms(262144), TYPE.OFFER);
|
||||
assert.equal(decodeDescriptor(flagged).maxMessageSize, 262144);
|
||||
assert.equal(decodeDescriptor(flagged).extensions.size, 0);
|
||||
assert.equal((flagged[1] & 0x80), 0, 'no extension area when the enum suffices');
|
||||
assert.equal(decodeDescriptor(await build(withMms(1073741823), TYPE.OFFER)).maxMessageSize, 1073741823);
|
||||
assert.equal(decodeDescriptor(await build(withMms(null), TYPE.OFFER)).maxMessageSize, 65536,
|
||||
'an absent attribute means 64 KiB per RFC 8841');
|
||||
|
||||
// A fifth value rides in a TLV record instead of being silently downgraded.
|
||||
const odd = await build(withMms(131072), TYPE.OFFER);
|
||||
const oddDesc = decodeDescriptor(odd);
|
||||
assert.equal(oddDesc.maxMessageSize, 131072, 'explicit max-message-size survives');
|
||||
assert.equal(oddDesc.extensions.get(EXT.MAX_MESSAGE_SIZE), 131072);
|
||||
assert.equal((odd[1] & 0x80) !== 0, true, 'extension flag set');
|
||||
assert.equal(odd.length - flagged.length, 7, 'the TLV costs exactly ext_len + type + len + 4');
|
||||
|
||||
// Unknown types are refused, not skipped. This is the downgrade defence.
|
||||
const unknown = Uint8Array.from(odd);
|
||||
unknown[unknown.length - 6] = 0x7f; // rewrite the record type
|
||||
rejects(() => decodeDescriptor(unknown), /unknown extension type 0x7f/, 'unknown extension type');
|
||||
|
||||
// Length games inside the area.
|
||||
const badLen = Uint8Array.from(odd); badLen[badLen.length - 5] = 3;
|
||||
rejects(() => decodeDescriptor(badLen), /must be 4 bytes/, 'wrong TLV length');
|
||||
const overrun = Uint8Array.from(odd); overrun[overrun.length - 5] = 200;
|
||||
rejects(() => decodeDescriptor(overrun), /truncated/, 'TLV length overruns the area');
|
||||
const emptyArea = Uint8Array.from(flagged.subarray(0, flagged.length));
|
||||
const withEmpty = new Uint8Array(emptyArea.length + 1);
|
||||
withEmpty.set(emptyArea); withEmpty[1] |= 0x80; withEmpty[withEmpty.length - 1] = 0;
|
||||
rejects(() => decodeDescriptor(withEmpty), /flagged but empty/, 'empty extension area');
|
||||
|
||||
// Ordering and duplicates: exactly one valid spelling per descriptor.
|
||||
const dup = new Uint8Array(odd.length + 6);
|
||||
dup.set(odd.subarray(0, odd.length - 7));
|
||||
dup[odd.length - 7] = 12; // ext_len = two records
|
||||
dup.set(odd.subarray(odd.length - 6), odd.length - 6);
|
||||
dup.set(odd.subarray(odd.length - 6), odd.length);
|
||||
rejects(() => decodeDescriptor(dup), /ascending type order/, 'duplicate extension record');
|
||||
|
||||
// Flags and area must agree in both directions.
|
||||
const promised = Uint8Array.from(flagged); promised[1] |= 0x30; // mmsIndex = 3, no area
|
||||
rejects(() => decodeDescriptor(promised), /no extension carries it/, 'explicit promised but absent');
|
||||
const unselected = Uint8Array.from(odd); unselected[1] &= ~0x30; // area present, enum selected
|
||||
rejects(() => decodeDescriptor(unselected), /do not select it/, 'extension present but unselected');
|
||||
|
||||
// A TLV that merely restates an enum value is non-canonical.
|
||||
const restate = Uint8Array.from(odd);
|
||||
restate[restate.length - 4] = 0x00; restate[restate.length - 3] = 0x04;
|
||||
restate[restate.length - 2] = 0x00; restate[restate.length - 1] = 0x00; // 262144
|
||||
rejects(() => decodeDescriptor(restate), /duplicates a value the flags already encode/, 'non-canonical TLV');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// rejection: malformed / hostile descriptors
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const good = await build(chrome.turn_all.offer, TYPE.OFFER);
|
||||
const UF = ufragLenOffset(TYPE.OFFER);
|
||||
|
||||
rejects(() => decodeDescriptor(new Uint8Array(0)), /empty/, 'empty');
|
||||
rejects(() => decodeDescriptor(good.subarray(0, good.length - 1)), /truncated/, 'truncated');
|
||||
rejects(() => decodeDescriptor(good.subarray(0, 20)), /truncated/, 'heavily truncated');
|
||||
|
||||
// Version gate: a v1 byte is an error, never an attempt at the old parser.
|
||||
for (const v of [0x00, 0x01, 0x03, 0xff]) {
|
||||
const x = Uint8Array.from(good); x[0] = v;
|
||||
rejects(() => decodeDescriptor(x), /unsupported descriptor version/, `version 0x${v.toString(16)}`);
|
||||
}
|
||||
|
||||
// Trailing bytes: never silently ignored.
|
||||
const trailing = new Uint8Array(good.length + 3);
|
||||
trailing.set(good); trailing.set([1, 2, 3], good.length);
|
||||
rejects(() => decodeDescriptor(trailing), /trailing byte/, 'trailing bytes');
|
||||
|
||||
// Reserved VALUES are refused. (Every flag bit is now allocated; growth goes
|
||||
// through the TLV area, which is itself deny-by-default.)
|
||||
const reservedType = Uint8Array.from(good); reservedType[1] = (reservedType[1] & ~0x03) | 0x02;
|
||||
rejects(() => decodeDescriptor(reservedType), /reserved descriptor type/, 'reserved type');
|
||||
const reservedSetup = Uint8Array.from(good); reservedSetup[1] = (reservedSetup[1] & ~0x0c) | 0x0c;
|
||||
rejects(() => decodeDescriptor(reservedSetup), /reserved DTLS setup role/, 'reserved setup');
|
||||
|
||||
rejects(() => decodeDescriptor(new Uint8Array(LIMITS.MAX_PAYLOAD_BYTES + 1)), /payload limit/, 'oversized');
|
||||
|
||||
// Candidate count and kind.
|
||||
const pwdOff = UF + 1 + good[UF];
|
||||
const countOff = pwdOff + 1 + good[pwdOff];
|
||||
const tooMany = Uint8Array.from(good); tooMany[countOff] = LIMITS.MAX_CANDIDATES + 1;
|
||||
rejects(() => decodeDescriptor(tooMany), /too many candidates/, 'candidate count over limit');
|
||||
const badKind = Uint8Array.from(good); badKind[countOff + 1] = 0xf0;
|
||||
rejects(() => decodeDescriptor(badKind), /reserved candidate kind/, 'reserved candidate kind');
|
||||
const badTcp = Uint8Array.from(good); badTcp[countOff + 1] = (badTcp[countOff + 1] & 0xf0) | 0x0f;
|
||||
rejects(() => decodeDescriptor(badTcp), /reserved TCP candidate type/, 'reserved tcptype');
|
||||
const firstKind = good[countOff + 1] >> 4;
|
||||
const portOff = countOff + 2 + (firstKind === 1 || firstKind >= 4 ? 16 : 4);
|
||||
const zeroPort = Uint8Array.from(good); zeroPort[portOff] = 0; zeroPort[portOff + 1] = 0;
|
||||
rejects(() => decodeDescriptor(zeroPort), /port must be non-zero/, 'zero port');
|
||||
|
||||
// ICE credential alphabet and length.
|
||||
const badUfragLen = Uint8Array.from(good); badUfragLen[UF] = 2;
|
||||
rejects(() => decodeDescriptor(badUfragLen), /ice-ufrag length out of range/, 'short ufrag');
|
||||
const badUfragChar = Uint8Array.from(good); badUfragChar[UF + 1] = '!'.charCodeAt(0);
|
||||
rejects(() => decodeDescriptor(badUfragChar), /ICE alphabet/, 'ufrag alphabet');
|
||||
|
||||
// A CR or LF inside a credential is what an SDP-injection attempt looks
|
||||
// like; it must die in the decoder, long before the serializer.
|
||||
for (const evil of [0x0d, 0x0a, 0x00]) {
|
||||
const inj = Uint8Array.from(good); inj[UF + 1] = evil;
|
||||
rejects(() => decodeDescriptor(inj), /non-printable byte/, `injected byte 0x${evil.toString(16)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// expiry, clock skew, one-shot
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const bytes = await build(chrome.stun.offer, TYPE.OFFER, { expiresAtMs: Date.now() + 60_000 });
|
||||
assert.ok(decodeDescriptor(bytes, { nowMs: Date.now() }), 'valid inside the window');
|
||||
|
||||
// Clock skew: a receiver running a minute fast still accepts.
|
||||
assert.ok(decodeDescriptor(bytes, { nowMs: Date.now() + 60_000 + 60_000 }),
|
||||
'one minute of receiver skew past expiry is tolerated');
|
||||
rejects(() => decodeDescriptor(bytes, { nowMs: Date.now() + 60_000 + LIMITS.CLOCK_SKEW_MS + 60_000 }),
|
||||
/expired/, 'beyond the skew allowance');
|
||||
|
||||
// The error must point at the clock, because that is the likely cause.
|
||||
rejects(() => decodeDescriptor(bytes, { nowMs: Date.now() + 3600_000 }),
|
||||
/clock or time zone/, 'expiry error names the clock');
|
||||
try {
|
||||
decodeDescriptor(bytes, { nowMs: Date.now() + 3600_000 });
|
||||
} catch (e) {
|
||||
assert.equal(e.code, 'expired', 'expiry carries a machine-readable code');
|
||||
}
|
||||
|
||||
// An attacker cannot mint a descriptor that never dies.
|
||||
const eternal = await build(chrome.stun.offer, TYPE.OFFER, {
|
||||
expiresAtMs: Date.now() + 365 * 24 * 3600 * 1000,
|
||||
});
|
||||
rejects(() => decodeDescriptor(eternal), /lifetime is implausibly long/, 'year-long lifetime');
|
||||
|
||||
// The answer's replay defence: its tag must be the binding tag of the exact
|
||||
// offer being shown, so an answer to any other offer is refused and each
|
||||
// offer is consumed once.
|
||||
const offerA = await build(chrome.stun.offer, TYPE.OFFER);
|
||||
const offerB = await build(chrome.turn_all.offer, TYPE.OFFER);
|
||||
const tagA = await bindingTag(digest, offerA);
|
||||
const answer = await build(chrome.stun.answer, TYPE.ANSWER, { bindingTag: tagA });
|
||||
assert.deepEqual(decodeDescriptor(answer).bindingTag, tagA, 'answer binds to its offer');
|
||||
assert.notDeepEqual(decodeDescriptor(answer).bindingTag, await bindingTag(digest, offerB), 'not to a different offer');
|
||||
|
||||
// The offer carries no nonce of its own; its uniqueness rides on ice-pwd,
|
||||
// which is inside the hashed bytes. Two offers differing only there must
|
||||
// still produce different tags.
|
||||
const raw = parseSdp(chrome.stun.offer);
|
||||
const mk = async (pwd) => encodeDescriptor({
|
||||
type: TYPE.OFFER, expiresAtMs: EXPIRY(),
|
||||
sdpFields: { ...raw, pwd, candidates: pruneCandidates(raw.candidates) },
|
||||
commitment: null,
|
||||
});
|
||||
const t1 = await bindingTag(digest, await mk('0123456789abcdef01234567'));
|
||||
const t2 = await bindingTag(digest, await mk('0123456789abcdef01234568'));
|
||||
assert.notDeepEqual(t1, t2, 'ice-pwd alone makes the offer unique');
|
||||
|
||||
const tampered = Uint8Array.from(offerA); tampered[40] ^= 0x01;
|
||||
assert.notDeepEqual(await bindingTag(digest, tampered), tagA, 'binding tag is sensitive to the offer bytes');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SAS transcript coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const offer = await build(chrome.turn_all.offer, TYPE.OFFER);
|
||||
const answer = await build(chrome.turn_all.answer, TYPE.ANSWER);
|
||||
const extOffer = await build([
|
||||
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
|
||||
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
|
||||
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
|
||||
'a=fingerprint:sha-256 ' + new Array(32).fill('DD').join(':'),
|
||||
'a=setup:actpass', 'a=max-message-size:131072',
|
||||
].join('\r\n') + '\r\n', TYPE.OFFER);
|
||||
const blobO = rnd(400);
|
||||
const blobA = rnd(400);
|
||||
const base = await digest(sasTranscript(offer, answer, blobO, blobA));
|
||||
|
||||
for (const [label, mutate] of [
|
||||
['offer version byte', () => { const x = Uint8Array.from(offer); x[0] = 3; return [x, answer, blobO, blobA]; }],
|
||||
['offer flags', () => { const x = Uint8Array.from(offer); x[1] ^= 0x10; return [x, answer, blobO, blobA]; }],
|
||||
['offer expiry', () => { const x = Uint8Array.from(offer); x[4] ^= 0x01; return [x, answer, blobO, blobA]; }],
|
||||
['offer fingerprint', () => { const x = Uint8Array.from(offer); x[6] ^= 0xff; return [x, answer, blobO, blobA]; }],
|
||||
['offer ice-pwd', () => { const x = Uint8Array.from(offer); x[50] ^= 0x01; return [x, answer, blobO, blobA]; }],
|
||||
['offer candidate port', () => { const x = Uint8Array.from(offer); x[x.length - 20] ^= 0x01; return [x, answer, blobO, blobA]; }],
|
||||
['answer binding tag', () => { const x = Uint8Array.from(answer); x[6] ^= 0xff; return [offer, x, blobO, blobA]; }],
|
||||
['answer bytes', () => { const x = Uint8Array.from(answer); x[20] ^= 0xff; return [offer, x, blobO, blobA]; }],
|
||||
['extension area', () => { const x = Uint8Array.from(extOffer); x[x.length - 1] ^= 0x01; return [x, answer, blobO, blobA]; }],
|
||||
['offer blob', () => { const x = Uint8Array.from(blobO); x[7] ^= 0x01; return [offer, answer, x, blobA]; }],
|
||||
['answer blob', () => { const x = Uint8Array.from(blobA); x[7] ^= 0x01; return [offer, answer, blobO, x]; }],
|
||||
]) {
|
||||
const args = mutate();
|
||||
const h = await digest(sasTranscript(...args));
|
||||
const ref = label === 'extension area'
|
||||
? await digest(sasTranscript(extOffer, answer, blobO, blobA))
|
||||
: base;
|
||||
assert.notDeepEqual(h, ref, `SAS must change when the ${label} changes`);
|
||||
}
|
||||
|
||||
// Length prefixes stop a boundary shift from producing a colliding
|
||||
// transcript.
|
||||
const shifted = await digest(sasTranscript(
|
||||
offer.subarray(0, offer.length - 1),
|
||||
new Uint8Array([offer[offer.length - 1], ...answer]),
|
||||
blobO, blobA,
|
||||
));
|
||||
assert.notDeepEqual(shifted, base, 'field boundaries are unambiguous');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// commitment
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const blob = rnd(400);
|
||||
const c = await commitBlob(digest, blob);
|
||||
assert.equal(c.length, LIMITS.COMMITMENT_BYTES);
|
||||
const other = Uint8Array.from(blob); other[123] ^= 0x01;
|
||||
assert.notDeepEqual(await commitBlob(digest, other), c, 'commitment binds every blob byte');
|
||||
|
||||
assert.deepEqual(decodeDescriptor(await build(chrome.stun.offer, TYPE.OFFER, { commitment: c })).commitment, c);
|
||||
assert.equal(decodeDescriptor(await build(chrome.stun.offer, TYPE.OFFER, { commitment: null })).commitment, null);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// text transport
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
for (let n = 0; n < 200; n++) {
|
||||
const b = rnd(n);
|
||||
assert.deepEqual(fromBase64Url(toBase64Url(b)), b, `base64url round-trip at ${n} bytes`);
|
||||
}
|
||||
|
||||
const bytes = await build(chrome.turn_all.offer, TYPE.OFFER);
|
||||
const text = encodeText(bytes);
|
||||
assert.ok(/^SB2:[A-Za-z0-9_-]+$/.test(text), 'text form uses only URL-safe characters');
|
||||
assert.deepEqual(decodeText(text), bytes);
|
||||
|
||||
const wrapped = text.slice(0, 40) + '\n' + text.slice(40, 90) + ' \r\n' + text.slice(90);
|
||||
assert.deepEqual(decodeText(wrapped), bytes, 'wrapped paste still decodes');
|
||||
|
||||
rejects(() => decodeText('SB1:bin:abcd'), /not an SB2 descriptor/, 'old prefix');
|
||||
rejects(() => decodeText('SB2:abc$def'), /outside base64url/, 'bad alphabet');
|
||||
rejects(() => decodeText('SB2:' + 'A'.repeat(5000)), /too long/, 'over-long text');
|
||||
rejects(() => fromBase64Url('AAAAA'), /impossible length/, 'impossible base64 length');
|
||||
rejects(() => fromBase64Url('AB'), /non-zero padding bits/, 'non-canonical tail');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// encoder-side input validation
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
const raw = parseSdp(chrome.stun.offer);
|
||||
const fields = { ...raw, candidates: pruneCandidates(raw.candidates) };
|
||||
const base = { type: TYPE.OFFER, expiresAtMs: EXPIRY(), sdpFields: fields };
|
||||
|
||||
rejects(() => encodeDescriptor({ ...base, type: 7 }), /invalid descriptor type/, 'bad type');
|
||||
rejects(() => encodeDescriptor({ ...base, bindingTag: rnd(8) }), /offers do not carry a binding tag/, 'tag on an offer');
|
||||
rejects(() => encodeDescriptor({ ...base, type: TYPE.ANSWER }), /answer needs an 8-byte binding tag/, 'answer without a tag');
|
||||
rejects(() => encodeDescriptor({ ...base, type: TYPE.ANSWER, bindingTag: rnd(7) }), /8-byte binding tag/, 'short tag');
|
||||
rejects(() => encodeDescriptor({ ...base, commitment: rnd(15) }), /commitment must be 16 bytes/, 'short commitment');
|
||||
rejects(() => encodeDescriptor({ ...base, sdpFields: { ...fields, ufrag: 'a b' } }), /invalid ice-ufrag/, 'ufrag with a space');
|
||||
rejects(() => encodeDescriptor({ ...base, sdpFields: { ...fields, pwd: 'short' } }), /invalid ice-pwd/, 'short pwd');
|
||||
for (const bad of [null, undefined, 0, -1, 1.5, 2 ** 31]) {
|
||||
rejects(
|
||||
() => encodeDescriptor({ ...base, sdpFields: { ...fields, maxMessageSize: bad } }),
|
||||
/max-message-size must be an integer/, `max-message-size ${bad}`,
|
||||
);
|
||||
}
|
||||
rejects(
|
||||
() => encodeDescriptor({ ...base, sdpFields: { ...fields, candidates: new Array(9).fill(fields.candidates[0]) } }),
|
||||
/too many candidates/, 'too many candidates',
|
||||
);
|
||||
|
||||
rejects(() => parseSdp('v=0\r\n'), /missing ICE credentials/, 'no ICE creds');
|
||||
rejects(
|
||||
() => parseSdp('a=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\n'),
|
||||
/missing a DTLS fingerprint/, 'no fingerprint',
|
||||
);
|
||||
rejects(
|
||||
() => parseSdp('a=ice-ufrag:abcd\r\na=ice-pwd:0123456789abcdef01234567\r\na=fingerprint:sha-1 AA:BB\r\n'),
|
||||
/unsupported DTLS fingerprint algorithm/, 'sha-1 fingerprint',
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`descriptor-sbq2: all assertions passed${firefox ? ' (chrome + firefox fixtures)' : ' (chrome fixtures only)'}`);
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"host_only": {
|
||||
"offer": "v=0\r\no=- 8025882406963837699 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=candidate:988732788 1 udp 2113937151 771d1a30-f1d1-4f8e-82b7-1305438c5940.local 59046 typ host generation 0 network-cost 999\r\na=ice-ufrag:w7U/\r\na=ice-pwd:KipG1wMsp+hOpj8ajZcvODlD\r\na=ice-options:trickle\r\na=fingerprint:sha-256 56:D6:BE:A5:4B:B9:EA:4F:FD:91:18:EA:28:C3:9F:36:F0:FA:FA:7E:3F:0B:B5:66:28:39:48:33:16:8B:FF:C8\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n",
|
||||
"answer": "v=0\r\no=- 6135472287747382945 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=candidate:790708617 1 udp 2113937151 57cc7e40-40b6-40e1-8248-81edf1d934be.local 65298 typ host generation 0 network-cost 999\r\na=ice-ufrag:tPvC\r\na=ice-pwd:XLZeDsbxW/+Nt33ftg0Io8v8\r\na=ice-options:trickle\r\na=fingerprint:sha-256 55:59:0E:D7:5F:BE:6D:19:A5:1D:00:5A:5B:A7:E6:98:36:13:1F:E6:ED:2A:02:13:27:DC:F6:DE:B2:42:EC:53\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
|
||||
},
|
||||
"stun": {
|
||||
"offer": "v=0\r\no=- 1287905459661968667 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 45882 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 152.166.136.164\r\na=candidate:2197344123 1 udp 2113937151 9b2c4f0d-6917-4559-af8f-edf002be1af4.local 54223 typ host generation 0 network-cost 999\r\na=candidate:203475045 1 udp 1677729535 152.166.136.164 45882 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=ice-ufrag:bBBc\r\na=ice-pwd:sbibKYjfuiROao15zgpzogob\r\na=ice-options:trickle\r\na=fingerprint:sha-256 B4:0C:45:B0:B8:00:8B:D2:07:C2:7B:FA:32:F8:5F:3B:77:23:89:50:7B:70:69:94:9D:C0:3B:BE:9C:3F:46:9F\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n",
|
||||
"answer": "v=0\r\no=- 9108354338848473407 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 42832 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 152.166.136.164\r\na=candidate:1702118434 1 udp 2113937151 9bddaddb-50f6-46c0-b861-c5c35e0d554f.local 49431 typ host generation 0 network-cost 999\r\na=candidate:3953935164 1 udp 1677729535 152.166.136.164 42832 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=ice-ufrag:cf/e\r\na=ice-pwd:OLfqFZLI/2SlC4cka2hdCCWY\r\na=ice-options:trickle\r\na=fingerprint:sha-256 A4:7E:1A:F3:FF:4F:B4:8E:51:19:75:35:96:B6:F4:82:44:FB:FB:14:97:0F:EB:D5:8B:37:8A:FD:B4:21:AA:16\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
|
||||
},
|
||||
"turn_all": {
|
||||
"offer": "v=0\r\no=- 6190378570176556335 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 60938 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:2999788131 1 udp 2113937151 c0f6ed16-adbe-42af-85d6-28e16e90c45d.local 60928 typ host generation 0 network-cost 999\r\na=candidate:1008018813 1 udp 1677729535 152.166.136.164 45400 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:946064782 1 udp 50340351 144.172.96.126 60938 typ relay raddr 152.166.136.164 rport 45400 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 63477 typ relay raddr 152.166.136.164 rport 33107 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 60311 typ relay raddr 152.166.136.164 rport 48747 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 49605 typ relay raddr 152.166.136.164 rport 33341 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 59290 typ relay raddr 152.166.136.164 rport 34318 generation 0 network-cost 999\r\na=ice-ufrag:iyCI\r\na=ice-pwd:1MIhkkUNEVx/gHU4fBSeaLKy\r\na=ice-options:trickle\r\na=fingerprint:sha-256 B4:51:9A:51:28:02:8F:BC:90:CF:8E:C9:DB:B1:0B:86:D3:D9:57:D4:0C:3C:0B:AD:CD:4A:9F:75:54:EF:E3:63\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n",
|
||||
"answer": "v=0\r\no=- 4208677942963212620 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 55935 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:1624328767 1 udp 2113937151 72cf1b0b-c850-49c0-984f-9fdba3cc1510.local 49687 typ host generation 0 network-cost 999\r\na=candidate:2588979035 1 udp 1677729535 152.166.136.164 38722 typ srflx raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:3202382357 1 udp 50340351 144.172.96.126 55935 typ relay raddr 152.166.136.164 rport 38722 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 51124 typ relay raddr 152.166.136.164 rport 46651 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 53603 typ relay raddr 152.166.136.164 rport 35591 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 51788 typ relay raddr 152.166.136.164 rport 37397 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 59006 typ relay raddr 152.166.136.164 rport 42296 generation 0 network-cost 999\r\na=ice-ufrag:ZNeq\r\na=ice-pwd:fmXvwyclqup2YcAOa3riQyf5\r\na=ice-options:trickle\r\na=fingerprint:sha-256 08:B6:8E:7F:FE:B6:0B:29:CB:14:9C:BC:71:9B:60:AE:13:13:B7:61:8E:99:F7:AA:07:8A:EC:7A:F8:69:38:35\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
|
||||
},
|
||||
"turn_relay_only": {
|
||||
"offer": "v=0\r\no=- 2484576169985209246 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 51269 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:2390409615 1 udp 50340351 144.172.96.126 51269 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 52454 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 50222 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 61797 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 50906 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=ice-ufrag:XE1k\r\na=ice-pwd:iSZ8bBcbrE0jhGE8jzuomXax\r\na=ice-options:trickle\r\na=fingerprint:sha-256 2A:23:07:B1:60:25:43:FA:70:3D:96:1E:8C:CC:8C:91:17:06:94:11:DA:AA:16:23:1C:1D:3F:3A:BA:CF:74:8F\r\na=setup:actpass\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n",
|
||||
"answer": "v=0\r\no=- 4269499427491218796 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=group:BUNDLE 0\r\na=extmap-allow-mixed\r\na=msid-semantic: WMS\r\nm=application 61535 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 61535 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:333114079 1 udp 16785663 144.172.96.126 60493 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 54229 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=candidate:866909729 1 udp 8191 144.172.96.126 65269 typ relay raddr 0.0.0.0 rport 0 generation 0 network-cost 999\r\na=ice-ufrag:ZtK/\r\na=ice-pwd:AtRcW+mdaWKxORtSAYpYFPY9\r\na=ice-options:trickle\r\na=fingerprint:sha-256 6E:02:61:A8:9D:92:2F:B5:FA:90:F6:C9:3E:F3:40:BA:81:5A:E8:B2:A6:3D:8B:D2:82:3D:4F:07:47:31:97:9F\r\na=setup:active\r\na=mid:0\r\na=sctp-port:5000\r\na=max-message-size:262144\r\n"
|
||||
}
|
||||
}
|
||||
Vendored
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"host_only": {
|
||||
"offer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 6954001307224301170 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 9F:98:22:05:43:42:63:71:20:48:78:9F:FB:FD:93:BE:6A:BF:CC:F9:C2:85:36:BD:0B:52:F5:82:2D:53:AA:5B\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=candidate:0 1 UDP 2122252543 02223491-7fdc-4329-abd2-4e6494b1a32c.local 56761 typ host\r\na=candidate:1 1 TCP 2105524479 02223491-7fdc-4329-abd2-4e6494b1a32c.local 9 typ host tcptype active\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:1f42449f683648b472545028e84e99e3\r\na=ice-ufrag:5a90c521\r\na=mid:0\r\na=setup:actpass\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"answer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 1218188487223042476 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 AD:6B:A7:BB:53:FA:0F:7C:F8:93:AD:78:00:C2:F3:36:4A:88:79:17:6E:2D:74:6C:68:DF:78:C1:C3:4B:C1:05\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 9 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 0.0.0.0\r\na=candidate:0 1 UDP 2122252543 c427d246-8c24-4844-9877-62014e6628f1.local 62174 typ host\r\na=candidate:1 1 TCP 2105524479 c427d246-8c24-4844-9877-62014e6628f1.local 9 typ host tcptype active\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:881e7b4139d6358e1e249445f504e85e\r\na=ice-ufrag:169c83ed\r\na=mid:0\r\na=setup:active\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"_meta": {
|
||||
"offerCands": 3,
|
||||
"answerCands": 3
|
||||
}
|
||||
},
|
||||
"stun": {
|
||||
"offer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 3614557044845955558 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 02:0E:0E:49:08:38:75:B7:F3:9A:19:15:4D:C3:E6:D9:52:14:C4:17:6B:FD:96:69:AB:38:DE:A3:4D:7E:E6:B8\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 33071 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 152.166.136.164\r\na=candidate:0 1 UDP 2122252543 86f4806a-e0c3-42d0-80f9-1ae04917aa57.local 61953 typ host\r\na=candidate:2 1 TCP 2105524479 86f4806a-e0c3-42d0-80f9-1ae04917aa57.local 9 typ host tcptype active\r\na=candidate:1 1 UDP 1686052863 152.166.136.164 33071 typ srflx raddr 0.0.0.0 rport 0\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:d36fd78fa1eb4585e10bd2666642e676\r\na=ice-ufrag:fb88e298\r\na=mid:0\r\na=setup:actpass\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"answer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 3048119113405332850 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 AB:2F:40:06:53:E8:E4:67:A7:34:10:98:A1:D5:2E:FC:B3:32:73:25:FB:97:DE:55:B4:42:5D:FF:CD:A8:59:6C\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 44924 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 152.166.136.164\r\na=candidate:0 1 UDP 2122252543 bc8a0794-1cd7-476e-96a4-796fa7ad5632.local 56644 typ host\r\na=candidate:2 1 TCP 2105524479 bc8a0794-1cd7-476e-96a4-796fa7ad5632.local 9 typ host tcptype active\r\na=candidate:1 1 UDP 1686052863 152.166.136.164 44924 typ srflx raddr 0.0.0.0 rport 0\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:e09a5ceae613caeb3e609c66248875dd\r\na=ice-ufrag:dd18d415\r\na=mid:0\r\na=setup:active\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"_meta": {
|
||||
"offerCands": 4,
|
||||
"answerCands": 4
|
||||
}
|
||||
},
|
||||
"turn_all": {
|
||||
"offer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 8689633657762425867 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 18:C8:12:B5:B0:25:7D:3B:62:BD:88:5F:64:DA:50:EC:41:F3:43:BE:1C:88:3B:CB:B5:12:CD:B2:F3:EB:46:EE\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 59174 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:0 1 UDP 2122252543 57c55eed-e6d9-4f89-857b-a6f34edb415a.local 64308 typ host\r\na=candidate:3 1 TCP 2105524479 57c55eed-e6d9-4f89-857b-a6f34edb415a.local 9 typ host tcptype active\r\na=candidate:1 1 UDP 1686052863 152.166.136.164 47676 typ srflx raddr 0.0.0.0 rport 0\r\na=candidate:2 1 UDP 92215807 144.172.96.126 59174 typ relay raddr 144.172.96.126 rport 59174\r\na=candidate:4 1 UDP 8331263 144.172.96.126 62952 typ relay raddr 144.172.96.126 rport 62952\r\na=candidate:4 1 UDP 8331263 144.172.96.126 50084 typ relay raddr 144.172.96.126 rport 50084\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:0a92df0d43e88e7580b6d798a494ec0a\r\na=ice-ufrag:bc424ef2\r\na=mid:0\r\na=setup:actpass\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"answer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 4251445331568348906 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 60:C9:4E:E2:78:64:DF:B3:E2:A7:88:05:12:7F:CB:AF:CF:33:57:E0:F5:68:E5:FA:83:FA:A6:B2:B0:69:F8:26\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 57033 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:0 1 UDP 2122252543 c7da1688-0dc6-45bc-aa1c-82be16147b26.local 52730 typ host\r\na=candidate:3 1 TCP 2105524479 c7da1688-0dc6-45bc-aa1c-82be16147b26.local 9 typ host tcptype active\r\na=candidate:1 1 UDP 1686052863 152.166.136.164 37734 typ srflx raddr 0.0.0.0 rport 0\r\na=candidate:2 1 UDP 92215807 144.172.96.126 57033 typ relay raddr 144.172.96.126 rport 57033\r\na=candidate:4 1 UDP 8331263 144.172.96.126 62819 typ relay raddr 144.172.96.126 rport 62819\r\na=candidate:4 1 UDP 8331263 144.172.96.126 59536 typ relay raddr 144.172.96.126 rport 59536\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:92f178d4b9daba2698d47002ef2cd4c8\r\na=ice-ufrag:3fe336de\r\na=mid:0\r\na=setup:active\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"_meta": {
|
||||
"offerCands": 7,
|
||||
"answerCands": 7
|
||||
}
|
||||
},
|
||||
"turn_relay_only": {
|
||||
"offer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 6644107097568718127 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 3B:11:07:3E:C7:C3:D3:4C:50:49:E7:08:6B:02:CC:6B:E9:19:7E:31:C1:9A:32:D0:98:64:FC:C8:6F:73:B6:9C\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 53665 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:0 1 UDP 92217343 144.172.96.126 53665 typ relay raddr 144.172.96.126 rport 53665\r\na=candidate:1 1 UDP 8331263 144.172.96.126 56510 typ relay raddr 144.172.96.126 rport 56510\r\na=candidate:1 1 UDP 8331263 144.172.96.126 54387 typ relay raddr 144.172.96.126 rport 54387\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:42d0ac92c82418c74b7982d1be58d482\r\na=ice-ufrag:de6c893b\r\na=mid:0\r\na=setup:actpass\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"answer": "v=0\r\no=mozilla...THIS_IS_SDPARTA-99.0 4625010855495189334 0 IN IP4 0.0.0.0\r\ns=-\r\nt=0 0\r\na=sendrecv\r\na=fingerprint:sha-256 FB:B2:B3:90:CF:BB:67:8C:B1:82:EF:D0:79:50:70:4E:61:71:7B:71:3B:F1:11:25:55:0B:B0:7D:24:D5:E5:97\r\na=group:BUNDLE 0\r\na=ice-options:trickle\r\na=msid-semantic:WMS *\r\nm=application 52546 UDP/DTLS/SCTP webrtc-datachannel\r\nc=IN IP4 144.172.96.126\r\na=candidate:0 1 UDP 92217343 144.172.96.126 52546 typ relay raddr 144.172.96.126 rport 52546\r\na=candidate:1 1 UDP 8331263 144.172.96.126 61188 typ relay raddr 144.172.96.126 rport 61188\r\na=candidate:1 1 UDP 8331263 144.172.96.126 62991 typ relay raddr 144.172.96.126 rport 62991\r\na=sendrecv\r\na=end-of-candidates\r\na=ice-pwd:471a7657cb6bd345820a44062601bf27\r\na=ice-ufrag:c99211cf\r\na=mid:0\r\na=setup:active\r\na=sctp-port:5000\r\na=max-message-size:1073741823\r\n",
|
||||
"_meta": {
|
||||
"offerCands": 4,
|
||||
"answerCands": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user