Files
rustification-server/docs/realtime/issues/09-voice-signaling-protokoll.md
MDietermann 55a2af4756 docs(realtime): record the design decisions behind the realtime layer
Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
2026-08-14 19:04:41 +02:00

14 KiB
Raw Blame History

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 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) §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.

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

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 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 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 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 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 (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. 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.