Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ 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 <https://bitdm.net>.

## 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).
Expand Down
165 changes: 161 additions & 4 deletions secure-messenger/app/lib/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,17 @@ class AppState extends ChangeNotifier {

List<GeraetAnzeige> 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;

Expand Down Expand Up @@ -450,6 +461,7 @@ class AppState extends ChangeNotifier {
return false;
} finally {
_amFaktor = false;
_faktorFertig();
}
return _nachDemOeffnen();
}
Expand All @@ -462,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<bool> bestaetigeMitBiometrie() =>
_bestaetigeMit(_keystoreFaktor(UnlockFactorKind.biometric));
Expand Down Expand Up @@ -501,6 +526,7 @@ class AppState extends ChangeNotifier {
return false;
} finally {
_amFaktor = false;
_faktorFertig();
}
}

Expand Down Expand Up @@ -530,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<void> 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);
}
Expand All @@ -553,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);
}
Expand Down Expand Up @@ -650,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<void> _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();
}
}
Expand Down Expand Up @@ -810,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]).
Expand All @@ -823,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) {
Expand All @@ -835,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
Expand Down Expand Up @@ -882,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 —
Expand Down Expand Up @@ -1078,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;

Expand Down Expand Up @@ -1112,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();
}
}

Expand Down Expand Up @@ -1149,6 +1294,8 @@ class AppState extends ChangeNotifier {
Future<void> sperreWieder() async {
if (faktoren.isEmpty) return;
_weggelegtUm = null;
_hintergrundSperre?.cancel();
_hintergrundSperre = null;
_wiederverbindung?.cancel();
_wiederverbindung = null;
for (final a in _abos) {
Expand All @@ -1157,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
Expand All @@ -1179,7 +1336,6 @@ class AppState extends ChangeNotifier {
_vergissAngezeigtes();
fernloeschung = const Fernloeschung();

gesperrt = true;
notifyListeners();
}

Expand Down Expand Up @@ -2965,6 +3121,7 @@ class AppState extends ChangeNotifier {
_entsorgt = true;
_wiederverbindung?.cancel();
_wiederverbindung = null;
_hintergrundSperre?.cancel();
_verfallTakt?.cancel();
_empfangsTimer?.cancel();
_fernTakt?.cancel();
Expand Down
20 changes: 20 additions & 0 deletions secure-messenger/app/lib/core/crypto/signal_errors.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading