Alle Beiträge
Blog5 Min. Lesezeit

Wie beisammen Ende-zu-Ende-verschlüsselte Videos abspielt

Ein lokaler HTTP-Proxy verbindet verschlüsselte Chunks mit AVPlayer und ExoPlayer.

ExpoReact NativeEnde-zu-Ende-VerschlüsselungVideo-Streaming

Das Problem beginnt beim Tippen auf Play

Ein Video kann vollständig hochgeladen und sicher im Bucket liegen. Trotzdem kann es in dem Moment scheitern, in dem jemand es ansehen will.

Jemand öffnet beisammen und tippt auf Play. Die App kennt das Video, den Kreisschlüssel und das verschlüsselte Dateiformat. Der Player auf dem Gerät kennt nichts davon. Er erwartet eine normale MP4-Datei oder eine HTTP-URL, von der er beliebige Byte-Bereiche lesen kann.

beisammen verschlüsselt Fotos und Videos auf dem Handy, bevor sie hochgeladen werden. Im Bucket liegt nur Ciphertext. Ich kann die Dateien nicht lesen. Wer die App selbst betreibt, kann es ebenfalls nicht. Die Schlüssel bleiben bei den Menschen im Kreis.

AVPlayer auf iOS und ExoPlayer auf Android sind dagegen für gewöhnliche Mediendateien gebaut. Sie schicken Range-Requests, suchen den moov-Atom und springen durch die Datei. Mit unseren XChaCha20-verschlüsselten Chunks können sie nichts anfangen.

01Ganze Datei

Vollständig laden

Der erste Frame kommt erst nach dem letzten Chunk - und auf dem Gerät liegt eine komplette Klartext-Kopie.

02Doppelte Logik

Zwei native Adapter

Direkter Zugriff auf die Medien-Pipelines, aber jede Änderung wird zweimal gebaut und gepflegt.

03Gewählt

Lokale HTTP-Brücke

Der erste Frame kommt nach den ersten Chunks - entschlüsselt wird nur im Speicher.

Die drei Architekturwege im Vergleich

Welche Wege offenstanden

Für die Verbindung zwischen verschlüsselter Datei und nativem Player gab es drei realistische Wege.

  1. Das ganze Video laden und als entschlüsselte Datei ablegen

    Vorteil: expo-video bekommt eine gewöhnliche file://-Quelle. AVPlayer und ExoPlayer können sie ohne Sonderbehandlung abspielen. Speichern und Teilen nutzen diesen Weg ohnehin.

    Nachteil: Die Wiedergabe beginnt erst nach dem vollständigen Download. Ein früher Seek ist nicht möglich. Zusätzlich entsteht eine komplette Klartext-Kopie, die Speicher belegt und bis zu ihrer Bereinigung auf dem Gerät bleibt.

  2. Für jede Plattform einen eigenen nativen Datenadapter bauen

    Vorteil: iOS und Android könnten die verschlüsselten Chunks direkt in ihre jeweilige Medien-Pipeline einbinden. Lokales HTTP wäre nicht nötig.

    Nachteil: Die Logik müsste zweimal gebaut und gepflegt werden. Ein Teil der Wiedergabe würde aus dem gemeinsamen Expo- und TypeScript-Pfad in zwei native Implementierungen wandern. Fehler würden sich je nach Plattform anders verhalten.

  3. Beiden Playern eine lokale HTTP-Quelle geben

    Vorteil: AVPlayer und ExoPlayer sprechen bereits HTTP und Range-Requests. Eine gemeinsame Implementierung kann die benötigten Chunks laden und im Speicher entschlüsseln. Die Wiedergabe kann beginnen, bevor das ganze Video angekommen ist.

    Nachteil: Der Proxy muss HTTP sehr genau beantworten. Android braucht eine enge Ausnahme für lokales Klartext-HTTP. Sessions, Tokens und abgebrochene Verbindungen müssen sauber verwaltet werden.

Wir haben uns für den dritten Weg entschieden. Er legt die zusätzliche Komplexität in einen kleinen Adapter, dessen Protokoll beide nativen Player bereits verstehen. Auf einem Cache-Miss muss keine vollständige Klartext-Datei entstehen. Nur die benötigten Chunks werden geladen und im Speicher entschlüsselt.

Die Entscheidung lautet also nicht „Klartext darf niemals auf dem Gerätespeicher liegen“. Wenn bereits eine entschlüsselte Cache-Datei existiert, nutzt der Player sie. Die Entscheidung lautet: Wir erzeugen nicht erst eine vollständige Klartext-Kopie, nur damit die Wiedergabe starten kann.

Wo Expo endet und native Wiedergabe beginnt

beisammen ist eine Expo-App. Die Oberfläche, die Schlüsselverwaltung und die Entscheidung, welche Quelle abgespielt wird, liegen in React Native.

Ein Hook prüft zuerst, ob bereits eine entschlüsselte Cache-Datei existiert. Wenn nicht, öffnet openEncryptedVideoStream eine verschlüsselte Streaming-Session und gibt eine URL auf 127.0.0.1 zurück. useVideoPlayerSource hält einen stabilen Player aus expo-video und setzt die URL erst dann mit replaceAsync ein, wenn sie bereit ist.

Ab diesem Punkt übernimmt die Plattform. Unter iOS arbeitet expo-video mit AVPlayer. Unter Android landet dieselbe URL bei ExoPlayer. Beide nativen Player behandeln sie wie eine gewöhnliche HTTP-Quelle und schicken ihre Requests zurück an den lokalen Server in der App.

  1. 01React Native

    Prüft den Cache und öffnet die verschlüsselte Streaming-Session.

  2. 02expo-video

    Übergibt die Loopback-URL mit replaceAsync an den Plattform-Player.

  3. 03AVPlayer · ExoPlayer

    Schickt Range-Requests an den Proxy im selben App-Prozess.

  4. 04Proxy auf 127.0.0.1

    Holt Ciphertext aus dem Bucket, entschlüsselt im Speicher und liefert exakte MP4-Bytes.

Vom Tap bis zum ersten Frame

Das ist die ungewöhnliche Schleife: Die Expo-Schicht gibt dem nativen Player eine URL. Der native Player verlässt die App dafür nicht. Er ruft einen Server auf, der im selben App-Prozess läuft.

Die lokale Brücke

Die App startet bei Bedarf einen kleinen TCP-Server über react-native-tcp-socket. Er bindet ausschließlich an 127.0.0.1. Das Betriebssystem wählt einen freien Port. Für den nativen Player sieht die Quelle ungefähr so aus:

http://127.0.0.1:49152/v/…

Der letzte Teil ist ein zufälliger Token mit 144 Bit Entropie und keine Asset-ID. Hinter der URL liegt keine fertige Datei. Der Token verweist auf eine Streaming-Session im Speicher.

Der Server versteht nur GET, HEAD und einen einzelnen Range-Bereich. Jede Antwort verwendet Connection: close. Das ist kein allgemeiner Webserver. Es ist ein schmaler Adapter zwischen dem HTTP-Modell des Players und dem verschlüsselten Dateiformat der App.

Wie aus einem Range-Request Chunks werden

Die Session lädt den Ciphertext direkt aus dem Bucket. Convex stellt dafür nur eine kurzlebige signierte URL aus. Sie wird vor Ablauf erneuert und nach einem 403 noch einmal angefordert.

Das BSE1-Format beginnt mit einem 36 Byte großen Header. Danach folgen unabhängig lesbare Chunks mit je 1 MiB. Aus dem Byte-Bereich des Players berechnet die Session, welche Chunks nötig sind. Sie lädt nur diese Ciphertext-Bereiche und entschlüsselt sie im Speicher. Anschließend schneidet sie exakt die MP4-Bytes heraus, die der Player angefordert hat.

iOS: AVPlayer bestimmt die exakten Antworten

AVPlayer liest vor dem ersten Bild nicht einfach von vorn nach hinten. Er prüft den Anfang und das Ende der Datei. Manche Bereiche fordert er direkt nacheinander erneut an. Die Session lädt Kopf und Ende deshalb vorab und hält acht Ciphertext-Chunks in einem kleinen LRU-Cache.

Auch die Länge jeder Antwort muss stimmen. Liefert der Server bei einem 206 Partial Content weniger Bytes als angekündigt, beendet CoreMedia die Wiedergabe mit -12939: content range mismatch. Der Proxy liefert deshalb genau den gültigen Bereich, den AVPlayer verlangt.

Gleichzeitig kann bytes=0- praktisch die gesamte Datei bedeuten. Der Proxy schreibt mit dem Tempo des Sockets weiter und wartet bei Gegendruck auf drain. Schließt AVPlayer die Verbindung, hört die Session sofort auf zu schreiben.

AVPlayers Stall-Heuristik sorgte für eine weitere Verzögerung. Auf einer langsamen Verbindung puffert sie so weit voraus, dass das erste Bild bis zu zehn Sekunden auf sich warten lässt. Für diese Quelle setzt die Expo-Schicht waitsToMinimizeStalling deshalb auf false.

Android: ExoPlayer darf den Loopback-Server nicht automatisch erreichen

ExoPlayer bekommt von expo-video dieselbe URL. Das Streaming-Modell ist gleich. Die Plattform blockiert den Aufruf aber an einer anderen Stelle.

Seit Android API 28 ist unverschlüsseltes HTTP standardmäßig gesperrt. Das gilt auch dann, wenn das Ziel 127.0.0.1 ist. Der Release-Build braucht deshalb eine Network Security Config, die Klartext nur für 127.0.0.1 und localhost erlaubt. Alle anderen Klartext-Verbindungen bleiben blockiert.

Debug-Builds sind absichtlich anders konfiguriert. Sie müssen Metro über das lokale Netzwerk erreichen können. Ein Expo-Config-Plugin erzeugt deshalb eine strenge Konfiguration für Release und eine offene Variante für Debug sowie Debug Optimized.

iOS und Android erhalten am Ende dieselbe Proxy-URL. Auf iOS steckt die meiste Arbeit in AVPlayers Leseverhalten. Auf Android kommt zusätzlich die Plattformfreigabe für lokales HTTP hinzu.

Cache und Download bleiben getrennt

Wenn bereits eine entschlüsselte Cache-Datei existiert, nutzt der Player sie direkt. Andernfalls bekommt er die Proxy-URL. Mehrere Stellen in der App teilen sich dieselbe Streaming-Session, damit nicht jeder Bildschirm dieselben Ciphertext-Chunks erneut lädt.

Der Download-Pfad bleibt davon getrennt. Speichern und Teilen erwarten Ciphertext und entschlüsseln erst nach dem vollständigen Download. Die Proxy-URL liefert bereits Klartext. Sie darf deshalb nie in diesen Pfad geraten.

Was der Proxy löst

Der native Player glaubt, eine gewöhnliche MP4-Datei zu streamen. Tatsächlich übersetzt ein lokaler Server seine Range-Requests in verschlüsselte Chunks. Die Wiedergabe kann beginnen, ohne vorher das ganze Video zu laden oder eine vollständige Klartext-Datei auf der Disk abzulegen. Der Rest des Verschlüsselungsmodells ist Stoff für eigene Beiträge.


Mehr zu beisammen: Projektseite · beisammen.app

Projekt anfragen