# Voice-Signaling-Protokoll Type: grilling Status: resolved Blocked by: — Parent: ../map.md ## Question Wie sieht das flüchtige Signaling-Format aus, mit dem Clients einen Voice-Room betreten, sich gegenseitig finden und SDP-/ICE-Daten austauschen? Das ist die eine Stelle, an der echte Nutzlast über den Socket geht — der Dumb Pipe trägt es nicht, weil ein SDP-Angebot flüchtig ist und in keiner Tabelle liegt. **Entblockt am 14.08.2026.** Was aus [*Voice-Topologie*](04-voice-topologie.md) feststeht und hier nicht neu aufgemacht wird: - **Mesh, harte Obergrenze sechs Teilnehmer**, serverseitig durchgesetzt — der Beitritt wird abgelehnt, nicht nur in der UI verhindert. Kein SFU-Vorbau; ein SFU wäre ein bewusster Protokollbruch in einem eigenen Effort. - **Eine Datenschutz-Politik je Instanz** (streng: `iceTransportPolicy: "relay"` + Opus `cbr=1`/`usedtx=0`; offen: Standard-ICE + Standard-Opus). Sie muss über das Signaling an den Client übermittelt werden, damit er sie umsetzen **und anzeigen** kann. - Im strengen Modus sind **TURN-Zugangsdaten zwingend** — ihre Übermittlung gehört damit in dieses Ticket. ### Zu entscheiden - **Room-Modell.** Ist ein Voice-Room an eine Conversation gebunden („Sprachkanal dieses Channels"), oder eine eigenständige Entität mit eigener Mitgliedschaft? Das erste ist einfacher und passt zum bestehenden Datenmodell; das zweite erlaubt Ad-hoc-Anrufe zwischen Leuten, die keine gemeinsame Conversation haben. - **Persistenz.** Ist der Room-Zustand rein im Speicher (weg beim Neustart), oder liegt er in der DB? In-Memory ist einfacher und passt zur Flüchtigkeit — aber beim Neustart fallen alle Gespräche, und bei Mehr-Instanz-Betrieb später ist In-Memory ein Problem. - **Teilnehmerliste.** Das ist die **einzige** Presence-Form im Scope dieses Efforts (allgemeine Chat-Presence liegt in der Fog). Wie erfährt ein Beitretender, wer schon drin ist? Wie erfahren die anderen von ihm? Was passiert bei einem Verbindungsabbruch — ab wann gilt jemand als weg? - **Nachrichtentypen.** Minimal nötig: `join`, `leave`, `participants`, `offer`, `answer`, `ice-candidate`. Braucht es mehr — `mute`, `speaking`, `renegotiate`? - **Adressierung.** SDP und ICE-Kandidaten gehen an einen *bestimmten* Peer, nicht an den Raum. Wie werden Peers identifiziert — User-ID reicht nicht, weil Multi-Device gefordert ist. Also eine Verbindungs- oder Session-ID. - **Wer bietet an?** Bei n Teilnehmern muss geregelt sein, wer bei einem Beitritt das Angebot macht, sonst gibt es Glare (beide bieten gleichzeitig). Deterministische Regel festlegen. - **TURN-Credentials.** Wenn TURN konfiguriert ist: wie kommen zeitbeschränkte Credentials zum Client? Gehört das in dieses Protokoll oder in einen REST-Endpunkt? Und was ist das Verhalten, wenn **kein** TURN konfiguriert ist — der Nutzer muss früh und ehrlich erfahren, dass die Verbindung scheitern kann, statt in einen Timeout zu laufen. - **Transport.** Derselbe WebSocket wie der Dumb Pipe, oder ein zweiter Endpunkt? Ein Socket spart Auth- und Reconnect-Verwaltung; zwei trennen die Belange sauberer. Hängt auch an *Wo lebt die WS-Verbindung im Client* — falls Chat in Rust und Voice in JS liegt, ist die Frage bereits beantwortet. ### Harte Randbedingung: DTLS-Fingerprint-Pinning Aus [*Recherche: WebRTC im Tauri-WebView (Mobile)*](01-research-webrtc-tauri-mobile.md) §7.4, und für dieses Projekt der wichtigste Sicherheitsbefund überhaupt: **RFC 8827 benennt den Angriff wörtlich** — ein On-Path-Angreifer kann die DTLS-SRTP-Fingerprints im Handshake austauschen und die Identität beider Endpunkte ersetzen. Der Signaling-Server relayed die SDP, kann also `a=fingerprint:` tauschen und zwei separate DTLS-Sitzungen terminieren: vollständiges MITM auf dem Audio, ohne dass irgendein WebRTC-Mechanismus anschlägt. Bei einem Bedrohungsmodell, in dem der Betreiber der Angreifer ist, ist das kein Randfall, sondern der Hauptfall. **Der spezifizierte Gegenmechanismus existiert nicht.** W3C *Identity for WebRTC 1.0* steht seit 2018 unverändert als Candidate Recommendation; `setIdentityProvider` / `peerIdentity` sind in Chrome, Safari und Android WebView **nicht implementiert** — also in keiner der vier Ziel-Engines. Was das Protokoll deshalb leisten muss: 1. Fingerprint deterministisch vorab bestimmen (`RTCPeerConnection.generateCertificate()` + `getFingerprints()`), statt ihn aus `localDescription.sdp` zu lesen. 2. Ihn **durch einen E2EE-authentifizierten Kanal** übertragen, signiert mit dem Langzeit-Identity-Key — nicht im Klartext-SDP, das der Server relayed. 3. Beim Empfang gegen den Fingerprint im relayed SDP vergleichen. Mismatch ⇒ Call abbrechen und als Sicherheitsereignis anzeigen, nicht still weiterlaufen lassen. 4. **Key Continuity** (RFC 8827): Fingerprints pro Gerät über Calls hinweg cachen, bei Änderung warnen. 5. Optional eine **SAS** (Emoji-/Wortliste aus dem Hash beider Fingerprints, à la ZRTP) für den paranoiden Pfad — verteidigt zusätzlich gegen einen kompromittierten Identity-Key. Genau so löst es Matrix in MSC3401/Element Call: Call-SDP läuft über verschlüsselte To-Device-Messages, der Homeserver sieht den Fingerprint nie im Klartext. **Konsequenz für die Effort-Grenzen:** Punkt 2 braucht einen E2EE-Kanal, den es heute nicht gibt — das volle E2EE-Design ist ein Folge-Effort. Dieses Ticket muss die **Naht** festlegen (wo im Signaling-Fluss der signierte Fingerprint sitzt, wie ein Mismatch behandelt wird), kann sie aber nicht einlösen. Sicheres Voice ist damit von der Fertigstellung des E2EE-Efforts abhängig — das explizit festhalten, statt es implizit zu lassen. ### Randbedingung aus *Voice auf Linux* Es gibt **zwei Medienpfade**: `RTCPeerConnection` im WebView auf Windows, Android und iOS, und `livekit/rust-sdks` im Rust-Layer auf Linux. Das Signaling-Protokoll muss beide bedienen. Konkret für das Fingerprint-Pinning oben: der signierte DTLS-Fingerprint muss auf **beiden** Pfaden erzeugbar sein — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die Naht darf also nicht an einer WebView-API hängen. ### Randbedingung **Kein Audio.** Codecs, Echo-Cancellation, Audio-Pipeline und SFU-Betrieb sind out of scope. Dieses Ticket endet bei „zwei Clients haben genug Information, um eine `RTCPeerConnection` aufzubauen". --- ## Answer Entschieden am 14.08.2026. ### 1. Der Voice-Room ist eine eigenständige Entität mit zwei möglichen Eltern **Discord-Modell:** ein Sprachkanal hängt am **Workspace** und ist per Default für alle Workspace-Mitglieder sichtbar und betretbar. Zusätzlich kann ein Raum an einer **Conversation** hängen — das ist der Direktanruf aus einer DM oder Gruppe heraus. ```sql CREATE TABLE voice_rooms ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE, conversation_id UUID REFERENCES conversations(id) ON DELETE CASCADE, name TEXT, created_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CHECK ((workspace_id IS NULL) <> (conversation_id IS NULL)) ); ``` Genau eines der beiden Elternfelder ist gesetzt — dasselbe Muster, das `conversations` heute schon benutzt (dort ist `workspace_id` für DMs null). **Eine Entität, ein Signaling-Protokoll, eine Prüfstelle für die Autorisierung**, die nur danach verzweigt, welcher Elternteil gesetzt ist. **Lebenszyklus:** Workspace-Räume sind dauerhaft (ein Ort, den man betritt und verlässt). Conversation-Räume entstehen mit dem Anruf und werden beim Auflegen abgeräumt. ⚠️ **Die Autorisierung muss durch genau eine Funktion laufen.** Später sollen Rollen *Conversations und Voice-Rooms gleichermaßen* einschränken — es darf deshalb **kein raumspezifisches Berechtigungsmodell** entstehen, sonst gibt es zwei Systeme, die auseinander laufen. Heute prüft diese Funktion Workspace- bzw. Conversation-Mitgliedschaft; das Rollenmodell setzt später an derselben Stelle an. ### 2. Raumzustand in Postgres, aufgeräumt über den bestehenden Heartbeat ```sql CREATE TABLE voice_participants ( room_id UUID NOT NULL REFERENCES voice_rooms(id) ON DELETE CASCADE, session_id UUID NOT NULL, user_id UUID NOT NULL REFERENCES users(id), joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (room_id, session_id) ); ``` Über Instanzen hinweg konsistent per Konstruktion — nötig, weil das Fan-out seit [*Fan-out-Architektur*](08-fan-out-architektur.md) immer über `NOTIFY` läuft und Mehr-Instanz-Betrieb damit ab Tag eins möglich ist. Die Teilnehmerliste, die ein Beitretender für seine Angebote braucht, ist eine simple Abfrage. **Geisterzeilen** nach einem Instanz-Absturz fängt `last_seen` ab, das der bestehende 15-Sekunden-Heartbeat aus [*Verbindungsaufbau & Auth*](07-verbindungsaufbau-und-auth.md) gratis mitführt: wer zwei Intervalle fehlt, wird entfernt. Bei sauberem Socket-Schluss geht der Abgang sofort. Verworfen: In-Memory je Instanz (`NOTIFY` hat keine Historie — nach einem Listener-Reconnect fehlt einer Instanz die Sicht, und abgestürzte Instanzen hinterlassen unaufräumbare Geister) und Raum-zu-Instanz-Bindung (bräuchte selbst wieder geteilten Zustand plus eine Umleitung, und ein Instanz-Absturz nähme den ganzen Raum mit statt ihn nur auszudünnen). ### 3. Adressierung über `session_id`, nicht `user_id` Multi-Device ist gefordert — derselbe Nutzer kann mit Desktop und Handy im selben Raum sein. SDP und ICE-Kandidaten gehen deshalb an eine **Verbindung**, nicht an ein Konto. Jede WebSocket-Verbindung bekommt beim Aufbau eine `session_id`; sie ist die Peer-Identität im Signaling. ### 4. Der Beitretende bietet an Wer beitritt, erhält die Teilnehmerliste und sendet n−1 Angebote; die Anwesenden antworten nur. Eine Regel statt einer Aushandlung darüber, wer aushandelt — und der Beitretende ist der Einzige, der in diesem Moment etwas Neues weiß. Kollisionen können praktisch nicht entstehen, weil es reines Audio ohne mitten im Gespräch wechselnde Spuren gibt. Der einzige verbleibende Fall ist ein ICE-Neustart: **wer neu startet, bietet an.** ⚠️ Das ist eine **Konvention, kein Mechanismus**. Kämen später Bildschirmfreigabe oder Video hinzu, muss sie durch Perfect Negotiation (höflich/unhöflich per `session_id`-Vergleich) ersetzt werden. Als Notiz festgehalten, nicht heute gebaut. ### 5. Fingerprint-Pinning: Naht jetzt, Füllung durch den E2EE-Effort Das Protokoll sieht das Feld für den signierten DTLS-Fingerprint und den Abgleich gegen das relayte SDP **von Anfang an** vor. Solange kein Identitätsschlüssel existiert, bleibt es leer, und der Client zeigt **dauerhaft und nicht wegklickbar** an, dass das Gespräch nicht verifiziert ist. Begründung gegen die verlockende Alternative (eine kleine Identitätsschicht nur für Voice): der Identitätsschlüssel ist das Kernstück des E2EE-Efforts. Ihn separat für Voice zu erzeugen hieße, dass Nutzer später zwei Identitäten und zwei Verifikationsrituale haben — oder dass eine davon migriert werden muss, und das ist bei Schlüsseln der unangenehmste aller Migrationsfälle. **Bewusst in Kauf genommen und in der Map vermerkt:** bis der E2EE-Effort steht, ist Voice gegen den Betreiber ungeschützt. Sichtbar, nicht stillschweigend. Beide Medienpfade aus [*Voice auf Linux*](11-voice-auf-linux.md) müssen den Fingerprint deterministisch vorab erzeugen können — im WebView über `RTCPeerConnection.generateCertificate()`, nativ über die Zertifikats-API von libwebrtc. Die Naht darf nicht an einer WebView-API hängen. ### 6. Nachrichtensatz (abgeleitet) Client → Server: `room.join` · `room.leave` · `peer.signal` (trägt Offer, Answer oder ICE-Kandidat, adressiert an eine `session_id`) Server → Client: `room.joined` (Antwort auf `join`: Teilnehmerliste, Politik, TURN-Zugangsdaten) · `room.participant_added` · `room.participant_removed` · `peer.signal` (weitergereicht) · `room.rejected` (Raum voll, nicht berechtigt) `mute` und `speaking` sind **nicht** enthalten: Stummschaltung ist lokal, und Sprechaktivität würde die Information über den Server leiten, die [*Voice-Topologie*](04-voice-topologie.md) im strengen Modus gerade per Opus-CBR versteckt. Beides gehört, falls gewünscht, über die Peer-Verbindung statt über das Signaling. ### 7. Politik und TURN-Zugangsdaten in der Beitrittsantwort (abgeleitet) `room.joined` trägt die Instanz-Politik aus [*Voice-Topologie*](04-voice-topologie.md) (*streng* → `iceTransportPolicy: "relay"` und Opus `cbr=1`/`usedtx=0`; *offen* → Standard) — der Client setzt sie um **und zeigt sie an**. Im strengen Modus sind TURN-Zugangsdaten zwingend und reisen mit: **zeitbeschränkte HMAC-Credentials** nach dem üblichen coturn-Muster, damit nichts Dauerhaftes im Client liegt. Erneuerung erfolgt durch erneuten Beitritt, nicht durch einen eigenen Mechanismus. ### 8. Raumgröße serverseitig durchgesetzt Die Obergrenze von sechs wird beim `room.join` geprüft und mit `room.rejected` abgelehnt — **nicht** nur in der Oberfläche verhindert. ### Offene Kopplung zum E2EE-Effort `voice_rooms.name` ist nutzererzeugter Inhalt und fiele damit unter die Blob-Regel aus [*Dumb-Pipe-Event & Backfill*](06-dumb-pipe-event-und-backfill.md). Der Server braucht ihn nicht — aber ein neues Workspace-Mitglied müsste die Raumliste lesen können, ohne vorher Schlüsselmaterial zu haben. Das ist ein Schlüsselverwaltungsproblem und gehört dem E2EE-Effort; hier nur festgehalten, damit es dort nicht übersehen wird.