From 4f8661d482f5980e4a07d4b877143c33cebe913f Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:22:34 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs(geraeteabmeldung):=20Entwurf=20=C2=A71?= =?UTF-8?q?2=20gegen=20einen=20Relay,=20der=20eine=20Abmeldung=20verschwei?= =?UTF-8?q?gt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Befund aus dem Security-Review vom 18.10.2026 plus drei Wege, auf denen das abgemeldete Geraet mit Hilfe des Relays wieder mitliest (Buendel und Erstnachricht unter fremder Kennung, Loeschen auf 'Zielgeraet unbekannt'). §5/§6 berichtigt: Stufe 2 hielt gegen Verschweigen nicht. Co-Authored-By: Claude Opus 5.5 --- secure-messenger/docs/GERAETEABMELDUNG.md | 221 +++++++++++++++++++++- 1 file changed, 215 insertions(+), 6 deletions(-) diff --git a/secure-messenger/docs/GERAETEABMELDUNG.md b/secure-messenger/docs/GERAETEABMELDUNG.md index c5a7312..c82f342 100644 --- a/secure-messenger/docs/GERAETEABMELDUNG.md +++ b/secure-messenger/docs/GERAETEABMELDUNG.md @@ -118,9 +118,11 @@ meldet der Relay an die anderen eigenen Geraete. Woerter ein und setzt zurueck. - **Stufe 2 — Kontakte pruefen selbst:** sie holen die Liste mit dem Buendel, pruefen beide Signaturen und die Epoche (kein Rueckschritt) und - verschluesseln nur an gelistete Geraete. Das haelt auch gegen einen - luegenden Relay (A3) und schliesst `MEHRGERAETE.md` §12.1 (Reflexion) - strukturell: auch die eigenen Spiegel gehen nur noch an gelistete Geraete. + verschluesseln nur an gelistete Geraete. Das haelt gegen einen Relay, der + eine Liste faelscht oder zurueckdreht, und schliesst `MEHRGERAETE.md` §12.1 + (Reflexion) strukturell: auch die eigenen Spiegel gehen nur noch an + gelistete Geraete. **Gegen einen Relay, der eine neue Fassung verschweigt, + hielt es in 2.0.0 nicht** — siehe §12. Wirkung: @@ -129,7 +131,9 @@ Wirkung: - A2 ◐: kann **nicht still** dazukommen. Sein einziger Zug ist das Zuruecksetzen, und das wirft den Besitzer sichtbar hinaus. Danach Ping-Pong, Heilung nur ueber eine neue Identitaet (§4). -- A3 ✔ mit Stufe 2. +- A3 ◐ mit Stufe 2: Faelschen und Zurueckdrehen ✔, Verschweigen erst mit + §12 (Listenstempel Ende-zu-Ende, Sitzungen an den Geraeteschluessel + gebunden). - Aufwand gross: Relay (Liste, zweite Signatur, Beitritt, Zuruecksetzen), App (Geraeteschluessel, Liste, Beitritts- und Abmeldeoberflaeche, Pruefung bei Kontakten), Tests auf beiden Seiten. @@ -157,9 +161,9 @@ fremder Hand → neue Identitaet", C spaeter als eigenes Vorhaben. | | A0 Schublade | A1 entsperrt | A2 Woerter | A3 Relay | Aufwand | |---|---|---|---|---|---| | A Sperrliste | ✔ | ✔ | ✘ still zurueck | ✘ | klein | -| B Geraeteliste | ✔ | ✔ | ◐ nur laut | ✔ (Stufe 2) | gross | +| B Geraeteliste | ✔ | ✔ | ◐ nur laut | ◐ Stufe 2, ✔ mit §12 | gross | | C Rotation | – | – | ✔ wenn ausserhalb abgeglichen | ✔ | sehr gross | -| D = B + Hinweis, C spaeter | ✔ | ✔ | ◐ + ehrlicher Weg | ✔ | gross | +| D = B + Hinweis, C spaeter | ✔ | ✔ | ◐ + ehrlicher Weg | ◐, ✔ mit §12 | gross | ## 7. Empfehlung @@ -487,3 +491,208 @@ hier ausliefert (siehe die Sperre fuer `fix/audit-2026-10-16` und alles darauf). Dazu im JSON-LD-Block oben (`"name": "What happens if I lose my phone?"`) derselbe englische Text. + +--- + +## 12. Relay verschweigt eine Abmeldung (Befund 18.10.2026) + +Entwurf, Stand 18.10.2026, Zweig `fix/geraeteliste-verschwiegen` ab `main` +(2.0.0). Zuerst zur Abstimmung; gebaut wird erst nach Henriks Ja. + +### 12.1 Befund + +Kontakte bekommen die signierte Liste nur ueber den Relay (`/prekey`, +`/geraeteliste`). `_nimmListe` weist nur eine AELTERE Fassung ab. Dass eine +neuere FEHLT, merkt die App nicht. Ein boeser Relay nimmt Epoche 3 (Tablet +abgemeldet) an, liefert allen weiter Epoche 2 und trennt das Tablet nicht. Die +Kontakte verschluesseln weiter ans Tablet, das Tablet schreibt weiter als +Besitzer, und der Besitzer sieht es als abgemeldet. §5 B ("A3 ✔ mit Stufe 2") +war zu stark, ebenso der Kommentar in `_frischeGeraeteAuf`. + +Beim Nachsehen kamen drei Wege dazu, auf denen das abgemeldete Geraet mit Hilfe +des Relays mitliest, **obwohl der Kontakt die neue Liste schon kennt**: + +- **(a) Buendel unter fremder Kennung.** Prekey-Buendel sind nur vom + Identitaetsschluessel signiert, und den hat das Tablet auch. Der Relay + liefert das Buendel des Tablets unter der Kennung des Laptops aus. Der + Kontakt baut eine Sitzung "zum Laptop", die nur das Tablet entschluesseln + kann (`prekey_bundle_bridge.dart` prueft nur die Adresse). +- **(b) Erstnachricht unter fremder Kennung.** `from_device` setzt der Relay + (`relay_client.dart`, Rahmen `message`). Eine Erstnachricht des Tablets mit + der Kennung des Laptops baut beim Kontakt eine neue Sitzung "Laptop" auf und + verdraengt die echte. Alle Antworten an den Laptop liest danach das Tablet. +- **(c) "Zielgeraet unbekannt" loescht die Sitzung** (`_sendePayload`). Damit + erzwingt der Relay einen Neuaufbau, und der laeuft ueber (a). + +### 12.2 Ziel + +1. Was der Relay verschweigt, erfahren Kontakte und eigene Geraete + Ende-zu-Ende vom Besitzer. +2. Solange sie es nicht haben, wissen sie, DASS ihnen etwas fehlt. Dann + schicken sie nichts an Geraete, die die neue Fassung nicht nachweislich + kennen, und nehmen von solchen nichts an. +3. Eine Sitzung gehoert nachweislich zu dem Geraeteschluessel, unter dessen + Kennung sie laeuft. `from_device` und die Buendel des Relays sind dann + geprueft und keine Behauptung mehr. + +### 12.3 Teil 1: Listenstempel und Verteilung Ende-zu-Ende (nur App) + +**Stempel.** Jede Nutzlast (`payload.dart`) traegt drei freiwillige Felder. +Aeltere Fassungen ueberlesen sie: + +| Feld | Inhalt | +|---|---| +| `le` | Epoche der eigenen Liste (nur, wenn dieses Geraet Mitglied ist) | +| `lh` | die ersten 12 Byte von SHA-256 ueber die signierten Zeichen (`liste`), base64 | +| `ke` | die Epoche der Liste des EMPFAENGERS, die der Absender kennt (0: keine) | + +Kosten: rund 40 Byte je Nutzlast. Statt bis etwa 180 Zeichen passt eine +Nachricht dann bis etwa 140 Zeichen in einen 256er-Block. + +**Neue Art `geraeteliste` (20).** In `x` steht die signierte Liste +(`{"liste","sig_identitaet","sig_geraet"}`). Sie traegt ihre Beweise selbst +(beide Signaturen, Kette), deshalb ist egal, wer sie ueberbringt. Sie wird +nicht gespiegelt und erscheint nie im Verlauf. 2.0.0 verwirft unbekannte Arten +still. + +**Regeln:** + +1. **Verteilen.** Wer eine Fassung veroeffentlicht (anlegen, hinzufuegen, + abmelden, zuruecksetzen), schickt sie ueber den Ausgang an alle aktiven + Kontakte und Gruppenmitglieder und an die eigenen Geraete der neuen Liste. + Einmal geht sie auch an jedes gerade abgemeldete Geraet, solange dessen + Sitzung noch steht. Es prueft sie und entfernt die Woerter, auch wenn der + Relay es nie trennt. +2. **Ankuendigung merken.** Nennt ein Stempel von Adresse X eine hoehere + Epoche, als wir kennen, merkt sich die App das in `meta`: Epoche, Hash und + welche Geraete von X sie genannt haben. Dann laedt sie die Liste beim Relay + nach. +3. **Veraltet.** Als veraltet gilt die bekannte Liste von X, solange sie hinter + der Ankuendigung liegt: kleinere Epoche, oder gleiche Epoche mit anderem + Hash. Dann gilt: + - Versand an X geht nur an Geraete, die die Ankuendigung selbst gestempelt + haben. Hat keines davon eine Sitzung, bleibt die Nachricht liegen. + - Post von X, deren Stempel hinter der Ankuendigung liegt oder fehlt, wird + nach dem Entschluesseln verworfen (quittiert, nicht angezeigt). Die Art 20 + ist ausgenommen. +4. **Heilen.** Schreibt ein Kontakt mit `ke` unter unserer Epoche, schickt der + Besitzer ihm die Liste nach, hoechstens einmal je 10 Minuten und Adresse. + Dasselbe gilt fuer ein eigenes Geraet mit kleinerem `le`. Eine verschluckte + Verteilung holt so die naechste Nachricht des Kontakts nach. +5. **Widerspruch.** Zwei verschiedene Fassungen derselben Epoche (aus Stempel + oder Liste) kann ein ehrlicher Relay nicht erzeugen, er nimmt nur genau +1 + an. Die App merkt sich das je Adresse und zeigt im Chat den Hinweis + "Geraeteliste widerspruechlich, Sicherheitsnummer vergleichen". Es bleibt + bei der zuerst gesehenen Fassung, versandt wird nach Regel 3. + +### 12.4 Teil 2: Der Besitzer prueft, was der Relay ausliefert (nur App) + +Nach jedem Veroeffentlichen und bei jedem Verbinden vergleicht die App die +eigene Liste, die der Relay liefert, mit der, die sie selbst kennt. Ist die +gelieferte aelter oder anders, zeigt "Geraete verwalten" eine Warnung: "Der +Server liefert deine letzte Aenderung der Geraeteliste nicht aus. Deine +Kontakte erfahren sie ueber deine Nachrichten." + +Grenze: Ein Relay, der gezielt luegt, zeigt dem Besitzer die Wahrheit. Dagegen +hilft Teil 1, nicht Teil 2. + +### 12.5 Teil 3: Sitzungen an den Geraeteschluessel binden (Relay und App) + +**(a) Buendel.** Die App signiert ihren signierten Prekey zusaetzlich mit dem +Geraeteschluessel: + +``` +spk_geraete_sig = Sig(GS, "BitDM-Prekey-v1\n" ‖ adresse ‖ "\n" ‖ device_id ‖ "\n" + ‖ signed_prekey_id ‖ "\n" ‖ base64(signed_prekey)) +``` + +Der Relay prueft sie bei `/register` gegen die Liste (falsch: 403). Er +speichert sie in einer neuen Spalte und gibt sie in `/prekey` je Geraet mit +aus. Der Absender prueft sie beim Sitzungsaufbau gegen den Schluessel aus der +Liste. Das genuegt: Ohne den privaten Teil des signierten Prekeys des Laptops +kann das Tablet die Sitzung nicht ausrechnen, auch nicht mit dem +Identitaetsschluessel (DH1 und DH3 in X3DH). + +**(b) Erstnachricht.** Solange libsignal fuer eine Sitzung noch +PreKey-Nachrichten schickt (im Sitzungsdatensatz steht `pending_pre_key`), +traegt die Nutzlast an genau dieses Geraet zusaetzlich: + +``` +gs = Sig(GS, "BitDM-Sitzung-v1\n" ‖ absender ‖ "\n" ‖ absendergeraet ‖ "\n" + ‖ empfaenger ‖ "\n" ‖ empfaengergeraet ‖ "\n" ‖ base64(basisschluessel)) +``` + +Der Empfaenger prueft `gs` gegen den Listenschluessel der Kennung, unter der +die Nachricht ankommt, und gegen den Basisschluessel der PreKey-Nachricht. +Passt sie nicht, verwirft er die neue Sitzung (Schnappschuss zurueck, wie bei +einer wiederholten Kyber-Erstnachricht) und zeigt nichts an. Damit ist +`from_device` geprueft: eine Nachricht unter fremder Kennung entschluesselt +entweder nicht, oder sie hat den falschen Nachweis. + +**(c) "Zielgeraet unbekannt"** loescht die Sitzung nicht mehr, wenn das Geraet +in der signierten Liste steht. Die App laedt die Liste nach. Fehlt das Geraet +dort, faellt die Sitzung ueber `_nimmListe` wie bisher. Steht es noch drin, +bleibt die Sitzung, und das Geraet wird bis zur naechsten Auffrischung +(hoechstens 6 Stunden) uebersprungen, damit die Nachricht nicht ewig liegt. +Ohne Liste (alter Relay) bleibt es beim Loeschen. (c) ist reine App und +schliesst schon vor (a) den einfachsten Weg in (a). + +**Uebergang.** Fassungen vor diesem Teil signieren nichts. Pflicht wird die +Pruefung erst, wenn keine solche Fassung mehr erwartet wird. Bis dahin gilt: +eine FALSCHE Signatur wird nie angenommen, eine FEHLENDE schon. Bei (b) hilft +das sofort, denn `gs` steht im verschluesselten Inhalt, und der Relay kann ihn +nicht entfernen. Ein abgemeldetes Geraet mit unveraenderter App nennt darin +seine eigene Kennung. Bei (a) hilft es erst mit der Pflicht, vorher kann der +Relay die Signatur einfach weglassen. + +### 12.6 Was bleibt + +- **Das Fenster bis zur ersten Nachricht.** Hat ein Kontakt seit der Abmeldung + nichts vom Besitzer bekommen, weder die Verteilung noch eine gestempelte + Nachricht, kann er nicht wissen, dass ihm etwas fehlt. Was er bis dahin an + X schickt, bekommt auch das Tablet. Ein Relay kann die Verteilung an + Zeitpunkt und Groesse erkennen und gezielt verwerfen. Die naechste + gewoehnliche Nachricht des Besitzers kann er von anderen nicht + unterscheiden. +- **Vollstaendiges Abschneiden.** Verwirft der Relay ALLES vom Besitzer an + einen Kontakt, bleibt der Kontakt auf dem alten Stand. Sichtbar wird das + beim Besitzer: seine Nachrichten an diesen Kontakt bekommen keinen zweiten + Haken. Ohne einen Weg am Relay vorbei (Nahbereich, Treffen) ist das nicht zu + schliessen. +- **Erster Kontakt.** Sieht ein Kontakt eine Adresse zum ersten Mal, kann der + Relay die Liste weglassen. Dann gilt der Altmodus, bis der erste Stempel + kommt. +- **Wer die zwoelf Woerter hat** (A2), kann Stempel und Listen erzeugen wie + der Besitzer (§4). + +### 12.7 Welche Fassung was bekommt (Vorschlag) + +| Teil | Wo | Fassung | +|---|---|---| +| 1 Stempel, Art 20, Verteilen, Heilen, Widerspruch | App | 2.0.1 | +| 2 Warnung beim Besitzer | App | 2.0.1 | +| 3c "Zielgeraet unbekannt" | App | 2.0.1 | +| 3a/3b signieren, speichern, ausliefern, falsche abweisen | Relay zuerst, dann App | 2.0.1 | +| 3a/3b fehlende Signatur abweisen (Pflicht) | App | 2.0.2, nach dem Pflicht-Fenster von 2.0.1 | + +Teil 3 braucht einen Relay mit neuer Spalte und neuem Feld. Beides ist +additiv: Alte Apps senden das Feld nicht, und alte Relays ignorieren es. Der +Relay geht wie immer zuerst raus. Neue Endpunkte gibt es nicht, an der +nginx-Liste aendert sich nichts. + +### 12.8 Belege, die dazu entstehen + +- `app/test/net/geraeteliste_verschwiegen_test.dart`, gegen eine Attrappe, die + Epoche 3 annimmt, aber weiter Epoche 2 ausliefert und das Tablet nicht + trennt. Faelle: Verteilung kommt an, Kontakt schickt nichts mehr ans Tablet + und nimmt nichts mehr von ihm; Verteilung verschluckt, die naechste + Nachricht des Besitzers macht den Kontakt veraltet; Heilen ueber `ke`; das + Tablet entfernt nach dem Abschied seine Woerter; Warnung beim Besitzer; + Widerspruch; Buendel des Tablets unter fremder Kennung abgewiesen; + Erstnachricht des Tablets unter fremder Kennung abgewiesen; "Zielgeraet + unbekannt" laesst die Sitzung stehen. +- `server/test_relay.py`: `spk_geraete_sig` wird geprueft, gespeichert und je + Geraet ausgeliefert; ohne Liste und ohne Feld bleibt alles wie bisher; nach + einer neuen Fassung liefern `/geraeteliste`, `/prekey` und + `/prekey?nur_geraete=1` dieselbe Epoche. +- Mutationsproben je Riegel wie in §9. From 06f40f88e212cda83a214ef41c344305a06fd001 Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:24:12 +0200 Subject: [PATCH 2/5] =?UTF-8?q?fix(geraeteabmeldung):=20ein=20Relay,=20der?= =?UTF-8?q?=20eine=20Abmeldung=20verschweigt=20(=C2=A712)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kontakte bekamen die signierte Geraeteliste nur ueber den Relay. Nahm er eine neue Fassung an und lieferte weiter die alte aus, verschluesselten sie weiter an das abgemeldete Geraet, und es schrieb weiter als Besitzer. Dazu drei Wege, auf denen es mit Hilfe des Relays mitlas, obwohl der Kontakt die neue Liste kannte (Buendel und Erstnachricht unter fremder Kennung, "Zielgeraet unbekannt" loescht die Sitzung). Umgesetzt nach docs/GERAETEABMELDUNG.md §12: App - Listenstempel le/lh/ke in jeder Nutzlast, die Liste als eigene Art 20. Verteilen nach jeder Aenderung (auch einmal an das abgemeldete Geraet), Ankuendigung merken, veraltete Liste: nur an Geraete, die sie gestempelt haben, und nur von ihnen annehmen. Heilen ueber ke, Widerspruch zweier Fassungen derselben Epoche mit Hinweis im Chat. - "Geraete verwalten" warnt, wenn der Relay die eigene letzte Aenderung nicht ausliefert. - Signierter Prekey zusaetzlich vom Geraeteschluessel signiert und beim Sitzungsaufbau gegen die Liste geprueft; Sitzungsnachweis in jeder PreKey-Nachricht (Basisschluessel aus dem Sitzungsdatensatz gelesen), beim Empfang geprueft, eine fremde neue Sitzung wird zurueckgenommen. Falsche Signaturen nie, fehlende bis zur Pflicht in 2.0.2. - "Zielgeraet unbekannt" laesst die Sitzung stehen, wenn das Geraet in der signierten Liste steht. Relay - spk_geraete_sig bei /register gegen die Liste geprueft (403), in einer neuen Spalte gespeichert, je Geraet in /prekey ausgeliefert. Nachladen nach einem Stempel hat eine eigene Bremse: mit der von Stufe 2 geteilt, verbrauchte der erste Stempel einer Kontaktanfrage die Minute, und ein Geraet, das darin zuruecksetzte, kam nicht mehr durch (sicherung_test.dart im Gesamtlauf). Abrufe, die nichts Neues bringen, entfallen (Fremde, Art 20, faellige Geraetepruefung, Pruefung nach dem Veroeffentlichen, wenn schon verglichen); zwei_geraete_test.dart lief sonst in die Herkunftsbremse des Test-Relays. Belege: geraeteliste_verschwiegen_test.dart (10 Faelle gegen eine verschweigende Attrappe), payload_listenstempel_test.dart, sitzungsdatensatz_test.dart, Oberflaechentest der Warnung, 8 Faelle in test_relay.py. Mutationsproben: App 20/20, Relay 6/6. Gesamtlauf: App 1542, Relay 176 gruen. Co-Authored-By: Claude Opus 5.5 --- secure-messenger/app/lib/app_state.dart | 11 + .../app/lib/core/crypto/signal_errors.dart | 20 + .../lib/core/crypto/sitzungsdatensatz.dart | 79 ++ .../app/lib/core/geraeteverwaltung.dart | 524 ++++++++++- secure-messenger/app/lib/core/models.dart | 5 + .../app/lib/core/net/geraeteliste.dart | 65 ++ .../app/lib/core/net/payload.dart | 135 ++- .../app/lib/core/net/relay_client.dart | 9 + .../app/lib/core/net/relay_protocol.dart | 7 + .../app/lib/core/real_messenger_core.dart | 185 +++- secure-messenger/app/lib/data.dart | 4 + secure-messenger/app/lib/geraete_seite.dart | 9 + .../lib/oberflaeche/home/unterhaltung.dart | 8 + .../test/crypto/sitzungsdatensatz_test.dart | 96 ++ .../net/geraeteliste_verschwiegen_test.dart | 840 ++++++++++++++++++ .../test/net/payload_listenstempel_test.dart | 139 +++ .../test/oberflaeche/geraete_seite_test.dart | 32 + .../app/test/support/relay_attrappe.dart | 32 +- secure-messenger/docs/GERAETEABMELDUNG.md | 135 ++- secure-messenger/server/relay_server.py | 78 +- secure-messenger/server/test_relay.py | 176 ++++ 21 files changed, 2527 insertions(+), 62 deletions(-) create mode 100644 secure-messenger/app/lib/core/crypto/sitzungsdatensatz.dart create mode 100644 secure-messenger/app/test/crypto/sitzungsdatensatz_test.dart create mode 100644 secure-messenger/app/test/net/geraeteliste_verschwiegen_test.dart create mode 100644 secure-messenger/app/test/net/payload_listenstempel_test.dart diff --git a/secure-messenger/app/lib/app_state.dart b/secure-messenger/app/lib/app_state.dart index 6010867..6a3959d 100644 --- a/secure-messenger/app/lib/app_state.dart +++ b/secure-messenger/app/lib/app_state.dart @@ -177,6 +177,17 @@ class AppState extends ChangeNotifier { List get eigeneGeraete => _echt?.eigeneGeraete ?? const []; + /// Die Epoche der eigenen Liste, die der Relay nicht ausliefert — oder null + /// (docs/GERAETEABMELDUNG.md §12.4). + int? get relayVerschweigtListe => _echt?.relayVerschweigtListe; + + /// Gibt es von der Gegenstelle dieses Chats zwei Fassungen derselben + /// Epoche (§12.3 Regel 5)? Der Kern meldet das als + /// [ContactEventType.deviceListConflict], das laedt neu. + bool geraetelisteWiderspruch(String chatId) => + !Gruppe.istGruppenId(chatId) && + (_echt?.geraetelisteWiderspruch(chatId) ?? false); + /// Der Code, den dieses Geraet gerade fuer ein neues zeigt — oder null. String? get beitrittscode => _echt?.beitrittscode; diff --git a/secure-messenger/app/lib/core/crypto/signal_errors.dart b/secure-messenger/app/lib/core/crypto/signal_errors.dart index 83483f1..e4ea7e9 100644 --- a/secure-messenger/app/lib/core/crypto/signal_errors.dart +++ b/secure-messenger/app/lib/core/crypto/signal_errors.dart @@ -63,6 +63,9 @@ SignalFailure classify(Object error) { // passt, hat der Relay geformt — daraus darf keine Kontaktanfrage werden // (sonst koennte er solche Anfragen fuer bekannte Kontakte erzeugen). if (error is FremdeIdentitaet) return SignalFailure.unreadable; + // Ebenso: eine Erstnachricht unter fremder Geraetekennung hat der Relay + // beschriftet (docs/GERAETEABMELDUNG.md §12.5 b). + if (error is FremdesGeraet) return SignalFailure.unreadable; if (error is! LibSignalException) return SignalFailure.unreadable; switch (error.code) { case LibSignalErrorCode.duplicatedMessage: @@ -105,6 +108,23 @@ class ErneutEingespielt implements Exception { String toString() => 'ErneutEingespielt: Erstnachricht wiederholt'; } +/// Eine Erstnachricht traegt keinen gueltigen Sitzungsnachweis fuer das +/// Geraet, unter dessen Kennung sie ankam (docs/GERAETEABMELDUNG.md §12.5 b). +/// +/// Die Kennung des Absendergeraets setzt der Relay. Ohne diese Pruefung +/// ersetzte eine Erstnachricht des abgemeldeten Tablets, beschriftet mit der +/// Kennung des Laptops, beim Kontakt die echte Sitzung zum Laptop. Der +/// Empfangspfad nimmt die neue Sitzung zurueck und wirft das; [classify] +/// behandelt es wie eine unlesbare Nachricht. +class FremdesGeraet implements Exception { + final String adresse; + final int geraet; + const FremdesGeraet(this.adresse, this.geraet); + @override + String toString() => + 'FremdesGeraet: Erstnachricht ohne gueltigen Nachweis fuer $adresse:$geraet'; +} + /// Ein Schluessel passt nicht zu der Adresse, fuer die er gelten soll. /// /// DER RIEGEL VOR LIBSIGNAL. Die offizielle Anbindung (libsignal 8.0.0) ruft diff --git a/secure-messenger/app/lib/core/crypto/sitzungsdatensatz.dart b/secure-messenger/app/lib/core/crypto/sitzungsdatensatz.dart new file mode 100644 index 0000000..9ad96f9 --- /dev/null +++ b/secure-messenger/app/lib/core/crypto/sitzungsdatensatz.dart @@ -0,0 +1,79 @@ +// sitzungsdatensatz.dart — ein Feld aus libsignals Sitzungsdatensatz lesen. +// +// WOZU: der Sitzungsnachweis (docs/GERAETEABMELDUNG.md §12.5 b) bindet die +// erste Nutzlast einer neuen Sitzung an deren Basisschluessel. Den kennt der +// Absender, bevor er verschluesselt — er steht im gespeicherten Datensatz —, +// aber die Anbindung (libsignal 8.0.0) reicht ihn nicht heraus. Also wird er +// hier gelesen. +// +// DAS FORMAT ist libsignals `storage.proto`, seit Jahren unveraendert: +// +// RecordStructure { SessionStructure current_session = 1; ... } +// SessionStructure { ... PendingPreKey pending_pre_key = 9; ... } +// PendingPreKey { uint32 pre_key_id = 1; bytes base_key = 2; ... } +// +// `pending_pre_key` steht genau so lange da, wie libsignal fuer diese Sitzung +// noch PreKey-Nachrichten schickt — bis die Gegenstelle zum ersten Mal +// geantwortet hat. Ob die Nummern stimmen, prueft +// test/crypto/sitzungsdatensatz_test.dart gegen `PreKeySignalMessage.baseKey()` +// einer echten Nachricht. Aendert libsignal das Format, wird dieser Test rot, +// nicht still die Sicherheit schwaecher. +// +// NIE EIN WURF: ein unlesbarer Datensatz heisst "kein offener Basisschluessel". + +import 'dart:typed_data'; + +/// Der Basisschluessel (33 Byte, mit Typbyte) der aktuellen Sitzung, solange +/// sie noch unbestaetigt ist — sonst null. +Uint8List? offenerBasisschluessel(List datensatz) { + final sitzung = _feld(datensatz, 1); + if (sitzung == null) return null; + final offen = _feld(sitzung, 9); + if (offen == null) return null; + final basis = _feld(offen, 2); + if (basis == null || basis.isEmpty) return null; + return Uint8List.fromList(basis); +} + +/// Das erste laengenbegrenzte Feld [nummer] einer Protobuf-Nachricht. +List? _feld(List b, int nummer) { + var i = 0; + while (i < b.length) { + final (schluessel, n1) = _varint(b, i); + if (n1 < 0) return null; + i = n1; + final art = schluessel & 7; + final feld = schluessel >> 3; + switch (art) { + case 0: + final (_, n2) = _varint(b, i); + if (n2 < 0) return null; + i = n2; + case 1: + i += 8; + case 5: + i += 4; + case 2: + final (laenge, n2) = _varint(b, i); + if (n2 < 0 || laenge < 0 || n2 + laenge > b.length) return null; + if (feld == nummer) return b.sublist(n2, n2 + laenge); + i = n2 + laenge; + default: + return null; + } + } + return null; +} + +/// (Wert, naechste Stelle) — oder (0, -1), wenn die Zahl abbricht oder fuer +/// diesen Zweck unsinnig gross ist. +(int, int) _varint(List b, int i) { + var wert = 0; + for (var stelle = 0; stelle < 35; stelle += 7) { + if (i >= b.length) return (0, -1); + final c = b[i++]; + wert |= (c & 0x7f) << stelle; + if (c & 0x80 == 0) return (wert, i); + } + return (0, -1); +} diff --git a/secure-messenger/app/lib/core/geraeteverwaltung.dart b/secure-messenger/app/lib/core/geraeteverwaltung.dart index 8a1a6df..fd63a27 100644 --- a/secure-messenger/app/lib/core/geraeteverwaltung.dart +++ b/secure-messenger/app/lib/core/geraeteverwaltung.dart @@ -34,6 +34,10 @@ const Duration _beitrittsTakt = Duration(seconds: 2); /// Post von einem unbekannten Geraet kam. const Duration _listenNachfrage = Duration(minutes: 1); +/// Wie oft hoechstens die eigene Liste an dieselbe Adresse nachgeschickt wird, +/// weil sie mit einer veralteten Epoche schrieb (§12.3 Regel 4). +const Duration _nachschickAbstand = Duration(minutes: 10); + extension Geraeteverwaltung on RealMessengerCore { // ═══════════════════════════════════════════════════════ fuer die Oberflaeche @@ -87,6 +91,17 @@ extension Geraeteverwaltung on RealMessengerCore { /// Prozess (die Woerter, und mit ihnen die Datenbank, sind danach weg). ({DateTime zeit, String? von})? get abmeldung => _abmeldung; + /// Die Epoche der eigenen Liste, die der Relay NICHT ausliefert — oder + /// null, wenn er liefert, was dieses Geraet kennt (docs/GERAETEABMELDUNG.md + /// §12.4). Dann erfahren Kontakte die Aenderung nur ueber unsere + /// Nachrichten; "Geraete verwalten" sagt das. + int? get relayVerschweigtListe => _relayVerschweigt; + + /// Ob es von [adresse] zwei verschiedene Fassungen derselben Epoche gibt + /// (§12.3 Regel 5). Ein ehrlicher Relay kann das nicht erzeugen. + bool geraetelisteWiderspruch(String adresse) => + _db?.meta('geraeteliste_widerspruch:$adresse') != null; + // ═══════════════════════════════════════════════════════ Aktionen /// Nimmt ein anderes eigenes Geraet aus der Liste. Der Relay trennt es @@ -110,10 +125,15 @@ extension Geraeteverwaltung on RealMessengerCore { /// Dieses Geraet geht — etwa vor dem Verkauf. Danach ist es abgemeldet wie /// jedes andere: die Woerter sind weg, die Datenbank bleibt verschluesselt. Future meldeDiesesGeraetAb() async { - final neu = await _veroeffentliche((alt) => [ - for (final e in alt.geraete) - if (e.id != geraetId) e, - ]); + // DIE VERTEILUNG ABWARTEN (§12.3 Regel 1): danach ist die Datenbank zu, + // und was noch im Ausgang laege, ginge nie mehr hinaus. Hoechstens zehn + // Sekunden — die anderen eigenen Geraete heilen den Rest. + final neu = await _veroeffentliche( + (alt) => [ + for (final e in alt.geraete) + if (e.id != geraetId) e, + ], + verteilungAbwarten: true); await _wirdAbgemeldet(neu); } @@ -214,6 +234,11 @@ extension Geraeteverwaltung on RealMessengerCore { _uebernimmEigeneListe(neu); _wirdMitglied(neu); _abgewiesen = false; + // Laut, und zwar Ende-zu-Ende (§12.3): jedes andere Geraet und jeder + // Kontakt erfaehrt es, auch wenn der Relay es verschwiege. Ueber den + // Ausgang — die Nachrichten gehen nach dem Verbinden hinaus. + unawaited(_verbreiteListe(neu, alt: alt)); + unawaited(_pruefeWasDerRelayLiefert(relay)); unawaited(connect()); return; } @@ -263,6 +288,7 @@ extension Geraeteverwaltung on RealMessengerCore { // Verbindung scheitert und wird spaeter neu versucht. throw const RelayException('Geraeteliste nicht pruefbar'); } + _vergleicheMitRelay(roh); final gs = _eigenerSchluesselOderNull(); if (gs != null && liste.enthaelt(geraetId, gs)) { _wirdMitglied(liste); @@ -336,8 +362,10 @@ extension Geraeteverwaltung on RealMessengerCore { final relay = _relay ?? (_store == null ? null : _httpRelay()); if (relay == null || _store == null) return; try { - final liste = _nimmListe(myId, await relay.holeGeraeteliste(myId)); + final roh = await relay.holeGeraeteliste(myId); + final liste = _nimmListe(myId, roh); if (liste == null) return; + _vergleicheMitRelay(roh); final gs = _eigenerSchluesselOderNull(); if (gs != null && liste.enthaelt(geraetId, gs)) { _meldeGeraete(); @@ -410,7 +438,7 @@ extension Geraeteverwaltung on RealMessengerCore { if (i != null) i.fillRange(0, i.length, 0); _infoSchluessel = null; _geraeteSchluessel = null; - _listen.clear(); + _vergissListenZustand(); _geraetId = null; await secretStore.delete(); @@ -484,8 +512,12 @@ extension Geraeteverwaltung on RealMessengerCore { /// Baut aus der aktuellen Fassung die naechste, unterschreibt sie und /// reicht sie ein. Bei 409 (jemand war schneller) einmal neu. + /// + /// Danach geht sie Ende-zu-Ende an Kontakte und Geraete (§12.3 Regel 1), + /// und es wird nachgesehen, ob der Relay sie auch ausliefert (§12.4). Future _veroeffentliche( - List Function(Geraeteliste alt) aendere) async { + List Function(Geraeteliste alt) aendere, + {bool verteilungAbwarten = false}) async { final store = _store; if (store == null) throw const NotInitializedException(); final relay = _relay ?? _httpRelay(); @@ -508,6 +540,13 @@ extension Geraeteverwaltung on RealMessengerCore { _uebernimmEigeneListe(neu); if (neu.kennungen.contains(geraetId)) _wirdMitglied(neu); _meldeGeraete(); + final verteilung = _verbreiteListe(neu, alt: alt); + if (verteilungAbwarten) { + await verteilung.timeout(const Duration(seconds: 10), onTimeout: () {}); + } else { + unawaited(verteilung); + unawaited(_pruefeSpaeterWasDerRelayLiefert(relay, neu.epoche)); + } return neu; } } @@ -534,10 +573,27 @@ extension Geraeteverwaltung on RealMessengerCore { } if (vorige != null && neu.epoche <= vorige.epoche) { // Gleiche Epoche, andere Zeichen: zwei Fassungen derselben Nummer. - // Die erste, die wir gesehen haben, gilt. + // Die erste, die wir gesehen haben, gilt — und der Nutzer erfaehrt, + // dass es zwei gibt (§12.3 Regel 5). + if (neu.epoche == vorige.epoche && neu.roh != vorige.roh) { + _merkeWiderspruch(adresse, neu.epoche); + } return vorige; } _speichereListe(adresse, neu); + // WAS ANGEKUENDIGT WAR, IST DA (§12.3) — oder eine andere Fassung + // derselben Epoche. Dann bleibt die Liste veraltet, und es gibt zwei. + final ank = _ankuendigungVon(adresse); + if (ank != null) { + if (neu.epoche > ank.epoche || + (neu.epoche == ank.epoche && neu.kurzHash == ank.hash)) { + _setzeAnkuendigung(adresse, null); + } else if (neu.epoche == ank.epoche) { + _merkeWiderspruch(adresse, neu.epoche); + } + } + final w = int.tryParse(_db?.meta('geraeteliste_widerspruch:$adresse') ?? ''); + if (w != null && neu.epoche > w) _loescheMeta('geraeteliste_widerspruch:$adresse'); final store = _store; if (store != null && vorige != null) { var weg = false; @@ -567,6 +623,14 @@ extension Geraeteverwaltung on RealMessengerCore { _meldeGeraete(); } + void _loescheMeta(String key) => + _db?.transaction((raw) => raw.execute('DELETE FROM meta WHERE key = ?', [key])); + + void _setzeMeta(String key, String wert) => _db?.transaction((raw) => raw.execute( + 'INSERT INTO meta (key, value) VALUES (?,?) ' + 'ON CONFLICT(key) DO UPDATE SET value = excluded.value', + [key, wert])); + void _speichereListe(String adresse, Geraeteliste l) { _listen[adresse] = l; _db?.transaction((raw) => raw.execute( @@ -649,6 +713,450 @@ extension Geraeteverwaltung on RealMessengerCore { return l == null; } + // ═══════════════════════════════════════════ Verschweigen (GERAETEABMELDUNG §12) + // + // Stufe 2 oben glaubt der Liste, die der Relay ausliefert, und weist nur + // eine AELTERE ab. Verschweigt der Relay eine neue Fassung, merkt sie + // nichts: Kontakte verschluesselten weiter an ein abgemeldetes Geraet, und + // es schrieb weiter als Besitzer. Deshalb reist die Liste auch + // Ende-zu-Ende — als eigene Nachricht nach jeder Aenderung (Art 20) und als + // Stempel in jeder Nutzlast. Wer einen Stempel sieht, der mehr kennt als er, + // weiss, DASS ihm etwas fehlt, und schickt bis dahin nur an Geraete, die + // es nachweislich haben. + + void _vergissListenZustand() { + _listen.clear(); + _ankuendigungen.clear(); + _listenAbrufe.clear(); + _stempelGefragt.clear(); + _listeNachgeschickt.clear(); + _relayKenntNicht.clear(); + _relayVerschweigt = null; + _verglichenBis = null; + } + + /// Die Stempel fuer eine Nutzlast an [an] (§12.3): die eigene Epoche samt + /// Kurzhash, wenn dieses Geraet in der eigenen Liste steht, und welche + /// Epoche der Liste von [an] wir kennen. Ohne Listen (alter Relay) keine + /// Stempel — die Nutzlast ist dann Zeichen fuer Zeichen die von 2.0.0. + ({int? le, String? lh, int? ke}) _stempelFuer(String an) { + int? le; + String? lh; + final eigene = _eigeneListeOderNull(); + final gs = _eigenerSchluesselOderNull(); + if (_geraeteStand == ListenStand.mitglied && + eigene != null && + gs != null && + eigene.enthaelt(geraetId, gs)) { + le = eigene.epoche; + lh = eigene.kurzHash; + } + final ihre = an == myId ? null : _listeVon(an); + final ke = an == myId || (le == null && ihre == null) ? null : (ihre?.epoche ?? 0); + return (le: le, lh: lh, ke: ke); + } + + Ankuendigung? _ankuendigungVon(String adresse) { + if (_ankuendigungen.containsKey(adresse)) return _ankuendigungen[adresse]; + final db = _db; + if (db == null) return null; + Ankuendigung? a; + final roh = db.meta('geraeteliste_ank:$adresse'); + if (roh != null) { + try { + a = Ankuendigung.lies(jsonDecode(roh)); + } on FormatException { + a = null; + } + } + _ankuendigungen[adresse] = a; + return a; + } + + void _setzeAnkuendigung(String adresse, Ankuendigung? a) { + _ankuendigungen[adresse] = a; + if (a == null) { + _loescheMeta('geraeteliste_ank:$adresse'); + } else { + _setzeMeta('geraeteliste_ank:$adresse', jsonEncode(a.alsJson())); + } + } + + /// Liegt die bekannte Liste von [adresse] hinter dem, was Stempel + /// angekuendigt haben (§12.3 Regel 3)? Kleinere Epoche, keine Liste, oder + /// dieselbe Epoche mit anderen Zeichen. + bool _listeVeraltet(String adresse) { + final ank = _ankuendigungVon(adresse); + if (ank == null) return false; + final l = _listeVon(adresse); + if (l == null || ank.epoche > l.epoche) return true; + return ank.epoche == l.epoche && ank.hash != l.kurzHash; + } + + /// Nur an diese Geraete von [adresse] — oder null, wenn die Liste nicht + /// veraltet ist und sie allein entscheidet. + Set? _nurAn(String adresse) => + _listeVeraltet(adresse) ? _ankuendigungVon(adresse)!.geraete : null; + + void _merkeAnkuendigung(String von, int epoche, String hash, int geraet) { + final ank = _ankuendigungVon(von); + if (ank == null || epoche > ank.epoche) { + _setzeAnkuendigung(von, Ankuendigung(epoche, hash, {geraet})); + } else if (epoche == ank.epoche) { + if (hash != ank.hash) { + _merkeWiderspruch(von, epoche); + } else if (!ank.geraete.contains(geraet)) { + _setzeAnkuendigung(von, Ankuendigung(epoche, hash, {...ank.geraete, geraet})); + } + } + } + + /// Zwei Fassungen derselben Epoche (§12.3 Regel 5). Gemerkt bis zu einer + /// hoeheren Fassung; der Chat zeigt einen Hinweis. + void _merkeWiderspruch(String adresse, int epoche) { + if (_db?.meta('geraeteliste_widerspruch:$adresse') != null) return; + _setzeMeta('geraeteliste_widerspruch:$adresse', '$epoche'); + _contacts.add(ContactEvent( + type: ContactEventType.deviceListConflict, + contactId: adresse, + at: DateTime.now().toUtc(), + )); + if (adresse == myId) _meldeGeraete(); + } + + /// Nimmt die Stempel einer entschluesselten Nutzlast (§12.3). Rueckgabe: + /// ob sie weiter verarbeitet wird. SYNCHRON — siehe [_darfEmpfangenSofort], + /// warum der Empfang hier nicht warten darf; Nachladen und Nachschicken + /// laufen nebenher. + bool _nimmStempel(String von, int vonGeraet, Payload p) { + if (_db == null) return true; + final le = p.listenEpoche; + final lh = p.listenHash; + if (le != null && lh != null) { + final bekannt = _listeVon(von); + if (bekannt != null && le == bekannt.epoche) { + // Dieselbe Epoche mit anderen Zeichen: zwei Fassungen (Regel 5). Der + // Stempel gilt trotzdem als Ankuendigung — versandt wird nach Regel 3. + if (lh != bekannt.kurzHash) { + _merkeWiderspruch(von, le); + _merkeAnkuendigung(von, le, lh, vonGeraet); + } + } else if (bekannt == null || le > bekannt.epoche) { + _merkeAnkuendigung(von, le, lh, vonGeraet); + // EIN FREMDER (erste Kontaktanfrage) kostet keine Anfrage — wie in + // [_darfEmpfangenSofort]. Die Ankuendigung steht trotzdem, und + // spaetestens die erste Antwort laedt nach (`_sendePayload`). Die + // Art 20 bringt die Liste selbst mit. + if (p.kind != PayloadKind.geraeteliste && + (von == myId || _chats?.kontakt(von) != null)) { + unawaited(_holeListeNach(von)); + } + } + } + + // HEILEN (Regel 4): wer mit einer aelteren Fassung unserer Liste + // schreibt, hat die Verteilung verpasst — vielleicht, weil der Relay sie + // verschluckt hat. Er bekommt sie nachgeschickt. + final eigene = _eigeneListeOderNull(); + if (eigene != null && _geraeteStand == ListenStand.mitglied) { + if (von == myId) { + if (le != null && le < eigene.epoche) unawaited(_schickeListeNach(myId)); + } else { + final ke = p.kenntEpoche; + if (ke != null && ke < eigene.epoche) unawaited(_schickeListeNach(von)); + // Er kennt eine neuere als wir: dann fehlt UNS etwas. + if (ke != null && ke > eigene.epoche) unawaited(_holeListeNach(myId)); + } + } + + // DIE LISTE SELBST traegt ihre Beweise — von wem auch immer. + if (p.kind == PayloadKind.geraeteliste) return true; + if (!_listeVeraltet(von)) return true; + // VERALTET: nur wer die angekuendigte Fassung selbst nennt, spricht noch + // fuer diese Adresse. Ein abgemeldetes Geraet, das der Relay weiter + // beliefert, kennt sie nicht. + final ank = _ankuendigungVon(von)!; + return le != null && + (le > ank.epoche || (le == ank.epoche && lh == ank.hash)); + } + + /// Laedt die Liste von [adresse] beim Relay nach — hoechstens eine Anfrage + /// zur Zeit und eine je [_listenNachfrage]. + /// + /// EIGENE BREMSE, nicht `_listeGefragt`: die gehoert [_darfEmpfangen], und + /// die Post eines eben beigetretenen Geraets braucht ihren Abruf. Teilten + /// sich beide eine, verbrauchte der erste Stempel einer Kontaktanfrage die + /// Minute — und ein Geraet, das in dieser Minute zuruecksetzte, kam nicht + /// mehr durch (test/net/sicherung_test.dart, Gesamtlauf vom 18.10.2026). + /// + /// Umgekehrt zaehlt ein Abruf von dort auch hier: er hat die Liste eben + /// geholt, und jeder Abruf kostet eine Marke der Herkunftsbremse des Relays + /// (dieselbe wie `/prekey`). + Future _holeListeNach(String adresse) { + final laeuft = _listenAbrufe[adresse]; + if (laeuft != null) return laeuft; + final jetzt = DateTime.now(); + final naechste = _stempelGefragt[adresse]; + final stufe2 = _listeGefragt[adresse]; + if ((naechste != null && jetzt.isBefore(naechste)) || + (stufe2 != null && jetzt.isBefore(stufe2))) { + return Future.value(); + } + _stempelGefragt[adresse] = jetzt.add(_listenNachfrage); + // `Future(...)` und nicht sofort: der Eintrag muss stehen, bevor der + // Abruf ihn im `finally` wieder entfernt. + final abruf = Future(() async { + try { + if (adresse == myId) { + await _frischeEigeneListeAuf(); + return; + } + final relay = _relay; + if (relay == null) return; + _nimmListe(adresse, await relay.holeGeraeteliste(adresse)); + } catch (_) { + // Dann eben mit dem naechsten Stempel oder der Verteilung. + } finally { + _listenAbrufe.remove(adresse); + } + }); + _listenAbrufe[adresse] = abruf; + return abruf; + } + + /// Schickt die eigene Liste an [an] nach (Regel 4). Best effort. + Future _schickeListeNach(String an) async { + final eigene = _eigeneListeOderNull(); + if (eigene == null || _geraeteStand != ListenStand.mitglied) return; + final jetzt = DateTime.now(); + final zuletzt = _listeNachgeschickt[an]; + if (zuletzt != null && jetzt.difference(zuletzt) < _nachschickAbstand) return; + _listeNachgeschickt[an] = jetzt; + try { + await _sendePayload( + an, Payload.geraeteliste(_neueId(), eigene.alsJson(), jetzt.toUtc()), + spiegeln: false); + } catch (_) { + // Die naechste Nachricht von dort fragt wieder. + } + } + + /// Verteilt eine eben veroeffentlichte Fassung Ende-zu-Ende (Regel 1). + /// + /// 1. ABSCHIED an jedes Geraet, das in [alt] stand und jetzt fehlt — + /// solange die Sitzung noch steht. Es prueft die Liste und entfernt die + /// Woerter, auch wenn der Relay es nie trennt. + /// 2. An die eigenen Geraete der neuen Liste. + /// 3. An alle Kontakte (ausser offenen fremden Anfragen) und Mitglieder + /// aktiver Gruppen — ueber den Ausgang, damit nichts verloren geht. + Future _verbreiteListe(Geraeteliste neu, {Geraeteliste? alt}) async { + final p = Payload.geraeteliste(_neueId(), neu.alsJson(), DateTime.now().toUtc()); + if (alt != null) { + for (final e in alt.geraete) { + if (e.id == geraetId || neu.enthaelt(e.id, e.schluessel)) continue; + try { + await _schickeAnGeraet(myId, e.id, p); + } catch (_) { + // Der Relay kennt es schon nicht mehr — dann hat er es getrennt. + } + } + } + // An die eigenen Geraete, die schon in der vorigen Fassung standen. Ein + // eben beigetretenes hat die Liste gerade selbst geholt — und haette noch + // keine Sitzung, also kostete es nur einen Buendelabruf. + if (neu.kennungen + .any((g) => g != geraetId && (alt == null || alt.kennungen.contains(g)))) { + try { + await _sendePayload(myId, p, spiegeln: false); + } catch (_) { + // Sie heilen sich mit der naechsten eigenen Nachricht (Regel 4). + } + } + for (final an in _listenEmpfaenger()) { + if (_store == null) return; + try { + await _sendeSteuerung(an, p); + } catch (_) { + // Bleibt im Ausgang. + } + } + } + + /// Wer Nachrichten an uns verschluesselt und deshalb die Liste braucht. + Set _listenEmpfaenger() { + final chats = _chats; + if (chats == null) return const {}; + final an = { + for (final k in chats.alleKontakte()) + if (k.state != ContactState.incomingPending) k.id, + for (final g in chats.alleGruppen()) + if (g.aktiv) ...g.mitglieder, + }; + an.remove(myId); + return an; + } + + /// Eine Nutzlast an GENAU EIN Geraet, an jedem Filter vorbei — fuer den + /// Abschied an ein Geraet, das in keiner Liste mehr steht. + Future _schickeAnGeraet(String an, int g, Payload p) async { + final store = _store; + final relay = _relay; + if (store == null || relay == null || !relay.isConnected) return; + final ziel = ProtocolAddress(name: an, deviceId: g); + if (!await store.containsSession(ziel)) return; + final ct = await _signalExklusiv(() async { + final ct = await _chiffre(store).encrypt(ziel, p.toBytes()); + _signalRepo!.commit(store); + return ct; + }); + await _RelayAusgang(relay, g).schicke(an, Envelope.of(ct).toBytes()); + } + + /// Eine Liste, die Ende-zu-Ende kam (Art 20). Bei der eigenen Adresse auch + /// die Folgen: weiter Mitglied, oder abgemeldet — dann gibt sie die Fassung + /// zurueck, und der Empfang meldet ab, sobald er fertig ist. + Geraeteliste? _nimmListeEndeZuEnde(String von, Payload p) { + final roh = p.geraetelisteRoh; + if (roh == null) return null; + final vorige = _listeVon(von); + final l = _nimmListe(von, roh); + if (von != myId || l == null || identical(l, vorige)) return null; + final gs = _eigenerSchluesselOderNull(); + if (gs != null && l.enthaelt(geraetId, gs)) { + if (_geraeteStand == ListenStand.mitglied) _wirdMitglied(l); + unawaited(_frischeEigeneGeraeteAuf()); + // Ob der Relay sie auch ausliefert, prueft das Geraet, das sie + // veroeffentlicht hat (§12.4) — hier kostete es nur eine Marke der + // Herkunftsbremse mehr. Dieses vergleicht beim naechsten Verbinden. + return null; + } + final warMitglied = int.tryParse(_db?.meta('geraeteliste_mitglied') ?? ''); + if (warMitglied != null && l.epoche > warMitglied) return l; + return null; + } + + /// Liefert der Relay die eigene Liste, die wir kennen (§12.4)? [roh] ist + /// das, was er eben auf `/geraeteliste` oder `/prekey` ausgeliefert hat. + void _vergleicheMitRelay(Map? roh) { + final eigene = _listeVon(myId); + if (eigene == null) return; + Geraeteliste? geliefert; + if (roh != null) { + try { + geliefert = Geraeteliste.pruefe(roh, adresse: myId); + } on GeraetelisteUngueltig { + geliefert = null; + } + } + final stimmt = geliefert != null && + (geliefert.epoche > eigene.epoche || geliefert.roh == eigene.roh); + _verglichenBis = eigene.epoche; + final neu = stimmt ? null : eigene.epoche; + if (neu != _relayVerschweigt) { + _relayVerschweigt = neu; + _meldeGeraete(); + } + } + + Future _pruefeWasDerRelayLiefert(RelayClient relay) async { + try { + _vergleicheMitRelay(await relay.holeGeraeteliste(myId)); + } catch (_) { + // Beim naechsten Verbinden wird ohnehin verglichen. + } + } + + /// Nach dem Veroeffentlichen von [epoche]: nur, wenn bis dahin keiner + /// verglichen hat. Ein ehrlicher Relay meldet die neue Fassung allen + /// Geraeten der Liste, dem veroeffentlichenden auch, und dessen Auffrischung + /// vergleicht ohnehin; ein zweiter Abruf kostete nur eine Marke der + /// Herkunftsbremse. Unterdrueckt der Relay die Meldung, greift dieser. + Future _pruefeSpaeterWasDerRelayLiefert( + RelayClient relay, int epoche) async { + await Future.delayed(const Duration(seconds: 3)); + final bis = _verglichenBis; + if (_store == null || (bis != null && bis >= epoche)) return; + await _pruefeWasDerRelayLiefert(relay); + } + + /// Gehoert das Buendel zum Geraet der Liste (§12.5 a)? Ohne Liste: ja. + /// Eine FALSCHE Signatur nie; eine FEHLENDE nur ohne + /// [RealMessengerCore.geraeteNachweisPflicht]. + bool _buendelPasstZurListe( + String an, RelayBundleResponse b, Geraeteliste? liste) { + if (liste == null) return true; + final sig = b.spkGeraeteSig; + if (sig == null) return !RealMessengerCore.geraeteNachweisPflicht; + final schluessel = liste.schluesselVon(b.deviceId); + if (schluessel == null) return false; + try { + return signaturGilt( + schluessel, + spkNachricht(BitdmAddress.normalize(an), b.deviceId, b.signedPreKeyId, + b.signedPreKey), + base64.decode(sig)); + } on FormatException { + return false; + } + } + + /// Traegt eine Erstnachricht den Nachweis des Geraets, unter dessen + /// Kennung sie kam (§12.5 b)? Ohne Liste des Absenders: ja. Eine + /// Nutzlast, die sich nicht lesen laesst, entscheidet der Aufrufer. + bool _sitzungsNachweisGilt( + String von, int vonGeraet, Uint8List basis, Uint8List klar) { + final liste = _listeVon(von); + if (liste == null) return true; + final String? gs; + try { + gs = Payload.fromBytes(klar).sitzungsNachweis; + } on PayloadFormatException { + return true; + } + if (gs == null) return !RealMessengerCore.geraeteNachweisPflicht; + final schluessel = liste.schluesselVon(vonGeraet); + if (schluessel == null) return false; + return signaturGilt( + schluessel, + sitzungsNachricht( + absender: BitdmAddress.normalize(von), + absenderGeraet: vonGeraet, + empfaenger: myId, + empfaengerGeraet: geraetId, + basisschluessel: basis, + ), + base64.decode(gs)); + } + + /// Der Klartext fuer [ziel]: der gemeinsame — oder, solange libsignal fuer + /// diese Sitzung noch PreKey-Nachrichten schickt, derselbe mit dem + /// Sitzungsnachweis (§12.5 b). Nur mit eigenem Geraeteschluessel. + Future _klartextFuer(BitdmSignalStore store, ProtocolAddress ziel, + Payload gestempelt, Uint8List klartext) async { + if (_geraeteStand != ListenStand.mitglied) return klartext; + if (_eigenerSchluesselOderNull() == null) return klartext; + final datensatz = await store.loadSession(ziel); + if (datensatz == null) return klartext; + final basis = offenerBasisschluessel(datensatz.serialize()); + if (basis == null) return klartext; + final sig = _geraeteSchluessel!.sign( + message: sitzungsNachricht( + absender: myId, + absenderGeraet: geraetId, + empfaenger: BitdmAddress.normalize(ziel.name()), + empfaengerGeraet: ziel.deviceId(), + basisschluessel: basis, + )); + return gestempelt + .gestempelt( + listenEpoche: gestempelt.listenEpoche, + listenHash: gestempelt.listenHash, + kenntEpoche: gestempelt.kenntEpoche, + sitzungsNachweis: base64.encode(sig), + ) + .toBytes(); + } + // ═══════════════════════════════════════════════════════ Hilfen /// Sucht eine Kennung, die weder in der Liste noch beim Relay belegt ist — diff --git a/secure-messenger/app/lib/core/models.dart b/secure-messenger/app/lib/core/models.dart index 80b52c8..13e253f 100644 --- a/secure-messenger/app/lib/core/models.dart +++ b/secure-messenger/app/lib/core/models.dart @@ -493,6 +493,11 @@ enum ContactEventType { requestDeclined, removed, needsUpdateChanged, + + /// Von dieser Adresse gibt es zwei verschiedene Fassungen der Geraeteliste + /// mit derselben Epoche (docs/GERAETEABMELDUNG.md §12.3 Regel 5). Ein + /// ehrlicher Relay kann das nicht erzeugen; der Chat zeigt einen Hinweis. + deviceListConflict, } class Message { diff --git a/secure-messenger/app/lib/core/net/geraeteliste.dart b/secure-messenger/app/lib/core/net/geraeteliste.dart index 4d96793..0a514f2 100644 --- a/secure-messenger/app/lib/core/net/geraeteliste.dart +++ b/secure-messenger/app/lib/core/net/geraeteliste.dart @@ -33,6 +33,40 @@ const String beitrittPraefix = 'BitDM-Beitritt-v1\n'; const String anmeldePraefix = 'BitDM-Geraet-Anmeldung-v1\n'; const String nachweisPraefix = 'BitDM-Beitritt-Nachweis-v1\n'; +/// Der signierte Prekey, zusaetzlich vom Geraeteschluessel unterschrieben +/// (docs/GERAETEABMELDUNG.md §12.5 a). Gegenstueck: relay_server.py +/// `PREKEY_PRAEFIX` und `spk_nachricht`. +const String prekeyPraefix = 'BitDM-Prekey-v1\n'; + +/// Der Sitzungsnachweis in der ersten Nutzlast einer neuen Sitzung (§12.5 b). +/// Nur zwischen Apps; der Relay sieht ihn nie. +const String sitzungPraefix = 'BitDM-Sitzung-v1\n'; + +/// Was der Geraeteschluessel am signierten Prekey unterschreibt. [spkB64] ist +/// der Prekey (33 Byte) als base64, so wie er im Buendel steht. +Uint8List spkNachricht(String adresse, int geraet, int spkId, String spkB64) => + Uint8List.fromList( + utf8.encode('$prekeyPraefix$adresse\n$geraet\n$spkId\n$spkB64')); + +/// Was der Geraeteschluessel des ABSENDERS in der ersten Nutzlast einer +/// Sitzung unterschreibt: wer an wen, Geraet fuer Geraet, und der +/// Basisschluessel dieser einen Sitzung. Damit gilt die Unterschrift fuer +/// genau eine Sitzung und laesst sich nirgends sonst wiederverwenden. +Uint8List sitzungsNachricht({ + required String absender, + required int absenderGeraet, + required String empfaenger, + required int empfaengerGeraet, + required List basisschluessel, +}) => + Uint8List.fromList(utf8.encode('$sitzungPraefix$absender\n$absenderGeraet\n' + '$empfaenger\n$empfaengerGeraet\n${base64.encode(basisschluessel)}')); + +/// Die ersten 12 Byte von SHA-256 ueber die signierten Zeichen einer Liste, +/// base64 (16 Zeichen) — der Listenstempel `lh` (§12.3). +String listenKurzHash(String roh) => base64.encode( + const DartSha256().hashSync(utf8.encode(roh)).bytes.sublist(0, 12)); + /// Groesser als der Relay je zulaesst (GERAETE_MAX 5). Der Relay entscheidet /// die echte Grenze; hier geht es nur darum, Unsinn nicht zu verarbeiten. const int _geraeteHoechstens = 32; @@ -189,6 +223,9 @@ class Geraeteliste { Set get kennungen => {for (final e in geraete) e.id}; + /// Der Stempel `lh` dieser Fassung (§12.3). + String get kurzHash => listenKurzHash(roh); + Uint8List? schluesselVon(int id) { for (final e in geraete) { if (e.id == id) return e.schluessel; @@ -382,6 +419,34 @@ class Geraeteliste { } } +/// Was die Listenstempel einer Adresse angekuendigt haben, das wir noch nicht +/// als Liste haben (docs/GERAETEABMELDUNG.md §12.3 Regel 2). +/// +/// [geraete] sind die Kennungen, die GENAU diese Fassung gestempelt haben — +/// sie kennen sie also. Solange die Liste fehlt, geht nur an sie etwas. +class Ankuendigung { + Ankuendigung(this.epoche, this.hash, Set geraete) + : geraete = {...geraete}; + + final int epoche; + final String hash; + final Set geraete; + + Map alsJson() => + {'e': epoche, 'h': hash, 'g': (geraete.toList()..sort())}; + + /// Null bei allem, was nicht so aussieht — eine kaputte Zeile in `meta` + /// heisst "nichts angekuendigt", nicht Absturz. + static Ankuendigung? lies(Object? j) { + if (j is! Map) return null; + final e = j['e'], h = j['h'], g = j['g']; + if (e is! int || h is! String || g is! List || g.any((x) => x is! int)) { + return null; + } + return Ankuendigung(e, h, g.cast().toSet()); + } +} + bool _gleich(List a, List b) { if (a.length != b.length) return false; var d = 0; diff --git a/secure-messenger/app/lib/core/net/payload.dart b/secure-messenger/app/lib/core/net/payload.dart index 7e315ec..4e21206 100644 --- a/secure-messenger/app/lib/core/net/payload.dart +++ b/secure-messenger/app/lib/core/net/payload.dart @@ -130,7 +130,18 @@ enum PayloadKind { /// Vertrauenskontakte, wie der Besitzer festgelegt hat, binnen 24 Stunden /// darum bitten, laeuft ein Countdown (siehe Fernloeschung in models.dart). /// Eine aeltere Fassung kennt die Art nicht und verwirft sie still. - loeschanfrage(19); + loeschanfrage(19), + + /// DIE SIGNIERTE GERAETELISTE DES ABSENDERS, Ende-zu-Ende + /// (docs/GERAETEABMELDUNG.md §12.3). In `x` steht sie so, wie der Relay sie + /// ausliefert: `{"liste","sig_identitaet","sig_geraet"}` als JSON. + /// + /// Sie traegt ihre Beweise selbst — beide Signaturen, die Kette —, deshalb + /// ist gleich, wer sie ueberbringt. Sie gibt es, weil der Relay eine neue + /// Fassung verschweigen kann: dann erfahren Kontakte und eigene Geraete sie + /// hierueber vom Besitzer. Nie im Verlauf, nie gespiegelt. 2.0.0 kennt die + /// Art nicht und verwirft sie still. + geraeteliste(20); const PayloadKind(this.code); final int code; @@ -149,6 +160,20 @@ final _kennungTaugt = RegExp(r'^[A-Za-z0-9_-]{1,64}$'); /// Wie eine Gruppenkennung aussehen darf — dieselbe Regel wie Gruppe.istGruppenId. final _gruppeTaugt = RegExp(r'^g-[A-Za-z0-9_-]{22}$'); +/// Die groesste Epoche, genau auch im Browser (wie in geraeteliste.dart). +const int _epocheHoechstens = 9007199254740992; + +/// base64 von genau [laenge] Byte — oder null. +Uint8List? _b64Genau(Object? w, int laenge) { + if (w is! String) return null; + try { + final roh = base64.decode(w); + return roh.length == laenge ? roh : null; + } on FormatException { + return null; + } +} + class Payload { final PayloadKind kind; @@ -208,6 +233,31 @@ class Payload { /// Bei den drei Gruppenarten: welche Gruppe. final String? gruppe; + // ─────────────── Listenstempel (docs/GERAETEABMELDUNG.md §12.3 und §12.5 b) + // + // Setzt NIE der Aufrufer, sondern `_sendePayload` im Kern unmittelbar vor + // dem Verschluesseln — sie beschreiben den Stand im Augenblick des Sendens, + // nicht des Verfassens. Eine aeltere Fassung ueberliest sie. + + /// `le`: die Epoche der EIGENEN Geraeteliste des Absenders. Nur, wenn er + /// darin steht. + final int? listenEpoche; + + /// `lh`: die ersten 12 Byte von SHA-256 ueber die signierten Zeichen dieser + /// Fassung, base64. Zwei verschiedene Fassungen derselben Epoche verraten + /// sich daran. + final String? listenHash; + + /// `ke`: welche Epoche der Liste des EMPFAENGERS der Absender kennt (0: + /// keine). Liegt sie hinter der echten, schickt der Empfaenger seine Liste + /// nach. + final int? kenntEpoche; + + /// `gs`: der Sitzungsnachweis (§12.5 b) — nur in der Nutzlast an EIN + /// Geraet, solange libsignal fuer diese Sitzung noch PreKey-Nachrichten + /// schickt. Signatur des Geraeteschluessels, base64. + final String? sitzungsNachweis; + Payload({ required this.kind, required this.messageId, @@ -218,8 +268,59 @@ class Payload { this.chatId, this.antwortAuf, this.gruppe, + this.listenEpoche, + this.listenHash, + this.kenntEpoche, + this.sitzungsNachweis, }); + /// Dieselbe Nutzlast mit den Stempeln des Augenblicks. Alles andere bleibt + /// Zeichen fuer Zeichen — Kennung und Zeit tragen die Entdoppelung. + Payload gestempelt({ + int? listenEpoche, + String? listenHash, + int? kenntEpoche, + String? sitzungsNachweis, + }) => + Payload( + kind: kind, + messageId: messageId, + sentAt: sentAt, + text: text, + refs: refs, + ttlSeconds: ttlSeconds, + chatId: chatId, + antwortAuf: antwortAuf, + gruppe: gruppe, + listenEpoche: listenEpoche, + listenHash: listenHash, + kenntEpoche: kenntEpoche, + sitzungsNachweis: sitzungsNachweis, + ); + + /// Die eigene signierte Geraeteliste, fuer Kontakte und eigene Geraete. + /// [liste] ist `Geraeteliste.alsJson()`. + factory Payload.geraeteliste( + String messageId, Map liste, DateTime sentAt) => + Payload( + kind: PayloadKind.geraeteliste, + messageId: messageId, + sentAt: sentAt, + text: jsonEncode(liste), + ); + + /// Die Liste aus einer Nutzlast der Art 20 — ungeprueft; das tut der Kern + /// mit `Geraeteliste.pruefe`. Null, wenn `x` kein JSON-Objekt ist. + Map? get geraetelisteRoh { + if (kind != PayloadKind.geraeteliste) return null; + try { + final j = jsonDecode(text); + return j is Map ? j.cast() : null; + } on FormatException { + return null; + } + } + factory Payload.text(String messageId, String text, DateTime sentAt, {Duration? lebensdauer, String? antwortAuf}) => Payload( @@ -429,6 +530,10 @@ class Payload { if (ttlSeconds != null) 'l': ttlSeconds, if (antwortAuf != null) 'a': antwortAuf, if (gruppe != null) 'g': gruppe, + if (listenEpoche != null) 'le': listenEpoche, + if (listenHash != null) 'lh': listenHash, + if (kenntEpoche != null) 'ke': kenntEpoche, + if (sitzungsNachweis != null) 'gs': sitzungsNachweis, }); final inhalt = [kind.code, ...utf8.encode(json)]; @@ -612,6 +717,30 @@ class Payload { g is String && _gruppeTaugt.hasMatch(g); + // DIE LISTENSTEMPEL (§12.3, §12.5 b). Freiwillig — fehlen sie, ist es + // eine Fassung vor 2.0.1. Stehen sie da und taugen nicht, ist das KEIN + // stilles Weglassen: ein fehlender Stempel und ein kaputter waeren sonst + // dasselbe, und der Kern entscheidet an ihnen, wem er glaubt. + final le = j['le']; + final lh = j['lh']; + final ke = j['ke']; + final gs = j['gs']; + if (le != null && (le is! int || le < 1 || le > _epocheHoechstens)) { + throw const PayloadFormatException('Listenepoche taugt nicht'); + } + if ((le == null) != (lh == null) || (lh != null && _b64Genau(lh, 12) == null)) { + throw const PayloadFormatException('Listenhash taugt nicht'); + } + if (ke != null && (ke is! int || ke < 0 || ke > _epocheHoechstens)) { + throw const PayloadFormatException('bekannte Epoche taugt nicht'); + } + if (gs != null && _b64Genau(gs, 64) == null) { + throw const PayloadFormatException('Sitzungsnachweis taugt nicht'); + } + if (kind == PayloadKind.geraeteliste && (text is! String || text.isEmpty)) { + throw const PayloadFormatException('Geraeteliste ohne Inhalt'); + } + return Payload( gruppe: (istGruppenArt || quittungAusGruppe) ? g as String : null, kind: kind, @@ -622,6 +751,10 @@ class Payload { ttlSeconds: gueltigeTtl, chatId: c as String?, antwortAuf: antwort, + listenEpoche: le as int?, + listenHash: lh as String?, + kenntEpoche: ke as int?, + sitzungsNachweis: gs as String?, ); } } diff --git a/secure-messenger/app/lib/core/net/relay_client.dart b/secure-messenger/app/lib/core/net/relay_client.dart index b6f7791..6321df7 100644 --- a/secure-messenger/app/lib/core/net/relay_client.dart +++ b/secure-messenger/app/lib/core/net/relay_client.dart @@ -296,6 +296,15 @@ class RelayClient { if (gs != null) 'geraete_signatur': base64.encode(gs.sign( message: [...utf8.encode(anmeldePraefix), ...nachricht])), + // DER SIGNIERTE PREKEY, AN DIESES GERAET GEBUNDEN + // (docs/GERAETEABMELDUNG.md §12.5 a). Kontakte pruefen sie gegen den + // Schluessel aus der Liste; ohne sie koennte der Relay das Buendel + // eines abgemeldeten Geraets unter dieser Kennung ausliefern. Ein Relay + // vor 2.0.1 ignoriert das Feld. + if (gs != null) + 'spk_geraete_sig': base64.encode(gs.sign( + message: spkNachricht(address, bundle.deviceId ?? 1, + bundle.signedPreKeyId, bundle.signedPreKey))), }); return antwort['one_time_prekeys'] as int? ?? 0; } diff --git a/secure-messenger/app/lib/core/net/relay_protocol.dart b/secure-messenger/app/lib/core/net/relay_protocol.dart index 64865e0..e07ad5a 100644 --- a/secure-messenger/app/lib/core/net/relay_protocol.dart +++ b/secure-messenger/app/lib/core/net/relay_protocol.dart @@ -267,6 +267,11 @@ class RelayBundleResponse { /// Welches Geraet dieser Adresse dieses Buendel gehoert. final int deviceId; + /// Die Signatur des GERAETESCHLUESSELS am signierten Prekey, base64 + /// (docs/GERAETEABMELDUNG.md §12.5 a), UNGEPRUEFT. Null bei einem Geraet + /// mit App vor 2.0.1 oder einem Relay, der das Feld nicht kennt. + final String? spkGeraeteSig; + /// Die WEITEREN Geraete derselben Adresse, jedes mit eigenem Buendel. /// /// Leer bei einem Relay, der die Erweiterung nicht kennt — dann gibt es @@ -294,6 +299,7 @@ class RelayBundleResponse { this.deviceId = 1, this.geraete = const [], this.geraeteliste, + this.spkGeraeteSig, }); /// Alle Geraete der Adresse, immer mindestens eines. @@ -324,6 +330,7 @@ class RelayBundleResponse { kyberPreKey: j['kyber_prekey'] as String?, kyberPreKeySignature: j['kyber_prekey_sig'] as String?, deviceId: j['device_id'] as int? ?? 1, + spkGeraeteSig: j['spk_geraete_sig'] as String?, geraete: rohGeraete is! List ? const [] : [ diff --git a/secure-messenger/app/lib/core/real_messenger_core.dart b/secure-messenger/app/lib/core/real_messenger_core.dart index b637d4c..aa361da 100644 --- a/secure-messenger/app/lib/core/real_messenger_core.dart +++ b/secure-messenger/app/lib/core/real_messenger_core.dart @@ -36,6 +36,7 @@ import 'crypto/address.dart'; import 'crypto/bip39.dart'; import 'crypto/key_derivation.dart'; import 'crypto/signal_errors.dart'; +import 'crypto/sitzungsdatensatz.dart'; import 'crypto/signal_identity.dart'; import 'fern_codewort.dart'; import 'messenger_core.dart'; @@ -368,6 +369,41 @@ class RealMessengerCore implements MessengerCore { /// von einem unbekannten Geraet kam. final _listeGefragt = {}; + /// Was Stempel je Adresse angekuendigt haben (docs/GERAETEABMELDUNG.md + /// §12.3 Regel 2), null = nichts Offenes. Spiegel von meta + /// `geraeteliste_ank:`. + final _ankuendigungen = {}; + + /// Laufende Nachfragen nach der Liste einer Adresse — eine je Adresse. + final _listenAbrufe = >{}; + + /// Wann die Liste einer Adresse wieder nachgeladen werden darf, weil ein + /// Stempel mehr kennt (§12.3). Getrennt von [_listeGefragt], siehe + /// `_holeListeNach`. + final _stempelGefragt = {}; + + /// Wann die eigene Liste zuletzt an eine Adresse nachgeschickt wurde + /// (Heilen, §12.3 Regel 4). + final _listeNachgeschickt = {}; + + /// "adresse:geraet" -> bis wann es uebersprungen wird, weil der Relay es + /// nicht kannte, obwohl es in der signierten Liste steht (§12.5 c). + final _relayKenntNicht = {}; + + /// Die Epoche der eigenen Liste, die der Relay zuletzt NICHT auslieferte + /// (§12.4) — null, wenn er liefert, was wir kennen. + int? _relayVerschweigt; + + /// Bis zu welcher eigenen Epoche schon mit dem Relay verglichen wurde. + int? _verglichenBis; + + /// Ab wann ein FEHLENDER Geraetenachweis abgewiesen wird — am Buendel + /// (§12.5 a) und an der Erstnachricht (§12.5 b). Ein FALSCHER wird immer + /// abgewiesen. Bis 2.0.1 Uebergang (Fassungen davor signieren nichts); + /// Pflicht ab 2.0.2, nach dem Pflicht-Fenster. Fuer Tests umschaltbar. + @visibleForTesting + static bool geraeteNachweisPflicht = false; + var _geraeteStand = ListenStand.unbekannt; final _geraeteCtl = StreamController.broadcast(); Uint8List? _hinzufuegeCode; @@ -666,7 +702,7 @@ class RealMessengerCore implements MessengerCore { final db = EncryptedDatabase.open(databasePath, keys.databaseKey); _ruheSchluessel = Uint8List.fromList(keys.attachmentKey); _infoSchluessel = Uint8List.fromList(keys.deviceInfoKey); - _listen.clear(); + _vergissListenZustand(); _geraeteSchluessel = null; // Wer zuletzt in der Liste stand, ist bis zum naechsten Verbinden // Mitglied; ob es noch stimmt, sagt die Liste beim Relay. @@ -2016,6 +2052,9 @@ class RealMessengerCore implements MessengerCore { var neueSitzung = false; try { final huelle = Envelope.fromBytes(umschlag); + // Der Basisschluessel einer Erstnachricht — gegen ihn prueft + // [_sitzungsNachweisGilt] den Nachweis des Absendergeraets. + Uint8List? basis; // VOR LIBSIGNAL: passt der Schluessel einer Erstnachricht zum Absender? // // Die Anbindung prueft das nicht (signal_errors.dart `FremdeIdentitaet`). @@ -2034,6 +2073,7 @@ class RealMessengerCore implements MessengerCore { if (absender != BitdmAddress.normalize(von)) { throw FremdeIdentitaet(von); } + basis = erst.baseKey(); } // DIE SITZUNG DES GERAETEPAARS, nicht "die Sitzung mit dieser Adresse". // Gegen die falsche zu probieren waere kein Fehlversuch, den man @@ -2057,6 +2097,17 @@ class RealMessengerCore implements MessengerCore { if (vorher != null) store.stelleWiederHer(vorher); throw const ErneutEingespielt(); } + // DER SITZUNGSNACHWEIS (docs/GERAETEABMELDUNG.md §12.5 b). Die + // Kennung des Absendergeraets setzt der Relay (`from_device`); eine + // Erstnachricht des abgemeldeten Tablets unter der Kennung des + // Laptops ersetzte sonst die echte Sitzung, und jede Antwort "an den + // Laptop" laese das Tablet. IN DER SPERRE und vor jedem + // Festschreiben: nur so laesst sich die neue Sitzung spurlos + // zuruecknehmen. + if (vorher != null && !_sitzungsNachweisGilt(von, vonGeraet, basis!, klar)) { + store.stelleWiederHer(vorher); + throw FremdesGeraet(von, vonGeraet); + } return klar; }); // GESPERRT, GELOESCHT ODER GESCHLOSSEN, WAEHREND LIBSIGNAL RECHNETE. @@ -2084,6 +2135,23 @@ class RealMessengerCore implements MessengerCore { return; } + // ═══════════════════════════ DIE LISTENSTEMPEL (docs/GERAETEABMELDUNG.md §12.3) + // + // Kuendigt der Stempel eine neuere Fassung der Liste des Absenders an, + // merkt der Kern sich das und laedt nach. Ist unsere Liste veraltet und + // kennt dieses Geraet die angekuendigte Fassung nicht, wird die Nutzlast + // verworfen: ein abgemeldetes Geraet, das der Relay weiter ausliefert, + // spricht sonst weiter als Besitzer. Der Sitzungsfortschritt bleibt, der + // Relay bekommt seinen Nachweis — derselbe Umschlag kaeme sonst wieder. + if (!_nimmStempel(von, vonGeraet, payload)) { + chats.speichereNurSitzung(store); + _bestaetigeEmpfang(nachweisFuer); + return; + } + // Eine eigene Fassung, die DIESES Geraet abmeldet (Art 20). Erst ganz am + // Ende ausgefuehrt: die Abmeldung schliesst die Datenbank. + Geraeteliste? abgemeldetDurch; + // ═══════════════════════════ VOM EIGENEN ZWEITGERAET ODER VON JEMANDEM? // // `von == myId` GENUEGT, UND ES IST GERECHNET STATT BEHAUPTET. Diese Bytes @@ -2119,6 +2187,10 @@ class RealMessengerCore implements MessengerCore { _nimmGruppenStand(von, payload, store); } else if (payload.kind == PayloadKind.gruppenAustritt) { _nimmAustritt(von, payload, store); + } else if (payload.kind == PayloadKind.geraeteliste) { + // Vor dem Zweig fuer die eigene Adresse: auch die eigenen Geraete + // schicken sich ihre Liste (§12.3 Regel 1). + abgemeldetDurch = _nimmListeEndeZuEnde(von, payload); } else if (von == myId) { _nimmSpiegel(payload); } else { @@ -2180,6 +2252,9 @@ class RealMessengerCore implements MessengerCore { break; case PayloadKind.loeschanfrage: _nimmLoeschanfrage(von, payload); + case PayloadKind.geraeteliste: + // Schon oben genommen. + break; case PayloadKind.spiegel: // Ein Spiegel von einer FREMDEN Adresse ergibt keinen Sinn: er // gehoert in einen Chat, ueber den der Absender nichts zu sagen @@ -2226,6 +2301,7 @@ class RealMessengerCore implements MessengerCore { unawaited(_richteNaheEin()); _bestaetigeEmpfang(nachweisFuer); + if (abgemeldetDurch != null) await _wirdAbgemeldet(abgemeldetDurch); } /// Sagt dem Relay, dass dieser Umschlag dauerhaft liegt. @@ -4883,8 +4959,30 @@ class RealMessengerCore implements MessengerCore { final store = _store; if (store == null) throw const NotInitializedException(); + // DIE LISTE DES EMPFAENGERS IST VERALTET (docs/GERAETEABMELDUNG.md §12.3): + // ein Stempel hat eine neuere Fassung angekuendigt. Erst nachsehen, ob + // der Relay sie inzwischen hat — sonst ginge diese Nachricht nur an die + // Geraete, die die Ankuendigung gestempelt haben, obwohl die Liste eine + // Antwort entfernt laege. NICHT, wenn die Geraetepruefung unten ohnehin + // faellig ist: die holt die Liste mit, und jeder Abruf kostet eine Marke + // der Herkunftsbremse des Relays. + final frischtAuf = + an != myId && !_prefs.nurNahbereich && _geraeteFaellig(an); + if (_listeVeraltet(an) && + (!frischtAuf || _bekannteGeraete(store, an).isEmpty)) { + await _holeListeNach(an); + } + if (!identical(_store, store)) throw const NotInitializedException(); + var ziele = _bekannteGeraete(store, an); + if (ziele.isEmpty && _listeVeraltet(an)) { + // Ohne Buendelabruf: der braechte nur Geraete, an die jetzt ohnehin + // nichts geht, und kostete jedes Mal ihre Einmalschluessel. + return const Wegbescheid(Weg.liegt, + 'die Geraeteliste dieser Adresse ist veraltet und noch nicht da'); + } + if (ziele.isEmpty) { if (_prefs.nurNahbereich) { // ERST FRAGEN, DANN LIEGENLASSEN. @@ -4930,7 +5028,7 @@ class RealMessengerCore implements MessengerCore { 'diese Adresse hat kein anderes Geraet', ); } - } else if (an != myId && !_prefs.nurNahbereich && _geraeteFaellig(an)) { + } else if (frischtAuf) { // AUFFRISCHEN, ABER NIE AUF KOSTEN DES VERSANDS: schlaegt es fehl, geht // die Nachricht an die bekannten Geraete hinaus. Die eigene Adresse ist // ausgenommen — die frischt `connect()` auf, nicht das Fenster. @@ -4940,7 +5038,19 @@ class RealMessengerCore implements MessengerCore { // EINMAL VERPACKT UND MEHRFACH VERSCHLUESSELT. `toBytes` fuellt auf 256er- // Bloecke auf und ist damit nicht umsonst; das Ergebnis ist fuer alle // Geraete dasselbe, nur die Verschluesselung unterscheidet sich. - final klartext = p.toBytes(); + // + // MIT DEN LISTENSTEMPELN DES AUGENBLICKS (§12.3): welche Fassung der + // eigenen Liste gilt und welche der Liste des Empfaengers wir kennen. + // Eine Ausnahme vom "einmal verpackt": eine Sitzung, fuer die libsignal + // noch PreKey-Nachrichten schickt, bekommt den Sitzungsnachweis dazu + // ([_klartextFuer], §12.5 b). + final stempel = _stempelFuer(an); + final gestempelt = p.gestempelt( + listenEpoche: stempel.le, + listenHash: stempel.lh, + kenntEpoche: stempel.ke, + ); + final klartext = gestempelt.toBytes(); var ueberRelay = false; var ueberNaehe = false; @@ -4954,7 +5064,8 @@ class RealMessengerCore implements MessengerCore { for (final g in ziele) { final ziel = ProtocolAddress(name: an, deviceId: g); final ct = await _signalExklusiv(() async { - final ct = await _chiffre(store).encrypt(ziel, klartext); + final ct = await _chiffre(store).encrypt( + ziel, await _klartextFuer(store, ziel, gestempelt, klartext)); _signalRepo!.commit(store); return ct; }); @@ -4997,11 +5108,27 @@ class RealMessengerCore implements MessengerCore { einesLiegt = einesLiegt || bescheid.weg == Weg.liegt; gruende.add('$g ${bescheid.grund}'); - // DER RELAY KENNT DIESES GERAET NICHT MEHR. Die Sitzung dazu ist damit - // wertlos — sie stehen zu lassen hiesse, bei jeder weiteren Nachricht + // DER RELAY KENNT DIESES GERAET NICHT MEHR. + // + // MIT SIGNIERTER LISTE ENTSCHEIDET SIE, NICHT DER RELAY + // (docs/GERAETEABMELDUNG.md §12.5 c). Bis 2.0.0 fiel hier die Sitzung + // — und der naechste Versand baute sie aus einem Buendel neu, das der + // Relay waehlt: das des abgemeldeten Geraets unter dieser Kennung + // (§12.1 a). Jetzt bleibt sie, das Geraet wird bis zur naechsten + // Auffrischung uebersprungen, und die Liste wird nachgeladen. Fehlt + // es dort, faellt die Sitzung ueber `_nimmListe`. + // + // OHNE LISTE wie bisher: dann ist der Relay die einzige Quelle, und + // eine Sitzung stehen zu lassen hiesse, bei jeder weiteren Nachricht // erneut dagegen zu verschluesseln und erneut abgewiesen zu werden. if (bescheid.grund.contains(zielgeraetUnbekannt)) { - await store.deleteSession(ziel); + final liste = _listeVon(an); + if (liste != null && liste.kennungen.contains(g)) { + _relayKenntNicht['$an:$g'] = DateTime.now().add(geraeteFenster); + unawaited(_holeListeNach(an)); + } else { + await store.deleteSession(ziel); + } _chats?.setzeGeraeteGeprueft(an, 0); } } @@ -5155,6 +5282,8 @@ class RealMessengerCore implements MessengerCore { // Eine Loeschanfrage gilt EINEM Geraet des Besitzers; gespiegelt zaehlte // sie auf dessen anderen Geraeten ein zweites Mal. PayloadKind.loeschanfrage || + // Die eigene Liste geht ohnehin an die eigenen Geraete (§12.3). + PayloadKind.geraeteliste || PayloadKind.spiegel => false, }; @@ -5176,6 +5305,21 @@ class RealMessengerCore implements MessengerCore { // Geraet 7 steht in keiner Liste, die der Relay faelschen koennte. final liste = _listeVon(an); if (liste != null) g.removeWhere((x) => !liste.kennungen.contains(x)); + // VERALTET (§12.3 Regel 3): ein Stempel kuendigt eine neuere Fassung an, + // die wir noch nicht haben. Dann nur an Geraete, die sie selbst gestempelt + // haben — ein abgemeldetes, das der Relay weiter ausliefert, gehoert + // nicht dazu. + final nur = _nurAn(an); + if (nur != null) g.removeWhere((x) => !nur.contains(x)); + // Der Relay kannte es eben nicht (§12.5 c): bis zur naechsten + // Auffrischung auslassen, die Sitzung bleibt. + if (_relayKenntNicht.isNotEmpty) { + final jetzt = DateTime.now(); + g.removeWhere((x) { + final bis = _relayKenntNicht['$an:$x']; + return bis != null && jetzt.isBefore(bis); + }); + } return g; } @@ -5280,6 +5424,8 @@ class RealMessengerCore implements MessengerCore { .take(geraeteMax)) { if (an == myId && b.deviceId == geraetId) continue; if (liste != null && !liste.kennungen.contains(b.deviceId)) continue; + final nur = _nurAn(an); + if (nur != null && !nur.contains(b.deviceId)) continue; // KENNUNG AUSSERHALB 1..127: ein Geraet mit einer App vor 2.0.0. // libsignal nimmt die Kennung nicht; ueberspringen wie ein Geraet ohne // Kyber, statt den Versand an alle anderen umzuwerfen. @@ -5287,6 +5433,11 @@ class RealMessengerCore implements MessengerCore { if (an == myId && await _istEigenesBuendel(store, b)) continue; final ziel = ProtocolAddress(name: an, deviceId: b.deviceId); if (!await store.containsSession(ziel)) { + // DAS BUENDEL GEHOERT ZUM GERAET DER LISTE (§12.5 a). Sonst baut der + // Kontakt eine Sitzung "zum Laptop", die nur das abgemeldete Tablet + // ausrechnen kann — sein Buendel ist vom Identitaetsschluessel ja + // genauso unterschrieben. + if (!_buendelPasstZurListe(an, b, liste)) continue; final PreKeyBundle buendel; try { buendel = PreKeyBundleBridge.fromRelay(b, deviceId: b.deviceId); @@ -5360,11 +5511,23 @@ class RealMessengerCore implements MessengerCore { // und zwar OHNE Vermerk — sonst gaelte die Liste sechs Stunden lang als // geprueft, obwohl niemand geprueft hat. if (liste == null) return bekannt; + // Was der Relay wieder kennt, wird wieder beliefert (§12.5 c). + for (final g in liste) { + _relayKenntNicht.remove('$an:$g'); + } // DIE SIGNIERTE GERAETELISTE DER GEGENSTELLE, wenn unser Relay Listen - // kennt. Erst sie verraet eine Abmeldung, die der Relay verschweigen - // wuerde — die Kennungen von oben kommen nur vom Relay. + // kennt. Sie verraet eine Abmeldung, die die Kennungen von oben (nur + // vom Relay, frei waehlbar) verschweigen koennten — aber NUR, wenn der + // Relay die neue Fassung auch ausliefert. Verschweigt er sie, hilft + // hier nichts; dagegen stehen die Listenstempel Ende-zu-Ende + // (docs/GERAETEABMELDUNG.md §12, `_nimmStempel`). Bis 2.0.0 behauptete + // dieser Kommentar das Gegenteil. if (hatGeraeteliste) { _nimmListe(an, await relay.holeGeraeteliste(an)); + // Frisch geholt: ein Stempel in der naechsten Minute fragt nicht + // noch einmal (`_holeListeNach`). + _stempelGefragt[an] = + DateTime.now().add(const Duration(minutes: 1)); bekannt = _bekannteGeraete(store, an); } if (liste.toSet().difference(bekannt.toSet()).isEmpty) { @@ -5692,7 +5855,7 @@ class RealMessengerCore implements MessengerCore { beendeHinzufuegen(); _infoSchluessel = null; _geraeteSchluessel = null; - _listen.clear(); + _vergissListenZustand(); _geraeteStand = ListenStand.unbekannt; await _raeumeVerbindungAb(); await _haltNahe(); @@ -5801,7 +5964,7 @@ class RealMessengerCore implements MessengerCore { if (info != null) info.fillRange(0, info.length, 0); _infoSchluessel = null; _geraeteSchluessel = null; - _listen.clear(); + _vergissListenZustand(); await _raeumeVerbindungAb(); // Der Funk geht mit, aus demselben Grund wie die Verbindung: solange die // App zu ist, koennte niemand eine ankommende Nachricht entschluesseln, diff --git a/secure-messenger/app/lib/data.dart b/secure-messenger/app/lib/data.dart index c66d65d..ce84432 100644 --- a/secure-messenger/app/lib/data.dart +++ b/secure-messenger/app/lib/data.dart @@ -202,6 +202,8 @@ const Map> strings = { 'geraeteHinzugefuegt': 'Device added.', 'geraeteVoll': 'This identity already has 5 devices. Sign one out first.', 'geraeteKeineListe': 'This server does not know device lists yet. Signing out a device only works after it is updated.', + 'geraeteRelayVerschweigt': 'The server is not delivering your latest change to the device list. Your contacts still learn about it through your messages. If this notice stays, do not trust this server.', + 'geraetelisteWiderspruch': 'There are two contradicting device lists for this contact. Only a manipulated server or someone with the 12 words of this contact can cause that. Compare the safety number and ask which devices they use.', 'geraeteFehler': 'That did not work. Check the connection and try again.', 'geraeteWoerterHinweis': 'Anyone who knows your 12 words can sign out all your devices and take over. If that has happened (say the phone was stolen unlocked, or the words were lying around), only a new identity helps.', 'beitrittTitel': 'This device is not confirmed yet', @@ -682,6 +684,8 @@ const Map> strings = { 'geraeteHinzugefuegt': 'Gerät hinzugefügt.', 'geraeteVoll': 'Diese Identität hat schon 5 Geräte. Melde erst eines ab.', 'geraeteKeineListe': 'Dieser Server kennt noch keine Geräteliste. Ein Gerät abmelden geht erst nach seinem Update.', + 'geraeteRelayVerschweigt': 'Der Server liefert deine letzte Änderung der Geräteliste nicht aus. Deine Kontakte erfahren sie trotzdem über deine Nachrichten. Bleibt dieser Hinweis, vertrau diesem Server nicht.', + 'geraetelisteWiderspruch': 'Für diesen Kontakt gibt es zwei widersprüchliche Gerätelisten. Das kann nur ein manipulierter Server oder jemand mit den 12 Wörtern dieses Kontakts erzeugen. Vergleicht die Sicherheitsnummer und fragt nach, welche Geräte er benutzt.', 'geraeteFehler': 'Das hat nicht geklappt. Verbindung prüfen und noch einmal versuchen.', 'geraeteWoerterHinweis': 'Wer deine 12 Wörter kennt, kann alle deine Geräte abmelden und selbst übernehmen. Ist das passiert (etwa weil das Telefon entsperrt gestohlen wurde oder die Wörter herumlagen), hilft nur eine neue Identität.', 'beitrittTitel': 'Dieses Gerät ist noch nicht bestätigt', diff --git a/secure-messenger/app/lib/geraete_seite.dart b/secure-messenger/app/lib/geraete_seite.dart index c8de7f0..347d3e7 100644 --- a/secure-messenger/app/lib/geraete_seite.dart +++ b/secure-messenger/app/lib/geraete_seite.dart @@ -161,6 +161,15 @@ class _GeraeteInhalt extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (!st.hatGeraeteliste) stil.hinweis(t('geraeteKeineListe')), + // DER RELAY LIEFERT UNSERE LETZTE AENDERUNG NICHT AUS + // (docs/GERAETEABMELDUNG.md §12.4). Kontakte erfahren sie trotzdem, + // Ende-zu-Ende — aber der Besitzer soll wissen, dass sein Server + // luegt oder hinterherhinkt. + if (st.relayVerschweigtListe != null) + KeyedSubtree( + key: const ValueKey('hinweis-relay-verschweigt'), + child: stil.hinweis(t('geraeteRelayVerschweigt')), + ), for (final g in geraete) Container( key: ValueKey('geraet-${g.id}'), diff --git a/secure-messenger/app/lib/oberflaeche/home/unterhaltung.dart b/secure-messenger/app/lib/oberflaeche/home/unterhaltung.dart index 9fc81e1..02a68b1 100644 --- a/secure-messenger/app/lib/oberflaeche/home/unterhaltung.dart +++ b/secure-messenger/app/lib/oberflaeche/home/unterhaltung.dart @@ -320,6 +320,14 @@ extension _Unterhaltung on _HomeState { padding: const EdgeInsets.fromLTRB(17, 0, 17, 8), child: _hinweisKasten(t('peerNeedsUpdate'), warnend: false), ), + // ZWEI FASSUNGEN DERSELBEN GERAETELISTE (docs/GERAETEABMELDUNG.md + // §12.3 Regel 5). Warnend: das erzeugt kein ehrlicher Server. + if (st.geraetelisteWiderspruch(cid)) + Padding( + key: const ValueKey('hinweis-geraeteliste-widerspruch'), + padding: const EdgeInsets.fromLTRB(17, 0, 17, 8), + child: _hinweisKasten(t('geraetelisteWiderspruch')), + ), if (chatFehlerText() != null) Container( width: double.infinity, diff --git a/secure-messenger/app/test/crypto/sitzungsdatensatz_test.dart b/secure-messenger/app/test/crypto/sitzungsdatensatz_test.dart new file mode 100644 index 0000000..5ca0d7b --- /dev/null +++ b/secure-messenger/app/test/crypto/sitzungsdatensatz_test.dart @@ -0,0 +1,96 @@ +// sitzungsdatensatz_test.dart — liest der Kern den Basisschluessel richtig? +// +// Der Sitzungsnachweis (docs/GERAETEABMELDUNG.md §12.5 b) haengt daran, dass +// `offenerBasisschluessel` aus libsignals Datensatz genau den Schluessel holt, +// den die naechste PreKey-Nachricht traegt — und dass er verschwindet, sobald +// die Gegenstelle geantwortet hat. Geprueft gegen echte libsignal-Nachrichten, +// nicht gegen eine zweite Annahme ueber die Feldnummern. + +import 'dart:typed_data'; + +import 'package:bitdm/core/crypto/bip39.dart'; +import 'package:bitdm/core/crypto/key_derivation.dart'; +import 'package:bitdm/core/crypto/signal_identity.dart'; +import 'package:bitdm/core/crypto/sitzungsdatensatz.dart'; +import 'package:bitdm/core/store/signal_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libsignal/libsignal.dart'; + +import '../support/signal_hilfe.dart'; + +Future _speicher() async { + final keys = await KeyDerivation.fromMnemonic(Bip39.generate()); + final id = SignalIdentityBridge.fromDerived(keys, + registrationId: SignalIdentityBridge.newRegistrationId()); + return BitdmSignalStore(identity: id); +} + +Future _buendel(BitdmSignalStore s) async { + final spk = neuerSpk(s.identity.keyPair, 1); + await s.storeSignedPreKey(1, spk); + final kyber = neuerKyber(s.identity.keyPair, 1); + await s.storeLastResortKyberPreKey(1, kyber); + final otk = neuerPreKey(7); + await s.storePreKey(7, otk); + return PreKeyBundle( + registrationId: s.identity.registrationId, + deviceId: 1, + preKeyId: 7, + preKeyPublic: otk.publicKey(), + signedPreKeyId: 1, + signedPreKeyPublic: spk.publicKey(), + signedPreKeySignature: spk.signature(), + identityKey: s.identity.keyPair.publicKey, + kyberPreKeyId: 1, + kyberPreKeyPublic: kyber.getPublicKey().serialize(), + kyberPreKeySignature: kyber.signature(), + ); +} + +void main() { + setUpAll(signalBereit); + + test('der Basisschluessel im Datensatz ist der der PreKey-Nachricht — und ' + 'faellt mit der ersten Antwort weg', () async { + final anna = await _speicher(); + final bob = await _speicher(); + final zuBob = adr(bob.identity.address); + final zuAnna = adr(anna.identity.address); + await bauerVon(anna).processPreKeyBundle(zuBob, await _buendel(bob)); + + final vorher = offenerBasisschluessel((await anna.loadSession(zuBob))!.serialize()); + expect(vorher, isNotNull); + expect(vorher!.length, 33, reason: 'mit Typbyte 0x05, wie baseKey()'); + + // Zwei Nachrichten vor der Antwort: beide PreKey, beide mit demselben + // Basisschluessel — und der steht vorher schon im Datensatz. + for (var i = 0; i < 2; i++) { + final ct = await chiffreVon(anna).encrypt(zuBob, text('hallo $i')); + expect(ct.type, CiphertextMessageType.preKey); + final erst = PreKeySignalMessage.deserialize(data: ct.ciphertext); + expect(gleich(erst.baseKey(), vorher), isTrue); + await chiffreVon(bob).decrypt( + zuAnna, CiphertextMessage(type: ct.type, ciphertext: ct.ciphertext)); + } + + final antwort = await chiffreVon(bob).encrypt(zuAnna, text('hallo zurueck')); + await chiffreVon(anna).decrypt(zuBob, + CiphertextMessage(type: antwort.type, ciphertext: antwort.ciphertext)); + expect(offenerBasisschluessel((await anna.loadSession(zuBob))!.serialize()), + isNull, + reason: 'nach der Antwort schickt libsignal keine PreKey-Nachricht mehr'); + final danach = await chiffreVon(anna).encrypt(zuBob, text('weiter')); + expect(danach.type, isNot(CiphertextMessageType.preKey)); + + // Beim Empfaenger hat die Sitzung nie einen offenen Basisschluessel. + expect(offenerBasisschluessel((await bob.loadSession(zuAnna))!.serialize()), + isNull); + }); + + test('Unsinn ergibt null, keinen Wurf', () { + expect(offenerBasisschluessel(const []), isNull); + expect(offenerBasisschluessel(Uint8List.fromList([0x0a, 0x7f, 1, 2])), isNull); + expect(offenerBasisschluessel(Uint8List.fromList([0xff, 0xff, 0xff])), isNull); + expect(offenerBasisschluessel(Uint8List.fromList(List.filled(40, 0x80))), isNull); + }); +} diff --git a/secure-messenger/app/test/net/geraeteliste_verschwiegen_test.dart b/secure-messenger/app/test/net/geraeteliste_verschwiegen_test.dart new file mode 100644 index 0000000..7aa7acf --- /dev/null +++ b/secure-messenger/app/test/net/geraeteliste_verschwiegen_test.dart @@ -0,0 +1,840 @@ +// geraeteliste_verschwiegen_test.dart — ein Relay, der eine Abmeldung +// verschweigt (docs/GERAETEABMELDUNG.md §12). +// +// Stufe 2 (geraeteliste_stufe2_test.dart) haelt einen Relay auf, der eine +// AELTERE Fassung wieder einspielt. Hier luegt er geschickter: er nimmt +// Fassung 2 (Tablet abgemeldet) an und liefert weiter Fassung 1 aus — und +// trennt das Tablet nicht. Dagegen stehen die Listenstempel Ende-zu-Ende, die +// Liste als eigene Nachricht (Art 20), die Warnung beim Besitzer und die +// Bindung jeder Sitzung an den Geraeteschluessel. +// +// GEGEN DIE ATTRAPPE: der echte relay_server.py luegt nicht. Die Gegenstellen +// sind echt — eigene libsignal-Speicher je Geraet, eigene +// Geraeteschluessel —, damit jeder Umschlag wirklich ver- und entschluesselt +// wird und kein Test an einer Abkuerzung vorbei gruen wird. + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:bitdm/core/crypto/bip39.dart'; +import 'package:bitdm/core/crypto/key_derivation.dart'; +import 'package:bitdm/core/crypto/signal_identity.dart'; +import 'package:bitdm/core/crypto/sitzungsdatensatz.dart'; +import 'package:bitdm/core/messenger_core.dart'; +import 'package:bitdm/core/net/envelope.dart'; +import 'package:bitdm/core/net/geraeteliste.dart'; +import 'package:bitdm/core/net/payload.dart'; +import 'package:bitdm/core/net/prekey_bundle_bridge.dart'; +import 'package:bitdm/core/net/relay_client.dart'; +import 'package:bitdm/core/net/relay_protocol.dart'; +import 'package:bitdm/core/real_messenger_core.dart'; +import 'package:bitdm/core/store/signal_store.dart'; +import 'package:bitdm/core/store/signal_store_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:libsignal/libsignal.dart'; + +import '../support/absender.dart'; +import '../support/relay_attrappe.dart'; +import '../support/signal_hilfe.dart'; + +/// Eine Adresse mit mehreren Geraeten — jedes mit eigenem Speicher, eigenem +/// Geraeteschluessel und eigenen Prekeys. +class Gegenstelle { + Gegenstelle._(this.identitaet); + + final SignalIdentity identitaet; + final speicher = {}; + final schluessel = {}; + final _karten = >{}; + + String get adresse => identitaet.address; + + /// [identitaet]: fuer weitere Geraete der EIGENEN Adresse des Kerns. + static Future mit(List geraete, + {SignalIdentity? identitaet}) async { + await signalBereit(); + final id = identitaet ?? + SignalIdentityBridge.fromDerived( + await KeyDerivation.fromMnemonic(Bip39.generate()), + registrationId: SignalIdentityBridge.newRegistrationId()); + final x = Gegenstelle._(id); + var n = 40; + for (final g in geraete) { + n++; + final s = BitdmSignalStore(identity: id); + x.speicher[g] = s; + x.schluessel[g] = PrivateKey.generate(); + x._karten[g] = + await legePrekeysAb(s, spkId: n, kyberId: n * 10, preKeyId: n * 100); + } + return x; + } + + GeraeteEintrag eintrag(int g) => + GeraeteEintrag(g, rohOeffentlich(schluessel[g]!), ''); + + /// Eine Fassung, unterschrieben vom Geraet [von]. + Geraeteliste fassung(int epoche, List geraete, {required int von}) => + Geraeteliste.unterschreibe( + identitaet: identitaet, + geraeteschluessel: schluessel[von]!, + epoche: epoche, + geraete: [for (final g in geraete) eintrag(g)], + unterzeichner: von, + ); + + /// Was der Relay unter Kennung [kennung] ausliefert: die Prekeys von + /// [prekeysVon] (sonst die eigenen) und die Signatur am signierten Prekey, + /// ausgestellt vom Schluessel [sigVon] auf [kennung] — oder keine. + /// [sig] ersetzt die Signatur ganz — fuer eine, die der Relay von einem + /// anderen Buendel herueberkopiert. + Map geraetekarte(int kennung, + {int? prekeysVon, int? sigVon, bool mitSig = true, String? sig}) { + final k = _karten[prekeysVon ?? kennung]!; + return { + 'device_id': kennung, + ...k, + if (mitSig) + 'spk_geraete_sig': sig ?? + base64.encode(schluessel[sigVon ?? prekeysVon ?? kennung]!.sign( + message: spkNachricht(adresse, kennung, + k['signed_prekey_id']! as int, k['signed_prekey']! as String))), + }; + } + + /// Die Antwort auf `/prekey/`. + Map karte(List> geraete, + {Geraeteliste? liste}) => + { + 'user_id': adresse, + 'identity_key': base64.encode(identitaet.rawPublicKey), + ...geraete.first, + 'geraete': geraete, + if (liste != null) 'geraeteliste': liste.alsJson(), + }; + + /// Ein Umschlag von Geraet [g] an [an]:[anGeraet]. Ohne Sitzung wird sie + /// aus [buendel] gebaut. Solange libsignal PreKey-Nachrichten schickt, kommt + /// der Sitzungsnachweis dazu — ausgestellt mit [nachweisMit] (sonst dem + /// eigenen Schluessel) auf [nachweisAls] (sonst die eigene Kennung). + Future schreibe(int g, String an, int anGeraet, Payload p, + {RelayBundleResponse? buendel, + bool nachweis = true, + PrivateKey? nachweisMit, + int? nachweisAls}) async { + final s = speicher[g]!; + final ziel = adr(an, anGeraet); + if (!await s.containsSession(ziel)) { + await bauerVon(s, geraet: g).processPreKeyBundle( + ziel, PreKeyBundleBridge.fromRelay(buendel!, deviceId: anGeraet)); + } + var nutz = p; + final basis = + offenerBasisschluessel((await s.loadSession(ziel))!.serialize()); + if (basis != null && nachweis) { + nutz = p.gestempelt( + listenEpoche: p.listenEpoche, + listenHash: p.listenHash, + kenntEpoche: p.kenntEpoche, + sitzungsNachweis: base64.encode((nachweisMit ?? schluessel[g]!).sign( + message: sitzungsNachricht( + absender: adresse, + absenderGeraet: nachweisAls ?? g, + empfaenger: an, + empfaengerGeraet: anGeraet, + basisschluessel: basis, + ))), + ); + } + final ct = await chiffreVon(s, geraet: g).encrypt(ziel, nutz.toBytes()); + return Envelope.of(ct).toBytes(); + } + + Future lies(int g, Uint8List umschlag, String von, int vonGeraet) async { + final klar = await Envelope.fromBytes(umschlag) + .decrypt(chiffreVon(speicher[g]!, geraet: g), adr(von, vonGeraet)); + return Payload.fromBytes(klar); + } +} + +DateTime jetzt() => DateTime.now().toUtc(); + +void main() { + late Directory ordner; + late Relaylage lage; + late RealMessengerCore kern; + late List woerter; + late List angekommen; + late List ereignisse; + final abos = >[]; + final karten = Function()>{}; + final gelesen = {}; + var q = 1000; + var kernBuendelNr = 0; + + Future warteBis(bool Function() fertig, + {Duration frist = const Duration(seconds: 10)}) async { + final ende = DateTime.now().add(frist); + while (!fertig() && DateTime.now().isBefore(ende)) { + await Future.delayed(const Duration(milliseconds: 20)); + } + return fertig(); + } + + setUp(() async { + await signalBereit(); + RealMessengerCore.geraeteNachweisPflicht = false; + ordner = await Directory.systemTemp.createTemp('bitdm-verschwiegen'); + karten.clear(); + gelesen.clear(); + lage = Relaylage() + ..liste = [] + ..kenntListen = true + ..buendel = (wen) => karten[wen]?.call() ?? {}; + kern = RealMessengerCore( + secretStore: SpeicherImKopf(), + databasePath: '${ordner.path}${Platform.pathSeparator}k.db', + relayUri: Uri.parse('http://127.0.0.1:1'), + relayFactory: (uri, id) { + final r = ZaehlRelay(id, lage); + lage.gebaut.add(r); + return r; + }, + ); + kern.quittungsVerzug = () => Duration.zero; + angekommen = []; + ereignisse = []; + abos + ..add(kern.incomingMessages.listen(angekommen.add)) + ..add(kern.contactEvents.listen(ereignisse.add)); + await kern.initialize(); + woerter = await kern.createIdentity(); + await kern.connect(); + lage.listeJe[kern.myId] = [kern.geraetId]; + expect(kern.geraeteStand, ListenStand.mitglied); + }); + + tearDown(() async { + RealMessengerCore.geraeteNachweisPflicht = false; + for (final a in abos) { + await a.cancel(); + } + abos.clear(); + await kern.dispose(); + try { + await ordner.delete(recursive: true); + } catch (_) { + // Windows gibt Handles verzoegert frei. + } + }); + + ZaehlRelay relay() => lage.gebaut.last as ZaehlRelay; + + Future ich() async => SignalIdentityBridge.fromDerived( + await KeyDerivation.fromMnemonic(woerter), + registrationId: 1); + + PrivateKey meinGeraeteschluessel() => PrivateKey.deserialize( + bytes: base64.decode(kern.datenbankFuerTest.meta('geraeteschluessel')!)); + + Geraeteliste eigeneListe() => + Geraeteliste.pruefe(lage.listen[kern.myId], adresse: kern.myId); + + /// Ein frisches Buendel des Kerns — jedes mit eigenem Einmalschluessel, + /// wie beim echten Relay. + Future kernBuendel() async => buendelAusSpeicher( + SignalStoreRepository(kern.datenbankFuerTest) + .openStore(await KeyDerivation.fromMnemonic(woerter)), + nr: kernBuendelNr++); + + List sitzungen() => kern.datenbankFuerTest.raw + .select('SELECT address FROM sessions') + .map((r) => r['address']! as String) + .toList(); + + Set geraeteAn(String adresse, int ab) => { + for (final s in lage.gesendet.skip(ab)) + if (s.an == adresse) s.geraet, + }; + + /// Alles, was der Kern an [x]:[g] geschickt hat und noch nicht gelesen + /// wurde — entschluesselt, in der Reihenfolge des Versands. + Future> post(Gegenstelle x, int g) async { + final aus = <({Payload p, Envelope huelle})>[]; + for (var i = 0; i < lage.gesendet.length; i++) { + final s = lage.gesendet[i]; + if (s.an != x.adresse || s.geraet != g || gelesen.contains(i)) continue; + gelesen.add(i); + aus.add(( + p: await x.lies(g, s.umschlag, kern.myId, kern.geraetId), + huelle: Envelope.fromBytes(s.umschlag), + )); + } + return aus; + } + + Future> texte(Gegenstelle x, int g) async => [ + for (final e in await post(x, g)) + if (e.p.kind == PayloadKind.text) e.p, + ]; + + /// Ein Umschlag kommt vom Relay herein, beschriftet mit [alsGeraet]. + /// Rueckgabe: die Kennung des Empfangsnachweises. + int herein(Gegenstelle x, int alsGeraet, Uint8List umschlag) { + final n = q++; + relay().herein(RelayMessage( + from: x.adresse, + vonGeraet: alsGeraet, + ciphertext: umschlag, + at: jetzt(), + q: n)); + return n; + } + + void alsKontakt(Gegenstelle x) => + kern.ablageFuerTest.speichereKontakt(Contact( + id: x.adresse, addedAt: jetzt(), state: ContactState.active)); + + /// [x] ist Kontakt, der Relay bietet [geraete] an (mit der Liste, die + /// [liste] gerade liefert), der Kern schreibt einmal an alle, und die + /// Geraete in [lesen] lesen es — danach haben beide Seiten Sitzungen. + Future verbinde(Gegenstelle x, List geraete, + {Geraeteliste? Function()? liste, List? lesen}) async { + lage.listeJe[x.adresse] = geraete; + karten[x.adresse] = () { + final l = liste?.call(); + if (l != null) lage.listen[x.adresse] = l.alsJson(); + return x.karte([for (final g in geraete) x.geraetekarte(g)], liste: l); + }; + karten[x.adresse]!(); + alsKontakt(x); + final ab = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'hallo'); + final erwartet = { + for (final g in geraete) + if (liste?.call()?.kennungen.contains(g) ?? true) g, + }; + expect(await warteBis(() => geraeteAn(x.adresse, ab).containsAll(erwartet)), + isTrue, + reason: 'der erste Versand erreicht die gelisteten Geraete'); + for (final g in lesen ?? const []) { + expect((await texte(x, g)).map((p) => p.text), contains('hallo')); + } + } + + // ═══════════════════════════════════════════ Teil 1: Stempel und Art 20 + + test( + 'VERSCHLUCKTE VERTEILUNG: der Stempel der naechsten Nachricht macht die ' + 'Liste veraltet — an das Tablet geht nichts mehr, von ihm kommt nichts ' + 'mehr an', () async { + final x = await Gegenstelle.mit([1, 7]); + final eins = x.fassung(1, [1, 7], von: 1); + final zwei = x.fassung(2, [1], von: 1); + // Der Relay hat Fassung 2 angenommen, liefert aber weiter Fassung 1 aus — + // mit beiden Geraeten — und trennt das Tablet nicht. + await verbinde(x, [1, 7], liste: () => eins, lesen: [1, 7]); + + // Geraet 1 schreibt mit dem Stempel von Fassung 2. Die Liste selbst (die + // Verteilung) hat der Relay verschluckt. + herein( + x, + 1, + await x.schreibe( + 1, + kern.myId, + kern.geraetId, + Payload.text('m-1', 'nach der Abmeldung', jetzt()).gestempelt( + listenEpoche: 2, listenHash: zwei.kurzHash, kenntEpoche: 1))); + expect(await warteBis(() => angekommen.any((m) => m.id == 'm-1')), isTrue, + reason: 'wer die neue Fassung nennt, spricht weiter fuer die Adresse'); + expect(kern.datenbankFuerTest.meta('geraeteliste_ank:${x.adresse}'), + isNotNull, + reason: 'die Ankuendigung steht in meta und ueberlebt einen Neustart'); + + // Der Kern hat beim Relay nachgefragt — und wieder Fassung 1 bekommen. + // Trotzdem: an das Tablet geht nichts. + var ab = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'nachher'); + expect(await warteBis(() => geraeteAn(x.adresse, ab).isNotEmpty), isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect(geraeteAn(x.adresse, ab), {1}, + reason: 'Geraet 7 hat Fassung 2 nicht gestempelt'); + expect(sitzungen(), contains('${x.adresse}:7'), + reason: 'die Liste selbst fehlt noch — die Sitzung bleibt, sie wird ' + 'nur nicht benutzt'); + + // Das Tablet schreibt — mit Fassung 1, und ganz ohne Stempel. + // Entschluesselt, verworfen, aber quittiert. + herein( + x, + 7, + await x.schreibe( + 7, + kern.myId, + kern.geraetId, + Payload.text('t-1', 'vom Tablet', jetzt()).gestempelt( + listenEpoche: 1, listenHash: eins.kurzHash, kenntEpoche: 1))); + final ohne = herein(x, 7, + await x.schreibe(7, kern.myId, kern.geraetId, + Payload.text('t-2', 'vom Tablet, ohne Stempel', jetzt()))); + expect(await warteBis(() => relay().bestaetigt.contains(ohne)), isTrue, + reason: 'quittiert, sonst kaeme derselbe Umschlag bei jedem Verbinden'); + // GEGENPROBE in derselben Reihenfolge: Geraet 1 kommt weiter durch. + herein( + x, + 1, + await x.schreibe( + 1, + kern.myId, + kern.geraetId, + Payload.text('m-2', 'noch einmal', jetzt()).gestempelt( + listenEpoche: 2, listenHash: zwei.kurzHash, kenntEpoche: 1))); + expect(await warteBis(() => angekommen.any((m) => m.id == 'm-2')), isTrue); + expect(angekommen.where((m) => m.id.startsWith('t-')), isEmpty, + reason: 'das abgemeldete Tablet spricht nicht mehr als Besitzer'); + + // Die Liste kommt doch noch an (Art 20 ueber Geraet 1): die Ankuendigung + // ist erfuellt, und die Sitzung zum Tablet geht. + herein( + x, + 1, + await x.schreibe( + 1, + kern.myId, + kern.geraetId, + Payload.geraeteliste('l-2', zwei.alsJson(), jetzt()).gestempelt( + listenEpoche: 2, listenHash: zwei.kurzHash, kenntEpoche: 1))); + expect( + await warteBis(() => !sitzungen().contains('${x.adresse}:7')), isTrue); + expect(kern.datenbankFuerTest.meta('geraeteliste_ank:${x.adresse}'), isNull); + expect(angekommen.where((m) => m.id == 'l-2'), isEmpty, + reason: 'die Liste erscheint nie im Verlauf'); + ab = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'mit Liste'); + expect(await warteBis(() => geraeteAn(x.adresse, ab).isNotEmpty), isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect(geraeteAn(x.adresse, ab), {1}); + }); + + test( + 'ZWEI FASSUNGEN DERSELBEN EPOCHE: Hinweis im Chat, es bleibt bei der ' + 'ersten — aus der Liste und aus dem Stempel', () async { + // Ueber die Liste (Art 20). + final x = await Gegenstelle.mit([1, 3, 7]); + await verbinde(x, [1, 7], + liste: () => x.fassung(1, [1, 7], von: 1), lesen: [1]); + final zweiA = x.fassung(2, [1], von: 1); + final zweiB = x.fassung(2, [1, 3, 7], von: 1); + herein(x, 1, + await x.schreibe(1, kern.myId, kern.geraetId, + Payload.geraeteliste('a', zweiA.alsJson(), jetzt()))); + expect( + await warteBis(() => !sitzungen().contains('${x.adresse}:7')), isTrue); + expect(kern.geraetelisteWiderspruch(x.adresse), isFalse); + herein(x, 1, + await x.schreibe(1, kern.myId, kern.geraetId, + Payload.geraeteliste('b', zweiB.alsJson(), jetzt()))); + expect(await warteBis(() => kern.geraetelisteWiderspruch(x.adresse)), isTrue); + expect( + ereignisse.where((e) => + e.type == ContactEventType.deviceListConflict && + e.contactId == x.adresse), + hasLength(1)); + expect(sitzungen(), isNot(contains('${x.adresse}:7')), + reason: 'es bleibt bei der zuerst gesehenen Fassung'); + + // Ueber den Stempel: Y kennt der Kern in Fassung 2 (A), ein Stempel + // nennt Fassung 2 mit anderem Hash. + final y = await Gegenstelle.mit([1, 7]); + final y2a = y.fassung(2, [1], von: 1); + final y2b = y.fassung(2, [1, 7], von: 1); + await verbinde(y, [1, 7], liste: () => y2a, lesen: [1]); + expect(kern.geraetelisteWiderspruch(y.adresse), isFalse); + herein( + y, + 1, + await y.schreibe( + 1, + kern.myId, + kern.geraetId, + Payload.text('y-1', 'mit anderem Hash', jetzt()).gestempelt( + listenEpoche: 2, listenHash: y2b.kurzHash, kenntEpoche: 1))); + expect(await warteBis(() => kern.geraetelisteWiderspruch(y.adresse)), isTrue); + }); + + test( + 'JEDE NUTZLAST TRAEGT DIE STEMPEL, JEDE PREKEY-NACHRICHT DEN ' + 'SITZUNGSNACHWEIS — bis die Gegenstelle antwortet', () async { + final x = await Gegenstelle.mit([1]); + await verbinde(x, [1]); + await kern.sendMessage(x.adresse, 'zwei'); + expect( + await warteBis( + () => lage.gesendet.where((s) => s.an == x.adresse).length >= 2), + isTrue); + final eigene = eigeneListe(); + final vorher = await post(x, 1); + expect(vorher.map((e) => e.p.text), containsAll(['hallo', 'zwei'])); + for (final e in vorher) { + expect(e.p.listenEpoche, eigene.epoche); + expect(e.p.listenHash, eigene.kurzHash); + expect(e.p.kenntEpoche, 0, reason: 'von X kennt der Kern keine Liste'); + expect(e.huelle.startetSitzung, isTrue); + final basis = + PreKeySignalMessage.deserialize(data: e.huelle.ciphertext).baseKey(); + expect( + signaturGilt( + rohOeffentlich(meinGeraeteschluessel()), + sitzungsNachricht( + absender: kern.myId, + absenderGeraet: kern.geraetId, + empfaenger: x.adresse, + empfaengerGeraet: 1, + basisschluessel: basis, + ), + base64.decode(e.p.sitzungsNachweis!)), + isTrue, + reason: 'der Nachweis gilt fuer genau diese Sitzung'); + } + + // X antwortet (kennt die Liste des Kerns). Ab jetzt keine + // PreKey-Nachricht und kein Nachweis mehr, die Stempel bleiben. + herein( + x, + 1, + await x.schreibe( + 1, + kern.myId, + kern.geraetId, + Payload.text('a-1', 'Antwort', jetzt()).gestempelt( + kenntEpoche: eigene.epoche))); + expect(await warteBis(() => angekommen.any((m) => m.id == 'a-1')), isTrue); + final ab = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'drei'); + expect(await warteBis(() => geraeteAn(x.adresse, ab).isNotEmpty), isTrue); + final drei = (await post(x, 1)).where((e) => e.p.text == 'drei').single; + expect(drei.huelle.startetSitzung, isFalse); + expect(drei.p.sitzungsNachweis, isNull); + expect(drei.p.listenEpoche, eigene.epoche); + }); + + test( + 'HEILEN: wer mit einer aelteren Epoche schreibt, bekommt die Liste ' + 'nachgeschickt — hoechstens einmal in zehn Minuten', () async { + final x = await Gegenstelle.mit([1]); + await verbinde(x, [1], lesen: [1]); + final eigene = eigeneListe(); + + Future antworte(String id, int ke) async { + herein( + x, + 1, + await x.schreibe(1, kern.myId, kern.geraetId, + Payload.text(id, 'Antwort', jetzt()).gestempelt(kenntEpoche: ke))); + expect(await warteBis(() => angekommen.any((m) => m.id == id)), isTrue); + // Das Nachschicken laeuft nebenher. + await Future.delayed(const Duration(milliseconds: 800)); + } + + Future> listen() async => [ + for (final e in await post(x, 1)) + if (e.p.kind == PayloadKind.geraeteliste) e.p, + ]; + + await antworte('a-1', eigene.epoche); + expect(await listen(), isEmpty, + reason: 'GEGENPROBE: wer die Epoche kennt, bekommt nichts'); + + await antworte('a-2', 0); + final l = await listen(); + expect(l, hasLength(1), reason: 'die verschluckte Verteilung kommt nach'); + expect( + Geraeteliste.pruefe(l.single.geraetelisteRoh, adresse: kern.myId).roh, + eigene.roh); + + await antworte('a-3', 0); + expect(await listen(), isEmpty, reason: 'nicht bei jeder Nachricht'); + }); + + test( + 'ABMELDEN VERTEILT: an Kontakte, an die eigenen Geraete — und einmal an ' + 'das abgemeldete', () async { + // Ein zweites eigenes Geraet (5) mit eigenem Speicher und Schluessel. + final ich5 = await Gegenstelle.mit([5], identitaet: await ich()); + final zwei = Geraeteliste.unterschreibe( + identitaet: await ich(), + geraeteschluessel: meinGeraeteschluessel(), + epoche: 2, + geraete: [...eigeneListe().geraete, ich5.eintrag(5)], + unterzeichner: kern.geraetId, + ); + lage.listen[kern.myId] = zwei.alsJson(); + lage.listeJe[kern.myId] = [kern.geraetId, 5]; + karten[kern.myId] = () => ich5.karte([ich5.geraetekarte(5)]); + relay().herein(const RelayGeraetelisteNeu(2)); + expect(await warteBis(() => sitzungen().contains('${kern.myId}:5')), isTrue, + reason: 'mit gueltiger Prekey-Signatur entsteht die Sitzung'); + + final x = await Gegenstelle.mit([1]); + await verbinde(x, [1], lesen: [1]); + + final ab = lage.gesendet.length; + await kern.meldeGeraetAb(5); + final drei = eigeneListe(); + expect(drei.epoche, 3); + expect(drei.kennungen, {kern.geraetId}); + + Future> listenBei(Gegenstelle wer, int g) async => [ + for (final e in await post(wer, g)) + if (e.p.kind == PayloadKind.geraeteliste) + Geraeteliste.pruefe(e.p.geraetelisteRoh, adresse: kern.myId), + ]; + + expect( + await warteBis(() => + geraeteAn(x.adresse, ab).isNotEmpty && + geraeteAn(kern.myId, ab).contains(5)), + isTrue); + expect((await listenBei(x, 1)).map((l) => l.roh), [drei.roh], + reason: 'der Kontakt erfaehrt es Ende-zu-Ende'); + expect((await listenBei(ich5, 5)).map((l) => l.roh), [drei.roh], + reason: 'der Abschied: das abgemeldete Geraet bekommt die Fassung, ' + 'die es entfernt'); + }); + + test( + 'DAS ABGEMELDETE GERAET ENTFERNT SEINE WOERTER, auch wenn der Relay es ' + 'nie trennt', () async { + // Der Kern ist hier das Tablet. Das Telefon (Geraet 5) meldet es ab, der + // Relay liefert weiter Fassung 2 und laesst das Tablet herein. + final telefon = await Gegenstelle.mit([5], identitaet: await ich()); + final zwei = Geraeteliste.unterschreibe( + identitaet: await ich(), + geraeteschluessel: meinGeraeteschluessel(), + epoche: 2, + geraete: [...eigeneListe().geraete, telefon.eintrag(5)], + unterzeichner: kern.geraetId, + ); + lage.listen[kern.myId] = zwei.alsJson(); + lage.listeJe[kern.myId] = [kern.geraetId, 5]; + karten[kern.myId] = () => telefon.karte([telefon.geraetekarte(5)]); + relay().herein(const RelayGeraetelisteNeu(2)); + expect(await warteBis(() => sitzungen().contains('${kern.myId}:5')), isTrue); + + final adresse = kern.myId; + final drei = telefon.fassung(3, [5], von: 5); + herein( + telefon, + 5, + await telefon.schreibe(5, kern.myId, kern.geraetId, + Payload.geraeteliste('ab', drei.alsJson(), jetzt()), + buendel: await kernBuendel())); + expect( + await warteBis(() => kern.geraeteStand == ListenStand.abgemeldet), isTrue); + expect(kern.hasIdentity, isFalse, + reason: 'die zwoelf Woerter sind von diesem Geraet entfernt'); + expect(lage.listen[adresse], zwei.alsJson(), + reason: 'und das, obwohl der Relay weiter Fassung 2 ausliefert'); + }); + + // ═════════════════════════════════════════ Teil 2: Warnung beim Besitzer + + test( + 'DER BESITZER SIEHT, WENN DER RELAY SEINE AENDERUNG NICHT AUSLIEFERT', + () async { + final zwei = Geraeteliste.unterschreibe( + identitaet: await ich(), + geraeteschluessel: meinGeraeteschluessel(), + epoche: 2, + geraete: [ + ...eigeneListe().geraete, + GeraeteEintrag(5, rohOeffentlich(PrivateKey.generate()), ''), + GeraeteEintrag(6, rohOeffentlich(PrivateKey.generate()), ''), + ], + unterzeichner: kern.geraetId, + ); + lage.listen[kern.myId] = zwei.alsJson(); + relay().herein(const RelayGeraetelisteNeu(2)); + expect(await warteBis(() => kern.eigeneGeraete.length == 3), isTrue); + + // Ehrlich: keine Warnung. + await kern.meldeGeraetAb(5); + await Future.delayed(const Duration(milliseconds: 300)); + expect(kern.relayVerschweigtListe, isNull); + + // Der Relay nimmt Fassung 4 an und liefert weiter Fassung 3 aus. + lage.verschweigtListen = true; + await kern.meldeGeraetAb(6); + expect(await warteBis(() => kern.relayVerschweigtListe == 4), isTrue); + expect(kern.eigeneGeraete.map((g) => g.id), [kern.geraetId], + reason: 'die eigene Anzeige folgt der eigenen Fassung'); + + // Liefert er sie spaeter doch, faellt die Warnung beim naechsten Verbinden. + lage.listen[kern.myId] = lage.verschwiegen[kern.myId]!; + await kern.disconnect(); + await kern.connect(); + expect(await warteBis(() => kern.relayVerschweigtListe == null), isTrue); + }); + + // ═════════════════════════ Teil 3: Sitzungen gehoeren zum Geraeteschluessel + + test( + 'DAS BUENDEL DES TABLETS UNTER DER KENNUNG DES LAPTOPS WIRD NICHT ' + 'ANGENOMMEN', () async { + // Fassung: 1 und 3 (der Laptop). Das Tablet (7) ist abgemeldet, hat aber + // die Woerter — und unterschreibt sein Buendel mit SEINEM Schluessel. + Future> aufbau(Map Function(Gegenstelle) laptop) async { + final x = await Gegenstelle.mit([1, 3, 7]); + final l = x.fassung(1, [1, 3], von: 1); + lage.listen[x.adresse] = l.alsJson(); + lage.listeJe[x.adresse] = [1, 3]; + karten[x.adresse] = + () => x.karte([x.geraetekarte(1), laptop(x)], liste: l); + alsKontakt(x); + final ab = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'hallo'); + expect(await warteBis(() => geraeteAn(x.adresse, ab).contains(1)), isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + return { + for (final s in sitzungen()) + if (s.startsWith('${x.adresse}:')) int.parse(s.split(':').last), + }; + } + + expect(await aufbau((x) => x.geraetekarte(3, prekeysVon: 7)), {1}, + reason: 'unterschrieben vom Schluessel des Tablets, nicht des Laptops'); + expect( + await aufbau((x) => x.geraetekarte(3, + prekeysVon: 7, sig: x.geraetekarte(3)['spk_geraete_sig']! as String)), + {1}, + reason: 'die echte Signatur des Laptops gilt fuer SEINEN Prekey, nicht ' + 'fuer den des Tablets'); + expect(await aufbau((x) => x.geraetekarte(3)), {1, 3}, + reason: 'GEGENPROBE: das echte Buendel des Laptops'); + + // UEBERGANG (§12.5): eine FEHLENDE Signatur geht noch durch, Fassungen vor + // 2.0.1 signieren nichts. Mit der Pflicht nicht mehr. + expect(await aufbau((x) => x.geraetekarte(3, prekeysVon: 7, mitSig: false)), + {1, 3}); + RealMessengerCore.geraeteNachweisPflicht = true; + expect(await aufbau((x) => x.geraetekarte(3, prekeysVon: 7, mitSig: false)), + {1}); + }); + + test( + 'DIE ERSTNACHRICHT DES TABLETS UNTER DER KENNUNG DES LAPTOPS verdraengt ' + 'die echte Sitzung nicht', () async { + final x = await Gegenstelle.mit([1, 3, 7]); + await verbinde(x, [1, 3], + liste: () => x.fassung(1, [1, 3], von: 1), lesen: [3]); + herein(x, 3, + await x.schreibe(3, kern.myId, kern.geraetId, + Payload.text('echt-1', 'vom Laptop', jetzt()))); + expect(await warteBis(() => angekommen.any((m) => m.id == 'echt-1')), isTrue); + + // Das Tablet baut eine frische Sitzung zum Kern und unterschreibt — wie + // seine unveraenderte App — mit seinem Schluessel auf seine Kennung. Der + // Relay beschriftet den Umschlag mit "Geraet 3". + final t1 = herein( + x, + 3, + await x.schreibe(7, kern.myId, kern.geraetId, + Payload.text('tablet-1', 'als Laptop', jetzt()), + buendel: await kernBuendel())); + expect(await warteBis(() => relay().bestaetigt.contains(t1)), isTrue); + // Und eines, das die Kennung des Laptops in den Nachweis schreibt — mit + // dem falschen Schluessel. (Dieselbe, noch unbestaetigte Sitzung: wieder + // eine PreKey-Nachricht.) + final t2 = herein( + x, + 3, + await x.schreibe(7, kern.myId, kern.geraetId, + Payload.text('tablet-2', 'als Laptop', jetzt()), + nachweisAls: 3)); + expect(await warteBis(() => relay().bestaetigt.contains(t2)), isTrue); + expect(angekommen.where((m) => m.id.startsWith('tablet')), isEmpty); + + // DIE ANTWORT "AN DEN LAPTOP" LIEST DER LAPTOP. Haette die Sitzung des + // Tablets die echte verdraengt, liefe sie darueber — dann laese sie das + // Tablet, und der Laptop koennte sie nicht entschluesseln. SOFORT und + // nicht erst nach der naechsten Nachricht des Laptops: die holte in + // libsignal den alten Sitzungszustand wieder nach vorn und verdeckte die + // Uebernahme (Mutationsprobe vom 18.10.2026). + final abAntwort = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'antwort'); + expect( + await warteBis(() => geraeteAn(x.adresse, abAntwort).contains(3)), isTrue); + expect((await texte(x, 3)).map((p) => p.text), contains('antwort')); + + // Und die echte Sitzung traegt weiter. + herein(x, 3, + await x.schreibe(3, kern.myId, kern.geraetId, + Payload.text('echt-2', 'wieder vom Laptop', jetzt()))); + expect(await warteBis(() => angekommen.any((m) => m.id == 'echt-2')), isTrue, + reason: 'die echte Sitzung zum Laptop ist nicht verdraengt'); + + // GEGENPROBE: Geraet 1 baut selbst eine Sitzung auf — mit gueltigem + // Nachweis kommt es an. Ohne Nachweis nur im Uebergang. + RealMessengerCore.geraeteNachweisPflicht = true; + final b = await kernBuendel(); + final ohne = herein( + x, + 1, + await x.schreibe(1, kern.myId, kern.geraetId, + Payload.text('eins-ohne', 'ohne Nachweis', jetzt()), + buendel: b, nachweis: false)); + expect(await warteBis(() => relay().bestaetigt.contains(ohne)), isTrue); + expect(angekommen.where((m) => m.id == 'eins-ohne'), isEmpty, + reason: 'mit Pflicht kein Sitzungsaufbau ohne Nachweis'); + herein(x, 1, + await x.schreibe(1, kern.myId, kern.geraetId, + Payload.text('eins-mit', 'mit Nachweis', jetzt()))); + expect( + await warteBis(() => angekommen.any((m) => m.id == 'eins-mit')), isTrue); + }); + + test( + '"ZIELGERAET UNBEKANNT" LAESST DIE SITZUNG STEHEN, wenn das Geraet in der ' + 'signierten Liste steht — ohne Liste wie bisher', () async { + final x = await Gegenstelle.mit([1, 7]); + await verbinde(x, [1, 7], liste: () => x.fassung(1, [1, 7], von: 1)); + + // Der Relay verleugnet Geraet 7. + lage.unbekannteGeraete = {7}; + lage.listeJe[x.adresse] = [1]; + final vorZwei = lage.versucht.length; + await kern.sendMessage(x.adresse, 'zwei'); + expect( + await warteBis(() => lage.versucht + .skip(vorZwei) + .any((v) => v.an == x.adresse && v.geraet == 7)), + isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect(sitzungen(), contains('${x.adresse}:7'), + reason: 'sonst baute der naechste Versand sie aus einem Buendel neu, ' + 'das der Relay waehlt'); + final ab = lage.versucht.length; + final abGesendet = lage.gesendet.length; + await kern.sendMessage(x.adresse, 'drei'); + expect(await warteBis(() => geraeteAn(x.adresse, abGesendet).isNotEmpty), + isTrue); + await Future.delayed(const Duration(milliseconds: 300)); + expect({ + for (final v in lage.versucht.skip(ab)) + if (v.an == x.adresse) v.geraet, + }, {1}, reason: 'bis zur naechsten Auffrischung uebersprungen'); + + // GEGENPROBE ohne Liste: die Sitzung faellt. + final y = await Gegenstelle.mit([1, 7]); + lage.unbekannteGeraete = {}; + await verbinde(y, [1, 7]); + lage.unbekannteGeraete = {7}; + lage.listeJe[y.adresse] = [1]; + await kern.sendMessage(y.adresse, 'zwei'); + expect( + await warteBis(() => !sitzungen().contains('${y.adresse}:7')), isTrue); + }); +} diff --git a/secure-messenger/app/test/net/payload_listenstempel_test.dart b/secure-messenger/app/test/net/payload_listenstempel_test.dart new file mode 100644 index 0000000..f130b12 --- /dev/null +++ b/secure-messenger/app/test/net/payload_listenstempel_test.dart @@ -0,0 +1,139 @@ +// payload_listenstempel_test.dart — die Listenstempel und die Art 20 auf der +// Leitung (docs/GERAETEABMELDUNG.md §12.3 und §12.5 b). +// +// Die Stempel entscheiden, wem der Kern glaubt. Ein kaputter Stempel darf +// deshalb nicht still zu "kein Stempel" werden — das waere fuer den Kern +// "Fassung vor 2.0.1" und damit ein anderer Fall. + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:bitdm/core/net/geraeteliste.dart'; +import 'package:bitdm/core/net/payload.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Uint8List roh(int art, Map json) { + final inhalt = [art, ...utf8.encode(jsonEncode(json)), 0x80]; + final rest = inhalt.length % Payload.blockSize; + return Uint8List.fromList( + [...inhalt, ...List.filled(rest == 0 ? 0 : Payload.blockSize - rest, 0)]); +} + +void main() { + final t = DateTime.utc(2026, 10, 18, 12); + final ms = t.millisecondsSinceEpoch; + final lh = base64.encode(List.filled(12, 7)); + final gs = base64.encode(List.filled(64, 9)); + + test('DIE ART 20 HAT IHRE FESTE NUMMER', () { + expect(PayloadKind.geraeteliste.code, 20); + }); + + test('RUNDLAUF: Stempel und Nachweis kommen heil zurueck, alles andere bleibt', + () { + final p = Payload.text('m1', 'hallo', t, antwortAuf: 'm0').gestempelt( + listenEpoche: 3, listenHash: lh, kenntEpoche: 2, sitzungsNachweis: gs); + final z = Payload.fromBytes(p.toBytes()); + expect(z.messageId, 'm1'); + expect(z.text, 'hallo'); + expect(z.antwortAuf, 'm0'); + expect(z.sentAt, t); + expect(z.listenEpoche, 3); + expect(z.listenHash, lh); + expect(z.kenntEpoche, 2); + expect(z.sitzungsNachweis, gs); + }); + + test('OHNE STEMPEL ZEICHEN FUER ZEICHEN WIE VORHER', () { + // Ein Relay ohne Listen: dann stempelt der Kern nichts, und eine App vor + // 2.0.1 sieht genau die Bytes, die sie kennt. + final p = Payload.text('m1', 'hallo', t); + expect(p.gestempelt().toBytes(), p.toBytes()); + final z = Payload.fromBytes(p.toBytes()); + expect([z.listenEpoche, z.listenHash, z.kenntEpoche, z.sitzungsNachweis], + everyElement(isNull)); + }); + + test('ke = 0 HEISST "KEINE LISTE BEKANNT" und ist erlaubt', () { + expect(Payload.fromBytes(roh(1, {'id': 'm', 't': ms, 'x': 'a', 'ke': 0})) + .kenntEpoche, 0); + }); + + test('DIE LISTE ALS NACHRICHT: Rundlauf und Rohform', () { + final liste = {'liste': '{"a":1}', 'sig_identitaet': 'x', 'sig_geraet': 'y'}; + final z = Payload.fromBytes(Payload.geraeteliste('l1', liste, t).toBytes()); + expect(z.kind, PayloadKind.geraeteliste); + expect(z.geraetelisteRoh, liste); + expect(Payload.text('m', '{}', t).geraetelisteRoh, isNull, + reason: 'nur die Art 20 traegt eine Liste'); + expect( + Payload.fromBytes(roh(20, {'id': 'l', 't': ms, 'x': '[1,2]'})) + .geraetelisteRoh, + isNull, + reason: 'kein Objekt — keine Liste, aber auch kein Absturz'); + }); + + test('WAS DIE GERAETESCHLUESSEL UNTERSCHREIBEN — Zeichen fuer Zeichen', () { + // DIESELBE ZEICHENKETTE steht in server/test_relay.py + // (test_spk_nachricht_ist_die_der_app). Weicht eine Seite ab, scheitert + // jede Anmeldung mit Signatur an 403 — mit einer Meldung, die nicht auf + // ein Zeilenende zeigt. + expect(utf8.decode(spkNachricht('ABCD', 7, 42, 'BQ==')), + 'BitDM-Prekey-v1\nABCD\n7\n42\nBQ=='); + expect( + utf8.decode(sitzungsNachricht( + absender: 'ABCD', + absenderGeraet: 3, + empfaenger: 'EFGH', + empfaengerGeraet: 1, + basisschluessel: [5, 0, 1], + )), + 'BitDM-Sitzung-v1\nABCD\n3\nEFGH\n1\nBQAB'); + expect(listenKurzHash('abc'), 'ungWv48Bz+pBQUDe', + reason: 'die ersten 12 Byte von SHA-256("abc")'); + }); + + group('WAS ABGEWIESEN WIRD', () { + void abgewiesen(int art, Map extra) => expect( + () => Payload.fromBytes( + roh(art, {'id': 'm', 't': ms, 'x': 'a', ...extra})), + throwsA(isA()), + reason: '$extra'); + + test('eine Epoche, die keine ist', () { + abgewiesen(1, {'le': 0, 'lh': lh}); + abgewiesen(1, {'le': -1, 'lh': lh}); + abgewiesen(1, {'le': '3', 'lh': lh}); + abgewiesen(1, {'le': 1.5, 'lh': lh}); + abgewiesen(1, {'le': 9007199254740993, 'lh': lh}); + }); + + test('Epoche und Hash nur zusammen', () { + abgewiesen(1, {'le': 3}); + abgewiesen(1, {'lh': lh}); + }); + + test('ein Hash mit falscher Laenge oder kein base64', () { + abgewiesen(1, {'le': 3, 'lh': base64.encode(List.filled(11, 1))}); + abgewiesen(1, {'le': 3, 'lh': base64.encode(List.filled(32, 1))}); + abgewiesen(1, {'le': 3, 'lh': '!!!!!!!!!!!!!!!!'}); + abgewiesen(1, {'le': 3, 'lh': 12}); + }); + + test('eine bekannte Epoche, die keine ist', () { + abgewiesen(1, {'ke': -1}); + abgewiesen(1, {'ke': 'zwei'}); + }); + + test('ein Nachweis, der keine Signatur sein kann', () { + abgewiesen(1, {'gs': base64.encode(List.filled(63, 1))}); + abgewiesen(1, {'gs': 'keine Signatur'}); + abgewiesen(1, {'gs': 64}); + }); + + test('eine Liste ohne Inhalt', () { + expect(() => Payload.fromBytes(roh(20, {'id': 'l', 't': ms, 'x': ''})), + throwsA(isA())); + }); + }); +} diff --git a/secure-messenger/app/test/oberflaeche/geraete_seite_test.dart b/secure-messenger/app/test/oberflaeche/geraete_seite_test.dart index 2121b21..1ac326b 100644 --- a/secure-messenger/app/test/oberflaeche/geraete_seite_test.dart +++ b/secure-messenger/app/test/oberflaeche/geraete_seite_test.dart @@ -154,4 +154,36 @@ void main() { await tester.pumpAndSettle(); expect(kern.beitrittscode, isNull); }); + + testWidgets( + 'LIEFERT DER SERVER DIE LETZTE AENDERUNG NICHT AUS, SAGT ES DIE LISTE ' + '(GERAETEABMELDUNG §12.4)', (tester) async { + await neuesGeraet(tester); + await tester.runAsync(() => kern.setzeZurueck()); + const warnung = ValueKey('hinweis-relay-verschweigt'); + + Future oeffne() async { + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: GeraeteKachel(st: st, stil: _stil)))); + await tester.tap(find.byKey(const ValueKey('geraete-verwalten'))); + await tester.pumpAndSettle(); + } + + await oeffne(); + expect(find.byKey(warnung), findsNothing, + reason: 'GEGENPROBE: der Server liefert, was dieses Geraet kennt'); + await tester.tap(find.text(_t('close')).last); + await tester.pumpAndSettle(); + + // Der Server nimmt die naechste Fassung an und liefert weiter die alte. + lage.verschweigtListen = true; + await tester.runAsync(() async { + await kern.setzeZurueck(); + await Future.delayed(const Duration(milliseconds: 300)); + }); + expect(kern.relayVerschweigtListe, isNotNull); + await oeffne(); + expect(find.byKey(warnung), findsOneWidget); + expect(find.text(_t('geraeteRelayVerschweigt')), findsOneWidget); + }); } diff --git a/secure-messenger/app/test/support/relay_attrappe.dart b/secure-messenger/app/test/support/relay_attrappe.dart index d58f516..88acc11 100644 --- a/secure-messenger/app/test/support/relay_attrappe.dart +++ b/secure-messenger/app/test/support/relay_attrappe.dart @@ -51,6 +51,10 @@ class Relaylage { /// nicht" — das Verhalten von vor dem Umbau. List? liste; + /// Wie [liste], aber je Adresse — hat Vorrang, wo gesetzt. Sonst gaelte + /// dieselbe Antwort fuer die eigene Adresse und jede Gegenstelle. + final listeJe = >{}; + /// Wenn gesetzt, wirft die Geraeteliste, statt zu antworten. RelayException? listeFehler; int listeAbrufe = 0; @@ -72,6 +76,10 @@ class Relaylage { final gesendet = <({String an, int geraet, Uint8List umschlag})>[]; + /// Jeder Versuch, auch die abgewiesenen — in [gesendet] stehen nur die + /// angenommenen. + final versucht = <({String an, int geraet})>[]; + /// Wie lange ein HTTP-Umlauf dauert. /// /// NICHT NULL BEI DEN REIHENFOLGE-FAELLEN. Eine Attrappe, die sofort @@ -92,6 +100,17 @@ class Relaylage { bool kenntListen = false; final listen = >{}; final beitritte = >>{}; + + /// DER RELAY, DER EINE ABMELDUNG VERSCHWEIGT (docs/GERAETEABMELDUNG.md + /// §12): er nimmt jede neue Fassung an und bestaetigt sie, liefert aber + /// weiter aus, was in [listen] steht. + bool verschweigtListen = false; + + /// Was der Relay angenommen, aber verschwiegen hat — je Adresse die letzte. + final verschwiegen = >{}; + + /// An diese Geraete antwortet der Relay "Zielgeraet unbekannt" (§12.5 c). + Set unbekannteGeraete = {}; } class RelayAttrappe implements RelayClient { @@ -126,7 +145,11 @@ class RelayAttrappe implements RelayClient { if (!lage.kenntListen) { throw const RelayException('Not Found', statusCode: 404); } - lage.listen[liste.adresse] = liste.alsJson(); + if (lage.verschweigtListen) { + lage.verschwiegen[liste.adresse] = liste.alsJson(); + } else { + lage.listen[liste.adresse] = liste.alsJson(); + } return liste.epoche; } @@ -162,7 +185,7 @@ class RelayAttrappe implements RelayClient { if (lage.umlauf > Duration.zero) await Future.delayed(lage.umlauf); final f = lage.listeFehler; if (f != null) throw f; - return lage.liste; + return lage.listeJe[userId] ?? lage.liste; } @override @@ -187,9 +210,14 @@ class RelayAttrappe implements RelayClient { // KEIN `to_device` HEISST GERAET 1 (Spezifikation §3) — dieselbe Regel wie // auf der Leitung, sonst zaehlte dieser Buchhalter anders als der Server. final g = geraet ?? 1; + lage.versucht.add((an: to, geraet: g)); if (lage.sendeFehlerFuerGeraet.contains(g)) { throw RelayException('Zustellung an Geraet $g abgelehnt'); } + if (lage.unbekannteGeraete.contains(g)) { + // Derselbe Text wie vom echten Relay (relay_server.py, Rahmen `error`). + throw const RelayException('Zielgeraet unbekannt'); + } lage.gesendet.add((an: to, geraet: g, umschlag: c)); } diff --git a/secure-messenger/docs/GERAETEABMELDUNG.md b/secure-messenger/docs/GERAETEABMELDUNG.md index c82f342..ce1f646 100644 --- a/secure-messenger/docs/GERAETEABMELDUNG.md +++ b/secure-messenger/docs/GERAETEABMELDUNG.md @@ -496,8 +496,9 @@ derselbe englische Text. ## 12. Relay verschweigt eine Abmeldung (Befund 18.10.2026) -Entwurf, Stand 18.10.2026, Zweig `fix/geraeteliste-verschwiegen` ab `main` -(2.0.0). Zuerst zur Abstimmung; gebaut wird erst nach Henriks Ja. +Stand 18.10.2026: gebaut, Zweig `fix/geraeteliste-verschwiegen` ab `main` +(2.0.0), fuer 2.0.1. Was beim Bauen vom Entwurf abwich, steht an Ort und +Stelle; die Belege stehen in §12.8. ### 12.1 Befund @@ -564,14 +565,18 @@ still. Sitzung noch steht. Es prueft sie und entfernt die Woerter, auch wenn der Relay es nie trennt. 2. **Ankuendigung merken.** Nennt ein Stempel von Adresse X eine hoehere - Epoche, als wir kennen, merkt sich die App das in `meta`: Epoche, Hash und - welche Geraete von X sie genannt haben. Dann laedt sie die Liste beim Relay - nach. + Epoche, als wir kennen, merkt sich die App das in `meta` + (`geraeteliste_ank:`): Epoche, Hash und welche Geraete von X sie + genannt haben. Dann laedt sie die Liste beim Relay nach, hoechstens einmal + je Minute und Adresse. Kommt die angekuendigte Fassung (oder eine neuere), + faellt die Ankuendigung. 3. **Veraltet.** Als veraltet gilt die bekannte Liste von X, solange sie hinter der Ankuendigung liegt: kleinere Epoche, oder gleiche Epoche mit anderem Hash. Dann gilt: - - Versand an X geht nur an Geraete, die die Ankuendigung selbst gestempelt - haben. Hat keines davon eine Sitzung, bleibt die Nachricht liegen. + - Versand an X fragt zuerst einmal beim Relay nach. Liegt die Liste dann + noch hinten, geht er nur an Geraete, die die Ankuendigung selbst + gestempelt haben. Hat keines davon eine Sitzung, bleibt die Nachricht + liegen (ohne Buendelabruf). - Post von X, deren Stempel hinter der Ankuendigung liegt oder fehlt, wird nach dem Entschluesseln verworfen (quittiert, nicht angezeigt). Die Art 20 ist ausgenommen. @@ -581,17 +586,45 @@ still. Verteilung holt so die naechste Nachricht des Kontakts nach. 5. **Widerspruch.** Zwei verschiedene Fassungen derselben Epoche (aus Stempel oder Liste) kann ein ehrlicher Relay nicht erzeugen, er nimmt nur genau +1 - an. Die App merkt sich das je Adresse und zeigt im Chat den Hinweis - "Geraeteliste widerspruechlich, Sicherheitsnummer vergleichen". Es bleibt - bei der zuerst gesehenen Fassung, versandt wird nach Regel 3. + an. Die App merkt sich das je Adresse (`geraeteliste_widerspruch:`, + bis eine hoehere Fassung kommt), meldet `deviceListConflict` und zeigt im + Chat einen warnenden Hinweis: zwei widerspruechliche Geraetelisten, das + erzeuge nur ein manipulierter Server oder jemand mit den zwoelf Woertern, + Sicherheitsnummer vergleichen. Es bleibt bei der zuerst gesehenen Fassung, + versandt wird nach Regel 3. + +Nachladen nach Regel 2 hat eine eigene Bremse (eine Minute je Adresse), nicht +die von Stufe 2: teilten sich beide eine, verbrauchte der erste Stempel einer +Kontaktanfrage die Minute, und ein Geraet, das darin zuruecksetzte, kam nicht +mehr durch (`sicherung_test.dart` im Gesamtlauf). Ein Abruf von Stufe 2 oder +der Geraetepruefung zaehlt aber auch fuer sie. Kein Nachladen fuer Fremde +(erste Kontaktanfrage, wie Stufe 2), fuer die Art 20 (sie bringt die Liste +mit) und vor dem Versand, wenn die Geraetepruefung ohnehin faellig ist. Die +Art 20 an eigene Geraete geht nur an solche, die schon in der vorigen Fassung +standen: ein eben beigetretenes hat die Liste gerade selbst geholt. + +Gebaut in `payload.dart` (Felder, Art 20, `gestempelt`), +`geraeteverwaltung.dart` (Abschnitt "Verschweigen") und +`real_messenger_core.dart` (`_sendePayload` stempelt, `_nimmUmschlagJetzt` +prueft). Ein kaputter Stempel ist ein Formatfehler und wird nicht still zu +"kein Stempel": der Kern entscheidet an ihm, wem er glaubt. +`abmelden` dieses Geraets wartet hoechstens zehn Sekunden auf die +Verteilung, danach ist die Datenbank zu; den Rest heilen die anderen eigenen +Geraete nach Regel 4. ### 12.4 Teil 2: Der Besitzer prueft, was der Relay ausliefert (nur App) Nach jedem Veroeffentlichen und bei jedem Verbinden vergleicht die App die -eigene Liste, die der Relay liefert, mit der, die sie selbst kennt. Ist die -gelieferte aelter oder anders, zeigt "Geraete verwalten" eine Warnung: "Der -Server liefert deine letzte Aenderung der Geraeteliste nicht aus. Deine -Kontakte erfahren sie ueber deine Nachrichten." +eigene Liste, die der Relay liefert, mit der, die sie selbst kennt. Nach dem +Veroeffentlichen erst nach drei Sekunden und nur, wenn bis dahin keiner +verglichen hat: ein ehrlicher Relay meldet die neue Fassung allen Geraeten +der Liste, dem veroeffentlichenden auch, und dessen Auffrischung vergleicht +ohnehin. Jeder Abruf kostet eine Marke derselben Herkunftsbremse wie +`/prekey`. Ist die gelieferte aelter oder anders, zeigt "Geraete +verwalten" eine Warnung: "Der Server liefert deine letzte Aenderung der +Geraeteliste nicht aus. Deine Kontakte erfahren sie trotzdem ueber deine +Nachrichten. Bleibt dieser Hinweis, vertrau diesem Server nicht." Liefert er +sie spaeter, faellt die Warnung beim naechsten Vergleich. Grenze: Ein Relay, der gezielt luegt, zeigt dem Besitzer die Wahrheit. Dagegen hilft Teil 1, nicht Teil 2. @@ -625,9 +658,17 @@ gs = Sig(GS, "BitDM-Sitzung-v1\n" ‖ absender ‖ "\n" ‖ absendergeraet ‖ " Der Empfaenger prueft `gs` gegen den Listenschluessel der Kennung, unter der die Nachricht ankommt, und gegen den Basisschluessel der PreKey-Nachricht. Passt sie nicht, verwirft er die neue Sitzung (Schnappschuss zurueck, wie bei -einer wiederholten Kyber-Erstnachricht) und zeigt nichts an. Damit ist -`from_device` geprueft: eine Nachricht unter fremder Kennung entschluesselt -entweder nicht, oder sie hat den falschen Nachweis. +einer wiederholten Kyber-Erstnachricht; Fehler `FremdesGeraet`, eingeordnet +wie unlesbar) und zeigt nichts an. Damit ist `from_device` geprueft: eine +Nachricht unter fremder Kennung entschluesselt entweder nicht, oder sie hat +den falschen Nachweis. + +Den Basisschluessel kennt der Absender vor dem Verschluesseln, die Anbindung +(libsignal 8.0.0) reicht ihn aber nicht heraus. `sitzungsdatensatz.dart` liest +ihn aus dem gespeicherten Datensatz (`storage.proto`: `current_session` = 1, +`pending_pre_key` = 9, `base_key` = 2). Der Test dazu vergleicht gegen +`PreKeySignalMessage.baseKey()` echter Nachrichten; aendert libsignal das +Format, wird er rot. **(c) "Zielgeraet unbekannt"** loescht die Sitzung nicht mehr, wenn das Geraet in der signierten Liste steht. Die App laedt die Liste nach. Fehlt das Geraet @@ -638,7 +679,8 @@ Ohne Liste (alter Relay) bleibt es beim Loeschen. (c) ist reine App und schliesst schon vor (a) den einfachsten Weg in (a). **Uebergang.** Fassungen vor diesem Teil signieren nichts. Pflicht wird die -Pruefung erst, wenn keine solche Fassung mehr erwartet wird. Bis dahin gilt: +Pruefung erst, wenn keine solche Fassung mehr erwartet wird (Schalter +`RealMessengerCore.geraeteNachweisPflicht`, in 2.0.1 aus). Bis dahin gilt: eine FALSCHE Signatur wird nie angenommen, eine FEHLENDE schon. Bei (b) hilft das sofort, denn `gs` steht im verschluesselten Inhalt, und der Relay kann ihn nicht entfernen. Ein abgemeldetes Geraet mit unveraenderter App nennt darin @@ -680,19 +722,44 @@ additiv: Alte Apps senden das Feld nicht, und alte Relays ignorieren es. Der Relay geht wie immer zuerst raus. Neue Endpunkte gibt es nicht, an der nginx-Liste aendert sich nichts. -### 12.8 Belege, die dazu entstehen - -- `app/test/net/geraeteliste_verschwiegen_test.dart`, gegen eine Attrappe, die - Epoche 3 annimmt, aber weiter Epoche 2 ausliefert und das Tablet nicht - trennt. Faelle: Verteilung kommt an, Kontakt schickt nichts mehr ans Tablet - und nimmt nichts mehr von ihm; Verteilung verschluckt, die naechste - Nachricht des Besitzers macht den Kontakt veraltet; Heilen ueber `ke`; das - Tablet entfernt nach dem Abschied seine Woerter; Warnung beim Besitzer; - Widerspruch; Buendel des Tablets unter fremder Kennung abgewiesen; - Erstnachricht des Tablets unter fremder Kennung abgewiesen; "Zielgeraet - unbekannt" laesst die Sitzung stehen. -- `server/test_relay.py`: `spk_geraete_sig` wird geprueft, gespeichert und je - Geraet ausgeliefert; ohne Liste und ohne Feld bleibt alles wie bisher; nach - einer neuen Fassung liefern `/geraeteliste`, `/prekey` und - `/prekey?nur_geraete=1` dieselbe Epoche. -- Mutationsproben je Riegel wie in §9. +### 12.8 Belege (18.10.2026) + +- `app/test/net/geraeteliste_verschwiegen_test.dart`, 10 Faelle gegen eine + Attrappe, die neue Fassungen annimmt und verschweigt + (`Relaylage.verschweigtListen`) und Geraete verleugnet + (`unbekannteGeraete`). Die Gegenstellen sind echt: eigener libsignal- + Speicher und eigener Geraeteschluessel je Geraet, jeder Umschlag wird + wirklich ver- und entschluesselt. Faelle: verschluckte Verteilung, der + naechste Stempel macht die Liste veraltet (nichts mehr ans Tablet, nichts + mehr von ihm, quittiert), danach kommt die Liste doch an; Widerspruch aus + Liste und aus Stempel; Stempel in jeder Nutzlast und Nachweis in jeder + PreKey-Nachricht bis zur Antwort; Heilen ueber `ke` mit Bremse; Abmelden + verteilt an Kontakt und abgemeldetes Geraet; das abgemeldete Geraet entfernt + seine Woerter, obwohl der Relay weiter Fassung 2 liefert; Warnung beim + Besitzer und ihr Wegfall; Buendel des Tablets unter der Kennung des Laptops + (fremde Signatur, kopierte Signatur, fehlende mit und ohne Pflicht); + Erstnachricht des Tablets unter der Kennung des Laptops, Antwort liest der + echte Laptop; "Zielgeraet unbekannt" mit und ohne Liste. +- `app/test/net/payload_listenstempel_test.dart`: Rundlauf, Bytes ohne + Stempel unveraendert, was abgewiesen wird, und die signierten Zeichen + (`spkNachricht`, `sitzungsNachricht`, `listenKurzHash`) an festen Werten — + dieselbe Zeichenkette prueft `test_spk_nachricht_ist_die_der_app` im Relay. +- `app/test/crypto/sitzungsdatensatz_test.dart`: der gelesene Basisschluessel + ist der der echten PreKey-Nachricht und faellt mit der ersten Antwort weg. +- `server/test_relay.py`, 7 Faelle: `spk_geraete_sig` wird geprueft (fremder + Schluessel, anderer Prekey, andere Kennung: 403; kein base64: 400), + gespeichert und je Geraet ausgeliefert, bei neuer Anmeldung ohne Feld + geloescht, ohne Liste nie gespeichert; eine alte Datenbank bekommt die + Spalte; nach einer neuen Fassung liefern `/geraeteliste`, `/prekey` und + `/prekey?nur_geraete=1` dieselbe. +- **Mutationsproben**: App 20 von 20 Riegeln (je Regel 1 bis 5, Stempel, + Warnung, 12.5 a/b/c samt Pflicht und Zuruecknahme der Sitzung), Relay 6 von + 6. Eine Probe war zuerst gruen: ohne Zuruecknahme der Sitzung bestand der + Test, weil die naechste Nachricht des echten Laptops den alten + Sitzungszustand in libsignal wieder nach vorn holte, bevor die Antwort + hinausging. Der Test schickt die Antwort jetzt sofort. +- **Gesamtlauf**: App 1542 Tests gruen, Relay 176. Zwei Zwischenbefunde dabei, + beide behoben: die geteilte Bremse (§12.3, `sicherung_test.dart`), und + `zwei_geraete_test.dart` lief in die Herkunftsbremse des Test-Relays (dort + teilen sich alle Nutzer 127.0.0.1). Gezaehlt: 182 Abrufe in 33 Sekunden bei + rund 186 erlaubten; nach dem Sparen (§12.3, §12.4) 168. diff --git a/secure-messenger/server/relay_server.py b/secure-messenger/server/relay_server.py index 8b03ccd..20b9769 100644 --- a/secure-messenger/server/relay_server.py +++ b/secure-messenger/server/relay_server.py @@ -487,6 +487,11 @@ def kyber_pflicht() -> bool: LISTE_PRAEFIX = b"BitDM-Geraeteliste-v1\n" BEITRITT_PRAEFIX = b"BitDM-Beitritt-v1\n" ANMELDE_PRAEFIX = b"BitDM-Geraet-Anmeldung-v1\n" +# Der signierte Prekey, zusaetzlich vom Geraeteschluessel unterschrieben +# (GERAETEABMELDUNG.md §12.5 a). Ohne das liesse sich das Buendel eines +# abgemeldeten Geraets unter der Kennung eines gelisteten ausliefern — vom +# Identitaetsschluessel signiert ist es ja auch. +PREKEY_PRAEFIX = b"BitDM-Prekey-v1\n" # Eine Liste mit 5 Geraeten ist rund 1,3 KB (je Eintrag 44 Zeichen Schluessel # und hoechstens GERAETE_INFO_MAX Info). 4096 laesst Luft und haelt jeden @@ -987,9 +992,37 @@ def init_db(path: Path) -> sqlite3.Connection: # vor dem Mehrgeraete-Umbau wieder weg, und der erste /register liefe in # "no such column". ergaenze_kyber_spalten(conn) + ergaenze_spk_geraete_sig(conn) return conn +def ergaenze_spk_geraete_sig(conn: sqlite3.Connection) -> bool: + """Die Spalte fuer die Geraeteschluessel-Signatur am signierten Prekey. + + Nullbar und ohne Vorgabe, wie die Kyber-Spalten: das ALTER beruehrt keine + Zeile. NULL heisst "nicht signiert" (App vor 2.0.1 oder Adresse ohne + Liste), /prekey laesst das Feld dann weg. Aus demselben Grund NACH + wandere_auf_geraete wie ergaenze_kyber_spalten. Rueckgabe: ob etwas zu + tun war. + """ + vorhanden = {row[1] for row in conn.execute("PRAGMA table_info(identities)")} + if "spk_geraete_sig" in vorhanden: + return False + conn.execute("ALTER TABLE identities ADD COLUMN spk_geraete_sig BLOB") + conn.commit() + return True + + +def spk_nachricht(user_id: str, geraet: int, spk_id: int, spk_b64: str) -> bytes: + """Was der Geraeteschluessel am signierten Prekey unterschreibt. + + Dieselben Zeichen baut die App in geraeteliste.dart (spkNachricht). Der + Prekey steht als base64 darin, so wie /prekey ihn ausliefert — der Relay + gibt ihn kanonisch kodiert aus, und so kodiert ihn die App beim Anmelden. + """ + return PREKEY_PRAEFIX + f"{user_id}\n{geraet}\n{spk_id}\n{spk_b64}".encode() + + def ergaenze_kyber_spalten(conn: sqlite3.Connection) -> bool: """Die drei Last-Resort-Spalten in eine bestehende `identities` holen. @@ -1816,6 +1849,10 @@ class RegisterRequest(BaseModel): # Nur verlangt, wenn die Adresse eine Geraeteliste hat (siehe register). # NICHT in canonical_bytes: die Bytes alter Clients bleiben, wie sie sind. geraete_signatur: str | None = Field(default=None, max_length=200) + # Der Geraeteschluessel ueber spk_nachricht(...) (GERAETEABMELDUNG.md + # §12.5 a). Nur mit Liste geprueft und gespeichert; Kontakte pruefen ihn + # selbst gegen die Liste. Ebenfalls NICHT in canonical_bytes. + spk_geraete_sig: str | None = Field(default=None, max_length=200) class ChallengeRequest(BaseModel): @@ -2635,6 +2672,9 @@ def register(req: RegisterRequest, request: Request): # Zahl ab, ob er Prekeys nachliefern muss. # Fehlt die Kennung, ist es Geraet 1 — die Regel fuer den ganzen Umbau. geraet = 1 if bundle.device_id is None else bundle.device_id + # Nur mit Liste und nur geprueft gesetzt, siehe unten. Ohne Liste gibt es + # keinen Schluessel, gegen den sie gelten koennte. + spk_sig_speichern: bytes | None = None with schreibsperre: # DIE GERAETELISTE (GERAETEABMELDUNG.md §8.4), und zwar INNERHALB der @@ -2658,6 +2698,24 @@ def register(req: RegisterRequest, request: Request): if not geraete_signatur_ok(schluessel, treffer + inhalt, req.geraete_signatur): raise HTTPException(403, "Geraeteschluessel fehlt oder passt nicht") + # DER SIGNIERTE PREKEY, AN DEN GERAETESCHLUESSEL GEBUNDEN + # (§12.5 a). Fehlt die Signatur, geht es weiter (App vor 2.0.1); + # eine FALSCHE wird nie gespeichert — sonst lieferte /prekey ein + # Buendel aus, an dem jeder Kontakt scheitert. + if req.spk_geraete_sig is not None: + try: + spk_sig = b64d(req.spk_geraete_sig) + except ValueError as exc: + raise HTTPException(400, "spk_geraete_sig ist kein " + "gueltiges base64") from exc + if not verify_signature( + schluessel, + spk_nachricht(bundle.user_id, geraet, + bundle.signed_prekey_id, + b64e(signed_prekey)), + spk_sig): + raise HTTPException(403, "spk_geraete_sig passt nicht") + spk_sig_speichern = spk_sig # DAS DACH, und zwar nur fuer NEUE Adressen. # @@ -2734,8 +2792,8 @@ def register(req: RegisterRequest, request: Request): "INSERT INTO identities (user_id, device_id, identity_key," " registration_id, signed_prekey_id, signed_prekey," " signed_prekey_sig, updated_at, kyber_prekey_id," - " kyber_prekey, kyber_prekey_sig)" - " VALUES (?,?,?,?,?,?,?,?,?,?,?)" + " kyber_prekey, kyber_prekey_sig, spk_geraete_sig)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" " ON CONFLICT(user_id, device_id) DO UPDATE SET" " identity_key=excluded.identity_key," " registration_id=excluded.registration_id," @@ -2745,10 +2803,14 @@ def register(req: RegisterRequest, request: Request): " updated_at=excluded.updated_at," " kyber_prekey_id=excluded.kyber_prekey_id," " kyber_prekey=excluded.kyber_prekey," - " kyber_prekey_sig=excluded.kyber_prekey_sig", + " kyber_prekey_sig=excluded.kyber_prekey_sig," + # IMMER UEBERSCHRIEBEN, auch mit NULL: die alte Signatur + # gehoert zum alten Prekey und passte zum neuen nicht. + " spk_geraete_sig=excluded.spk_geraete_sig", (bundle.user_id, geraet, identity_key, bundle.registration_id, bundle.signed_prekey_id, signed_prekey, - signed_prekey_sig, time.time(), *kyber_spalten), + signed_prekey_sig, time.time(), *kyber_spalten, + spk_sig_speichern), ) # NUR die Einmalschluessel DIESES Geraets. Ohne das # `AND device_id=?` raeumte jede Nachlieferung eines Geraets den @@ -2877,7 +2939,7 @@ def get_prekey(user_id: str, request: Request, nur_geraete: bool = False): rows = db.execute( "SELECT device_id, identity_key, signed_prekey_id, signed_prekey," " signed_prekey_sig, registration_id, kyber_prekey_id, kyber_prekey," - " kyber_prekey_sig FROM identities" + " kyber_prekey_sig, spk_geraete_sig FROM identities" " WHERE user_id=? ORDER BY device_id", (user_id,) ).fetchall() # NUR GERAETE DER LISTE. Eine Zeile ausserhalb gibt es nur fuer einen @@ -2983,6 +3045,10 @@ def als_geraet(r) -> dict: {"key_id": otk[0], "public_key": b64e(otk[1])} if otk else None ), } + if r[9] is not None: + # §12.5 a: der Kontakt prueft sie gegen den Schluessel der Liste. + # Fehlt sie, fehlt das Feld ganz (wie bei Kyber). + eintrag["spk_geraete_sig"] = b64e(r[9]) if r[7] is not None: # Einmal- ODER Last-Resort-Schluessel, in denselben drei Feldern. # libsignals PreKeyBundle hat nur EINEN Platz fuer einen @@ -3014,7 +3080,7 @@ def als_geraet(r) -> dict: # Flach dieselben Kyber-Felder wie in geraete[0] — derselbe Schluessel, # kein zweiter gezogener, aus demselben Grund wie beim EC-Einmalschluessel. for feld in ("kyber_prekey_id", "kyber_prekey", "kyber_prekey_sig", - "kyber_einmal"): + "kyber_einmal", "spk_geraete_sig"): if feld in erstes: antwort[feld] = erstes[feld] antwort["geraete"] = geraete diff --git a/secure-messenger/server/test_relay.py b/secure-messenger/server/test_relay.py index 3921571..dd15945 100644 --- a/secure-messenger/server/test_relay.py +++ b/secure-messenger/server/test_relay.py @@ -5023,3 +5023,179 @@ def abmelden_genau_jetzt(): assert {"type": "abgemeldet", "epoche": 3} in sock.gesendet assert not any(m.get("type") == "message" for m in sock.gesendet) assert rs.connections == {} + + +# --------------------------------------------------------------------------- # +# Relay verschweigt Epoche 3 (docs/GERAETEABMELDUNG.md §12, 18.10.2026) +# --------------------------------------------------------------------------- # +# +# Gegen einen Relay, der luegt, schuetzt die App sich selbst (Listenstempel +# Ende-zu-Ende, app/test/net/geraeteliste_verschwiegen_test.dart). Was der +# Relay dafuer beitraegt, steht hier: er nimmt die Geraeteschluessel-Signatur +# am signierten Prekey an, prueft sie gegen die Liste und gibt sie je Geraet +# aus (§12.5 a). Und ein ehrlicher Relay liefert eine neue Fassung auf allen +# drei Wegen gleich aus — darauf baut die Warnung beim Besitzer (§12.4). + +PREKEY_PRAEFIX_T = b"BitDM-Prekey-v1\n" + + +def _spk_sig(user, geraet, gs, *, spk=None, spk_id=None): + """Die Signatur des Geraeteschluessels am signierten Prekey, wie die App + sie baut (geraeteliste.dart spkNachricht).""" + b = user["bundle"] + nachricht = PREKEY_PRAEFIX_T + ( + f"{user['user_id']}\n{geraet}\n{spk_id or b['signed_prekey_id']}\n" + f"{spk or b['signed_prekey']}").encode() + return b64(sign(gs["priv"], nachricht)) + + +def _melde_an_mit_spk(rs, user, geraet, gs, spk_sig): + kennung = None if geraet == 1 else geraet + bundle = _bundle_mit(user, kennung) + nonce = rs.issue_nonce(user["user_id"], kennung) + inhalt = hashlib.sha256(bundle.canonical_bytes()).digest() + return rs.register(rs.RegisterRequest( + bundle=bundle, + signature=b64(sign(user["priv"], nonce + inhalt)), + geraete_signatur=b64(sign(gs["priv"], ANMELDE_PRAEFIX_T + nonce + inhalt)), + spk_geraete_sig=spk_sig), _Anfrage()) + + +def _echter_user(): + """make_user mit einem echten 33-Byte-Prekey — register() prueft die Form.""" + user = make_user() + user["bundle"]["signed_prekey"] = b64(b"\x05" + os.urandom(32)) + return user + + +def _spk_eintraege(rs, user): + voll = rs.get_prekey(user["user_id"], _Anfrage()) + return voll, {g["device_id"]: g.get("spk_geraete_sig") for g in voll["geraete"]} + + +def test_die_prekey_signatur_des_geraets_wird_gespeichert_und_ausgeliefert(relay): + rs = relay + henrik = _echter_user() + handy, tablet = _geraet(), _geraet() + _setze(rs, _liste(henrik, 1, {1: handy}, 1)) + _setze(rs, _liste(henrik, 2, {1: handy, 7: tablet}, 1)) + + sig7 = _spk_sig(henrik, 7, tablet) + assert _melde_an_mit_spk(rs, henrik, 7, tablet, sig7)["ok"] is True + # Geraet 1 meldet sich ohne an, wie eine App vor 2.0.1. Das geht noch + # (Uebergang bis 2.0.2), und das Feld fehlt dann ganz. + assert _melde_an_mit(rs, henrik, 1, handy)["ok"] is True + + voll, je_geraet = _spk_eintraege(rs, henrik) + assert je_geraet == {1: None, 7: sig7} + assert "spk_geraete_sig" not in voll, \ + "flach steht eine Kopie von geraete[0], und das hat keine" + # Pruefbar mit genau dem, was ausgeliefert wird. + g7 = next(g for g in voll["geraete"] if g["device_id"] == 7) + assert rs.verify_signature( + tablet["pub"], + rs.spk_nachricht(henrik["user_id"], 7, g7["signed_prekey_id"], + g7["signed_prekey"]), + rs.b64d(g7["spk_geraete_sig"])) + + +def test_eine_falsche_prekey_signatur_wird_nie_gespeichert(relay): + """Das Buendel des Tablets unter der Kennung des Handys (§12.1 a) beginnt + hier: das Tablet unterschreibt mit SEINEM Schluessel. Der Relay nimmt das + nicht an — und auch keine Signatur ueber einen anderen Prekey.""" + rs = relay + henrik = _echter_user() + handy, tablet = _geraet(), _geraet() + _setze(rs, _liste(henrik, 1, {1: handy}, 1)) + _setze(rs, _liste(henrik, 2, {1: handy, 7: tablet}, 1)) + + fremd = _spk_sig(henrik, 1, tablet) + assert _status(lambda: _melde_an_mit_spk(rs, henrik, 1, handy, fremd)) == 403 + anderer_spk = _spk_sig(henrik, 1, handy, spk=b64(b"\x05" + os.urandom(32))) + assert _status(lambda: _melde_an_mit_spk(rs, henrik, 1, handy, anderer_spk)) == 403 + falsche_kennung = _spk_sig(henrik, 7, handy) + assert _status(lambda: _melde_an_mit_spk(rs, henrik, 1, handy, falsche_kennung)) == 403 + assert _status(lambda: _melde_an_mit_spk(rs, henrik, 1, handy, "kein base64!")) == 400 + assert _geraete(rs, henrik["user_id"]) == [] + + +def test_eine_neue_anmeldung_ohne_signatur_loescht_die_alte(relay): + """Die alte Signatur gehoert zum alten Prekey. Bliebe sie stehen, stuende + sie neben einem neuen, zu dem sie nicht passt.""" + rs = relay + henrik = _echter_user() + handy = _geraet() + _setze(rs, _liste(henrik, 1, {1: handy}, 1)) + sig = _spk_sig(henrik, 1, handy) # XEdDSA ist zufaellig: einmal bauen + _melde_an_mit_spk(rs, henrik, 1, handy, sig) + assert _spk_eintraege(rs, henrik)[1] == {1: sig} + _melde_an_mit(rs, henrik, 1, handy) + assert _spk_eintraege(rs, henrik)[1] == {1: None} + + +def test_ohne_liste_wird_keine_prekey_signatur_gespeichert(relay): + """Ohne Liste gibt es keinen Schluessel, gegen den sie gelten koennte.""" + rs = relay + alt = _echter_user() + bundle = _bundle_mit(alt) + nonce = rs.issue_nonce(alt["user_id"], None) + inhalt = hashlib.sha256(bundle.canonical_bytes()).digest() + assert rs.register(rs.RegisterRequest( + bundle=bundle, signature=b64(sign(alt["priv"], nonce + inhalt)), + spk_geraete_sig=b64(os.urandom(64))), _Anfrage())["ok"] is True + assert _spk_eintraege(rs, alt)[1] == {1: None} + + +def test_eine_neue_fassung_kommt_auf_allen_wegen_gleich_an(relay): + """Der ehrliche Relay: nach Epoche 3 liefern /geraeteliste, /prekey und + /prekey?nur_geraete=1 genau dieselbe Fassung — jedem Abrufer. Die App des + Besitzers vergleicht damit (§12.4); ein Relay, der hier Epoche 2 zeigt, + verschweigt die Abmeldung.""" + rs = relay + henrik = _echter_user() + handy, tablet = _geraet(), _geraet() + _setze(rs, _liste(henrik, 1, {1: handy}, 1)) + _setze(rs, _liste(henrik, 2, {1: handy, 7: tablet}, 1)) + _melde_an_mit(rs, henrik, 1, handy) + _melde_an_mit(rs, henrik, 7, tablet) + drei = _liste(henrik, 3, {1: handy}, 1) + assert _setze(rs, drei) == {"ok": True, "epoche": 3} + + erwartet = {"liste": drei.liste, "sig_identitaet": drei.sig_identitaet, + "sig_geraet": drei.sig_geraet} + assert rs.hole_geraeteliste(henrik["user_id"], _Anfrage()) == erwartet + assert rs.get_prekey(henrik["user_id"], _Anfrage())["geraeteliste"] == erwartet + knapp = rs.get_prekey(henrik["user_id"], _Anfrage(), nur_geraete=True) + assert knapp["geraeteliste"] == erwartet + assert [g["device_id"] for g in knapp["geraete"]] == [1] + + +def test_eine_datenbank_ohne_spalte_fuer_die_prekey_signatur_wird_erweitert( + relay, tmp_path, monkeypatch): + """Der Fall beim Aufspielen: die laufende Datenbank kennt die Spalte nicht. + Jede Zeile steht danach unveraendert da, nur ohne Signatur.""" + rs = relay + pfad = tmp_path / "vor_spk_sig.db" + henrik = make_user() + _datenbank_vor_kyber(pfad, henrik) + conn = rs.init_db(pfad) + try: + spalten = {r[1] for r in conn.execute("PRAGMA table_info(identities)")} + assert "spk_geraete_sig" in spalten + assert conn.execute("SELECT spk_geraete_sig FROM identities").fetchall() \ + == [(None,)] + conn.close() + conn = rs.init_db(pfad) + assert rs.ergaenze_spk_geraete_sig(conn) is False + monkeypatch.setattr(rs, "db", conn) + assert "spk_geraete_sig" not in rs.get_prekey( + henrik["user_id"], _Anfrage())["geraete"][0] + finally: + conn.close() + + +def test_spk_nachricht_ist_die_der_app(relay): + """DIESELBE ZEICHENKETTE steht in app/test/net/payload_listenstempel_test.dart. + Weicht eine Seite ab, scheitert jede Anmeldung mit Signatur an 403.""" + assert relay.spk_nachricht("ABCD", 7, 42, "BQ==") == \ + b"BitDM-Prekey-v1\nABCD\n7\n42\nBQ==" From dc75a3545755a9dbd5845496282d50f0eda091c2 Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:38:34 +0200 Subject: [PATCH 3/5] test: Test-Relays mit Luft in der Herkunftsbremse Im Test teilen sich alle simulierten Nutzer einer Datei die Herkunft 127.0.0.1 und damit je Familie einen Eimer; im Betrieb hat jeder seine eigene. Gemessen am 18.10.2026, jede Datei einzeln gegen die ausgelieferten 120 Marken und 2 je Sekunde: - zwei_geraete_test.dart: pk (/prekey, /geraeteliste) 174 Abrufe in 31 s, kleinster Rest 7 Marken - real_core_test.dart, nachrichten_funktionen_test.dart: reg (/register, je 5 Marken) 25 und 28 Anmeldungen, Rest 9 - anhang_end_to_end_test.dart 21, gruppen_test.dart 27, alle anderen ueber 45 relay_process.dart setzt jetzt BITDM_RATE_BURST=5000 als Vorgabe fuer jeden Test-Relay; [umgebung] geht vor. Kleinster Rest danach rund 4900. geraeteabmeldung_test.dart brauchte dafuer eine eigene Einstellung und nutzt jetzt die Vorgabe (hoechstens 124 Marken je Familie). Die Bremse selbst prueft test_relay.py per monkeypatch auf RATE_CAPACITY, unberuehrt (176 passed). Kein App-Test prueft sie am echten Relay; die 429 in mehrgeraete_riegel_test und protokoll_befunde_test kommen aus Attrappen. flutter test: 1542 passed. Co-Authored-By: Claude Opus 5.5 --- .../app/test/net/geraeteabmeldung_test.dart | 10 ++----- .../app/test/support/relay_process.dart | 30 +++++++++++++++---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/secure-messenger/app/test/net/geraeteabmeldung_test.dart b/secure-messenger/app/test/net/geraeteabmeldung_test.dart index cbe8644..8ca9dba 100644 --- a/secure-messenger/app/test/net/geraeteabmeldung_test.dart +++ b/secure-messenger/app/test/net/geraeteabmeldung_test.dart @@ -38,14 +38,8 @@ void main() { late Directory tmp; var n = 0; - // EIN GROSSER EIMER FUER DIE HERKUNFTSBREMSE. Alle Faelle teilen sich einen - // Relay und eine Herkunft (127.0.0.1); jede Anmeldung und jede Fassung der - // Liste kostet 5 Marken, und nach drei Faellen war der Eimer leer (429 im - // vierten). Die Bremse prueft test_relay.py; hier geht es um die Abmeldung. - setUpAll(() async => relay = await Relay.starten(umgebung: { - 'BITDM_RATE_BURST': '5000', - 'BITDM_RATE_REFILL': '500', - })); + // Die Herkunftsbremse hat hier genug Luft: siehe Relay._testvorgaben. + setUpAll(() async => relay = await Relay.starten()); tearDownAll(() async => relay?.beenden()); setUp(() => tmp = Directory.systemTemp.createTempSync('bitdm_abmeldung')); diff --git a/secure-messenger/app/test/support/relay_process.dart b/secure-messenger/app/test/support/relay_process.dart index 5cde0cf..536b6ae 100644 --- a/secure-messenger/app/test/support/relay_process.dart +++ b/secure-messenger/app/test/support/relay_process.dart @@ -19,6 +19,24 @@ class Relay { Uri get uri => Uri.parse('http://127.0.0.1:$port'); + /// Was jeder Test-Relay anders hat als der ausgelieferte. + /// + /// EIN GROSSER EIMER FUER DIE HERKUNFTSBREMSE. Im Test teilen sich alle + /// simulierten Nutzer einer Datei die Herkunft 127.0.0.1 und damit je + /// Familie einen Eimer (relay_server.py, herkunft_ok); im Betrieb hat jeder + /// Nutzer seine eigene. Mit den ausgelieferten 120 Marken und 2 je Sekunde + /// blieben am 18.10.2026 zwei_geraete_test.dart noch 7 Marken bei /prekey + /// und /geraeteliste (174 Abrufe in 31 s), real_core_test.dart und + /// nachrichten_funktionen_test.dart noch 9 bei /register (25 und 28 + /// Anmeldungen zu je 5). Der naechste Fall waere mit 429 rot geworden — ein + /// Fehler der Testanordnung, nicht der App. Keine Datei brauchte mehr als + /// 174 Marken je Familie; 5000 reichen auch ohne Nachfuellen. + /// + /// Die Bremse selbst prueft test_relay.py mit den ausgelieferten Werten. + /// Ein App-Test, der sie am echten Relay braucht, setzt BITDM_RATE_BURST + /// ueber [umgebung] zurueck. + static const _testvorgaben = {'BITDM_RATE_BURST': '5000'}; + /// Startet den Relay. Gibt null zurueck, wenn Python oder die /// Abhaengigkeiten fehlen — der Aufrufer soll das laut sagen, statt /// stillschweigend nichts zu pruefen. @@ -27,11 +45,12 @@ class Relay { /// Marken-Anfrage mit "Zwischenlager nicht eingerichtet" ab — richtig so, /// aber fuer einen Anhang-Test muss es dastehen. /// - /// [umgebung] reicht weitere Umgebungsvariablen durch. Gebraucht fuer - /// BITDM_GERAETE_MAX: die Obergrenze steht sonst bei 5, und ein Test, der - /// sie erreicht, muesste sechs vollstaendige Clients aufsetzen — sechs - /// Datenbanken, sechs Identitaeten, sechs Anmeldungen fuer eine Zahl, die - /// der Betreiber ohnehin per Umgebungsvariable setzt (relay_server.py:186). + /// [umgebung] reicht weitere Umgebungsvariablen durch und geht vor + /// [_testvorgaben]. Gebraucht fuer BITDM_GERAETE_MAX: die Obergrenze steht + /// sonst bei 5, und ein Test, der sie erreicht, muesste sechs vollstaendige + /// Clients aufsetzen — sechs Datenbanken, sechs Identitaeten, sechs + /// Anmeldungen fuer eine Zahl, die der Betreiber ohnehin per + /// Umgebungsvariable setzt (relay_server.py:186). static Future starten({ String? blobGeheimnis, Map? umgebung, @@ -54,6 +73,7 @@ class Relay { ], environment: { 'BITDM_DB': '${tmp.path}/relay.db', + ..._testvorgaben, if (blobGeheimnis != null) 'BITDM_BLOB_SECRET': blobGeheimnis, ...?umgebung, }, From e9b395c4e556539eefdafe29f84c2f4a1ea4632f Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:42:59 +0200 Subject: [PATCH 4/5] fix(sperre): Faktor hinzufuegen nur mit frischem Nachweis, Sperre greift im Hintergrund Zwei Befunde aus der Sicherheitspruefung vom 18.10.2026. 1. Faktor hinzufuegen ohne frische Anmeldung (bestaetigt) Nur das Entfernen verlangte frischBestaetigt. Wer das entsperrte Telefon kurz in der Hand hatte, legte ein eigenes App-Passwort an und wies sich danach damit aus: zwoelf Woerter, Sperrfrist "nie", Fernloeschung, Teilgeheimnisse. - VaultSecretStore.fuegeHinzu und fuegePanikHinzu verlangen einen FrischerNachweis, sobald es Faecher gibt. Geprueft vor dem Anlegen, eingeloest danach; wird dazwischen gesperrt, wird das Fach verworfen (Fachschluessel im Geraet inklusive). - Ein Fach aus der laufenden Sitzung ist kein Nachweis (taugtAlsNachweis), ausser die Sperre entstand erst in dieser Sitzung. - AppState.setzePanikPasswort prueft den Nachweis vor der Probe gegen das echte Passwort (sonst ein Passwort-Orakel). - Oberflaeche: methodAct ruft frischBestaetigt auch vor dem Einrichten; ein abgelaufener Nachweis wird einmal neu erfragt (_mitNachweis); frischBestaetigt bietet nur Faktoren an, die als Ausweis taugen. 2. Sperre griff erst im Vordergrund Bei "Sofort" blieben Entropie, Datenbank und Schluessel offen, solange die App im Hintergrund lag. - vordergrund(false) sperrt bei Frist 0 sofort, sonst per Wecker nach der Frist; beim Zurueckkommen wird er abgestellt. - Ausnahmen (_amFaktor, Dateiwahl) bleiben, im Hintergrund hoechstens systemDialogHoechstens. - Entscheidung: der Hintergrundempfang verlaengert die Frist NICHT. Bei 1/5 Minuten wird nur in dieser Zeit empfangen; dauerhaft nur mit "nie". Texte bgConflict/bgUntilLock sagen das. - sperreWieder setzt gesperrt sofort; vordergrund(true) fasst einen gerade zugehenden Kern nicht mehr an. Tests: nachweis_beim_hinzufuegen_test, hintergrund_sperre_test (neu); bestehende Tests legen weitere Faktoren jetzt mit Nachweis an. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 + secure-messenger/app/lib/app_state.dart | 154 +++++++- .../app/lib/core/lock/vault_store.dart | 145 +++++++- secure-messenger/app/lib/data.dart | 8 +- .../lib/oberflaeche/home/einstellungen.dart | 7 + .../app/lib/oberflaeche/home/notfall.dart | 7 +- .../app/lib/oberflaeche/home/sperre.dart | 16 +- .../app/lib/oberflaeche/home/zugriff.dart | 42 ++- .../test/lock/hintergrund_sperre_test.dart | 250 +++++++++++++ .../lock/nachweis_beim_hinzufuegen_test.dart | 336 ++++++++++++++++++ .../app/test/lock/panik_passwort_test.dart | 16 + .../app/test/lock/tresor_haertung_test.dart | 21 +- .../app/test/lock/vault_store_test.dart | 12 +- .../app/test/lock/vier_faktoren_test.dart | 7 + .../oberflaeche/sperre_oberflaeche_test.dart | 2 + .../test/oberflaeche/sperre_zustand_test.dart | 2 + 16 files changed, 988 insertions(+), 43 deletions(-) create mode 100644 secure-messenger/app/test/lock/hintergrund_sperre_test.dart create mode 100644 secure-messenger/app/test/lock/nachweis_beim_hinzufuegen_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 03a4772..760e7da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Alle Fassungen der App BitDM. Details und Belege je Funktion: [`secure-messenger/docs/FUNKTIONSVERGLEICH.md`](secure-messenger/docs/FUNKTIONSVERGLEICH.md). Downloads: [GitHub-Releases](https://github.com/Henner4746/BitDM/releases) und . +## 2.0.1 (unveröffentlicht) + +**App-Sperre: zwei Lücken aus der Sicherheitsprüfung vom 18.10.2026.** +- **Einen Faktor hinzufügen verlangt jetzt eine frische Anmeldung**, genau wie das Entfernen. Bis 2.0.0 konnte jemand mit dem kurz entsperrten Telefon ein eigenes App-Passwort anlegen und sich damit ausweisen: für die zwölf Wörter, die Sperrfrist, die Fernlöschung und die Teilgeheimnisse. Dasselbe gilt für das Panik-Wort. Ein neu angelegter Faktor zählt erst nach dem nächsten Entsperren als Ausweis. +- **Die Sperre greift jetzt auch im Hintergrund.** Bis 2.0.0 ging die App erst beim Zurückkommen zu. Bei „Sofort“ blieben die Schlüssel offen, solange sie im Hintergrund lag. Jetzt sperrt sie bei „Sofort“ beim Weglegen und sonst, sobald die Frist um ist. **Der Hintergrundempfang verlängert die Frist nicht:** Bei 1 oder 5 Minuten empfängt die App nur in dieser Zeit. Dauerhaft geht es nur mit „Nie“. Die Einstellungen sagen das jetzt dazu. + ## 2.0.0 (18.10.2026) **Signals offizielle libsignal statt der Dart-Nachbildung.** Bis 1.9.x lief die Verschlüsselung über `libsignal_protocol_dart`, eine nie geprüfte Nachbildung in reinem Dart. Das Sicherheitsaudit vom 16.10.2026 fand darin zwei schwere Fehler in der Zustandsverwaltung. Ab 2.0.0 rechnet Signals eigene Bibliothek (Rust, libsignal 0.105, über das Paket `libsignal` 8.0.0). Plan und Nachweise: [`secure-messenger/docs/LIBSIGNAL-UMSTIEG.md`](secure-messenger/docs/LIBSIGNAL-UMSTIEG.md). diff --git a/secure-messenger/app/lib/app_state.dart b/secure-messenger/app/lib/app_state.dart index 6a3959d..352d331 100644 --- a/secure-messenger/app/lib/app_state.dart +++ b/secure-messenger/app/lib/app_state.dart @@ -461,6 +461,7 @@ class AppState extends ChangeNotifier { return false; } finally { _amFaktor = false; + _faktorFertig(); } return _nachDemOeffnen(); } @@ -473,6 +474,19 @@ class AppState extends ChangeNotifier { /// koennte, und die Oberflaeche fragt dann hoechstens nach. bool get brauchtFrischeAnmeldung => sichtbareFaktoren.isNotEmpty; + /// Ob ein Faktor dieser Art gerade als Ausweis taugt. + /// + /// Nicht jeder vorhandene: ein Faktor, der seit dem letzten Entsperren + /// dazukam, zaehlt erst nach dem naechsten + /// ([VaultSecretStore.taugtAlsNachweis]). Die Oberflaeche bietet ihn + /// deshalb gar nicht erst an, statt ihn mit "Entsperren fehlgeschlagen" + /// abzulehnen. + bool kannNachweisen(UnlockFactorKind art) { + final t = tresor; + return sichtbareFaktoren.any((s) => + s.kind == art && (t == null || t.taugtAlsNachweis(s.id))); + } + /// Weist sich mit dem Fingerabdruck aus — ohne neu zu entsperren. Future bestaetigeMitBiometrie() => _bestaetigeMit(_keystoreFaktor(UnlockFactorKind.biometric)); @@ -512,6 +526,7 @@ class AppState extends ChangeNotifier { return false; } finally { _amFaktor = false; + _faktorFertig(); } } @@ -541,9 +556,19 @@ class AppState extends ChangeNotifier { /// nichts, es loest nur das Loeschen aus, und ein Wort, das man unter /// Druck sicher tippt, ist hier wichtiger als Entropie. Das Fach selbst /// ist trotzdem gebaut wie jedes Passwort-Fach (Argon2id, gleiche Werte). + /// + /// VERLANGT EINEN FRISCHEN NACHWEIS (seit 18.10.2026, die Oberflaeche holt + /// ihn ueber `frischBestaetigt`). Sonst [NachweisNoetigException] — und + /// zwar VOR der Probe gegen die echten Faecher: ohne Nachweis soll auch + /// nicht zu erfahren sein, ob ein Wort das echte Passwort ist. Future setzePanikPasswort(String passwort) async { final t = tresor; if (t == null) throw const LockUnavailableException('nicht verfuegbar'); + // Ohne Sperre lehnt der Tresor ohnehin ab (kein Panik-Fach ohne echten + // Faktor) — dann mit seinem eigenen Grund. + if (brauchtFrischeAnmeldung && !t.nachweisGilt(_nachweis)) { + throw const NachweisNoetigException(); + } if (passwort.trim().length < panikMinLaenge) { throw const PanikWortUngueltigException(PanikWortFehler.zuKurz); } @@ -564,7 +589,8 @@ class AppState extends ChangeNotifier { } } final alt = einstellungen.panikFach; - final slot = await t.fuegePanikHinzu(faktor); + final slot = await t.fuegePanikHinzu(faktor, nachweis: _nachweis); + _nachweis = null; if (alt != null && faktoren.any((s) => s.id == alt)) { await t.entferne(alt); } @@ -661,17 +687,27 @@ class AppState extends ChangeNotifier { )); } + /// Gibt es schon einen Faktor, verlangt der Tresor einen frischen Nachweis + /// (die Oberflaeche holt ihn vorher ueber `frischBestaetigt`). Sonst + /// [NachweisNoetigException]. + /// + /// Der Beleg bleibt bei einem Fehlschlag stehen — ein zu schwaches + /// Passwort soll nicht schon wieder den Fingerabdruck kosten. Ob er noch + /// gilt (einmal, zwei Minuten, bis zum Sperren), entscheidet der Tresor. Future _fuegeHinzu(UnlockFactor faktor) async { final t = tresor; if (t == null) throw const LockUnavailableException('nicht verfuegbar'); _amFaktor = true; try { - await t.fuegeHinzu(faktor); + await t.fuegeHinzu(faktor, nachweis: _nachweis); + _nachweis = null; } finally { _amFaktor = false; // Auch nach einem Fehlschlag neu einlesen: beim Stick kann der Zugang // schon angelegt sein, wenn erst das Holen des Geheimnisses scheitert. await _ladeFaktoren(); + // DANACH: beim ersten Faktor gibt es erst jetzt etwas zu sperren. + _faktorFertig(); notifyListeners(); } } @@ -821,6 +857,9 @@ class AppState extends ChangeNotifier { if (_imVordergrund == sichtbar) return; _imVordergrund = sichtbar; if (sichtbar) { + // Ab hier entscheidet [_sollWiederSperren] — mit derselben Rechnung. + _hintergrundSperre?.cancel(); + _hintergrundSperre = null; unawaited(_beendeHintergrundempfang()); // Zurueck aus der App, die einen Anhang geoeffnet hat: deren // Klartextkopie hat ausgedient ([anhangOeffnen]). @@ -834,6 +873,9 @@ class AppState extends ChangeNotifier { unawaited(sperreWieder()); return; } + // Schon im Hintergrund zugesperrt (oder gerade dabei): nichts, was + // jetzt folgt, darf den Kern noch anfassen. + if (gesperrt) return; // Was im Hintergrund in die offene Unterhaltung kam, ist jetzt gesehen. final offen = offeneUnterhaltung; if (offen != null && ungelesen.remove(offen) != null) { @@ -846,6 +888,9 @@ class AppState extends ChangeNotifier { } } else { _weggelegtUm = DateTime.now(); + // ZUERST: bei "sofort" ist danach nichts mehr offen, und der + // Hintergrundempfang unten startet gar nicht erst. + _planeHintergrundSperre(); _verfallTakt?.cancel(); _verfallTakt = null; // AM RECHNER UND IM BROWSER BLEIBT DIE LEITUNG. Dort gibt es weder @@ -893,6 +938,12 @@ class AppState extends ChangeNotifier { bool get _sperrtGleich => faktoren.isNotEmpty && !_nieSperren && sperrfrist == Duration.zero; + /// Ob der Hintergrundempfang mit der Sperrfrist endet (1 oder 5 Minuten). + /// Die Frist wird dafuer NICHT verlaengert — siehe "Sperren im + /// Hintergrund". Die Einstellungen sagen das dazu. + bool get empfangEndetMitFrist => + empfangMoeglich && faktoren.isNotEmpty && !_nieSperren; + /// Die Anbindung an den Verteiler auf dem Telefon. /// /// Ersetzbar, weil der Verteiler eine ANDERE App auf demselben Telefon ist — @@ -1089,11 +1140,91 @@ class AppState extends ChangeNotifier { // bleibt (oder ihn offen liegen laesst), kommt trotzdem an die Sperre. final zurueck = _systemDialogZurueck; final imDialog = _systemDialoge > 0 || - (zurueck != null && DateTime.now().difference(zurueck) < const Duration(seconds: 3)); + (zurueck != null && DateTime.now().difference(zurueck) < _systemDialogNachlauf); if (imDialog && fort < systemDialogHoechstens) return false; return fort >= sperrfrist; } + // ─────────────────────────────────────────────── Sperren im Hintergrund + // + // BIS 18.10.2026 GING DIE APP ERST BEIM ZURUECKKOMMEN ZU. [_sollWiederSperren] + // lief nur in `vordergrund(true)`: bei "sofort" blieben Entropie, Datenbank + // und Schluessel offen, solange die App im Hintergrund lag — Stunden, wenn + // sie so lange dort lag. Zu sehen war davon nichts (beim Zurueckkommen + // sperrte sie ja), aber wer den Prozess lesen kann, las in dieser Zeit alles. + // Das widersprach dem, was die App verspricht: "Wie lange die App im + // Hintergrund offen bleiben darf" (lockDelaySub) und "hat die App keinen + // Schluessel, sobald du sie weglegst" (bgConflict). + // + // Jetzt sperrt sie im Hintergrund selbst: bei "sofort" beim Weglegen, sonst + // per Wecker, sobald die Frist um ist. Beim Zurueckkommen wird der Wecker + // abgestellt, und es gilt wie bisher [_sollWiederSperren]. + // + // DER HINTERGRUNDEMPFANG VERLAENGERT DIE FRIST NICHT. Entschieden am + // 18.10.2026: bei einer Minute oder fuenf Minuten empfaengt die App nur in + // dieser Zeit; danach ist sie zu und holt erst nach dem Entsperren nach. + // Wer dauerhaft im Hintergrund empfangen will, stellt "nie" ein — und + // entscheidet damit selbst, dass die Schluessel dafuer offen bleiben. Die + // Frist still zu verlaengern, weil gerade ein Takt laeuft, hiesse: die + // Einstellung sagt fuenf Minuten, und offen ist es die ganze Nacht. + // + // DIE AUSNAHMEN BLEIBEN, aber mit Obergrenze: waehrend ein Faktor bedient + // wird ([_amFaktor], etwa die USB-Freigabe) oder die eigene Dateiwahl offen + // ist ([imSystemDialog]), sperrt der Wecker hoechstens + // [systemDialogHoechstens] nach dem Weglegen — auch beim Faktor, der beim + // Zurueckkommen keine Obergrenze hat (dort bedient man ja die App). + + /// Der Wecker fuer das Sperren im Hintergrund. Null im Vordergrund. + Timer? _hintergrundSperre; + + /// Wie lange nach dem Ende einer Dateiwahl sie noch als offen gilt — siehe + /// [_systemDialogZurueck]. + static const Duration _systemDialogNachlauf = Duration(seconds: 3); + + /// Sperrt jetzt, oder stellt den Wecker auf den Zeitpunkt, zu dem die Frist + /// um ist. Tut nichts im Vordergrund, ohne Faktor, gesperrt oder bei "nie". + /// + /// Der Wecker ruft diese Methode selbst wieder auf, statt blind zu sperren: + /// endet die Ausnahme vorher, gilt ab dann die gewoehnliche Frist; dauert + /// sie an, die Obergrenze. + void _planeHintergrundSperre() { + _hintergrundSperre?.cancel(); + _hintergrundSperre = null; + if (_imVordergrund || _entsorgt) return; + final weg = _weggelegtUm; + if (weg == null || faktoren.isEmpty || gesperrt || _nieSperren) return; + final jetzt = DateTime.now(); + var faellig = weg.add(sperrfrist); + final aufschub = _aufschubBis(weg, jetzt); + if (aufschub != null && aufschub.isAfter(faellig)) faellig = aufschub; + final rest = faellig.difference(jetzt); + if (rest <= Duration.zero) { + // Der Anfang von [sperreWieder] laeuft ohne Warten: der Tresor ist zu, + // bevor diese Methode zurueckkehrt. + unawaited(sperreWieder()); + return; + } + _hintergrundSperre = Timer(rest, _planeHintergrundSperre); + } + + /// Bis wann die eigene Bedienung das Sperren im Hintergrund aufschiebt, + /// oder null. Nie ueber [systemDialogHoechstens] nach dem Weglegen hinaus. + DateTime? _aufschubBis(DateTime weg, DateTime jetzt) { + final grenze = weg.add(systemDialogHoechstens); + if (_amFaktor || _systemDialoge > 0) return grenze; + final zurueck = _systemDialogZurueck; + if (zurueck == null) return null; + final nachlauf = zurueck.add(_systemDialogNachlauf); + if (!nachlauf.isAfter(jetzt)) return null; + return nachlauf.isBefore(grenze) ? nachlauf : grenze; + } + + /// Ein Faktor ist fertig bedient. Liegt die App dabei im Hintergrund, gilt + /// ab jetzt wieder die gewoehnliche Frist — dieselbe Rechnung, die beim + /// Zurueckkommen [_sollWiederSperren] anstellt (dort zaehlt [_amFaktor] + /// auch nur, solange er laeuft). + void _faktorFertig() => _planeHintergrundSperre(); + /// Wie viele vom Nutzer selbst geoeffnete Systemdialoge gerade offen sind. int _systemDialoge = 0; @@ -1123,6 +1254,9 @@ class AppState extends ChangeNotifier { } finally { _systemDialoge--; _systemDialogZurueck = DateTime.now(); + // Kam das Ergebnis, waehrend die App noch verdeckt ist (so liefert es + // Android), gilt ab jetzt nur noch der Nachlauf. + _planeHintergrundSperre(); } } @@ -1160,6 +1294,8 @@ class AppState extends ChangeNotifier { Future sperreWieder() async { if (faktoren.isEmpty) return; _weggelegtUm = null; + _hintergrundSperre?.cancel(); + _hintergrundSperre = null; _wiederverbindung?.cancel(); _wiederverbindung = null; for (final a in _abos) { @@ -1168,6 +1304,16 @@ class AppState extends ChangeNotifier { _abos.clear(); tresor?.sperre(); + // GESPERRT AB HIER, nicht erst nach dem Schliessen der Datenbank. Seit + // die App auch im Hintergrund sperrt, kann sie zurueckkommen, waehrend + // `core.lock()` noch laeuft — und `vordergrund(true)` raeumte dann + // abgelaufene Nachrichten auf und verband neu, gegen einen Kern, der + // gerade zugeht. Alles dort fragt `gesperrt`. + gesperrt = true; + // Im Hintergrund gesperrt: ein Dienst mit "Empfangsbereit" waere jetzt + // eine Behauptung ohne etwas dahinter (siehe [empfangMoeglich]). Im + // Vordergrund laeuft keiner — `vordergrund(true)` hat ihn schon beendet. + if (!_imVordergrund) unawaited(_beendeHintergrundempfang()); await core.lock(); // Auch das, was die Oberflaeche schon geholt hat, muss weg. Die Verlaeufe @@ -1190,7 +1336,6 @@ class AppState extends ChangeNotifier { _vergissAngezeigtes(); fernloeschung = const Fernloeschung(); - gesperrt = true; notifyListeners(); } @@ -2976,6 +3121,7 @@ class AppState extends ChangeNotifier { _entsorgt = true; _wiederverbindung?.cancel(); _wiederverbindung = null; + _hintergrundSperre?.cancel(); _verfallTakt?.cancel(); _empfangsTimer?.cancel(); _fernTakt?.cancel(); diff --git a/secure-messenger/app/lib/core/lock/vault_store.dart b/secure-messenger/app/lib/core/lock/vault_store.dart index c6c23ea..b26a28d 100644 --- a/secure-messenger/app/lib/core/lock/vault_store.dart +++ b/secure-messenger/app/lib/core/lock/vault_store.dart @@ -41,6 +41,15 @@ // - Die offene Entropie wird beim Sperren ueberschrieben, und [read] gibt // eine Kopie heraus. // - Das Panik-Loeschen nimmt die Fachschluessel im Geraet mit. +// +// SEIT 18.10.2026 (Sicherheitspruefung des ganzen Codes) +// - Einen Faktor HINZUZUFUEGEN verlangt ebenfalls einen frischen Nachweis, +// sobald es schon Faecher gibt ([fuegeHinzu], [fuegePanikHinzu]). Vorher +// konnte jemand mit dem entsperrten Telefon ein eigenes Passwort anlegen +// und sich damit ausweisen — fuer die zwoelf Woerter, die Sperrfrist, die +// Fernloeschung. +// - Ein Fach, das seit dem letzten Entsperren dazukam, ist kein Nachweis +// ([weiseNach], [taugtAlsNachweis]). import 'dart:convert'; import 'dart:io'; @@ -65,15 +74,16 @@ class PanikAusgeloestException implements Exception { String toString() => 'PanikAusgeloestException'; } -/// Das letzte Fach sollte entfernt werden, ohne dass gerade ein Faktor -/// vorgelegt wurde. +/// Etwas, das die Sperre aendert, sollte ohne frisch vorgelegten Faktor +/// passieren: das letzte Fach entfernen, ein weiteres hinzufuegen, ein +/// Panik-Passwort anlegen. /// /// Die Oberflaeche reagiert darauf, indem sie einen Faktor abfragt /// ([VaultSecretStore.weiseNach]) und es mit dem Nachweis erneut versucht. class NachweisNoetigException implements Exception { const NachweisNoetigException(); @override - String toString() => 'NachweisNoetigException: zum Abschalten der Sperre ' + String toString() => 'NachweisNoetigException: zum Aendern der Sperre ' 'muss ein Faktor frisch vorgelegt werden'; } @@ -86,6 +96,12 @@ class NachweisNoetigException implements Exception { /// konnte die Sperre mit zwei Tipps dauerhaft abschalten und spaeter in Ruhe /// wiederkommen. /// +/// Seit 18.10.2026 verlangt auch das HINZUFUEGEN eines Faktors einen +/// Nachweis (sobald es schon Faecher gibt). Ein neuer Faktor ist ein neuer +/// Ausweis: wer ihn ohne Nachweis anlegen kann, kann sich danach mit ihm +/// ausweisen — und alles tun, wofuer die Oberflaeche eine frische Anmeldung +/// verlangt. +/// /// Nur [VaultSecretStore.weiseNach] stellt ihn aus. Er gilt fuer diesen /// Tresor, EINMAL, hoechstens [VaultSecretStore.nachweisGueltigkeit] lang /// und nur bis zum naechsten Sperren. @@ -163,6 +179,16 @@ class VaultSecretStore implements SecretStore { /// Epoche gilt nicht mehr. int _epoche = 0; + /// Die Faecher, die seit dem letzten Oeffnen dazukamen (Kennungen). + /// Beim Sperren vergessen — nach dem naechsten Entsperren zaehlen sie wie + /// jedes andere. Siehe [taugtAlsNachweis]. + final Set _neueFaecher = {}; + + /// Ob der Tresor in dieser Epoche mit einem Faktor geoeffnet wurde + /// ([entsperreMit]) — und nicht, weil gerade erst der erste Faktor + /// eingerichtet wurde ([fuegeHinzu]). + bool _mitFaktorGeoeffnet = false; + /// Ob die Faecher schon eingelesen wurden. bool _geladen = false; @@ -298,6 +324,7 @@ class VaultSecretStore implements SecretStore { final (slot, oeffnung) = await _oeffneMit(faktor, slotId: slotId); _vergiss(); _offen = oeffnung.geheimnis; + _mitFaktorGeoeffnet = true; await _pflegeNachDemOeffnen(slot, oeffnung); return Uint8List.fromList(oeffnung.geheimnis); } @@ -309,20 +336,39 @@ class VaultSecretStore implements SecretStore { /// enthalten. Das Panik-Passwort loest auch hier [PanikAusgeloestException] /// aus: wer unter Zwang "zum Abschalten bitte das Passwort" hoert, soll /// genau das eingeben koennen. + /// + /// EIN FACH AUS DIESER SITZUNG ZAEHLT NICHT ([taugtAlsNachweis]) — es + /// endet in [UnlockFailedException], wie ein falsches Passwort. Future weiseNach(UnlockFactor faktor, {String? slotId}) async { if (_offen == null) throw const LockedException(); final epoche = _epoche; - final (_, oeffnung) = await _oeffneMit(faktor, slotId: slotId); + final (slot, oeffnung) = await _oeffneMit(faktor, slotId: slotId); final offen = _offen; final passt = offen != null && epoche == _epoche && + taugtAlsNachweis(slot.id) && gleichInKonstanterZeit(oeffnung.geheimnis, offen); _ueberschreibe(oeffnung.geheimnis); if (!passt) throw const UnlockFailedException(); return FrischerNachweis._(this, _epoche, jetzt()); } + /// Ob das Fach [slotId] gerade als Nachweis ([weiseNach]) taugt. + /// + /// NICHT, wenn es seit dem letzten Entsperren dazukam. Sonst waere der + /// Nachweis beim Hinzufuegen die einzige Huerde: wer sie einmal nimmt (oder + /// an ihr vorbeikommt), haette danach einen Ausweis, den er selbst + /// ausgestellt hat. Nach dem naechsten Sperren und Entsperren zaehlt das + /// Fach wie jedes andere. + /// + /// AUSNAHME: die Sperre entstand erst in dieser Sitzung (der Tresor wurde + /// nicht mit einem Faktor geoeffnet, sondern durch den ersten Faktor). + /// Dann gibt es kein aelteres Fach, das ein Nachweis sein koennte — und + /// vorher war die App ungesperrt, ein Ausweis also gar nicht zu verlangen. + bool taugtAlsNachweis(String slotId) => + !_mitFaktorGeoeffnet || !_neueFaecher.contains(slotId); + /// Probiert die Faecher, die zu [faktor] passen. /// /// Mehrere Faecher derselben Sorte kommen vor: zwei Sticks, oder ein Stick @@ -448,14 +494,24 @@ class VaultSecretStore implements SecretStore { /// der Liste, derselbe Anlagezeitpunkt. Wer die Datei vorher und nachher /// nebeneinanderlegt, sieht an Reihenfolge und Zeitstempel nicht, dass sich /// etwas an der Bedeutung geaendert hat. - Future fuegePanikHinzu(UnlockFactor faktor) async { - final alt = await _roh(); - if (alt == null || alt.isEmpty) { + /// + /// Verlangt einen [nachweis] wie [fuegeHinzu] — sonst koennte jemand mit + /// dem entsperrten Telefon dem Besitzer ein Wort unterschieben, das beim + /// naechsten Vertippen alles loescht. + Future fuegePanikHinzu(UnlockFactor faktor, + {FrischerNachweis? nachweis}) async { + final vorher = await _roh(); + if (vorher == null || vorher.isEmpty) { throw StateError('Ein Panik-Passwort braucht eine eingerichtete Sperre'); } - final e = _offen; - if (e == null) throw const LockedException(); + if (_offen == null) throw const LockedException(); + _pruefeNachweis(nachweis); var slot = await faktor.createSlot(panikMarke, createdAt: jetzt()); + _loeseEin(nachweis); + // Nach dem Einloesen ist sicher, dass seit dem Nachweis nicht gesperrt + // wurde (die Epoche stimmt) — der Tresor ist also noch offen. + final e = _offen!; + final alt = (await _roh())!; final liste = [...alt.slots]; var stelle = -1; @@ -474,6 +530,7 @@ class VaultSecretStore implements SecretStore { liste.add(slot); } await _schreibe(await _gleicheAttrappenAus(alt.mitSlots(liste), e), e); + _neueFaecher.add(slot.id); return slot; } @@ -490,6 +547,8 @@ class VaultSecretStore implements SecretStore { if (o != null) _ueberschreibe(o); _offen = null; _epoche++; + _neueFaecher.clear(); + _mitFaktorGeoeffnet = false; } static void _ueberschreibe(Uint8List b) => b.fillRange(0, b.length, 0); @@ -498,20 +557,64 @@ class VaultSecretStore implements SecretStore { /// /// Beim ERSTEN Faktor wandert die Entropie aus dem Schluesselspeicher in das /// Fach und wird dort entfernt. Ab da ist die App gesperrt. - Future fuegeHinzu(UnlockFactor faktor) async { + /// + /// GIBT ES SCHON FAECHER, gelten dieselben Regeln wie beim Entfernen des + /// letzten ([entferne]): offener Tresor (sonst [LockedException]) und ein + /// [nachweis] aus [weiseNach] (sonst [NachweisNoetigException]). Ein + /// offener Tresor allein genuegt nicht — offen ist er auch, wenn jemand + /// anderes das entsperrte Telefon in der Hand haelt, und mit einem eigenen + /// Passwort koennte der sich danach als Besitzer ausweisen. + /// + /// Der Nachweis wird VOR dem Anlegen geprueft (niemand soll umsonst den + /// Stick beruehren) und erst DANACH eingeloest: scheitert das Anlegen — zu + /// schwaches Passwort, Stick abgezogen —, gilt er fuer den naechsten + /// Versuch weiter. Wurde dazwischen gesperrt, gilt er nicht mehr, und das + /// neue Fach wird verworfen. + Future fuegeHinzu(UnlockFactor faktor, + {FrischerNachweis? nachweis}) async { + final vorher = await _roh(); + final ersterFaktor = vorher == null || vorher.isEmpty; final offen = _offen; - final entropie = - offen != null ? Uint8List.fromList(offen) : await basis.read(); + final Uint8List? entropie; + if (ersterFaktor) { + entropie = offen != null ? Uint8List.fromList(offen) : await basis.read(); + } else { + if (offen == null) throw const LockedException(); + _pruefeNachweis(nachweis); + entropie = Uint8List.fromList(offen); + } if (entropie == null) { throw StateError('Ohne Identitaet gibt es nichts zu verschliessen'); } final slot = await faktor.createSlot(entropie, createdAt: jetzt()); + if (!ersterFaktor) { + try { + _loeseEin(nachweis); + } on NachweisNoetigException { + _ueberschreibe(entropie); + if (faktor is KeystoreFactor) { + try { + await faktor.entferne(slot.id); + } catch (_) { + // Ein uebrig gebliebener Fachschluessel oeffnet ohne Fach nichts. + } + } + rethrow; + } + } final alt = await _roh(); final neu = await _gleicheAttrappenAus( (alt ?? const KeyVault(slots: [])).mitSlot(slot), entropie); await _schreibe(neu, entropie); + if (ersterFaktor) { + // Die Sperre entsteht gerade erst. Es gibt kein aelteres Fach, also ist + // dieses der Ausweis — siehe [taugtAlsNachweis]. + _neueFaecher.clear(); + _mitFaktorGeoeffnet = false; + } _offen ??= Uint8List.fromList(entropie); + _neueFaecher.add(slot.id); if (alt == null || alt.isEmpty) { // Der Punkt ohne Rueckweg — ab jetzt gibt es die Entropie nur noch im @@ -583,7 +686,24 @@ class VaultSecretStore implements SecretStore { /// [NachweisNoetigException]. void loeseNachweisEin(FrischerNachweis? n) => _loeseEin(n); + /// Ob [n] gerade gilt — ohne ihn zu verbrauchen. Fuer Pruefungen, die + /// VOR der eigentlichen Aenderung stehen sollen. + bool nachweisGilt(FrischerNachweis? n) { + try { + _pruefeNachweis(n); + return true; + } on NachweisNoetigException { + return false; + } + } + void _loeseEin(FrischerNachweis? n) { + _pruefeNachweis(n); + n!._verbraucht = true; + } + + /// Prueft [n], OHNE ihn zu verbrauchen. Wirft [NachweisNoetigException]. + void _pruefeNachweis(FrischerNachweis? n) { if (n == null || !identical(n._aussteller, this) || n._verbraucht || @@ -594,7 +714,6 @@ class VaultSecretStore implements SecretStore { if (alter < 0 || alter > nachweisGueltigkeit.inMilliseconds) { throw const NachweisNoetigException(); } - n._verbraucht = true; } /// Wie lange die App im Hintergrund offen bleiben darf. diff --git a/secure-messenger/app/lib/data.dart b/secure-messenger/app/lib/data.dart index ce84432..c9804e4 100644 --- a/secure-messenger/app/lib/data.dart +++ b/secure-messenger/app/lib/data.dart @@ -267,7 +267,8 @@ const Map> strings = { 'bg15Note': 'Connects briefly every 15 minutes. Feels almost live, uses noticeably less battery than a constant connection, and needs no second app. The sensible default. Pick this unless you have a reason not to.', 'bg60Note': 'Once an hour. Enough if nobody is waiting for an instant reply. Barely measurable on the battery.', 'bg240Note': 'Four times a day. For anyone who treats BitDM like a mailbox rather than a chat.', - 'bgConflict': 'This does not work right now: the relay only answers with a signature from your identity key, and that key sits behind the app lock. With "Lock again after: Instantly" the app has no key the moment you put it down. Set the lock delay to 5 minutes or Never, or accept that messages arrive when you open the app.', + 'bgConflict': 'This does not work right now: the relay only answers with a signature from your identity key, and that key sits behind the app lock. With "Lock again after: Instantly" the app has no key the moment you put it down. To receive in the background all the time, set the lock delay to Never. With 1 or 5 minutes the app only receives until that time is up. Otherwise messages arrive when you open the app.', + 'bgUntilLock': 'In the background the app only receives until the lock delay is up. After that it is locked and fetches messages once you unlock it. If the next check in this rhythm comes later than that, nothing arrives in the background at all. Receiving all the time only works with "Lock again after: Never".', 'bgNotifTitle': 'BitDM', 'bgNotifText': 'Ready to receive', 'pushGuideTitle': 'Set up push', @@ -555,6 +556,7 @@ const Map> strings = { 'freshAuthTitle': 'Confirm it is you', 'freshAuthPw': 'Enter your app password to continue.', 'freshAuthNone': 'None of your unlock methods works on this device.', + 'freshAuthNew': 'Unlock methods added since the last unlock only count as confirmation after the next unlock, for example after restarting the app.', 'phraseShow': 'Show recovery words', 'phraseShowSub': 'The 12 words that restore your identity. Asks for your unlock first.', 'phraseShowWarn': 'Anyone who sees these words can be you. Do not photograph them, do not type them anywhere.', @@ -747,7 +749,8 @@ const Map> strings = { 'bg15Note': 'Verbindet sich alle 15 Minuten kurz. Fühlt sich fast wie ständig an, braucht spürbar weniger Akku als eine dauerhafte Verbindung und kommt ohne zweite App aus. Der sinnvolle Standard. Nimm das, wenn nichts dagegen spricht.', 'bg60Note': 'Einmal pro Stunde. Reicht, wenn niemand auf eine sofortige Antwort wartet. Am Akku kaum messbar.', 'bg240Note': 'Viermal am Tag. Für alle, die BitDM eher wie ein Postfach behandeln als wie einen Chat.', - 'bgConflict': 'Das geht gerade nicht: der Relay antwortet nur auf eine Unterschrift mit deinem Identitätsschlüssel, und der liegt hinter der App-Sperre. Bei „Wieder sperren nach: Sofort" hat die App keinen Schlüssel, sobald du sie weglegst. Stell die Sperrfrist auf 5 Minuten oder Nie, oder nimm hin, dass Nachrichten beim Öffnen ankommen.', + 'bgConflict': 'Das geht gerade nicht: der Relay antwortet nur auf eine Unterschrift mit deinem Identitätsschlüssel, und der liegt hinter der App-Sperre. Bei „Wieder sperren nach: Sofort" hat die App keinen Schlüssel, sobald du sie weglegst. Dauerhaft im Hintergrund empfängt sie nur mit „Nie". Bei 1 oder 5 Minuten empfängt sie nur, bis die Frist um ist. Sonst kommen Nachrichten beim Öffnen an.', + 'bgUntilLock': 'Im Hintergrund empfängt die App nur, bis die Sperrfrist um ist. Danach ist sie zu und holt Nachrichten erst nach dem Entsperren. Kommt die nächste Abfrage in diesem Takt später, kommt im Hintergrund gar nichts an. Dauerhaft geht es nur mit „Wieder sperren nach: Nie".', 'bgNotifTitle': 'BitDM', 'bgNotifText': 'Empfangsbereit', 'pushGuideTitle': 'Anstoß einrichten', @@ -989,6 +992,7 @@ const Map> strings = { 'freshAuthTitle': 'Bestätige, dass du es bist', 'freshAuthPw': 'Gib dein App-Passwort ein, um fortzufahren.', 'freshAuthNone': 'Keine deiner Anmeldearten funktioniert auf diesem Gerät.', + 'freshAuthNew': 'Anmeldearten, die seit dem letzten Entsperren dazukamen, gelten erst nach dem nächsten Entsperren als Bestätigung, etwa nach einem Neustart der App.', 'phraseShow': 'Wiederherstellungswörter zeigen', 'phraseShowSub': 'Die 12 Wörter, mit denen du deine Identität zurückholst. Fragt vorher nach deiner Anmeldung.', 'phraseShowWarn': 'Wer diese Wörter sieht, kann du sein. Nicht fotografieren, nirgends eintippen.', diff --git a/secure-messenger/app/lib/oberflaeche/home/einstellungen.dart b/secure-messenger/app/lib/oberflaeche/home/einstellungen.dart index 9808c61..6060658 100644 --- a/secure-messenger/app/lib/oberflaeche/home/einstellungen.dart +++ b/secure-messenger/app/lib/oberflaeche/home/einstellungen.dart @@ -189,6 +189,13 @@ extension _Einstellungen on _HomeState { if (st.empfangsTakt.an && !st.empfangMoeglich) ...[ const SizedBox(height: 10), _hinweisKasten(t('bgConflict'), warnend: false), + ] + // Und die mildere Form davon: bei 1 oder 5 Minuten empfaengt die + // App nur, bis die Frist um ist — sie verlaengert sich dafuer + // nicht (AppState, "Sperren im Hintergrund"). + else if (st.empfangEndetMitFrist) ...[ + const SizedBox(height: 10), + _hinweisKasten(t('bgUntilLock'), warnend: false), ], ])), ], diff --git a/secure-messenger/app/lib/oberflaeche/home/notfall.dart b/secure-messenger/app/lib/oberflaeche/home/notfall.dart index a196de7..a2546d1 100644 --- a/secure-messenger/app/lib/oberflaeche/home/notfall.dart +++ b/secure-messenger/app/lib/oberflaeche/home/notfall.dart @@ -299,7 +299,12 @@ extension _Notfall on _HomeState { return; } try { - await st.setzePanikPasswort(eins.text); + // Den Nachweis holte der Schalter vor diesem Dialog; ist er + // beim Tippen verfallen, wird noch einmal gefragt. + if (!await _mitNachweis( + () => st.setzePanikPasswort(eins.text))) { + return; + } if (ctx.mounted) Navigator.pop(ctx); } on PanikWortUngueltigException catch (e) { neu(() => fehler = t(e.grund == PanikWortFehler.zuKurz diff --git a/secure-messenger/app/lib/oberflaeche/home/sperre.dart b/secure-messenger/app/lib/oberflaeche/home/sperre.dart index cf980b0..9259e52 100644 --- a/secure-messenger/app/lib/oberflaeche/home/sperre.dart +++ b/secure-messenger/app/lib/oberflaeche/home/sperre.dart @@ -171,7 +171,8 @@ extension _Sperre on _HomeState { } /// Verlangt eine FRISCHE Anmeldung, bevor etwas Folgenreiches passiert: die - /// Sperre aendern, die Fernloeschung aufhalten oder umstellen, die zwoelf + /// Sperre aendern (seit 18.10.2026 auch einen Faktor HINZUFUEGEN, siehe + /// [methodAct]), die Fernloeschung aufhalten oder umstellen, die zwoelf /// Woerter oder ihre Teile zeigen. /// /// EIN OFFENER BILDSCHIRM IST KEIN AUSWEIS. Wer das entsperrte Telefon @@ -199,13 +200,18 @@ extension _Sperre on _HomeState { return ok == true; } // Nur, was hier auch geht — am Rechner bleibt vom Faktor-Satz nur das - // Passwort (siehe [_faktorGehtHier]). + // Passwort (siehe [_faktorGehtHier]). Und nur, was als Ausweis taugt: ein + // Faktor, der seit dem letzten Entsperren dazukam, zaehlt erst nach dem + // naechsten (AppState.kannNachweisen) — sonst stellte sich jeder, der + // einen anlegen kann, den Ausweis selbst aus. + final hier = zugriffsZeilen.where(_faktorGehtHier).toList(); final arten = [ - for (final k in zugriffsZeilen.where(_faktorGehtHier)) - if (st.hatFaktor(zeilenArt[k]!)) k, + for (final k in hier) + if (st.kannNachweisen(zeilenArt[k]!)) k, ]; if (arten.isEmpty) { - _hinweis(t('freshAuthNone')); + final nurNeue = hier.any((k) => st.hatFaktor(zeilenArt[k]!)); + _hinweis(t(nurNeue ? 'freshAuthNew' : 'freshAuthNone')); return false; } var wahl = arten.first; diff --git a/secure-messenger/app/lib/oberflaeche/home/zugriff.dart b/secure-messenger/app/lib/oberflaeche/home/zugriff.dart index 55bebee..9a0f952 100644 --- a/secure-messenger/app/lib/oberflaeche/home/zugriff.dart +++ b/secure-messenger/app/lib/oberflaeche/home/zugriff.dart @@ -72,9 +72,14 @@ extension _Zugriff on _HomeState { if (art == null || laeuftZeile != null) return; final vorhanden = st.sichtbareFaktoren.where((s) => s.kind == art).toList(); + // ERST AUSWEISEN, in beide Richtungen. Einen Faktor herausnehmen schwaecht + // die Sperre. Einen HINZUFUEGEN stellt einen neuen Ausweis aus: bis + // 18.10.2026 ging das ohne Rueckfrage, und wer das entsperrte Telefon kurz + // in der Hand hatte, legte ein eigenes Passwort an und liess sich damit + // die zwoelf Woerter zeigen. Ohne Faktor fragt [frischBestaetigt] nicht — + // dann gibt es nichts, womit man sich ausweisen koennte. + if (!await frischBestaetigt()) return; if (vorhanden.isNotEmpty) { - // Einen Faktor herausnehmen schwaecht die Sperre — also erst ausweisen. - if (!await frischBestaetigt()) return; await _entferneFaktor(key, vorhanden.first.id); return; } @@ -117,7 +122,7 @@ extension _Zugriff on _HomeState { } return; } - await anlegen(); + await _mitNachweis(anlegen); } on AnmeldungFehlgeschlagen catch (e) { // Ein Abbruch ist keine Panne, sondern eine Entscheidung — dafuer keine // rote Meldung. @@ -135,6 +140,25 @@ extension _Zugriff on _HomeState { } } + /// Fuehrt [tun] aus — und holt EINMAL eine neue Bestaetigung, wenn der + /// Tresor einen frischen Nachweis verlangt. + /// + /// Den Nachweis holt [methodAct] schon beim Antippen der Zeile. Er gilt + /// aber nur zwei Minuten (VaultSecretStore.nachweisGueltigkeit), und wer + /// ein langes Passwort zweimal tippt oder den Stick erst suchen muss, ist + /// manchmal laenger. Dann lieber noch einmal fragen als mit einer + /// Fehlermeldung abbrechen. Rueckgabe: false, wenn der Nutzer abbricht. + Future _mitNachweis(Future Function() tun) async { + try { + await tun(); + return true; + } on NachweisNoetigException { + if (!await frischBestaetigt()) return false; + await tun(); + return true; + } + } + /// Stellt um, wann die App sich von selbst wieder abschliesst. Future _setzeSperrfrist(int sekunden) async { if (sekunden == st.sperrfristAlsZahl) return; @@ -190,7 +214,7 @@ extension _Zugriff on _HomeState { } setState(() => lockFehler = null); try { - await st.fuegePasswortHinzu(pw); + if (!await _mitNachweis(() => st.fuegePasswortHinzu(pw))) return; pwCtl.clear(); pwCtl2.clear(); if (mounted) setState(() => enroll = null); @@ -224,10 +248,12 @@ extension _Zugriff on _HomeState { lockFehler = null; }); try { - await st.fuegeStickHinzu( - weg: stickWeg, - pin: stickPinCtl.text.isEmpty ? null : stickPinCtl.text, - ); + final pin = stickPinCtl.text.isEmpty ? null : stickPinCtl.text; + if (!await _mitNachweis( + () => st.fuegeStickHinzu(weg: stickWeg, pin: pin))) { + if (mounted) setState(() => stickSchritt = null); + return; + } stickPinCtl.clear(); if (mounted) setState(() { enroll = null; stickSchritt = null; }); } catch (e) { diff --git a/secure-messenger/app/test/lock/hintergrund_sperre_test.dart b/secure-messenger/app/test/lock/hintergrund_sperre_test.dart new file mode 100644 index 0000000..bca1bde --- /dev/null +++ b/secure-messenger/app/test/lock/hintergrund_sperre_test.dart @@ -0,0 +1,250 @@ +// hintergrund_sperre_test.dart — sperrt die App, WAEHREND sie weg ist? +// +// DIE LUECKE (Sicherheitspruefung vom 18.10.2026): die Frist wurde nur beim +// Zurueckkommen geprueft. Bei "sofort" blieben Entropie, Datenbank und +// Schluessel offen, solange die App im Hintergrund lag. Zu sehen war davon +// nichts — beim Zurueckkommen sperrte sie ja —, aber wer den Prozess lesen +// kann, las in der Zeit alles. +// +// auto_sperre_test prueft, was beim Zurueckkommen passiert. Hier geht es um +// die Zeit dazwischen. + +import 'dart:async'; +import 'dart:io'; + +import 'package:bitdm/app_state.dart'; +import 'package:bitdm/core/empfang.dart'; +import 'package:bitdm/core/fake_messenger_core.dart'; +import 'package:bitdm/core/lock/keystore_factor.dart'; +import 'package:bitdm/core/lock/vault_store.dart'; +import 'package:bitdm/core/secret_store.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeBasis implements SecretStore { + Uint8List? inhalt = Uint8List.fromList(List.generate(16, (i) => i)); + @override + Future read() async => inhalt; + @override + Future write(Uint8List e) async => inhalt = e; + @override + Future delete() async => inhalt = null; +} + +/// Der gesicherte Bereich. [halt] laesst das Lesen warten — wie ein +/// Fingerabdruck-Dialog, auf den noch niemand den Finger gelegt hat. +class FakeAblage implements SchluesselAblage { + final Map daten = {}; + Completer? halt; + + @override + Future lies(String k) async { + final h = halt; + if (h != null) await h.future; + return daten[k]; + } + + @override + Future schreibe(String k, String v) async => daten[k] = v; + @override + Future loesche(String k) async => daten.remove(k); +} + +class FakeDienst implements EmpfangsDienst { + bool _laeuft = false; + @override + bool get laeuft => _laeuft; + @override + Future starte({required String titel, required String text}) async => + _laeuft = true; + @override + Future stoppe() async => _laeuft = false; +} + +/// Eine Frist, die ein Test abwarten kann. Die Oberflaeche bietet nur 0, 60, +/// 300 und "nie" an; gerechnet wird mit jeder Dauer gleich. +const kurz = Duration(milliseconds: 80); + +/// Lange genug, dass ein Wecker mit [kurz] sicher gelaufen ist. +Future abwarten() => Future.delayed(const Duration(milliseconds: 400)); + +void main() { + late Directory verzeichnis; + late VaultSecretStore tresor; + late FakeAblage ablage; + late FakeMessengerCore core; + late FakeDienst dienst; + late AppState st; + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('bitdm/fenster'), (_) async => null); + }); + + setUp(() async { + verzeichnis = Directory.systemTemp.createTempSync('bitdm-hgsperre'); + ablage = FakeAblage(); + dienst = FakeDienst(); + tresor = VaultSecretStore( + datei: vaultDateiIn(verzeichnis.path), + basis: FakeBasis(), + jetzt: () => 1000, + ); + core = FakeMessengerCore()..simulateExistingIdentity = true; + st = AppState(core, tresor: tresor, ablagen: (_) => ablage) + ..empfangsDienst = dienst; + await st.boot(); + await st.fuegeBiometrieHinzu(); + expect(tresor.istOffen, isTrue); + }); + + tearDown(() { + st.dispose(); + core.dispose(); + try { + verzeichnis.deleteSync(recursive: true); + } catch (_) {} + }); + + /// Stellt eine Frist ein (wie die Oberflaeche) und kuerzt sie dann auf + /// [kurz], damit der Test nicht eine Minute wartet. + Future mitFrist() async { + await st.setzeSperrfrist(60); + st.sperrfrist = kurz; + } + + group('Bei "sofort"', () { + test('DER FALL: der Tresor ist zu, sobald die App weg ist', () async { + st.vordergrund(false); + + expect(tresor.istOffen, isFalse, + reason: 'bis 18.10.2026 blieb er offen, bis die App wiederkam'); + expect(st.gesperrt, isTrue); + await Future.delayed(Duration.zero); + expect(core.isInitialized, isFalse, + reason: 'die Datenbank muss wirklich zu sein'); + }); + + test('kommt sie zurueck, waehrend die Datenbank noch zugeht, fasst nichts ' + 'den Kern an', () async { + // Ohne `gesperrt` gleich zu Beginn von sperreWieder raeumte + // `vordergrund(true)` abgelaufene Nachrichten auf — gegen einen Kern, + // der gerade zuging (NotInitializedException). + st.vordergrund(false); + st.vordergrund(true); + await Future.delayed(Duration.zero); + expect(st.gesperrt, isTrue); + expect(await st.entsperreMitBiometrie(), isTrue); + }); + }); + + group('Mit Frist', () { + test('offen, bis die Frist um ist — dann zu, auch ohne Zurueckkommen', + () async { + await mitFrist(); + st.vordergrund(false); + expect(tresor.istOffen, isTrue, + reason: 'eine kurze Abwesenheit darf nichts kosten'); + + await abwarten(); + expect(tresor.istOffen, isFalse); + expect(st.gesperrt, isTrue); + expect(core.isInitialized, isFalse); + }); + + test('kommt sie vorher zurueck, sperrt der Wecker nicht nachtraeglich', + () async { + await mitFrist(); + st.vordergrund(false); + st.vordergrund(true); + await abwarten(); + expect(st.gesperrt, isFalse); + expect(tresor.istOffen, isTrue); + }); + + test('DER HINTERGRUNDEMPFANG VERLAENGERT DIE FRIST NICHT', () async { + // Entschieden am 18.10.2026 (app_state.dart, "Sperren im Hintergrund"): + // die Einstellung sagt "wie lange die App im Hintergrund offen bleiben + // darf" — und so lange bleibt sie offen, nicht laenger. + await mitFrist(); + await st.setzeEmpfangsTakt(EmpfangsTakt.staendig); + expect(st.empfangEndetMitFrist, isTrue, + reason: 'die Einstellungen sollen das dazusagen'); + + st.vordergrund(false); + await Future.delayed(Duration.zero); + expect(dienst.laeuft, isTrue, reason: 'bis zur Frist wird empfangen'); + + await abwarten(); + expect(tresor.istOffen, isFalse); + expect(dienst.laeuft, isFalse, + reason: 'ein Dienst mit "Empfangsbereit" neben einer gesperrten App ' + 'behauptet etwas, das nicht stimmt'); + }); + + test('"nie" bleibt offen', () async { + await st.setzeSperrfrist(-1); + expect(st.empfangEndetMitFrist, isFalse); + st.vordergrund(false); + await abwarten(); + expect(tresor.istOffen, isTrue); + expect(st.gesperrt, isFalse); + }); + }); + + group('Die Ausnahmen haben eine Obergrenze', () { + test('ein laufender Faktor schiebt auf — aber nicht endlos', () async { + // Bei der USB-Freigabe oder der Geraete-PIN geht die App in den + // Hintergrund, waehrend der Faktor laeuft. Bleibt der Dialog liegen, + // sperrt sie nach [systemDialogHoechstens]. + st.systemDialogHoechstens = kurz; + ablage.halt = Completer(); + final bestaetigt = st.bestaetigeMitBiometrie(); + await Future.delayed(Duration.zero); + + st.vordergrund(false); + expect(tresor.istOffen, isTrue, + reason: 'sonst liesse sich ein Stick bei "sofort" nie einrichten'); + + await abwarten(); + expect(tresor.istOffen, isFalse); + + ablage.halt!.complete(); + expect(await bestaetigt, isFalse, + reason: 'ein Nachweis aus der Zeit vor dem Sperren gilt nicht'); + }); + + test('endet der Faktor im Hintergrund, gilt wieder die Frist', () async { + // Dieselbe Rechnung wie beim Zurueckkommen: dort zaehlt der Faktor auch + // nur, solange er laeuft. + ablage.halt = Completer(); + final bestaetigt = st.bestaetigeMitBiometrie(); + await Future.delayed(Duration.zero); + st.vordergrund(false); + expect(tresor.istOffen, isTrue); + + ablage.halt!.complete(); + await bestaetigt; + expect(tresor.istOffen, isFalse); + }); + + test('die eigene Dateiwahl, offen liegen gelassen', () async { + st.systemDialogHoechstens = kurz; + final waehler = Completer(); + final ergebnis = st.imSystemDialog(() async { + st.vordergrund(false); // Android legt den Waehler vor die App + return waehler.future; + }); + await Future.delayed(Duration.zero); + expect(tresor.istOffen, isTrue, + reason: 'die selbst geoeffnete Dateiwahl sperrt nicht sofort'); + + await abwarten(); + expect(tresor.istOffen, isFalse); + waehler.complete('datei'); + expect(await ergebnis, 'datei'); + }); + }); +} diff --git a/secure-messenger/app/test/lock/nachweis_beim_hinzufuegen_test.dart b/secure-messenger/app/test/lock/nachweis_beim_hinzufuegen_test.dart new file mode 100644 index 0000000..d543ac0 --- /dev/null +++ b/secure-messenger/app/test/lock/nachweis_beim_hinzufuegen_test.dart @@ -0,0 +1,336 @@ +// nachweis_beim_hinzufuegen_test.dart — ein neuer Faktor ist ein neuer Ausweis. +// +// DIE LUECKE (Sicherheitspruefung vom 18.10.2026): nur das ENTFERNEN eines +// Faktors verlangte eine frische Anmeldung, das Hinzufuegen nicht. Wer das +// entsperrte Telefon einen Moment in der Hand hatte, legte ein eigenes +// App-Passwort an und wies sich danach damit aus — fuer die zwoelf Woerter, +// die Sperrfrist "nie", die Fernloeschung, die Teilgeheimnisse. +// +// Geprueft wird beides: dass das Hinzufuegen ohne Nachweis scheitert, UND dass +// ein Faktor aus der laufenden Sitzung kein Nachweis ist. Das zweite haelt, +// falls das erste je wieder ein Loch bekommt. + +import 'dart:async'; +import 'dart:io'; + +import 'package:bitdm/app_state.dart'; +import 'package:bitdm/core/app_lock.dart'; +import 'package:bitdm/core/fake_messenger_core.dart'; +import 'package:bitdm/core/lock/key_vault.dart'; +import 'package:bitdm/core/lock/keystore_factor.dart'; +import 'package:bitdm/core/lock/unlock_factor.dart'; +import 'package:bitdm/core/lock/vault_store.dart'; +import 'package:bitdm/core/secret_store.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeBasis implements SecretStore { + Uint8List? inhalt = Uint8List.fromList(List.generate(16, (i) => i + 3)); + @override + Future read() async => inhalt; + @override + Future write(Uint8List e) async => inhalt = e; + @override + Future delete() async => inhalt = null; +} + +/// Der gesicherte Bereich. [halt] laesst das Schreiben warten — so laesst +/// sich mitten im Anlegen sperren. +class FakeAblage implements SchluesselAblage { + final Map daten = {}; + Completer? halt; + + @override + Future lies(String k) async => daten[k]; + @override + Future schreibe(String k, String v) async { + final h = halt; + if (h != null) await h.future; + daten[k] = v; + } + + @override + Future loesche(String k) async => daten.remove(k); +} + +const echtes = 'Kupfer-Regen-Turm-Zaun-9042'; +const fremdes = 'Moewe-Anker-Salz-Kran-7715'; + +PassphraseFactor pw(String p) => PassphraseFactor(p, geraeteGebunden: false); + +void main() { + late Directory verzeichnis; + late FakeBasis basis; + late FakeAblage ablage; + + setUpAll(() { + TestWidgetsFlutterBinding.ensureInitialized(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('bitdm/fenster'), (_) async => null); + }); + + setUp(() { + verzeichnis = Directory.systemTemp.createTempSync('bitdm-nachweis-neu'); + basis = FakeBasis(); + ablage = FakeAblage(); + }); + + tearDown(() { + try { + verzeichnis.deleteSync(recursive: true); + } catch (_) {} + }); + + VaultSecretStore tresor() => VaultSecretStore( + datei: vaultDateiIn(verzeichnis.path), basis: basis, jetzt: () => 1000); + + KeystoreFactor finger([FakeAblage? a]) => KeystoreFactor(ablage: a ?? ablage); + + KeyVault aufPlatte() => KeyVault.fromJsonString( + vaultDateiIn(verzeichnis.path).readAsStringSync()); + + /// Wie nach einem Neustart: der Fingerabdruck ist eingerichtet, und der + /// Tresor wurde mit ihm geoeffnet — die Lage, in der jemand das entsperrte + /// Telefon in die Hand bekommt. + Future mitFingerGeoeffnet() async { + await tresor().fuegeHinzu(finger()); + final t = tresor(); + await t.entsperreMit(finger()); + return t; + } + + group('Tresor: Hinzufuegen ohne Nachweis schlaegt fehl', () { + test('DER ANGRIFF: ein eigenes Passwort neben dem Fingerabdruck', () async { + final t = await mitFingerGeoeffnet(); + final vorher = aufPlatte().toJsonString(); + + await expectLater(t.fuegeHinzu(pw(fremdes)), + throwsA(isA())); + + expect(aufPlatte().toJsonString(), vorher, + reason: 'die Fachdatei darf sich nicht veraendert haben'); + await expectLater(t.weiseNach(pw(fremdes)), + throwsA(isA()), + reason: 'es gibt kein Fach, das dieses Passwort oeffnet'); + }); + + test('auch kein zweiter Fingerabdruck, kein Stick, kein Panik-Fach', + () async { + final t = await mitFingerGeoeffnet(); + final zweite = FakeAblage(); + await expectLater(t.fuegeHinzu(finger(zweite)), + throwsA(isA())); + expect(zweite.daten, isEmpty, + reason: 'ohne Nachweis wird gar nicht erst angelegt'); + await expectLater(t.fuegePanikHinzu(pw(fremdes)), + throwsA(isA())); + expect(aufPlatte().slots, hasLength(1)); + }); + + test('mit Nachweis geht es — einmal', () async { + final t = await mitFingerGeoeffnet(); + final n = await t.weiseNach(finger()); + await t.fuegeHinzu(pw(echtes), nachweis: n); + expect((await t.faecher())!.slots, hasLength(2)); + + await expectLater(t.fuegeHinzu(finger(FakeAblage()), nachweis: n), + throwsA(isA()), + reason: 'ein Nachweis gilt fuer genau eine Aenderung'); + }); + + test('ein zu schwaches Passwort verbraucht den Nachweis nicht', () async { + // Sonst kostete jeder Tippfehler beim neuen Passwort einen weiteren + // Fingerabdruck — der Nachweis wird erst eingeloest, wenn das Fach steht. + final t = await mitFingerGeoeffnet(); + final n = await t.weiseNach(finger()); + await expectLater(t.fuegeHinzu(pw('1234'), nachweis: n), + throwsA(isA())); + await t.fuegeHinzu(pw(echtes), nachweis: n); + expect((await t.faecher())!.slots, hasLength(2)); + }); + + test('ein verfallener Nachweis gilt nicht', () async { + var uhr = 1000; + await tresor().fuegeHinzu(finger()); + final t = VaultSecretStore( + datei: vaultDateiIn(verzeichnis.path), basis: basis, jetzt: () => uhr); + await t.entsperreMit(finger()); + final n = await t.weiseNach(finger()); + uhr += VaultSecretStore.nachweisGueltigkeit.inMilliseconds + 1; + await expectLater(t.fuegeHinzu(pw(echtes), nachweis: n), + throwsA(isA())); + }); + + test('bei zugem Tresor: gar nicht', () async { + await tresor().fuegeHinzu(finger()); + await expectLater( + tresor().fuegeHinzu(pw(echtes)), throwsA(isA())); + }); + + test('WIRD MITTEN IM ANLEGEN GESPERRT, wird das neue Fach verworfen', + () async { + // Das Anlegen dauert: Argon2id, zwei Stick-Beruehrungen, ein + // Systemdialog. Sperrt die App dazwischen (etwa im Hintergrund), darf + // danach kein Fach stehen, das mit der alten Erlaubnis entstand — und + // der Tresor darf nicht still wieder aufgehen. + final t = await mitFingerGeoeffnet(); + final n = await t.weiseNach(finger()); + final langsam = FakeAblage()..halt = Completer(); + final anlegen = t.fuegeHinzu(finger(langsam), nachweis: n); + await Future.delayed(Duration.zero); + + t.sperre(); + langsam.halt!.complete(); + + await expectLater(anlegen, throwsA(isA())); + expect(t.istOffen, isFalse); + expect(aufPlatte().slots, hasLength(1)); + expect(langsam.daten, isEmpty, + reason: 'der Fachschluessel im Geraet gehoert zu keinem Fach mehr'); + }); + + test('DER ERSTE Faktor braucht keinen Nachweis — es gibt nichts, womit', + () async { + final t = tresor(); + await t.fuegeHinzu(pw(echtes)); + expect(t.istOffen, isTrue); + expect(await t.hatFaecher(), isTrue); + }); + }); + + group('Tresor: ein Faktor aus dieser Sitzung ist kein Nachweis', () { + test('das gerade angelegte Passwort oeffnet weiseNach nicht', () async { + final t = await mitFingerGeoeffnet(); + final neu = await t.fuegeHinzu(pw(echtes), + nachweis: await t.weiseNach(finger())); + + expect(t.taugtAlsNachweis(neu.id), isFalse); + await expectLater(t.weiseNach(pw(echtes)), + throwsA(isA())); + // Der alte Faktor gilt weiter. + expect(await t.weiseNach(finger()), isA()); + }); + + test('auch nicht mit der Kennung des Fachs', () async { + final t = await mitFingerGeoeffnet(); + final neu = await t.fuegeHinzu(pw(echtes), + nachweis: await t.weiseNach(finger())); + await expectLater(t.weiseNach(pw(echtes), slotId: neu.id), + throwsA(isA())); + }); + + test('nach dem naechsten Entsperren zaehlt es wie jedes andere', () async { + final t = await mitFingerGeoeffnet(); + final neu = await t.fuegeHinzu(pw(echtes), + nachweis: await t.weiseNach(finger())); + t.sperre(); + await t.entsperreMit(finger()); + + expect(t.taugtAlsNachweis(neu.id), isTrue); + expect(await t.weiseNach(pw(echtes)), isA()); + }); + + test('DIE ERSTE SPERRE: ihr Faktor ist der Ausweis', () async { + // Die Sperre entsteht erst in dieser Sitzung. Es gibt kein aelteres Fach, + // und vorher war die App offen — ein Ausweis war gar nicht zu verlangen. + // Ohne diese Ausnahme liesse sich direkt nach dem Einrichten kein + // zweiter Faktor anlegen und kein Wort zeigen, bis die App einmal zu war. + final t = tresor(); + final erster = await t.fuegeHinzu(pw(echtes)); + expect(t.taugtAlsNachweis(erster.id), isTrue); + final n = await t.weiseNach(pw(echtes)); + await t.fuegeHinzu(finger(), nachweis: n); + expect((await t.faecher())!.slots, hasLength(2)); + }); + + test('auch nach dem Entfernen der letzten Sperre', () async { + // Letzten Faktor raus, App ungesperrt — der naechste ist wieder eine + // ERSTE Sperre und damit der Ausweis. + final t = await mitFingerGeoeffnet(); + await t.entferne(aufPlatte().slots.single.id, + faktor: finger(), nachweis: await t.weiseNach(finger())); + final neu = await t.fuegeHinzu(pw(echtes)); + expect(t.taugtAlsNachweis(neu.id), isTrue); + expect(await t.weiseNach(pw(echtes)), isA()); + }); + }); + + group('AppState', () { + late VaultSecretStore t; + late AppState st; + + setUp(() async { + // Fingerabdruck aus einer frueheren Sitzung, heute damit entsperrt. + await tresor().fuegeHinzu(finger()); + t = tresor(); + st = AppState(FakeMessengerCore()..simulateExistingIdentity = true, + tresor: t, ablagen: (_) => ablage); + await st.boot(); + // Die Attrappe des Kerns kennt keine Sperre und startet offen — der + // Tresor ist es nicht. Also so, wie es auf dem Geraet aussieht: + // gesperrt, dann mit dem Fingerabdruck geoeffnet. + await st.sperreWieder(); + expect(st.gesperrt, isTrue); + expect(await st.entsperreMitBiometrie(), isTrue); + expect(t.istOffen, isTrue); + }); + + tearDown(() => st.dispose()); + + test('DER ANGRIFF ueber die App: kein eigenes Passwort ohne Fingerabdruck', + () async { + await expectLater(st.fuegePasswortHinzu(fremdes), + throwsA(isA())); + expect(st.hatFaktor(UnlockFactorKind.passphrase), isFalse); + expect(await st.bestaetigeMitPasswort(fremdes), isFalse, + reason: 'damit liesse sich dann die frische Anmeldung bestehen'); + }); + + test('nach frischBestaetigt geht es, und der Beleg ist danach weg', + () async { + expect(await st.bestaetigeMitBiometrie(), isTrue); + await st.fuegePasswortHinzu(echtes); + expect(st.hatFaktor(UnlockFactorKind.passphrase), isTrue); + + await expectLater(st.fuegeGeraetePinHinzu(), + throwsA(isA())); + }); + + test('ein frisch angelegter Faktor besteht frischBestaetigt nicht', + () async { + expect(await st.bestaetigeMitBiometrie(), isTrue); + await st.fuegePasswortHinzu(echtes); + + expect(st.kannNachweisen(UnlockFactorKind.passphrase), isFalse, + reason: 'die Oberflaeche soll ihn gar nicht erst anbieten'); + expect(st.kannNachweisen(UnlockFactorKind.biometric), isTrue); + expect(await st.bestaetigeMitPasswort(echtes), isFalse); + + // Nach dem naechsten Entsperren schon. + await st.sperreWieder(); + expect(await st.entsperreMitBiometrie(), isTrue); + expect(st.kannNachweisen(UnlockFactorKind.passphrase), isTrue); + expect(await st.bestaetigeMitPasswort(echtes), isTrue); + }); + + test('Panik-Wort: ohne Nachweis abgelehnt — und kein Passwort-Orakel', + () async { + expect(await st.bestaetigeMitBiometrie(), isTrue); + await st.fuegePasswortHinzu(echtes); + + // Ohne Nachweis darf nicht einmal herauskommen, ob ein Wort das echte + // Passwort ist (PanikGleichException) — sonst liesse es sich mit dem + // entsperrten Telefon durchprobieren. + await expectLater(st.setzePanikPasswort(echtes), + throwsA(isA())); + await expectLater(st.setzePanikPasswort(fremdes), + throwsA(isA())); + expect(st.hatPanikPasswort, isFalse); + + expect(await st.bestaetigeMitBiometrie(), isTrue); + await st.setzePanikPasswort(fremdes); + expect(st.hatPanikPasswort, isTrue); + }); + }); +} diff --git a/secure-messenger/app/test/lock/panik_passwort_test.dart b/secure-messenger/app/test/lock/panik_passwort_test.dart index 3b86c7c..352b858 100644 --- a/secure-messenger/app/test/lock/panik_passwort_test.dart +++ b/secure-messenger/app/test/lock/panik_passwort_test.dart @@ -76,8 +76,14 @@ void main() { } catch (_) {} }); + /// Seit 18.10.2026 verlangt das Panik-Wort einen frischen Nachweis — die + /// Oberflaeche holt ihn ueber frischBestaetigt, bevor sie den Dialog zeigt. + Future ausweisen() async => + expect(await st.bestaetigeMitPasswort(echtes), isTrue); + test('DAS PANIK-PASSWORT LOESCHT, DAS ECHTE OEFFNET', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(panik); expect(st.hatPanikPasswort, isTrue); @@ -100,6 +106,7 @@ void main() { // probiert das Panik-Fach mit dem ECHTEN Passwort zuerst — das darf // weder loeschen noch das Oeffnen verhindern. await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(panik); final v = (await tresor.faecher())!; final umgedreht = KeyVault(slots: v.slots.reversed.toList()); @@ -118,6 +125,7 @@ void main() { test('VON AUSSEN SIEHT DAS PANIK-FACH AUS WIE EIN PASSWORT-FACH', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(panik); final slots = (await tresor.faecher())!.slots; expect(slots, hasLength(2)); @@ -134,6 +142,7 @@ void main() { test('DAS ECHTE PASSWORT KANN NICHT ZUM PANIK-PASSWORT WERDEN', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await expectLater(st.setzePanikPasswort(echtes), throwsA(isA())); expect(st.hatPanikPasswort, isFalse); @@ -145,6 +154,7 @@ void main() { test('DER LETZTE ECHTE FAKTOR NIMMT DAS PANIK-FACH MIT', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(panik); // Den letzten Faktor nimmt der Tresor nur mit frischem Nachweis heraus // (die Oberflaeche holt ihn ueber frischBestaetigt). @@ -161,6 +171,7 @@ void main() { test('EIN SATZ MIT LEERZEICHEN LOESCHT — genau so getippt wie eingerichtet', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(satz); await st.sperreWieder(); // Ohne das Leerzeichen, anders geschrieben: nichts passiert. @@ -176,6 +187,7 @@ void main() { test('KURZ IST ERLAUBT — ab vier Zeichen, ohne Staerkeprobe', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await expectLater( st.setzePanikPasswort('abc'), throwsA(isA() @@ -188,6 +200,7 @@ void main() { test('LEERZEICHEN AM RAND WERDEN ABGELEHNT, NICHT STILL ENTFERNT', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await expectLater( st.setzePanikPasswort(' $satz'), throwsA(isA() @@ -201,6 +214,7 @@ void main() { await st.fuegeBiometrieHinzu(); expect(st.sperreBietetPasswort, isFalse, reason: 'ohne Passwort-Fach gibt es auch kein Feld'); + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.setzePanikPasswort(satz); expect(st.hatFaktor(UnlockFactorKind.passphrase), isFalse, reason: 'die Einstellungen zeigten das Panik-Wort als Passwort'); @@ -231,7 +245,9 @@ void main() { test('EIN NEUES PANIK-PASSWORT ERSETZT DAS ALTE', () async { await st.fuegePasswortHinzu(echtes); + await ausweisen(); await st.setzePanikPasswort(panik); + await ausweisen(); await st.setzePanikPasswort('Birke-Nebel-Sand-Horn-3381'); expect(st.faktoren, hasLength(2), reason: 'das alte Panik-Fach blieb stehen'); await st.sperreWieder(); diff --git a/secure-messenger/app/test/lock/tresor_haertung_test.dart b/secure-messenger/app/test/lock/tresor_haertung_test.dart index 4d26c7b..170f5b3 100644 --- a/secure-messenger/app/test/lock/tresor_haertung_test.dart +++ b/secure-messenger/app/test/lock/tresor_haertung_test.dart @@ -268,11 +268,14 @@ void main() { () async { final t = tresor(); await t.fuegeHinzu(finger()); - await t.fuegeHinzu(pw(starkesPasswort)); + // Seit 18.10.2026 je mit frischem Nachweis (Befund 6, Hinzufuegen). + await t.fuegeHinzu(pw(starkesPasswort), + nachweis: await t.weiseNach(finger())); final vorher = aufPlatte().slots; expect(vorher, hasLength(3)); - final panik = await t.fuegePanikHinzu(pw(panikPasswort)); + final panik = await t.fuegePanikHinzu(pw(panikPasswort), + nachweis: await t.weiseNach(finger())); final nachher = aufPlatte().slots; expect(nachher, hasLength(3), reason: 'die Zahl darf sich nicht aendern'); expect(nachher[2].id, panik.id); @@ -288,7 +291,8 @@ void main() { test('ohne Panik-Passwort rueckt wieder ein Platzhalter nach', () async { final t = tresor(); await t.fuegeHinzu(pw(starkesPasswort)); - final panik = await t.fuegePanikHinzu(pw(panikPasswort)); + final panik = await t.fuegePanikHinzu(pw(panikPasswort), + nachweis: await t.weiseNach(pw(starkesPasswort))); await t.entferne(panik.id); final platte = aufPlatte().slotsOf(UnlockFactorKind.passphrase); @@ -301,7 +305,8 @@ void main() { () async { final t = tresor(); await t.fuegeHinzu(finger()); - await t.fuegePanikHinzu(pw(panikPasswort)); + await t.fuegePanikHinzu(pw(panikPasswort), + nachweis: await t.weiseNach(finger())); expect(aufPlatte().slotsOf(UnlockFactorKind.passphrase), hasLength(2)); }); @@ -548,7 +553,10 @@ void main() { test('ein Nachweis gilt nur einmal', () async { final n = await t.weiseNach(pw(starkesPasswort)); - await t.fuegeHinzu(finger()); + // Das Hinzufuegen braucht einen EIGENEN Nachweis — [n] bleibt dafuer + // unberuehrt. + await t.fuegeHinzu(finger(), + nachweis: await t.weiseNach(pw(starkesPasswort))); final fingerFach = aufPlatte() .slots .firstWhere((s) => s.kind == UnlockFactorKind.biometric) @@ -597,7 +605,8 @@ void main() { }); test('das Panik-Passwort loest auch hier aus', () async { - await t.fuegePanikHinzu(pw(panikPasswort)); + await t.fuegePanikHinzu(pw(panikPasswort), + nachweis: await t.weiseNach(pw(starkesPasswort))); await expectLater(t.weiseNach(pw(panikPasswort)), throwsA(isA())); }); diff --git a/secure-messenger/app/test/lock/vault_store_test.dart b/secure-messenger/app/test/lock/vault_store_test.dart index ab70385..0906073 100644 --- a/secure-messenger/app/test/lock/vault_store_test.dart +++ b/secure-messenger/app/test/lock/vault_store_test.dart @@ -178,9 +178,11 @@ void main() { test('jeder oeffnet fuer sich — kein Faktor braucht einen anderen', () async { await tresor.fuegeHinzu(finger('Fingerabdruck')); + // Seit 18.10.2026 mit frischem Nachweis — siehe tresor_haertung_test. await tresor.fuegeHinzu( PassphraseFactor('ein ziemlich langes Passwort mit Zahlen 12345', - geraeteGebunden: false)); + geraeteGebunden: false), + nachweis: await tresor.weiseNach(finger())); final frisch = VaultSecretStore( datei: vaultDateiIn(verzeichnis.path), @@ -223,7 +225,8 @@ void main() { await tresor.fuegeHinzu(finger('erster')); final zweiteAblage = FakeAblage(); await tresor.fuegeHinzu( - KeystoreFactor(ablage: zweiteAblage, label: 'zweiter')); + KeystoreFactor(ablage: zweiteAblage, label: 'zweiter'), + nachweis: await tresor.weiseNach(finger())); final frisch = VaultSecretStore( datei: vaultDateiIn(verzeichnis.path), @@ -286,8 +289,9 @@ void main() { test('ein nicht-letzter laesst die anderen unberuehrt', () async { await tresor.fuegeHinzu(finger('erster')); final zweiteAblage = FakeAblage(); - await tresor - .fuegeHinzu(KeystoreFactor(ablage: zweiteAblage, label: 'zweiter')); + await tresor.fuegeHinzu( + KeystoreFactor(ablage: zweiteAblage, label: 'zweiter'), + nachweis: await tresor.weiseNach(finger())); final ersterSlot = (await tresor.faecher())!.slots.first; await tresor.entferne(ersterSlot.id, faktor: finger()); diff --git a/secure-messenger/app/test/lock/vier_faktoren_test.dart b/secure-messenger/app/test/lock/vier_faktoren_test.dart index f1d1152..3b977bb 100644 --- a/secure-messenger/app/test/lock/vier_faktoren_test.dart +++ b/secure-messenger/app/test/lock/vier_faktoren_test.dart @@ -126,7 +126,11 @@ void main() { test('alle drei nebeneinander, jeder oeffnet allein', () async { await st.fuegeBiometrieHinzu(); + // Jeder weitere Faktor seit 18.10.2026 nur mit frischem Nachweis — die + // Oberflaeche holt ihn ueber frischBestaetigt. + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.fuegeGeraetePinHinzu(); + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.fuegePasswortHinzu(starkesPasswort); expect(st.faktoren, hasLength(3)); @@ -147,6 +151,7 @@ void main() { await st.fuegeBiometrieHinzu(); final bioSlot = st.faktoren.single.id; + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.fuegeGeraetePinHinzu(); expect(st.faktoren, hasLength(2), @@ -159,6 +164,7 @@ void main() { test('die Geraetesperre zu entfernen laesst den Fingerabdruck stehen', () async { await st.fuegeBiometrieHinzu(); + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.fuegeGeraetePinHinzu(); final pinSlot = st.faktoren .firstWhere((s) => s.kind == UnlockFactorKind.deviceCredential); @@ -180,6 +186,7 @@ void main() { // gesicherten Bereich, schreibt die eine der anderen den Schluessel um — // und die Anmeldung faellt still weg. await st.fuegeBiometrieHinzu(); + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.fuegeGeraetePinHinzu(); expect(bioAblage.daten, hasLength(1)); diff --git a/secure-messenger/app/test/oberflaeche/sperre_oberflaeche_test.dart b/secure-messenger/app/test/oberflaeche/sperre_oberflaeche_test.dart index 5f78c48..6e90d2f 100644 --- a/secure-messenger/app/test/oberflaeche/sperre_oberflaeche_test.dart +++ b/secure-messenger/app/test/oberflaeche/sperre_oberflaeche_test.dart @@ -315,6 +315,8 @@ void main() { await starte(tester); await tester.runAsync(() async { await st.fuegeBiometrieHinzu(); + // Seit 18.10.2026 nur mit frischem Nachweis (frischBestaetigt). + expect(await st.bestaetigeMitBiometrie(), isTrue); await st.setzePanikPasswort(satz); await st.sperreWieder(); }); diff --git a/secure-messenger/app/test/oberflaeche/sperre_zustand_test.dart b/secure-messenger/app/test/oberflaeche/sperre_zustand_test.dart index 50ef2cb..3377eae 100644 --- a/secure-messenger/app/test/oberflaeche/sperre_zustand_test.dart +++ b/secure-messenger/app/test/oberflaeche/sperre_zustand_test.dart @@ -197,6 +197,8 @@ void main() { test('das Panik-Passwort loescht auch hier', () async { await st.fuegePasswortHinzu(echtes); + // Das Panik-Wort selbst nur mit frischem Nachweis (seit 18.10.2026). + expect(await st.bestaetigeMitPasswort(echtes), isTrue); await st.setzePanikPasswort(panik); expect(await st.bestaetigeMitPasswort(panik), isFalse); expect(st.hatIdentitaet, isFalse); From bbd3bbe0d1eed6a6ed8680d3096d914598bbb96b Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Fri, 23 Oct 2026 23:36:58 +0200 Subject: [PATCH 5/5] docs(changelog): 2.0.1 nennt Geraeteliste (PR #14), Relay zuerst und certifi (PR #15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Geraeteliste gegen einen Relay, der eine Abmeldung verschweigt (GERAETEABMELDUNG.md §12): Stempel und Verteilung Ende-zu-Ende, Warnung in "Geraete verwalten", Widerspruch im Chat, Sitzungen am Geraeteschluessel, "Zielgeraet unbekannt", was bleibt. - Erst den Relay aktualisieren, dann die App (neue Spalte, additiv). - Relay: certifi 2026.10.23 aus PR #15. Der Fix selbst liegt noch nicht auf diesem Zweig. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 760e7da..d9d336f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,22 @@ Downloads: [GitHub-Releases](https://github.com/Henner4746/BitDM/releases) und < ## 2.0.1 (unveröffentlicht) +**Erst den Relay aktualisieren, dann die App.** Der Relay dieser Fassung speichert eine zusätzliche Signatur je Gerät und prüft sie bei der Anmeldung (siehe Geräteliste unten). Das ist additiv: Ältere Apps schicken das Feld nicht und laufen weiter, ein älterer Relay ignoriert es. Neue Endpunkte gibt es nicht, an der nginx-Liste ändert sich nichts. + **App-Sperre: zwei Lücken aus der Sicherheitsprüfung vom 18.10.2026.** - **Einen Faktor hinzufügen verlangt jetzt eine frische Anmeldung**, genau wie das Entfernen. Bis 2.0.0 konnte jemand mit dem kurz entsperrten Telefon ein eigenes App-Passwort anlegen und sich damit ausweisen: für die zwölf Wörter, die Sperrfrist, die Fernlöschung und die Teilgeheimnisse. Dasselbe gilt für das Panik-Wort. Ein neu angelegter Faktor zählt erst nach dem nächsten Entsperren als Ausweis. - **Die Sperre greift jetzt auch im Hintergrund.** Bis 2.0.0 ging die App erst beim Zurückkommen zu. Bei „Sofort“ blieben die Schlüssel offen, solange sie im Hintergrund lag. Jetzt sperrt sie bei „Sofort“ beim Weglegen und sonst, sobald die Frist um ist. **Der Hintergrundempfang verlängert die Frist nicht:** Bei 1 oder 5 Minuten empfängt die App nur in dieser Zeit. Dauerhaft geht es nur mit „Nie“. Die Einstellungen sagen das jetzt dazu. +**Geräteliste: ein Relay, der eine Abmeldung verschweigt** (Befund aus derselben Prüfung). Bis 2.0.0 bekamen Kontakte die signierte Geräteliste nur über den Relay. Nahm er eine neue Fassung an und lieferte weiter die alte aus, verschlüsselten die Kontakte weiter an das abgemeldete Gerät, und es konnte weiter als Besitzer schreiben. Protokoll und Grenzen: [`secure-messenger/docs/GERAETEABMELDUNG.md`](secure-messenger/docs/GERAETEABMELDUNG.md) §12. +- **Die Liste geht jetzt auch Ende-zu-Ende.** Jede Nachricht trägt einen Stempel der eigenen Liste, und nach jeder Änderung geht die Liste selbst an die Kontakte. Wer einen neueren Stempel sieht, als seine Liste hergibt, schickt nur noch an Geräte, die diesen Stempel tragen, und nimmt nur von ihnen an. +- **„Geräte verwalten“ warnt**, wenn der Relay die eigene letzte Änderung nicht ausliefert. Gibt es von einem Kontakt zwei widersprüchliche Listen, sagt der Chat das dazu. +- **Sitzungen hängen am Geräteschlüssel.** Der signierte Prekey ist zusätzlich mit dem Geräteschlüssel signiert, und jede Erstnachricht trägt einen Nachweis. Ein abgemeldetes Gerät kann so auch mit Hilfe des Relays keine Sitzung unter der Kennung eines anderen Geräts aufbauen. Eine falsche Signatur wird nie angenommen. Eine fehlende wird erst ab 2.0.2 abgewiesen, damit Geräte mit älterer App bis dahin erreichbar bleiben. +- **„Zielgerät unbekannt“ vom Relay löscht die Sitzung nicht mehr**, wenn das Gerät in der signierten Liste steht. +- **Was bleibt:** Hat ein Kontakt seit der Abmeldung nichts vom Besitzer bekommen, weiß er nicht, dass ihm etwas fehlt. Verwirft der Relay alles vom Besitzer an einen Kontakt, sieht man das nur daran, dass die eigenen Nachrichten dort keinen zweiten Haken bekommen. + +**Relay** +- certifi auf 2026.10.23. Die bisher gesperrte Fassung 2026.7.22 hat eine kritische Lücke. Alle anderen Abhängigkeiten bleiben, wie sie waren. + ## 2.0.0 (18.10.2026) **Signals offizielle libsignal statt der Dart-Nachbildung.** Bis 1.9.x lief die Verschlüsselung über `libsignal_protocol_dart`, eine nie geprüfte Nachbildung in reinem Dart. Das Sicherheitsaudit vom 16.10.2026 fand darin zwei schwere Fehler in der Zustandsverwaltung. Ab 2.0.0 rechnet Signals eigene Bibliothek (Rust, libsignal 0.105, über das Paket `libsignal` 8.0.0). Plan und Nachweise: [`secure-messenger/docs/LIBSIGNAL-UMSTIEG.md`](secure-messenger/docs/LIBSIGNAL-UMSTIEG.md).