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:
lockbitchat
2026-08-06 11:54:05 -04:00
parent 3212138a0d
commit 6e82cfcae2
14 changed files with 1765 additions and 37 deletions
+37
View File
@@ -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 20002400 characters and needs QR version 3840, 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 **98149
bytes**, which is **QR version 68** — 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.
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.7.2-3ecf8e.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-5.8.0-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app)
[![Encryption](https://img.shields.io/badge/crypto-ECDH%20P--384%20%C2%B7%20AES--256--GCM-blue.svg)](#security-model)
[![Forward secrecy](https://img.shields.io/badge/forward%20secrecy-Double%20Ratchet-3ecf8e.svg)](#forward-secrecy)
+1 -1
View File
@@ -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}`;
+1 -1
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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.
+294
View File
@@ -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
20002400 characters to **98149 bytes**, and the QR from version 3840 down to
**version 68** 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 |
|---|---|
| 01 | type: 0 offer, 1 answer. 2 and 3 are reserved → **reject** |
| 23 | DTLS setup role: 0 actpass, 1 active, 2 passive. 3 reserved → **reject** |
| 45 | 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. 715 reserved → **reject**.
`tcptype`: 0 udp, 1 tcp/passive, 2 tcp/active, 3 tcp/so. 415 → **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 23, setup role 3, candidate kind 715,
tcptype 415) 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
View File
@@ -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>
+7 -7
View File
@@ -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
View File
@@ -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",
+824
View File
@@ -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 };
+1 -1
View File
@@ -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() {
+521
View File
@@ -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)'}`);
+18
View File
@@ -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"
}
}
+34
View File
@@ -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
}
}
}