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.
256 lines
14 KiB
Markdown
256 lines
14 KiB
Markdown
# 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.
|