1 · Architektur-Überblick
Aegis besteht aus zwei bewusst getrennten Ebenen, die sich nur das App-Gehäuse teilen – nicht die Vertrauensgrenze.
Zwei Ebenen, zwei Vertrauensgrenzen
| Chat-Ebene | Control-Ebene | |
|---|---|---|
| Zweck | Messenger für Freunde und Familie | Überwachung und Steuerung der eigenen Infrastruktur |
| Erreichbarkeit | öffentlich über TLS (Port 443) | nur über WireGuard-VPN, gebunden an 10.10.2.1 |
| Vertrauen in den Server | keines nötig – Zero-Knowledge, nur Chiffretext | voll – der Controller sieht die eigenen Metriken im Klartext |
| Zugang | Einladung, ohne VPN nutzbar | WireGuard-Peer + Biometrie + Berechtigung |
Datenfluss
Öffentlicher Pfad – vom Handy bis in die Datenbank kommt nur Chiffretext an:
relay:8080, nicht öffentlich publiziertVPN-Pfad – unverändert abgeschottet:
10.10.2.1:8000 · FastAPI + mobile Web-UI10.10.2.1:8428 · gespeist von node_exporter und cAdvisorDie WebSocket-Verbindung der App läuft über denselben Edge:
wss://<host>/v1/ws. Caddy erkennt das Upgrade selbst und tunnelt es an den Relay
weiter; der Relay läuft mit --proxy-headers und sieht darum weiterhin
X-Forwarded-Proto: https.
Komponenten
| Komponente | Technik | Erreichbarkeit |
|---|---|---|
| Android-App | Kotlin, klassische Views/XML, Material3 DayNight, libsignal, SQLCipher, OkHttp, WireGuard-GoBackend, UnifiedPush | Client |
| Relay | Python 3.13, FastAPI, Starlette-WebSockets, uvicorn, asyncpg, Pydantic v2 | intern relay:8080 im Docker-Netz aegis-edge |
| Datenbank | PostgreSQL 16 | nur privates Compose-Netz |
| Edge | Caddy 2, automatisches Let's Encrypt | öffentlich 80/443 |
| Controller | Python 3.13, FastAPI, Casbin, mobile Web-UI | 10.10.2.1:8000 – VPN-only |
| Metrik-Speicher | VictoriaMetrics, Aufbewahrung 12 Monate | 10.10.2.1:8428 – VPN-only |
| Metrik-Erfassung | node_exporter (Host), cAdvisor (Container) | kein veröffentlichter Port, nur internes Netz |
| Agent | Python/FastAPI-Daemon je Zielserver, Docker-SDK statt Shell | nur vom Controller aus, Token-Auth |
Repo-Aufbau
| Verzeichnis | Inhalt |
|---|---|
messenger-server/ | Relay (FastAPI) + Compose + Tests inkl. Echo-Testkontakt |
messenger-edge/ | Caddy-Edge: Caddyfile, Compose, statische Seiten unter srv/ |
app-android/ | native App (Chat-Tab + Dashboard-Tab) |
controller/ | Controller-API, Modul-Registry, Rechte, Health-Engine |
agent/ | Least-Privilege-Daemon für die Zielserver |
app/ | Web-UI des Dashboards (im WebView der App) |
deploy/monitoring/ | VictoriaMetrics + node_exporter + cAdvisor |
docs/ | Architektur, Messenger-Architektur, Infrastruktur, Konventionen, Roadmap |
Protokoll in Kurzform
REST über HTTPS für Setup und Abholung, eine dauerhafte WebSocket-Verbindung für den Live-Kanal.
Bis auf Registrierung und Restore verlangt jeder Aufruf Identity-Key-Auth:
Der Client signiert eine Server-Nonce mit dem privaten Geräteschlüssel und schickt das Ergebnis im
Header X-Aegis-Auth. Es gibt keine Passwörter.
| Endpunkt | Zweck |
|---|---|
POST /v1/register | Konto anlegen – nur mit gültigem, einmaligem Invite-Token |
POST /v1/invites | Invite-Token erzeugen (Admin-Token oder bestehender Nutzer) |
POST /v1/auth/challenge | Nonce für die Challenge-Response holen |
POST /v1/keys · GET /v1/keys/{name} | eigene Prekeys nachfüllen · Bundles aller Geräte eines Kontakts holen |
POST /v1/messages · GET · /ack | Envelope einliefern · Mailbox abholen · bestätigen (löscht serverseitig) |
GET /v1/devices, /devices/provision* | Geräteliste und QR-Provisioning für weitere Geräte |
POST /v1/push/register | Push-Endpunkt für den inhaltslosen Wake-up melden |
PUT /v1/backup, GET /v1/backup/… | opaken, passphrase-verschlüsselten Identitäts-Blob ablegen/holen |
GET /health | Lebenszeichen inkl. Datenbank-Prüfung (unauthentifiziert) |
WSS /v1/ws | Live-Kanal: challenge · auth · deliver · ack · send · ping |
Was der Server tatsächlich speichert, ist ein Envelope ohne jeden lesbaren Inhalt:
{ "to": "bob", "toDeviceId": 1,
"type": "PREKEY | CIPHERTEXT | UNIDENTIFIED",
"ciphertext": "<base64 libsignal-CiphertextMessage, gepolstert>" }
envelopeId und serverTs vergibt der Server. Im Sealed-Sender-Modus gibt es
kein Absenderfeld – der Absender steckt authentifiziert im Chiffretext.
2 · Sicherheitsmodell
Kern-Annahme: Der Server ist nicht vertrauenswürdig. Betrachtet werden ein passiver Netz-Beobachter, ein aktiver MITM, ein vollständig übernommener Relay (Root, Live-Mitschnitt, Datenbank-Dump), Geräteverlust und der Versuch fremder Clients oder Server, unsere Nutzer zu erreichen.
Garantie: Wer Root auf dem Relay hat und den kompletten Datenbestand abzieht, erhält keinen Nachrichtenklartext und keine privaten Schlüssel – nur opake Blobs und grobe Routing-Metadaten.
Was der Server sieht – und was nicht
| Datum | Server sieht | Server sieht nicht |
|---|---|---|
| Nachrichtentext | opaken Ciphertext | Klartext; Länge nur grob, weil auf Bucket-Größen gepolstert wird |
| Öffentliche Schlüssel, Prekey-Bundles | ja – das ist ihr Zweck | die privaten Gegenstücke |
| Konten | welcher Benutzername existiert | Klarname, Telefonnummer, E-Mail (gibt es nicht) |
| Zustell-Metadaten | Empfänger-ID, Ziel-Gerät, Zeitstempel | den Absender im Sealed-Sender-Modus |
| Push | den Push-Endpunkt | Inhalt – der Push ist leer |
| Identitäts-Backup | opaken Blob + Salt/KDF-Parameter | Passphrase und Inhalt |
| Medien geplant | nur den AES-GCM-Ciphertext-Blob und seine Größe | Inhalt, Dateiname, Typ – alles steckt im E2EE-Umschlag |
| Anrufe geplant | über coturn nur SRTP-Chiffretext | Audio/Video, SRTP-Schlüssel, das komplette Signaling |
Schlüsselverwaltung
- Account-Identity-Key – die stabile Identität des Kontos. Aus ihr und der Identität des Gegenübers wird die Sicherheitsnummer berechnet.
- Geräteschlüssel – jedes Gerät hat ein eigenes Schlüsselpaar. Das zugehörige Gerätezertifikat ist eine Signatur des Account-Schlüssels über den Geräteschlüssel; der Relay prüft sie bei Registrierung und Geräte-Verknüpfung.
- Signed-Prekey, One-Time-Prekeys, Kyber-Prekeys – ermöglichen den Sessionaufbau, während der Empfänger offline ist. Ein Kyber-Prekey ist als last resort markiert und wird nie verbraucht, damit ein Gerät nie unerreichbar wird.
- Content-Key – pro Gerät ein Curve25519-Schlüsselpaar für die Inhalts-Schicht. Der öffentliche Teil liegt im Prekey-Bundle, der private ausschließlich im Android-Keystore mit erzwungener Nutzer-Authentifizierung (StrongBox/TEE, nicht exportierbar).
- Alle privaten Schlüssel und der Session-Zustand liegen ausschließlich in der SQLCipher-Datenbank des Geräts – nie im Klartext auf der Platte, nie auf dem Server.
Signaturen sind Curve25519/XEdDSA, wie libsignal sie erzeugt. Der Relay verifiziert sie, ohne je privates Material zu sehen – die Zero-Knowledge-Eigenschaft bleibt unberührt.
Zwei Schichten – warum die Benachrichtigung funktioniert
| Im Hintergrund lesbar (nicht biometrisch) | Erst nach Entsperrung (biometrisch) |
|---|---|
| Absender-Identität | Nachrichtentext |
| Zeitstempel, Nachrichtentyp, Zuordnung zur Unterhaltung | Medien-Inhalte – ihr AES-Schlüssel liegt in Schicht 2 |
| Zustell- und Lesestatus, Tipp-Anzeige, Anruf-Signale | Zitate, Vorschauen, Vorschaubilder |
| → genug für „Neue Nachricht von X“ | → bewusst keine Inhaltsvorschau im gesperrten Zustand |
Trade-off, ehrlich benannt: Der Content-Key lebt länger als die Ratchet-Schlüssel. Abgefedert wird das durch einen eigenen, ephemeren Schlüssel pro Nachricht und durch Rotation des Content-Keys. Die Forward Secrecy des Transports bleibt davon unberührt – Schicht 1 ratcheted weiter.
Warum kein Fremder hereinkommt
- Keine Föderation – es existiert keine Server-zu-Server-Schnittstelle. Ein fremder Server hat nichts, womit er sprechen könnte.
- Invite-gated – ohne gültigen, einmaligen Token wird kein Konto angelegt.
- Identity-Key-Auth – jeder authentifizierte Aufruf verlangt eine Signatur mit einem Geräteschlüssel, den der Server bereits kennt. Unbekannte Schlüssel werden abgewiesen.
- Rate-Limits und Lockout auf Registrierung, Prekey-Abruf, Versand und Backup-Download.
Ehrliche Grenzen
- Ein einzelner, authentifizierter Relay kann Metadaten nicht vollständig eliminieren. Solange ein Gerät seine Verbindung mit dem eigenen Konto authentifiziert, ist auf Verbindungsebene ein zeitliches Muster beobachtbar. Sealed Sender entkoppelt den Envelope-Inhalt, nicht die physische Verbindung.
- Ein in der App mitgeliefertes Client-Geheimnis ist grundsätzlich extrahierbar. Die echte Schließung sind Föderationsverzicht, Invite-Zwang und Schlüssel-Auth – nicht das App-Geheimnis.
- Ein kompromittiertes Endgerät mit laufender Schadsoftware ist außerhalb des Modells: Dort liegt der Klartext ohnehin offen.
- Langfristige Traffic-Analyse auf Timing-Ebene wird minimiert, aber nicht ausgeschlossen.
Backup-Hinweise
Für Nutzer
Das Identitäts-Backup ist client-seitig mit einer Passphrase verschlüsselt (Argon2id als Schlüsselableitung, AES-256-GCM); der Server hält nur den opaken Blob samt Salt und KDF-Parametern. Ohne Passphrase gibt es keine Wiederherstellung – niemand kann sie zurücksetzen. Bis der Restore-Fluss in der App verdrahtet ist, gilt: Geht das Gerät verloren, sind Identität und Verlauf weg, und Kontakte müssen die neue Sicherheitsnummer bestätigen.
Für Betreiber
- PostgreSQL – Konten, öffentliche Schlüssel, Prekeys, wartende Envelopes:
docker compose exec -T postgres pg_dump -U aegis aegis_relay | gzip > relay-$(date +%F).sql.gz - Volumes – die opaken Backup-Blobs (
backups) und der ACME-Zustand von Caddy (caddy_data: Konto und Zertifikate). Letzterer spart bei einem Umzug neue Ausstellungen. .env-Dateien gehören nie ins Repo, aber unbedingt in den Passwort-Manager – vor allemINVITE_SIGNING_SECRET, sonst werden ausgegebene Invites ungültig.- Release-Keystore der App offline sichern: Ohne ihn gibt es für installierte
Apps keinen Update-Pfad mehr, und der Fingerabdruck in
assetlinks.jsonstimmt nicht mehr. - Ein Server-Backup enthält keinen Nachrichtenklartext. Es rettet Konten und Zustellbarkeit, nicht den Gesprächsverlauf – der liegt ausschließlich auf den Geräten.
3 · Selbst hosten
Der komplette Chat-Stack besteht aus zwei Docker-Compose-Stacks, die sich ein Netz teilen. Der Controller-Teil ist optional und völlig unabhängig davon.
Voraussetzungen
- Server: ein kleiner Linux-Server mit Docker und Compose-Plugin. Debian 13 (trixie) ist die Referenz. Für einen Freundeskreis genügen 1–2 vCPU und 2 GB RAM; Speicherbedarf entsteht praktisch nur durch wartende Envelopes und Blobs.
- Domain: ein A-Record auf die öffentliche IP des Servers (DNS-only, kein Proxy davor – die ACME-Validierung muss den Server direkt erreichen).
- Ports: eingehend
443/tcpzwingend,80/tcpfür den HTTP-Redirect und die HTTP-01-Challenge. Bei Anbietern mit vorgelagerter Cloud-Firewall (z. B. IONOS) müssen die Ports zweimal geöffnet werden: im Anbieter-Panel und lokal inufw. - Android-Seite: zum eigenen Ausliefern der App JDK 17, Android SDK 34 und Gradle 8.7 sowie ein eigener Release-Keystore.
1 Relay aufsetzen
Beide Stacks hängen an einem gemeinsamen, externen Docker-Netz. Das wird einmalig angelegt, bevor irgendetwas startet:
docker network create aegis-edge
Danach das Repo klonen und den Relay-Teil nach dem Projekt-Muster
„ein Verzeichnis pro Dienst“ ablegen, also z. B. nach /opt/aegis/messenger.
Die Geheimnisse kommen in eine .env daneben – niemals ins Repo:
DB_PASSWORD=<openssl rand -base64 36>
ADMIN_TOKEN=<openssl rand -base64 36>
INVITE_SIGNING_SECRET=<openssl rand -base64 36>
Was die docker-compose.yml des Relays macht:
- Dienst
relaywird aus./relaygebaut, lauscht intern auf0.0.0.0:8080ohne TLS (RELAY_TLS=off) und hat keinenports:-Eintrag – er ist damit nie direkt aus dem Internet erreichbar, sondern nur überexpose: 8080im Netzaegis-edge. - Dienst
postgres(PostgreSQL 16) hängt nur im privatendefault-Netz, hat ebenfalls keinen veröffentlichten Port und einenpg_isready-Healthcheck, auf den der Relay wartet. - Volumes:
pgdatafür die Datenbank,backupsfür die opaken Identitäts-Backup-Blobs. - Alles mit
restart: unless-stopped, damit der Stack einen Reboot übersteht.
cd /opt/aegis/messenger
docker compose up -d --build
docker compose logs -f relay
2 Edge mit TLS aufsetzen
Der Edge liegt unter /opt/aegis/edge und besteht aus einem einzigen
Caddy-Container, der am selben aegis-edge-Netz hängt. Er veröffentlicht 80 und 443,
mountet das Caddyfile und das statische Verzeichnis srv/ read-only und
hält den ACME-Zustand im Volume caddy_data – das Volume ist wichtig, sonst wird bei
jedem Neustart ein neues Zertifikat beantragt.
Das Caddyfile macht vier Dinge:
- TLS automatisch – Let's Encrypt mit Ausstellung und Erneuerung ohne Zutun. HTTP-01 (Port 80) und TLS-ALPN-01 (Port 443) sind beide aktiv; blockiert der Anbieter Port 80, greift der ALPN-Weg auf 443 von allein.
- Reverse-Proxy für
/healthund/v1/*anrelay:8080. Das WebSocket-Upgrade auf/v1/wserkennt Caddy selbst. - Statisches Hosting für diese Seite, den Docs-Bereich, das
Download-Verzeichnis und
/.well-known/assetlinks.json. - Header-Härtung:
Referrer-Policy: no-referrer,X-Content-Type-Options: nosniff, keinServer-Header.
cd /opt/aegis/edge
docker compose up -d
docker compose logs -f caddy # ACME-Fortschritt beobachten
Der Landing-Bereich nutzt Caddys templates-Direktive: Der
Download-Button erscheint nur, wenn srv/download/aegis.apk wirklich existiert, und
die angezeigte Version stammt aus einem beim Deployment erzeugten HTML-Schnipsel – sie kann also
nicht veralten.
3 Firewall
Grundregel des Projekts: eingehend alles verbieten, ausgehend erlauben, und nur gezielt öffnen. Öffentlich ist ausschließlich der Chat-Pfad.
| Port | Protokoll | Wofür | Ebene |
|---|---|---|---|
| 22 | tcp | SSH (nur mit Schlüssel, Passwort-Login aus) | lokal |
| 80 | tcp | HTTP-Redirect und ACME HTTP-01 | Anbieter + lokal |
| 443 | tcp | REST, WebSocket, Landing-Page | Anbieter + lokal |
| 51820 | udp | WireGuard – nur wenn der Controller-Teil betrieben wird | Anbieter + lokal |
| 3478 / 5349 / 49160–49200 geplant | udp+tcp | coturn für Anrufe (Stufe 3) | Anbieter + lokal |
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
Alles andere – Datenbank, Metrik-Speicher, Git-Server, Controller – bindet an die VPN-Adresse und bekommt nie eine Freigabe nach außen.
4 Einladung erzeugen und registrieren
Die Registrierung ist geschlossen: Ohne einmaligen Invite-Token legt der Relay kein Konto an. Tokens erzeugt entweder der Betreiber mit dem Admin-Token oder ein bereits registrierter Nutzer aus der App heraus.
curl -s -X POST https://<host>/v1/invites \
-H "X-Admin-Token: $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"count":1}'
Daraus wird ein Einladungslink der Form
https://<host>/i/<token>#u=<einladender>&aik=<dessen Identitätsschlüssel>
Ist die App installiert, öffnet Android diesen Link dank verifizierter App-Links direkt in Aegis;
der Einladende wird gepinnt und die Freundschaft automatisch angelegt. Ist sie es nicht, landet
man auf dieser Website mit Download und Kopier-Button. Der Teil hinter dem
# wird nie an den Server gesendet – er bleibt im Browser, weshalb der Link
wortwörtlich kopiert werden muss.
5 App bauen und ausliefern
- Im App-Modul die eigene Domain eintragen (Relay-Basis-URL sowie Host und Basis-URL für die Invite-Links) und mit einem eigenen Release-Keystore signieren. Der Keystore gehört nie ins Repo; der Build löst ihn über Umgebungsvariablen oder eine Properties-Datei auf und baut andernfalls einfach unsigniert weiter.
- Den SHA-256-Fingerabdruck des eigenen Signaturzertifikats in
srv/.well-known/assetlinks.jsoneintragen – nur dann verifiziert Android die App-Links und öffnet/i/…direkt in der App. - APK ausrollen:
messenger-edge/deploy-apk.sh <pfad/zur/app-release.apk>. Das Skript kopiert die Datei atomar nachsrv/download/und schreibtversion.jsonundversion.html– Version, Größe, SHA-256 und Datum werden aus der APK selbst gelesen, nichts ist fest verdrahtet.
Prüfen lässt sich die Verknüpfung auf echter Hardware mit
adb shell pm get-app-links <package> – erwartet wird „verified“.
6 Controller und Monitoring (optional)
Der Control-Teil ist vom Messenger unabhängig und bleibt strikt im VPN.
- WireGuard einrichten, z. B. Subnetz
10.10.2.0/24mit dem Server auf10.10.2.1und je einem Peer pro Gerät. Ein Subnetz wählen, das nicht mit dem heimischen Netz kollidiert. - Monitoring-Stack aus
deploy/monitoring/starten: VictoriaMetrics veröffentlicht ausschließlich10.10.2.1:8428; node_exporter und cAdvisor bekommen keinen Port und werden über ihre Compose-Servicenamen gescrapt. Aufbewahrung 12 Monate, Daten im Volumevmdata. - Controller aus
controller/starten: veröffentlicht10.10.2.1:8000, hängt im Netz des Monitoring-Stacks und erreicht VictoriaMetrics über den Servicenamen. Die mobile Web-UI wird read-only ausapp/hineingemountet und same-origin ausgeliefert. - Agents auf den Zielservern ausrollen – ein Daemon mit fester Whitelist, Token-Auth und Docker-SDK statt Shell. Kein Root-SSH vom Controller.
Danach zeigt der Dashboard-Tab der App Ampel-Status und Verlaufscharts – erreichbar nur mit aktivem Tunnel und nach Biometrie.
Zertifikat und Pinning
Zwei Wege, je nachdem, ob eine Domain im Spiel ist:
- Mit Domain (empfohlen): Caddy holt automatisch ein Let's-Encrypt-Zertifikat. Wichtig für das Pinning in der App: Bei jeder Erneuerung – etwa alle 60 Tage – erzeugt Caddy standardmäßig einen neuen Leaf-Schlüssel, der Leaf-Pin ändert sich also regelmäßig. Deshalb nicht nur den Leaf pinnen, sondern die stabile obere Kette (Intermediate und Root) und immer mindestens einen Backup-Pin mitgeben. Es genügt, wenn irgendein Pin auf die Kette passt.
- Ohne Domain: ein selbst-signiertes Zertifikat mit der öffentlichen IP als SAN; die App pinnt dessen SPKI. Keine CA und keine Domain nötig – dafür ist eine Rotation nur mit koordiniertem App-Update möglich, weshalb der Backup-Pin hier das Sicherheitsnetz ist.
Aktuellen Leaf-Pin jederzeit selbst ziehen:
echo | openssl s_client -connect <host>:443 -servername <host> 2>/dev/null \
| openssl x509 -pubkey -noout \
| openssl pkey -pubin -outform DER \
| openssl dgst -sha256 -binary | openssl enc -base64
Die App-Links-Verifikation nutzt ohnehin den System-Trust-Store; das Pinning betrifft nur die Relay-Verbindung und kann parallel bestehen.
Betrieb und Prüfen
curl -s https://<host>/health– Lebenszeichen des Relays inklusive Datenbank-Prüfung.docker compose logs -f relaybzw.caddy– ACME-Ausstellung, Verbindungen, Rate-Limit-Treffer.- Zustellung wird laufend geräumt: bestätigte Envelopes verschwinden sofort, unbestätigte nach 30 Tagen. Der Speicherbedarf bleibt dadurch von selbst klein.
- Zum Testen ohne zweite Person gibt es einen dauerhaft laufenden Echo-Testkontakt: Er nimmt Nachrichten an, entschlüsselt sie durch beide Schichten und antwortet verschlüsselt zurück. Er ist ausdrücklich eine Testeinrichtung – ein unbeaufsichtigter Prozess auf dem Server liest, was man ihm schickt. Der Relay bleibt davon unberührt zero-knowledge.
4 · Stand und Grenzen
Fertig live
- Zero-Knowledge-Relay mit Store-and-Forward, WebSocket-Live-Kanal und Fan-out je Gerät
- Invite-gated Registrierung, Identity-Key-Auth, Rate-Limits
- 1:1-Textchat Ende-zu-Ende verschlüsselt, inklusive Zwei-Schicht-Verschlüsselung
- Echtzeit-Zustellung über einen Vordergrunddienst plus inhaltslosem Push als Rückfallweg
- Sicherheitsnummer im Chat, TOFU-Pinning beim Hinzufügen
- Öffentlicher Edge mit automatischem TLS, App-Links und APK-Auslieferung
- Controller: Health-Ampel, Verlaufscharts, Modul-Registry, Casbin-Rechte, Audit-Log
Offen geplant
- Medien und Dateien – client-seitig verschlüsselt vor dem Upload; der Server bekäme nur einen opaken Blob (Stufe 2)
- Sprach- und Videoanrufe – WebRTC mit eigenem coturn, Signaling als E2EE-Nachricht (Stufe 3)
- Multi-Device – die Relay-Endpunkte für QR-Provisioning stehen, der App-Flow fehlt
- Verschlüsseltes Identitäts-Backup – serverseitig vorhanden, in der App noch nicht verdrahtet
- Controller: Login und 2FA über Authentik, selbstheilender Watchdog, Updates mit Rollback, restic-Backups, Module für UniFi-Gateway und Plex
- Gruppenchats – noch nicht umgesetzt
- Ein Status-Feature ist bewusst nicht vorgesehen