fix(descriptor): rebuild SDP that Firefox accepts; release v5.8.1

Live cross-browser testing found three defects in the SBQ2 SDP template, all
invisible to Chrome and all fatal to Firefox.

The candidate lines omitted raddr/rport on srflx and relay candidates. RFC 8839
section 5.1 makes rel-addr and rel-port mandatory for non-host candidates even
though ICE never reads them; Chrome tolerates the omission and Firefox drops the
candidate. Relay-only connections to Firefox failed 0/8 against 8/8 for the
browser's own SDP. The STUN and TURN profiles hid it because a host pair
connected instead -- the relay candidates were never actually needed there.

The template also advertised ice-options:trickle without ever closing the
candidate set, though a descriptor is a complete one-shot set with no channel to
trickle over, and hard-coded the m= port and c= line to the 9 / 0.0.0.0 null
default candidate, which is the trickle convention for "nothing gathered yet".
Both are now correct: no trickle, an explicit a=end-of-candidates, and the most
publicly reachable candidate as the default.

All three are serializer-side and cost zero descriptor bytes; sizes are
unchanged at 98-149 bytes, QR version 6-8. Verified 48/48 across all 16
combinations of {Chrome, Firefox} squared and four network profiles, with every
relay-only pair now connecting over the relay.
This commit is contained in:
lockbitchat
2026-08-06 17:25:46 -04:00
parent 6e82cfcae2
commit fb959d7ca7
12 changed files with 207 additions and 42 deletions
+32
View File
@@ -1,5 +1,37 @@
# Changelog # Changelog
## v5.8.1 — SBQ2 rebuilds SDP that Firefox accepts
Still nothing in the application calls the SBQ2 descriptor; this fixes defects in
it found by running live connections between real browsers.
### Fixed
- The rebuilt SDP omitted `raddr`/`rport` on srflx and relay candidates. RFC 8839
§5.1 makes them mandatory for non-host candidates, and while Chrome tolerates
the omission, **Firefox drops the candidate entirely**. Relay-only connections
to Firefox failed 0/8 where the browser's own SDP succeeded 8/8. The STUN and
TURN profiles hid it, because a host candidate pair connected instead.
- The template advertised `a=ice-options:trickle` and never closed the candidate
set. A descriptor is a complete one-shot set with no channel to trickle over,
so this promised candidates that could never arrive. Trickle is gone and
`a=end-of-candidates` is emitted.
- The `m=` port and `c=` line were hard-coded to the `9` / `0.0.0.0` null default
candidate, which is the trickle-ICE "nothing gathered yet" convention and false
here. The most publicly reachable candidate is advertised instead — relay, then
srflx, then host — falling back to the null form only when every candidate is
mDNS, as Chrome does.
None of these change the descriptor: all three are serializer-side and cost zero
bytes. Sizes are unchanged at 98149 bytes, QR version 68.
Verified across all 16 combinations of {Chrome, Firefox} x {Chrome, Firefox} and
four network profiles: **48/48 connections**, with every relay-only pair now
connecting over the relay.
## v5.8.0 — A connection descriptor that fits in a small QR code ## 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 No change to how messages are protected, and no change to how a connection is
+1 -1
View File
@@ -9,7 +9,7 @@
No accounts. No servers storing your messages. No installation required. No accounts. No servers storing your messages. No installation required.
[![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE) [![License: MIT](https://img.shields.io/badge/License-MIT-f0892a.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-5.8.0-3ecf8e.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-5.8.1-3ecf8e.svg)](CHANGELOG.md)
[![PWA](https://img.shields.io/badge/PWA-installable-3ecf8e.svg)](#install-as-an-app) [![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) [![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) [![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()); var import_NotificationIntegration = __toESM(require_NotificationIntegration());
// package.json // package.json
var version = "5.8.0"; var version = "5.8.1";
// src/components/ui/Header.jsx // src/components/ui/Header.jsx
var APP_VERSION = `v${version}`; 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.8.0 | | Release | v5.8.1 |
| Protocol version | 4.1 | | Protocol version | 4.1 |
| Ratchet wire version | 1 | | Ratchet wire version | 1 |
@@ -214,5 +214,5 @@ worse than one that reports nothing.
## Scope ## Scope
This describes the browser implementation as it stands in v5.8.0. It is not a This describes the browser implementation as it stands in v5.8.1. It is not a
substitute for independent cryptographic review. substitute for independent cryptographic review.
+29 -3
View File
@@ -83,9 +83,35 @@ Foundation and priority are **not** transmitted. Priority only orders
connectivity checks, and each peer computes its own local priorities anyway; the 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`, 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 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 kind and transport, satisfying both halves of §5.1.1.3. `generation` and
diagnostics that ICE does not consume, and `generation`/`network-cost` are Chrome `network-cost` are Chrome extensions and are dropped.
extensions.
### What the serializer must emit anyway
Three things are not in the descriptor but must appear in the rebuilt SDP. All
three are constants or derived from data already present, so none costs a byte —
and each was found by a live cross-browser test rather than by reading the spec.
- **`raddr` / `rport` on every srflx, prflx and relay candidate.** RFC 8839 §5.1
makes rel-addr and rel-port mandatory for non-host candidates even though ICE's
own algorithm never reads them. An earlier draft left them out as
"diagnostics". Chrome accepts such a line; **Firefox drops the candidate.**
Measured: relay-only connections to Firefox went 0/8 while the browser's own
SDP went 8/8, and the fault was invisible in the STUN and TURN profiles because
a host pair connected instead. `0.0.0.0`/`0` (or `::`/`0`) is emitted — the
same placeholder Chrome uses when it has no base address to disclose.
- **No `a=ice-options:trickle`, and an explicit `a=end-of-candidates`.** A
descriptor is a complete one-shot candidate set; there is no channel to trickle
over. Advertising trickle promises candidates that can never arrive and leaves
the peer waiting for them.
- **A real default candidate on the `m=` port and `c=` line.** `m=application 9`
with `c=IN IP4 0.0.0.0` is the trickle convention for "nothing gathered yet"
(RFC 8840 §4.1) and is simply false here. The serializer advertises the most
publicly reachable candidate — relay, then srflx, then host, which is the order
Chrome uses and the reverse of ICE priority — falling back to the null form
only when every candidate is mDNS, which is also what Chrome does.
### TLV extension area ### TLV extension area
+22 -22
View File
@@ -24,7 +24,7 @@
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="./manifest.json"> <link rel="manifest" href="./manifest.json">
<link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786031423211"> <link rel="icon" type="image/x-icon" href="./logo/favicon.ico?v=1786051485967">
<!-- PWA Meta Tags --> <!-- PWA Meta Tags -->
<meta name="mobile-web-app-capable" content="yes"> <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"> <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 --> <!-- Apple Touch Icons -->
<link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786031423211"> <link rel="apple-touch-icon" href="./logo/icon-180x180.png?v=1786051485967">
<link rel="apple-touch-icon" sizes="57x57" href="./logo/icon-57x57.png"> <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="60x60" href="./logo/icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="./logo/icon-72x72.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="120x120" href="./logo/icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="./logo/icon-144x144.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="152x152" href="./logo/icon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786031423211"> <link rel="apple-touch-icon" sizes="180x180" href="./logo/icon-180x180.png?v=1786051485967">
<!-- Microsoft Tiles --> <!-- Microsoft Tiles -->
<meta name="msapplication-TileColor" content="#ff6b35"> <meta name="msapplication-TileColor" content="#ff6b35">
@@ -183,7 +183,7 @@
<!-- Render-blocking JS is deferred: classic deferred scripts and module scripts <!-- Render-blocking JS is deferred: classic deferred scripts and module scripts
both execute in document order after parsing, so React still runs before the 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. --> app modules below, but the parser / first paint is no longer blocked. -->
<script defer src="config/ice-servers.js?v=1786031423211"></script> <script defer src="config/ice-servers.js?v=1786051485967"></script>
<script defer src="libs/react/react.production.min.js"></script> <script defer src="libs/react/react.production.min.js"></script>
<script defer src="libs/react-dom/react-dom.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 — <!-- 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). --> Its CSS is loaded async via load-async-css.js (not paint-critical). -->
<script defer src="libs/prism/prism.js"></script> <script defer src="libs/prism/prism.js"></script>
<!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). --> <!-- Critical, paint-defining CSS stays render-blocking (avoids FOUC / layout shift). -->
<link rel="stylesheet" href="assets/tailwind.css?v=1786031423211"> <link rel="stylesheet" href="assets/tailwind.css?v=1786051485967">
<link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786031423211"> <link rel="icon" type="image/x-icon" href="/logo/favicon.ico?v=1786051485967">
<!-- Preload only the fonts needed for first paint. fa-solid covers the bulk of UI <!-- 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 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. --> 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-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="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="/assets/fonts/inter/inter.css">
<link rel="stylesheet" href="src/styles/main.css?v=1786031423211"> <link rel="stylesheet" href="src/styles/main.css?v=1786051485967">
<link rel="stylesheet" href="src/styles/animations.css?v=1786031423211"> <link rel="stylesheet" href="src/styles/animations.css?v=1786051485967">
<link rel="stylesheet" href="src/styles/components.css?v=1786031423211"> <link rel="stylesheet" href="src/styles/components.css?v=1786051485967">
<!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. --> <!-- Non-critical CSS (FontAwesome ~102KB, Prism) loaded async — no longer blocks paint. -->
<script defer src="src/scripts/load-async-css.js?v=1786031423211"></script> <script defer src="src/scripts/load-async-css.js?v=1786051485967"></script>
<noscript> <noscript>
<link rel="stylesheet" href="/assets/fontawesome/css/all.min.css"> <link rel="stylesheet" href="/assets/fontawesome/css/all.min.css">
<link rel="stylesheet" href="libs/prism/prism.css"> <link rel="stylesheet" href="libs/prism/prism.css">
</noscript> </noscript>
<script defer src="src/scripts/fa-check.js?v=1786031423211"></script> <script defer src="src/scripts/fa-check.js?v=1786051485967"></script>
<!-- Update Manager - система принудительного обновления --> <!-- Update Manager - система принудительного обновления -->
<script defer src="src/utils/updateManager.js?v=1786031423211"></script> <script defer src="src/utils/updateManager.js?v=1786051485967"></script>
<script type="module" src="src/components/UpdateChecker.jsx?v=1786031423211"></script> <script type="module" src="src/components/UpdateChecker.jsx?v=1786051485967"></script>
<script type="module" src="dist/qr-local.js?v=1786031423211"></script> <script type="module" src="dist/qr-local.js?v=1786051485967"></script>
<script type="module" src="src/components/QRScanner.js?v=1786031423211"></script> <script type="module" src="src/components/QRScanner.js?v=1786051485967"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1786031423211"></script> <script type="module" src="dist/app-boot.js?v=1786051485967"></script>
<script type="module" src="dist/app.js?v=1786031423211"></script> <script type="module" src="dist/app.js?v=1786051485967"></script>
<script defer src="src/scripts/pwa-register.js?v=1786031423211"></script> <script defer src="src/scripts/pwa-register.js?v=1786051485967"></script>
<script src="./src/pwa/install-prompt.js?v=1786031423211" type="module"></script> <script src="./src/pwa/install-prompt.js?v=1786051485967" type="module"></script>
<script src="./src/pwa/pwa-manager.js?v=1786031423211" type="module"></script> <script src="./src/pwa/pwa-manager.js?v=1786051485967" type="module"></script>
<script defer src="./src/scripts/pwa-offline-test.js?v=1786031423211"></script> <script defer src="./src/scripts/pwa-offline-test.js?v=1786051485967"></script>
<link rel="stylesheet" href="./src/styles/pwa.css?v=1786031423211"> <link rel="stylesheet" href="./src/styles/pwa.css?v=1786051485967">
</body> </body>
</html> </html>
+7 -7
View File
@@ -1,10 +1,10 @@
{ {
"version": "1786031423211", "version": "1786051485967",
"buildVersion": "1786031423211", "buildVersion": "1786051485967",
"appVersion": "5.8.0", "appVersion": "5.8.1",
"buildTime": "2026-08-06T15:50:23.257Z", "buildTime": "2026-08-06T21:24:46.003Z",
"buildId": "1786031423211-3212138", "buildId": "1786051485967-6e82cfc",
"gitHash": "3212138", "gitHash": "6e82cfc",
"generated": true, "generated": true,
"generatedAt": "2026-08-06T15:50:23.258Z" "generatedAt": "2026-08-06T21:24:46.005Z"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "securebit-chat", "name": "securebit-chat",
"version": "5.8.0", "version": "5.8.1",
"description": "Secure P2P Communication Application with End-to-End Encryption", "description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html", "main": "index.html",
"scripts": { "scripts": {
+46 -3
View File
@@ -659,6 +659,37 @@ function renderAddr(kind, addr) {
*/ */
export function serializeSdp(desc, { sessionId = '1' } = {}) { export function serializeSdp(desc, { sessionId = '1' } = {}) {
const isOffer = desc.type === TYPE.OFFER; const isOffer = desc.type === TYPE.OFFER;
// The m-line port and c-line carry the DEFAULT CANDIDATE, and they must be
// real whenever we have one.
//
// `m=application 9` with `c=IN IP4 0.0.0.0` is the trickle-ICE convention
// for "no candidate yet" (RFC 8840 §4.1). A descriptor is never trickle —
// the candidate set is complete by construction — so a null default
// candidate here is simply a lie, and Firefox acts on it: with a relay-only
// Chrome offer, rebuilding with 9/0.0.0.0 connected 0/4 while the browser's
// own SDP connected 4/4. Advertising the highest-priority routable candidate
// instead, exactly as Chrome and Firefox do, costs zero descriptor bytes
// because that candidate is already in the list.
//
// An mDNS candidate cannot appear here (c= needs a literal address), so if
// that is all we have we fall back to the null form — which is also what
// Chrome emits in that case.
// Preference order is relay > srflx > host — the most publicly reachable
// address, which is what the default candidate is for, and the order Chrome
// itself uses (its STUN-profile SDP names the srflx address and its TURN
// profile names the relay). That is the reverse of ICE priority, which
// ranks host first.
const DEFAULT_RANK = { relay: 0, srflx: 1, host: 2 };
const def = desc.candidates
.filter((c) => c.kind !== KIND.HOST_MDNS && c.tcptype === 0)
.sort((x, y) => DEFAULT_RANK[KIND_TYPE[x.kind]] - DEFAULT_RANK[KIND_TYPE[y.kind]])[0];
const defIsV6 = def && KIND_FAMILY[def.kind] === 'v6';
const mPort = def ? def.port : 9;
const cLine = def
? `c=IN IP${defIsV6 ? '6' : '4'} ${renderAddr(def.kind, def.addr)}`
: 'c=IN IP4 0.0.0.0';
const lines = [ const lines = [
'v=0', 'v=0',
`o=- ${sessionId} 2 IN IP4 127.0.0.1`, `o=- ${sessionId} 2 IN IP4 127.0.0.1`,
@@ -666,8 +697,8 @@ export function serializeSdp(desc, { sessionId = '1' } = {}) {
't=0 0', 't=0 0',
'a=group:BUNDLE 0', 'a=group:BUNDLE 0',
'a=msid-semantic: WMS', 'a=msid-semantic: WMS',
'm=application 9 UDP/DTLS/SCTP webrtc-datachannel', `m=application ${mPort} UDP/DTLS/SCTP webrtc-datachannel`,
'c=IN IP4 0.0.0.0', cLine,
'a=ice-ufrag:' + desc.ufrag, 'a=ice-ufrag:' + desc.ufrag,
'a=ice-pwd:' + desc.pwd, 'a=ice-pwd:' + desc.pwd,
// Deliberately NOT `a=ice-options:trickle`. A descriptor is a complete, // Deliberately NOT `a=ice-options:trickle`. A descriptor is a complete,
@@ -696,6 +727,18 @@ export function serializeSdp(desc, { sessionId = '1' } = {}) {
// transport satisfies both halves of that. // transport satisfies both halves of that.
const foundation = String(c.kind * 4 + c.tcptype + 1); 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}`; let line = `a=candidate:${foundation} 1 ${transport} ${priority} ${renderAddr(c.kind, c.addr)} ${c.port} typ ${ctype}`;
// rel-addr/rel-port are MANDATORY for srflx, prflx and relay candidates
// (RFC 8839 §5.1), even though ICE's own algorithm never reads them.
// Omitting them is not a harmless economy: Chrome accepts such a line,
// Firefox drops the candidate outright. That cost 0/8 relay-only
// connections to Firefox while the browser's own SDP made 8/8, and it
// stayed invisible in the STUN and TURN profiles because a host pair
// connected instead. The value is not transmitted — 0.0.0.0/0 is what
// Chrome itself emits when it has no base address to disclose, and it
// satisfies the grammar at zero descriptor bytes.
if (ctype !== 'host') {
line += KIND_FAMILY[c.kind] === 'v6' ? ' raddr :: rport 0' : ' raddr 0.0.0.0 rport 0';
}
if (transport === 'tcp') line += ` tcptype ${TCPTYPE[c.tcptype]}`; if (transport === 'tcp') line += ` tcptype ${TCPTYPE[c.tcptype]}`;
return line; return line;
}); });
@@ -703,7 +746,7 @@ export function serializeSdp(desc, { sessionId = '1' } = {}) {
// (RFC 8838 §14), so the peer stops waiting for more and can start failing // (RFC 8838 §14), so the peer stops waiting for more and can start failing
// pairs promptly instead of sitting in checking until a timeout. // pairs promptly instead of sitting in checking until a timeout.
candLines.push('a=end-of-candidates'); candLines.push('a=end-of-candidates');
const at = lines.indexOf('c=IN IP4 0.0.0.0') + 1; const at = lines.indexOf(cLine) + 1;
lines.splice(at, 0, ...candLines); lines.splice(at, 0, ...candLines);
return { type: isOffer ? 'offer' : 'answer', sdp: lines.join('\r\n') + '\r\n' }; return { type: isOffer ? 'offer' : 'answer', sdp: lines.join('\r\n') + '\r\n' };
+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 // 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, // 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. // reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1786031423211'; const SW_BUILD_VERSION = '1786051485967';
// Load version from meta.json on install // Load version from meta.json on install
async function getAppVersion() { async function getAppVersion() {
+64
View File
@@ -191,6 +191,70 @@ for (const [browser, fixtures] of [['chrome', chrome], ['firefox', firefox]]) {
assert.deepEqual([...re.candidates[2].addr].slice(12), [198, 51, 100, 9], 'NAT64 tail round-trips'); assert.deepEqual([...re.candidates[2].addr].slice(12), [198, 51, 100, 9], 'NAT64 tail round-trips');
} }
// ---------------------------------------------------------------------------
// SDP grammar conformance of the rebuilt candidate lines
//
// Regression guard. rel-addr/rel-port are mandatory for srflx/prflx/relay
// (RFC 8839 §5.1). Leaving them out is silently fine in Chrome and fatal in
// Firefox, which drops the candidate: measured 0/8 relay-only connections to
// Firefox versus 8/8 for the browser's own SDP, and invisible in the STUN and
// TURN profiles because a host pair connected instead.
// ---------------------------------------------------------------------------
{
const sdp = [
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
'a=candidate:2 1 udp 1677729535 203.0.113.4 40001 typ srflx raddr 192.168.1.9 rport 40000',
'a=candidate:3 1 udp 50340351 144.172.96.126 40002 typ relay raddr 203.0.113.4 rport 40001',
'a=candidate:4 1 udp 50341375 2001:db8::5 40003 typ relay',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('EE').join(':'), 'a=setup:actpass',
].join('\r\n') + '\r\n';
const out = serializeSdp(decodeDescriptor(await build(sdp, TYPE.OFFER))).sdp;
for (const line of out.split('\r\n').filter((l) => l.startsWith('a=candidate:'))) {
const type = line.split(' typ ')[1].split(' ')[0];
if (type === 'host') {
assert.ok(!line.includes('raddr'), `host candidates must not carry raddr: ${line}`);
} else {
assert.match(line, / raddr (0\.0\.0\.0|::) rport 0/, `${type} candidate needs rel-addr/rel-port: ${line}`);
}
}
// The default candidate must be a real, routable address whenever one
// exists — `m=... 9` with `c=IN IP4 0.0.0.0` is the trickle "none yet"
// form, and a descriptor is never trickle.
assert.ok(!out.includes('a=ice-options:trickle'), 'a complete candidate set must not advertise trickle');
assert.match(out, /^a=end-of-candidates$/m, 'the candidate set is explicitly closed');
// relay beats srflx beats host: the default candidate is the most publicly
// reachable one, matching what Chrome emits. Asserted on a v4-only set so
// the expected winner is unambiguous.
const v4only = [
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 2113937151 192.168.1.9 40000 typ host',
'a=candidate:2 1 udp 1677729535 203.0.113.4 40001 typ srflx raddr 192.168.1.9 rport 40000',
'a=candidate:3 1 udp 50340351 144.172.96.126 40002 typ relay raddr 203.0.113.4 rport 40001',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('EE').join(':'), 'a=setup:actpass',
].join('\r\n') + '\r\n';
const vOut = serializeSdp(decodeDescriptor(await build(v4only, TYPE.OFFER))).sdp;
assert.match(vOut, /^m=application 40002 /m, 'm-line carries the relay port as default');
assert.match(vOut, /^c=IN IP4 144\.172\.96\.126$/m, 'c-line carries the relay address as default');
// With only an mDNS candidate there is no literal address to advertise, so
// the null form is correct — and it is what Chrome emits in that case too.
const mdnsOnly = [
'v=0', 'm=application 9 UDP/DTLS/SCTP webrtc-datachannel',
'a=candidate:1 1 udp 2113937151 7d9c00c2-bcef-48b6-9166-428899e0582e.local 40000 typ host',
'a=ice-ufrag:abcd', 'a=ice-pwd:0123456789abcdef01234567',
'a=fingerprint:sha-256 ' + new Array(32).fill('EE').join(':'), 'a=setup:actpass',
].join('\r\n') + '\r\n';
const mOut = serializeSdp(decodeDescriptor(await build(mdnsOnly, TYPE.OFFER))).sdp;
assert.match(mOut, /^m=application 9 /m);
assert.match(mOut, /^c=IN IP4 0\.0\.0\.0$/m);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TCP candidates // TCP candidates
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------