From d496f446011116084dff602da56f7948c1f0df5a Mon Sep 17 00:00:00 2001 From: Henner4746 <145707117+Henner4746@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:54:02 +0100 Subject: [PATCH] feat(app): copy button for the 12 recovery words on all platforms Henrik wants to copy the recovery words, e.g. into a password manager, instead of retyping or photographing them. - A "Copy" button under the words on the 'phrase' screen after creating an identity and in Settings > Show recovery words (dialog). After copying, a note right below the button says the clipboard is cleared in 60 s and not to paste the words into chats or cloud notes. On the web the note says the browser does not always allow clearing. - New lib/core/geheime_ablage.dart: copy through the native channel "bitdm/ablage" where there is one, otherwise through Flutter's clipboard; clear after the deadline only if the clipboard still holds exactly the copied text. Clears immediately when the identity is deleted (panic/remote wipe), not on lock (the app locks in the background, which is when people paste into a password manager). - Android (GeheimeAblage.kt): ClipDescription EXTRA_IS_SENSITIVE, so the Android 13+ preview shows dots and keyboards do not offer it. Clearing runs natively because apps in the background cannot read the clipboard since Android 10; if it cannot read, it clears anyway. - Windows (geheime_ablage.cpp): ExcludeClipboardContentFromMonitorProcessing, CanIncludeInClipboardHistory=0 and CanUploadToCloudClipboard=0, so neither Win+V history nor cloud clipboard keeps the words; "leere" empties the clipboard instead of writing an empty history entry. - Linux (geheime_ablage.cc): offers the text plus x-kde-passwordManagerHint=secret via gtk_clipboard_set_with_data, so Klipper and similar histories skip it. - Trust shares (Teilgeheimnisse) now copy through the same path, keeping their 30 s. - Screenshot protection unchanged: both views still force it while the words are visible; copying does not touch it. Tests: test/core/geheime_ablage_test.dart (deadline, foreign content stays, Android leaves clearing to native, desktop clears via "leere"), test/oberflaeche/woerter_kopieren_test.dart (button on 'phrase' on phone and desktop layout, button and note inside the settings dialog, close button unchanged, protection stays, wipe clears). Whole app suite on Linux (CT 107): 1585 passed, 5 skipped, 1 unrelated relay timing test under -j 4 that passes on its own. integration_test/geheime_ablage_test.dart passed on Linux (Xvfb, xclip) and Android 15 (emulator); Windows Win32 part checked with MinGW and under Wine. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 8 + .../kotlin/com/bitdm/bitdm/GeheimeAblage.kt | 150 +++++++++++ .../kotlin/com/bitdm/bitdm/MainActivity.kt | 6 + .../integration_test/geheime_ablage_test.dart | 174 ++++++++++++ .../app/lib/core/geheime_ablage.dart | 162 +++++++++++ secure-messenger/app/lib/data.dart | 6 + .../app/lib/oberflaeche/home.dart | 11 + .../app/lib/oberflaeche/home/identitaet.dart | 130 ++++++--- .../oberflaeche/home/wiederherstellung.dart | 10 +- .../app/linux/runner/CMakeLists.txt | 1 + .../app/linux/runner/geheime_ablage.cc | 113 ++++++++ .../app/linux/runner/geheime_ablage.h | 17 ++ .../app/linux/runner/my_application.cc | 7 + .../app/test/core/geheime_ablage_test.dart | 234 ++++++++++++++++ .../oberflaeche/woerter_kopieren_test.dart | 255 ++++++++++++++++++ .../app/windows/runner/CMakeLists.txt | 1 + .../app/windows/runner/flutter_window.cpp | 3 + .../app/windows/runner/flutter_window.h | 5 + .../app/windows/runner/geheime_ablage.cpp | 132 +++++++++ .../app/windows/runner/geheime_ablage.h | 38 +++ 20 files changed, 1419 insertions(+), 44 deletions(-) create mode 100644 secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/GeheimeAblage.kt create mode 100644 secure-messenger/app/integration_test/geheime_ablage_test.dart create mode 100644 secure-messenger/app/lib/core/geheime_ablage.dart create mode 100644 secure-messenger/app/linux/runner/geheime_ablage.cc create mode 100644 secure-messenger/app/linux/runner/geheime_ablage.h create mode 100644 secure-messenger/app/test/core/geheime_ablage_test.dart create mode 100644 secure-messenger/app/test/oberflaeche/woerter_kopieren_test.dart create mode 100644 secure-messenger/app/windows/runner/geheime_ablage.cpp create mode 100644 secure-messenger/app/windows/runner/geheime_ablage.h diff --git a/CHANGELOG.md b/CHANGELOG.md index 2716d38..0c604ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,14 @@ Downloads: [GitHub-Releases](https://github.com/Henner4746/BitDM/releases) und < **Neues App-Symbol.** Ein Anzeigefenster mit Punktmatrix, DM leuchtet im Akzent. Es ersetzt die schwarz-weiße Sprechblase. +**Die zwölf Wörter lassen sich kopieren**, etwa in einen Passwortmanager: unter den Wörtern nach dem Anlegen und unter Einstellungen › Wiederherstellungswörter zeigen. Die App leert die Zwischenablage nach 60 s wieder, wenn dort noch die Wörter stehen, und sofort, wenn die Identität gelöscht wird. Was man inzwischen selbst kopiert hat, bleibt. +- **Android** markiert sie als sensibel: Die Vorschau unten links (ab Android 13) zeigt Punkte statt der Wörter, und Tastaturen bieten sie nicht zum Einfügen an. Geleert wird auch, wenn die App gerade im Hintergrund ist. Lesen darf sie die Ablage dort nicht, also leert sie ohne Vergleich: Wer in dieser Minute in einer anderen App etwas kopiert hat, verliert es. +- **Windows** nimmt sie aus dem Verlauf (Win+V) und aus der Cloud-Zwischenablage heraus. +- **Linux** setzt den Hinweis, mit dem Klipper und andere Verläufe Passwörter auslassen. +- **Im Browser** lässt sich die Zwischenablage nicht verlässlich leeren. Die App sagt das dort dazu. + +Die Teilgeheimnisse (Vertrauenskontakte) kopiert die App jetzt auf demselben Weg, weiter mit 30 s. + ## 2.0.1 (24.10.2026) **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. Beim Relay selbst zuerst das Zwischenlager aktualisieren: Es zählt jetzt die Gesamtmenge der Anhänge (siehe unten). diff --git a/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/GeheimeAblage.kt b/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/GeheimeAblage.kt new file mode 100644 index 0000000..0f947ea --- /dev/null +++ b/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/GeheimeAblage.kt @@ -0,0 +1,150 @@ +package com.bitdm.bitdm + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.os.PersistableBundle +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Kanal "bitdm/ablage": die zwoelf Woerter (und Teile davon) in die + * Zwischenablage — und nach einer Frist wieder heraus. Die Gegenstelle ist + * lib/core/geheime_ablage.dart; dort steht, warum es den Knopf gibt. + * + * ALS SENSIBEL MARKIERT. Ab Android 13 zeigt das System nach jedem Kopieren + * unten links eine Vorschau des Inhalts — bei den Woertern also die Woerter, + * gross und lesbar, auch auf einem Bildschirm, den gerade jemand mitfilmt. + * Mit EXTRA_IS_SENSITIVE stehen dort Punkte. Tastaturen wie Gboard lesen + * dasselbe Merkmal und bieten den Inhalt nicht als Einfuegevorschlag an. + * Der Schluessel steht als Text da und nicht als Konstante: die gibt es erst + * ab API 33, das Merkmal selbst wirkt bei Tastaturen auch darunter (so + * empfiehlt es die Android-Doku fuer aeltere Fassungen). + * + * DAS LEEREN GESCHIEHT HIER UND NICHT IN DART. Ab Android 10 bekommt nur die + * App im Vordergrund die Zwischenablage zu lesen. Wer die Woerter kopiert, + * wechselt aber gerade in eine andere App, um sie dort einzufuegen — ein + * Dart-Zeitgeber, der nach einer Minute nachsieht, bekaeme null und liesse + * sie stehen. Schreiben (und damit Leeren) darf die App auch im Hintergrund. + * + * Nach der Frist gilt deshalb: + * - Laesst sich die Ablage lesen (App im Vordergrund): nur leeren, wenn dort + * noch GENAU das steht. Was der Nutzer inzwischen kopiert hat, bleibt. + * - Hat die App waehrenddessen gesehen, dass etwas anderes kopiert wurde + * (der Waechter unten meldet das, solange sie im Vordergrund ist): nichts + * tun. + * - Sonst (Hintergrund, nichts gesehen): leeren, ohne nachzusehen. Das kann + * einen Text treffen, den jemand in dieser Minute in einer anderen App + * kopiert hat. Die Alternative waere, die Woerter liegen zu lassen, bis + * die App wieder vorne ist — womoeglich Stunden. Passwortmanager machen es + * genauso. + * + * Stirbt der Prozess vor Ablauf der Frist, leert niemand mehr. Ab Android 13 + * raeumt das System die Ablage nach einer Stunde selbst. + */ +class GeheimeAblage(context: Context) : MethodChannel.MethodCallHandler { + + companion object { + const val KANAL = "bitdm/ablage" + + /** = ClipDescription.EXTRA_IS_SENSITIVE (API 33). */ + private const val SENSIBEL = "android.content.extra.IS_SENSITIVE" + + /** Die Beschriftung des Eintrags — sichtbar in manchen Verlaeufen. */ + private const val BESCHRIFTUNG = "BitDM" + } + + private val ablage = + context.applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) + as ClipboardManager + + // Der Haupt-Looper und nicht die Activity: die wird beim Drehen neu + // gebaut, und die Frist soll das ueberstehen. + private val wecker = Handler(Looper.getMainLooper()) + + /** Was zuletzt kopiert wurde, bis die Frist um ist. */ + private var inhalt: String? = null + + /** Ob seitdem nachweislich etwas anderes in der Ablage steht. */ + private var ersetzt = false + + private val raeumen = Runnable { raeume() } + + private val waechter = ClipboardManager.OnPrimaryClipChangedListener { + // Kommt auch fuer das eigene Kopieren an. Nur wer die Ablage lesen + // kann (Vordergrund), erfaehrt hier etwas; im Hintergrund meldet + // Android nichts. + val text = inhalt ?: return@OnPrimaryClipChangedListener + val jetzt = lies() + if (jetzt != null && jetzt != text) ersetzt = true + } + + override fun onMethodCall(aufruf: MethodCall, ergebnis: MethodChannel.Result) { + when (aufruf.method) { + "kopiere" -> { + val text = aufruf.argument("text") + val sekunden = aufruf.argument("sekunden") ?: 60 + if (text.isNullOrEmpty()) { + ergebnis.success(false) + return + } + ergebnis.success(kopiere(text, sekunden.coerceIn(1, 3600))) + } + // Sofort, etwa wenn die Identitaet geloescht wurde. + "raeume" -> { + wecker.removeCallbacks(raeumen) + raeume() + ergebnis.success(true) + } + else -> ergebnis.notImplemented() + } + } + + private fun kopiere(text: String, sekunden: Int): Boolean { + wecker.removeCallbacks(raeumen) + val clip = ClipData.newPlainText(BESCHRIFTUNG, text) + clip.description.extras = PersistableBundle().apply { + putBoolean(SENSIBEL, true) + } + try { + ablage.setPrimaryClip(clip) + } catch (e: RuntimeException) { + // Etwa zu gross fuer den Binder — bei zwoelf Woertern nicht, aber + // die Dart-Seite soll eine Antwort bekommen statt einer Ausnahme. + return false + } + inhalt = text + ersetzt = false + ablage.removePrimaryClipChangedListener(waechter) + ablage.addPrimaryClipChangedListener(waechter) + wecker.postDelayed(raeumen, sekunden * 1000L) + return true + } + + private fun raeume() { + val text = inhalt ?: return + inhalt = null + ablage.removePrimaryClipChangedListener(waechter) + if (ersetzt) return + val jetzt = lies() + if (jetzt != null && jetzt != text) return + // Gleich dem Kopierten — oder nicht lesbar, weil die App im + // Hintergrund ist. Siehe oben, warum dann trotzdem geleert wird. + try { + ablage.clearPrimaryClip() + } catch (e: RuntimeException) { + // Nichts mehr zu machen. + } + } + + /** Der Text in der Ablage, oder null, wenn sie leer oder nicht lesbar ist. */ + private fun lies(): String? = try { + val clip = ablage.primaryClip + if (clip == null || clip.itemCount == 0) null + else clip.getItemAt(0).text?.toString() ?: "" + } catch (e: RuntimeException) { + null + } +} diff --git a/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/MainActivity.kt b/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/MainActivity.kt index 875fff4..c18d64c 100644 --- a/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/MainActivity.kt +++ b/secure-messenger/app/android/app/src/main/kotlin/com/bitdm/bitdm/MainActivity.kt @@ -168,6 +168,12 @@ class MainActivity : FlutterFragmentActivity() { MethodChannel(flutterEngine.dartExecutor.binaryMessenger, DateiKanal.KANAL) .setMethodCallHandler(dateiKanal) + // Die zwoelf Woerter in die Zwischenablage: als sensibel markiert und + // nach einer Frist wieder geleert, auch wenn die App dann im + // Hintergrund ist. Siehe GeheimeAblage.kt. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, GeheimeAblage.KANAL) + .setMethodCallHandler(GeheimeAblage(this)) + // "In der Naehe". Auf einer eigenen Warteschlange, nicht auf dem // Haupt-Thread: startScan und openGattServer gehen ueber den // Bluetooth-Dienst, und ein Binder-Aufruf zu einem beschaeftigten diff --git a/secure-messenger/app/integration_test/geheime_ablage_test.dart b/secure-messenger/app/integration_test/geheime_ablage_test.dart new file mode 100644 index 0000000..08884d4 --- /dev/null +++ b/secure-messenger/app/integration_test/geheime_ablage_test.dart @@ -0,0 +1,174 @@ +// geheime_ablage_test.dart — der Kanal bitdm/ablage im echten Programm. +// +// Seit 09.11.2026, mit dem Kopierknopf fuer die zwoelf Woerter. Die +// Widget-Tests (test/core/geheime_ablage_test.dart, +// test/oberflaeche/woerter_kopieren_test.dart) pruefen die Dart-Seite gegen +// Attrappen; was die Gegenstellen mit der ECHTEN Zwischenablage tun, sieht man +// nur hier: +// +// * Linux (X11, z. B. Xvfb): mit xclip von aussen gelesen — der Text, das +// Ziel "x-kde-passwordManagerHint" mit "secret", und dass "leere" die +// Ablage freigibt. +// * Windows: mit PowerShell gelesen — der Text und die drei Formate, die +// Verlauf (Win+V) und Cloud-Zwischenablage ausschliessen. +// * Android: Rundweg und das Leeren nach der Frist, das GeheimeAblage.kt +// selbst erledigt. Ob die Vorschau Punkte zeigt (EXTRA_IS_SENSITIVE), +// liest keine App; das sieht man nur auf dem Bildschirm. +// +// Kopiert wird ein Probetext, nie Woerter. +// +// xvfb-run -a flutter test integration_test/geheime_ablage_test.dart -d linux +// flutter test integration_test/geheime_ablage_test.dart -d windows +// flutter test integration_test/geheime_ablage_test.dart -d + +import 'dart:io'; + +import 'package:bitdm/core/geheime_ablage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +const _probe = 'bitdm probe ablage äöü'; + +Future _lies() async => + (await Clipboard.getData(Clipboard.kTextPlain))?.text; + +/// Wartet in echter Zeit, bis [bedingung] gilt — hoechstens [frist]. +Future _bis(Future Function() bedingung, + {Duration frist = const Duration(seconds: 15)}) async { + final ende = DateTime.now().add(frist); + while (DateTime.now().isBefore(ende)) { + if (await bedingung()) return true; + await Future.delayed(const Duration(milliseconds: 200)); + } + return bedingung(); +} + +// ---- Linux ---------------------------------------------------------------- + +Future _xclip(List argumente) async { + final r = await Process.run( + 'xclip', ['-o', '-selection', 'clipboard', ...argumente]); + return r.exitCode == 0 ? (r.stdout as String) : ''; +} + +// ---- Windows -------------------------------------------------------------- + +/// Liest die Formate der Ablage. Eine Zeile je Wert, "name=wert". +const _formateWindows = r''' +Add-Type -AssemblyName System.Windows.Forms +$d = [System.Windows.Forms.Clipboard]::GetDataObject() +if ($d -eq $null) { 'leer=1'; exit } +foreach ($f in 'CanIncludeInClipboardHistory', 'CanUploadToCloudClipboard') { + $s = $d.GetData($f) + if ($s -is [System.IO.MemoryStream]) { + "$f=" + [System.BitConverter]::ToUInt32($s.ToArray(), 0) + } else { "$f=fehlt" } +} +'Exclude=' + $d.GetDataPresent('ExcludeClipboardContentFromMonitorProcessing') +'Text=' + $d.GetDataPresent([System.Windows.Forms.DataFormats]::UnicodeText) +'''; + +Future> _formate() async { + final r = await Process.run('powershell.exe', + ['-NoProfile', '-NonInteractive', '-STA', '-Command', _formateWindows]); + final werte = {}; + for (final zeile in (r.stdout as String).split(RegExp(r'\r?\n'))) { + final i = zeile.indexOf('='); + if (i > 0) werte[zeile.substring(0, i).trim()] = zeile.substring(i + 1).trim(); + } + return werte; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + final linux = !kIsWeb && Platform.isLinux; + final windows = !kIsWeb && Platform.isWindows; + final android = !kIsWeb && Platform.isAndroid; + + tearDown(GeheimeAblage.vergiss); + + test('die Gegenstelle ist da und kopiert', () async { + const kanal = MethodChannel('bitdm/ablage'); + final ok = await kanal.invokeMethod( + 'kopiere', {'text': _probe, 'sekunden': 60}); + expect(ok, isTrue, reason: 'bitdm/ablage antwortet nicht'); + expect(await _lies(), _probe); + // Diesen Aufruf hat GeheimeAblage nicht gesehen — von Hand wegraeumen. + if (android) { + await kanal.invokeMethod('raeume'); + } else { + await kanal.invokeMethod('leere'); + } + }); + + test('Linux: Text und KDE-Hinweis von aussen lesbar, leere gibt frei', + () async { + expect(await GeheimeAblage.kopiere(_probe), isTrue); + final ziele = await _xclip(['-t', 'TARGETS']); + expect(ziele, contains('x-kde-passwordManagerHint')); + expect(ziele, contains('UTF8_STRING')); + expect(await _xclip(['-t', 'x-kde-passwordManagerHint']), 'secret'); + expect(await _xclip(['-t', 'UTF8_STRING']), _probe); + + await GeheimeAblage.raeumeJetzt(); + expect(await _xclip(['-t', 'TARGETS']), + isNot(contains('x-kde-passwordManagerHint'))); + expect(await _xclip(['-t', 'UTF8_STRING']), isEmpty); + }, skip: !linux); + + test('Linux: was jemand anderes kopiert hat, bleibt', () async { + expect(await GeheimeAblage.kopiere(_probe), isTrue); + // Ein anderes Programm uebernimmt die Ablage. -quiet haelt xclip im + // Vordergrund, bis es beendet wird; es liefert so oft wie gefragt. + final fremd = await Process.start( + 'xclip', ['-i', '-selection', 'clipboard', '-quiet']); + fremd.stdin.write('fremder text'); + await fremd.stdin.close(); + expect(await _bis(() async => await _lies() == 'fremder text'), isTrue); + + await GeheimeAblage.raeumeJetzt(); + expect(await _lies(), 'fremder text'); + fremd.kill(); + }, skip: !linux); + + test('Windows: Text, nicht im Verlauf, nicht in der Cloud', () async { + expect(await GeheimeAblage.kopiere(_probe), isTrue); + expect(await _lies(), _probe); + final f = await _formate(); + expect(f['CanIncludeInClipboardHistory'], '0', reason: '$f'); + expect(f['CanUploadToCloudClipboard'], '0', reason: '$f'); + expect(f['Exclude'], 'True', reason: '$f'); + expect(f['Text'], 'True', reason: '$f'); + + await GeheimeAblage.raeumeJetzt(); + expect(await _lies(), anyOf(isNull, isEmpty)); + }, skip: !windows); + + test('Android: nach der Frist leert GeheimeAblage.kt selbst', () async { + expect( + await GeheimeAblage.kopiere(_probe, frist: const Duration(seconds: 2)), + isTrue); + expect(await _lies(), _probe); + expect(await _bis(() async => (await _lies() ?? '').isEmpty), isTrue, + reason: 'nach der Frist stand der Probetext noch da'); + }, skip: !android); + + test('Android: was inzwischen kopiert wurde, bleibt', () async { + expect( + await GeheimeAblage.kopiere(_probe, frist: const Duration(seconds: 2)), + isTrue); + await Clipboard.setData(const ClipboardData(text: 'fremder text')); + await Future.delayed(const Duration(seconds: 4)); + expect(await _lies(), 'fremder text'); + }, skip: !android); + + test('Android: raeumeJetzt leert sofort', () async { + expect(await GeheimeAblage.kopiere(_probe), isTrue); + expect(await _lies(), _probe); + await GeheimeAblage.raeumeJetzt(); + expect(await _bis(() async => (await _lies() ?? '').isEmpty), isTrue); + }, skip: !android); +} diff --git a/secure-messenger/app/lib/core/geheime_ablage.dart b/secure-messenger/app/lib/core/geheime_ablage.dart new file mode 100644 index 0000000..3f272b2 --- /dev/null +++ b/secure-messenger/app/lib/core/geheime_ablage.dart @@ -0,0 +1,162 @@ +// geheime_ablage.dart — Geheimes in die Zwischenablage, und wieder heraus. +// +// Fuer die zwoelf Woerter und die Teilgeheimnisse daraus. Die Gegenstellen +// heissen ueberall "bitdm/ablage": GeheimeAblage.kt (Android), +// windows/runner/geheime_ablage.cpp und linux/runner/geheime_ablage.cc. +// +// WARUM ES EINEN KOPIERKNOPF FUER DIE WOERTER UEBERHAUPT GIBT (09.11.2026): +// wer sie in einen Passwortmanager legen will, tippte bis dahin zwoelf Woerter +// ab — oder fotografierte sie, und genau das soll niemand tun. Kopieren ist +// der kleinere Schaden, wenn es so passiert, wie es hier passiert: +// +// * ANDROID markiert den Inhalt als sensibel +// (ClipDescription.EXTRA_IS_SENSITIVE). Die Vorschau unten links ab +// Android 13 zeigt dann Punkte statt der Woerter, und Tastaturen wie +// Gboard bieten ihn nicht als Einfuegevorschlag an. +// * WINDOWS nimmt ihn aus dem Verlauf (Win+V) und aus der Synchronisation +// ueber das Microsoft-Konto heraus — dieselben Formate, die +// Passwortmanager setzen. +// * LINUX setzt den Hinweis, den KDE (Klipper) und andere +// Zwischenablage-Verlaeufe als "Passwort, nicht merken" lesen. +// * UEBERALL wird die Ablage nach der Frist wieder geleert — aber nur, wenn +// dort noch GENAU das steht. Was der Nutzer inzwischen selbst kopiert hat, +// bleibt. +// +// IM BROWSER geht davon nur das Kopieren. Lesen loest dort eine Rueckfrage des +// Browsers aus (Chrome fragt nach der Erlaubnis, Firefox und Safari zeigen +// einen "Einfuegen"-Knopf), und Schreiben ohne Tippen des Nutzers lassen +// Firefox und Safari gar nicht zu, Chrome nur, solange der Reiter den Fokus +// hat. Deshalb wird dort nach der Frist ohne Vergleich geleert, wo es geht, +// und die Oberflaeche sagt, dass es nicht verlaesslich ist ([leertSicher]). + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class GeheimeAblage { + GeheimeAblage._(); + + static const MethodChannel _kanal = MethodChannel('bitdm/ablage'); + + /// So lange duerfen die zwoelf Woerter in der Zwischenablage stehen. + /// + /// Lang genug, um in einen Passwortmanager zu wechseln und dort einzufuegen, + /// kurz genug, dass sie nicht bis zum Abend herumliegen. + static const Duration frist = Duration(seconds: 60); + + /// Ob die App die Ablage nach der Frist zuverlaessig leeren kann. Im + /// Browser nicht (siehe oben); dort soll die Oberflaeche das nicht + /// versprechen. + static bool get leertSicher => !kIsWeb; + + /// ANDROID RAEUMT SELBST AUF, sobald die Gegenstelle das Kopieren + /// uebernommen hat. Ab Android 10 liest nur die App im Vordergrund die + /// Zwischenablage — ein Zeitgeber hier, der nach einer Minute im + /// Hintergrund nachsieht, bekaeme null und liesse die Woerter stehen. + /// GeheimeAblage.kt weiss, wie es damit umgeht. + static bool get _raeumtNativ => + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + + static Timer? _wecker; + static String? _inhalt; + static bool _nativAktiv = false; + + /// Legt [text] in die Zwischenablage und leert sie nach [frist] wieder, + /// wenn dort dann noch [text] steht. + /// + /// Ein neuer Aufruf ersetzt den alten: es wird immer nur das zuletzt + /// Kopierte beobachtet. + /// + /// Rueckgabe: ob kopiert wurde. Wirft nie. + static Future kopiere(String text, + {Duration frist = GeheimeAblage.frist}) async { + _wecker?.cancel(); + _wecker = null; + _inhalt = null; + _nativAktiv = false; + + var nativ = false; + try { + nativ = await _kanal.invokeMethod( + 'kopiere', {'text': text, 'sekunden': frist.inSeconds}) == + true; + } on MissingPluginException { + // Kein Gegenstueck — im Browser und im Test. Dann wie jede andere App. + } on PlatformException { + // Ebenso: lieber ohne Zusatzschutz kopieren als gar nicht. + } + if (!nativ) { + try { + await Clipboard.setData(ClipboardData(text: text)); + } catch (_) { + return false; + } + } + if (nativ && _raeumtNativ) { + _nativAktiv = true; + return true; + } + _inhalt = text; + _wecker = Timer(frist, () => unawaited(_raeume())); + return true; + } + + /// Leert die Ablage SOFORT, wenn dort noch das zuletzt Kopierte steht — + /// etwa wenn die Identitaet geloescht wurde. Wirft nie. + static Future raeumeJetzt() async { + _wecker?.cancel(); + _wecker = null; + if (_nativAktiv) { + _nativAktiv = false; + try { + await _kanal.invokeMethod('raeume'); + } catch (_) { + // Ohne Gegenstelle gibt es auch nichts, was sie haette raeumen koennen. + } + return; + } + await _raeume(); + } + + /// Ob nach der Frist noch etwas ansteht. Nur fuer Tests. + @visibleForTesting + static bool get wartet => _wecker != null || _nativAktiv; + + /// Vergisst alles, ohne die Ablage anzufassen. Nur fuer Tests. + @visibleForTesting + static void vergiss() { + _wecker?.cancel(); + _wecker = null; + _inhalt = null; + _nativAktiv = false; + } + + static Future _raeume() async { + final text = _inhalt; + _inhalt = null; + _wecker = null; + if (text == null) return; + try { + if (!kIsWeb) { + final jetzt = await Clipboard.getData(Clipboard.kTextPlain); + if (jetzt?.text != text) return; + // Die Gegenstelle leert die Ablage ganz. Clipboard.setData('') legte + // auf Windows einen leeren Eintrag in den Verlauf. + if (await _leereNativ()) return; + } + await Clipboard.setData(const ClipboardData(text: '')); + } catch (_) { + // Keine Zwischenablage, oder der Browser laesst es nicht zu — dann + // gibt es nichts mehr, was diese App tun koennte. + } + } + + static Future _leereNativ() async { + try { + return await _kanal.invokeMethod('leere') == true; + } catch (_) { + return false; + } + } +} diff --git a/secure-messenger/app/lib/data.dart b/secure-messenger/app/lib/data.dart index c9804e4..1713352 100644 --- a/secure-messenger/app/lib/data.dart +++ b/secure-messenger/app/lib/data.dart @@ -562,6 +562,9 @@ const Map> strings = { 'phraseShowWarn': 'Anyone who sees these words can be you. Do not photograph them, do not type them anywhere.', 'phraseShowFailed': 'The words could not be loaded.', 'copiedSecret': 'Copied. The clipboard is cleared in 30 seconds', + 'copiedWords': 'Copied. Cleared in {s} s. Do not paste into chats or cloud notes.', + 'copiedWordsWeb': 'Copied. The browser does not always let BitDM clear the clipboard: copy something else once you have pasted them. Do not paste into chats or cloud notes.', + 'copySecretFailed': 'Could not copy.', 'wipeBannerSec': 'Remote wipe in {s} s, requested by your trusted contacts.', 'wipeCancelAsk': 'Stop the remote wipe that your trusted contacts requested?', 'wipeChangeAsk': 'Change or turn off remote wipe?', @@ -998,6 +1001,9 @@ const Map> strings = { 'phraseShowWarn': 'Wer diese Wörter sieht, kann du sein. Nicht fotografieren, nirgends eintippen.', 'phraseShowFailed': 'Die Wörter ließen sich nicht laden.', 'copiedSecret': 'Kopiert. Die Zwischenablage wird in 30 Sekunden geleert', + 'copiedWords': 'Kopiert. Wird in {s} s gelöscht. Nicht in Chats oder Cloud-Notizen einfügen.', + 'copiedWordsWeb': 'Kopiert. Der Browser lässt das Leeren nicht immer zu: kopiere nach dem Einfügen etwas anderes. Nicht in Chats oder Cloud-Notizen einfügen.', + 'copySecretFailed': 'Kopieren ging nicht.', 'wipeBannerSec': 'Fernlöschung in {s} s, angefordert von deinen Vertrauenskontakten.', 'wipeCancelAsk': 'Die Fernlöschung abbrechen, die deine Vertrauenskontakte angefordert haben?', 'wipeChangeAsk': 'Fernlöschung ändern oder ausschalten?', diff --git a/secure-messenger/app/lib/oberflaeche/home.dart b/secure-messenger/app/lib/oberflaeche/home.dart index d7ec3f1..02ff2fb 100644 --- a/secure-messenger/app/lib/oberflaeche/home.dart +++ b/secure-messenger/app/lib/oberflaeche/home.dart @@ -32,6 +32,7 @@ import '../core/crypto/wordlist_english.dart'; import '../core/crypto/address.dart'; import '../core/empfang.dart'; import '../core/fenster.dart'; +import '../core/geheime_ablage.dart'; import '../core/fern_codewort.dart'; import '../core/push.dart'; import '../core/qr_bild.dart'; @@ -193,6 +194,10 @@ class _HomeState extends State with WidgetsBindingObserver, TickerProvider bool sheet = false, panic = false, wiped = false, copied = false; + /// Ob die zwoelf Woerter auf 'phrase' kopiert wurden — dann steht der + /// Hinweis unter dem Knopf (identitaet.dart, _woerterKopierknopf). + bool woerterKopiert = false; + /// Was das letzte "Anfrage senden" bewirkt hat; null, solange es nichts zu /// sagen gibt. Bis 15.10.2026 ein bool, und "wahr" hiess immer "Anfrage /// gesendet" — siehe [Hinzugefuegt]. @@ -358,6 +363,12 @@ class _HomeState extends State with WidgetsBindingObserver, TickerProvider _warGesperrt = gesperrtJetzt; _hatteIdentitaet = identitaetJetzt; if (gesperrtWorden || geloeschtWorden) _raeumeOberflaecheAuf(); + // DIE IDENTITAET IST WEG — dann auch die Woerter aus der Zwischenablage, + // falls sie dort noch stehen. Nach einer Panik- oder Fernloeschung holten + // sie sonst genau das zurueck, was gerade geloescht wurde. Beim SPERREN + // nicht: die App sperrt im Hintergrund, und das ist der Moment, in dem + // jemand die Woerter in seinen Passwortmanager einfuegt. + if (geloeschtWorden) unawaited(GeheimeAblage.raeumeJetzt()); if (geloeschtWorden) { // Filter und Suche der alten Identitaet gehoeren nicht zur neuen. _filter = 'alle'; diff --git a/secure-messenger/app/lib/oberflaeche/home/identitaet.dart b/secure-messenger/app/lib/oberflaeche/home/identitaet.dart index 5a81e6c..f8881a9 100644 --- a/secure-messenger/app/lib/oberflaeche/home/identitaet.dart +++ b/secure-messenger/app/lib/oberflaeche/home/identitaet.dart @@ -6,9 +6,8 @@ // und zugriff.dart und sucht in den uebrigen Teilen nach frischBestaetigt. part of '../home.dart'; -/// Kopiert etwas Geheimes und raeumt die Zwischenablage nach 30 Sekunden -/// wieder — aber nur, wenn dort noch GENAU das steht. Was der Nutzer -/// inzwischen selbst kopiert hat, bleibt. +/// Wie lange ein TEIL der zwoelf Woerter (wiederherstellung.dart) in der +/// Zwischenablage bleibt. Die ganzen Woerter bleiben [GeheimeAblage.frist]. /// /// Die Zwischenablage lesen andere Apps, Tastaturen und auf manchen /// Telefonen auch die Cloud-Synchronisation; ein Teil der zwoelf Woerter @@ -23,7 +22,7 @@ extension _Identitaet on _HomeState { /// und wer sie nie zu sehen bekommt, verliert seine Identitaet beim ersten /// kaputten Bildschirm. Deshalb liegt jetzt ein Schritt dazwischen. Future doCreate() async { - setState(() { screen = 'creating'; wiped = false; }); + setState(() { screen = 'creating'; wiped = false; woerterKopiert = false; }); await st.identitaetAnlegen(); if (!mounted) return; setState(() => screen = 'phrase'); @@ -42,38 +41,47 @@ extension _Identitaet on _HomeState { } if (!mounted) return; await st.geheimnisSichtbar(true); + // Ob in DIESEM Dialog schon kopiert wurde — fuer den Hinweis darunter. + // Lokal und nicht im _HomeState: jedes Oeffnen faengt ohne an. + var kopiert = false; try { if (!mounted) return; await showDialog( context: context, - builder: (ctx) => AlertDialog( - key: const ValueKey('woerter-dialog'), - backgroundColor: p.surf, - title: Text(t('phraseTitle'), style: doto(size: 17, color: p.ink)), - content: SizedBox( - width: 340, - child: SingleChildScrollView( - child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(t('phraseShowWarn'), style: mono(size: 11.5, color: p.tintInk, height: 1.5)), - const SizedBox(height: 12), - _zweiSpaltenGitter( - woerter.length, - (i) => Container( - padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), - decoration: BoxDecoration(color: p.surf2, borderRadius: BorderRadius.circular(4)), - child: Row(children: [ - SizedBox(width: 20, child: Text('${i + 1}', style: mono(size: 10, color: p.dim))), - Expanded(child: Text(woerter[i], - style: doto(size: 14, weight: FontWeight.w600, color: p.ink, spacing: 0.6))), - ]), + builder: (ctx) => StatefulBuilder( + builder: (ctx, neu) => AlertDialog( + key: const ValueKey('woerter-dialog'), + backgroundColor: p.surf, + title: Text(t('phraseTitle'), style: doto(size: 17, color: p.ink)), + content: SizedBox( + width: 340, + child: SingleChildScrollView( + child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(t('phraseShowWarn'), style: mono(size: 11.5, color: p.tintInk, height: 1.5)), + const SizedBox(height: 12), + _zweiSpaltenGitter( + woerter.length, + (i) => Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 7), + decoration: BoxDecoration(color: p.surf2, borderRadius: BorderRadius.circular(4)), + child: Row(children: [ + SizedBox(width: 20, child: Text('${i + 1}', style: mono(size: 10, color: p.dim))), + Expanded(child: Text(woerter[i], + style: doto(size: 14, weight: FontWeight.w600, color: p.ink, spacing: 0.6))), + ]), + ), ), - ), - ]), + const SizedBox(height: 12), + ..._woerterKopierknopf(woerter, kopiert, () { + if (ctx.mounted) neu(() => kopiert = true); + }), + ]), + ), ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t('close'))), + ], ), - actions: [ - TextButton(onPressed: () => Navigator.pop(ctx), child: Text(t('close'))), - ], ), ); } finally { @@ -81,18 +89,54 @@ extension _Identitaet on _HomeState { } } - void _kopiereGeheim(String text) { - unawaited(Clipboard.setData(ClipboardData(text: text))); - Timer(ablageFrist, () async { - try { - final jetzt = await Clipboard.getData(Clipboard.kTextPlain); - if (jetzt?.text == text) { - await Clipboard.setData(const ClipboardData(text: '')); - } - } catch (_) { - // Keine Zwischenablage — dann gibt es auch nichts zu raeumen. - } - }); + /// Der Knopf "Kopieren" unter den zwoelf Woertern, und nach dem Kopieren + /// der Hinweis darunter — auf 'phrase' und im Dialog der Einstellungen. + /// + /// DER HINWEIS STEHT AM KNOPF und nicht als Leiste am unteren Rand: im + /// Dialog laege die Leiste hinter dessen Abdunklung, und auf 'phrase' weit + /// weg von dem, worauf er sich bezieht. Er bleibt stehen, solange die + /// Woerter dastehen — er ist zugleich die Warnung, wohin sie NICHT gehoeren. + /// + /// NICHT HERVORGEHOBEN (accent: false): der Hauptweg bleibt Papier, und auf + /// 'phrase' ist der Hauptknopf "Habe ich notiert". + List _woerterKopierknopf( + List woerter, bool kopiert, VoidCallback danach) { + return [ + KeyedSubtree( + key: const ValueKey('woerter-kopieren'), + child: _knopfBreite(outlineBtn( + t('copy'), + woerter.isEmpty + ? null + : () async { + // Mit einfachen Leerzeichen, klein, ohne Nummern — so nimmt + // sie jede Wiederherstellung an, auch die dieser App. + final ok = await GeheimeAblage.kopiere(woerter.join(' ')); + if (!mounted) return; + if (!ok) { + _hinweis(t('copySecretFailed')); + return; + } + danach(); + }, + accent: false, + padding: const EdgeInsets.all(11), + weight: FontWeight.w400, + )), + ), + if (kopiert) ...[ + const SizedBox(height: 8), + Semantics( + liveRegion: true, + child: Text( + t(GeheimeAblage.leertSicher ? 'copiedWords' : 'copiedWordsWeb') + .replaceFirst('{s}', '${GeheimeAblage.frist.inSeconds}'), + key: const ValueKey('woerter-kopiert'), + style: mono(size: 11, color: p.tintInk, height: 1.5), + ), + ), + ], + ]; } /// Die eigene Adresse, wie der Entwurf sie zeigt: Vierergruppen mit @@ -338,6 +382,10 @@ extension _Identitaet on _HomeState { // lief der Inhalt vorher in den gelb-schwarzen Ueberlaufbalken. if (_imFenster) gitter else Expanded(child: SingleChildScrollView(child: gitter)), const SizedBox(height: 12), + ..._woerterKopierknopf(woerter, woerterKopiert, () { + setState(() => woerterKopiert = true); + }), + const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( diff --git a/secure-messenger/app/lib/oberflaeche/home/wiederherstellung.dart b/secure-messenger/app/lib/oberflaeche/home/wiederherstellung.dart index 34aceae..c94724b 100644 --- a/secure-messenger/app/lib/oberflaeche/home/wiederherstellung.dart +++ b/secure-messenger/app/lib/oberflaeche/home/wiederherstellung.dart @@ -75,9 +75,13 @@ extension _Wiederherstellung on _HomeState { SelectableText(teile[i].alsText(), style: mono(size: 11, color: p.ink, height: 1.5)), const SizedBox(height: 8), Row(children: [ - Expanded(child: outlineBtn(t('copy'), () { - _kopiereGeheim(teile[i].alsText()); - _hinweis(t('copiedSecret')); + Expanded(child: outlineBtn(t('copy'), () async { + // Derselbe Weg wie fuer die ganzen Woerter (als sensibel + // markiert, aus dem Verlauf heraus), nur mit der + // kuerzeren Frist, die hier schon immer galt. + final ok = await GeheimeAblage.kopiere(teile[i].alsText(), + frist: ablageFrist); + _hinweis(t(ok ? 'copiedSecret' : 'copySecretFailed')); }, padding: const EdgeInsets.all(8))), const SizedBox(width: 8), Expanded(child: outlineBtn(t('trustSend'), () => _sendeTeil(teile[i]), diff --git a/secure-messenger/app/linux/runner/CMakeLists.txt b/secure-messenger/app/linux/runner/CMakeLists.txt index 85a666d..102a68b 100644 --- a/secure-messenger/app/linux/runner/CMakeLists.txt +++ b/secure-messenger/app/linux/runner/CMakeLists.txt @@ -8,6 +8,7 @@ project(runner LANGUAGES CXX) # Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} "datei_kanal.cc" + "geheime_ablage.cc" "main.cc" "my_application.cc" "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" diff --git a/secure-messenger/app/linux/runner/geheime_ablage.cc b/secure-messenger/app/linux/runner/geheime_ablage.cc new file mode 100644 index 0000000..3e408f3 --- /dev/null +++ b/secure-messenger/app/linux/runner/geheime_ablage.cc @@ -0,0 +1,113 @@ +#include "geheime_ablage.h" + +#include + +// SEIT 09.11.2026, mit dem Kopierknopf fuer die zwoelf Woerter. +// +// Flutters eigene Zwischenablage (gtk_clipboard_set_text) bietet nur Text an, +// und Verlaufsprogramme wie Klipper (KDE) merken sich jeden Text — auch, wenn +// die App die Ablage nach einer Minute wieder leert. Passwortmanager legen +// deshalb ein zweites Ziel daneben: "x-kde-passwordManagerHint" mit dem +// Inhalt "secret". Klipper und andere Verlaeufe lassen solche Eintraege aus. +// +// Ueber gtk_clipboard_set_with_data: die Ablage gehoert dann dieser App, und +// der Text wird erst ausgeliefert, wenn jemand einfuegt. Uebernimmt ein +// anderes Programm die Ablage, ruft GTK [vergiss] — dann wird der Text hier +// sofort ueberschrieben und freigegeben. + +namespace { + +// Was gerade in der Ablage steht, solange sie dieser App gehoert. Statisch: +// GTK kann [liefere] und [vergiss] rufen, solange die App lebt, auch nachdem +// der Kanal schon weg ist. +gchar* g_inhalt = nullptr; + +// Die "info"-Werte der beiden Ziele. +constexpr guint kText = 1; +constexpr guint kHinweis = 2; + +GtkClipboard* ablage() { + return gtk_clipboard_get(GDK_SELECTION_CLIPBOARD); +} + +void liefere(GtkClipboard* clipboard, GtkSelectionData* auswahl, guint info, + gpointer daten) { + if (g_inhalt == nullptr) return; + if (info == kHinweis) { + static const guchar kGeheim[] = "secret"; + gtk_selection_data_set(auswahl, gtk_selection_data_get_target(auswahl), 8, + kGeheim, 6); + } else { + gtk_selection_data_set_text(auswahl, g_inhalt, -1); + } +} + +void vergiss(GtkClipboard* clipboard, gpointer daten) { + if (g_inhalt == nullptr) return; + // Ueberschreiben, bevor der Speicher zurueckgeht: g_free raeumt nicht. + memset(g_inhalt, 0, strlen(g_inhalt)); + g_free(g_inhalt); + g_inhalt = nullptr; +} + +gboolean kopiere(const gchar* text) { + if (text == nullptr || *text == '\0') return FALSE; + GtkTargetList* liste = gtk_target_list_new(nullptr, 0); + gtk_target_list_add_text_targets(liste, kText); + gtk_target_list_add(liste, gdk_atom_intern_static_string( + "x-kde-passwordManagerHint"), + 0, kHinweis); + gint anzahl = 0; + GtkTargetEntry* ziele = gtk_target_table_new_from_list(liste, &anzahl); + gtk_target_list_unref(liste); + // ERST UEBERNEHMEN, DANN MERKEN. Gehoerte die Ablage schon dieser App, ruft + // GTK hier drin [vergiss] fuer den alten Inhalt. + const gboolean ok = gtk_clipboard_set_with_data( + ablage(), ziele, anzahl, liefere, vergiss, nullptr); + gtk_target_table_free(ziele, anzahl); + if (ok) g_inhalt = g_strdup(text); + return ok; +} + +gboolean leere() { + if (g_inhalt == nullptr) return FALSE; // gehoert schon jemand anderem + // Gibt die Ablage frei; GTK ruft dabei [vergiss]. + gtk_clipboard_clear(ablage()); + return TRUE; +} + +const gchar* text_feld(FlValue* argumente, const char* name) { + if (argumente == nullptr || fl_value_get_type(argumente) != FL_VALUE_TYPE_MAP) { + return nullptr; + } + FlValue* wert = fl_value_lookup_string(argumente, name); + if (wert == nullptr || fl_value_get_type(wert) != FL_VALUE_TYPE_STRING) { + return nullptr; + } + return fl_value_get_string(wert); +} + +void bearbeite(FlMethodChannel* channel, FlMethodCall* aufruf, gpointer daten) { + const gchar* methode = fl_method_call_get_name(aufruf); + if (strcmp(methode, "kopiere") == 0) { + g_autoptr(FlValue) ging = fl_value_new_bool( + kopiere(text_feld(fl_method_call_get_args(aufruf), "text"))); + fl_method_call_respond_success(aufruf, ging, nullptr); + } else if (strcmp(methode, "leere") == 0) { + g_autoptr(FlValue) ging = fl_value_new_bool(leere()); + fl_method_call_respond_success(aufruf, ging, nullptr); + } else { + fl_method_call_respond_not_implemented(aufruf, nullptr); + } +} + +} // namespace + +FlMethodChannel* geheime_ablage_neu(FlBinaryMessenger* messenger) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + FlMethodChannel* channel = fl_method_channel_new(messenger, "bitdm/ablage", + FL_METHOD_CODEC(codec)); + fl_method_channel_set_method_call_handler(channel, bearbeite, nullptr, + nullptr); + return channel; +} diff --git a/secure-messenger/app/linux/runner/geheime_ablage.h b/secure-messenger/app/linux/runner/geheime_ablage.h new file mode 100644 index 0000000..20c0173 --- /dev/null +++ b/secure-messenger/app/linux/runner/geheime_ablage.h @@ -0,0 +1,17 @@ +#ifndef RUNNER_GEHEIME_ABLAGE_H_ +#define RUNNER_GEHEIME_ABLAGE_H_ + +#include +#include + +// Kanal "bitdm/ablage" unter Linux — dasselbe wie GeheimeAblage.kt unter +// Android und geheime_ablage.cpp unter Windows. Die Gegenstelle ist +// lib/core/geheime_ablage.dart. +// +// "kopiere" bietet den Text zusammen mit dem Hinweis +// "x-kde-passwordManagerHint: secret" an; "leere" gibt die Ablage frei, +// solange sie noch dieser App gehoert. Wann geleert wird, entscheidet die +// Dart-Seite. +FlMethodChannel* geheime_ablage_neu(FlBinaryMessenger* messenger); + +#endif // RUNNER_GEHEIME_ABLAGE_H_ diff --git a/secure-messenger/app/linux/runner/my_application.cc b/secure-messenger/app/linux/runner/my_application.cc index 9ea3d0a..1f5ebbc 100644 --- a/secure-messenger/app/linux/runner/my_application.cc +++ b/secure-messenger/app/linux/runner/my_application.cc @@ -6,6 +6,7 @@ #endif #include "datei_kanal.h" +#include "geheime_ablage.h" #include "flutter/generated_plugin_registrant.h" struct _MyApplication { @@ -13,6 +14,9 @@ struct _MyApplication { char** dart_entrypoint_arguments; // Kanal "bitdm/dateien" — Dateiauswahl und Oeffnen, siehe datei_kanal.cc. FlMethodChannel* datei_kanal; + // Kanal "bitdm/ablage" — die zwoelf Woerter mit Verlaufs-Sperre in die + // Zwischenablage, siehe geheime_ablage.cc. + FlMethodChannel* geheime_ablage; }; G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) @@ -79,6 +83,8 @@ static void my_application_activate(GApplication* application) { fl_register_plugins(FL_PLUGIN_REGISTRY(view)); self->datei_kanal = datei_kanal_neu( fl_engine_get_binary_messenger(fl_view_get_engine(view)), window); + self->geheime_ablage = geheime_ablage_neu( + fl_engine_get_binary_messenger(fl_view_get_engine(view))); gtk_widget_grab_focus(GTK_WIDGET(view)); } @@ -127,6 +133,7 @@ static void my_application_dispose(GObject* object) { MyApplication* self = MY_APPLICATION(object); g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); g_clear_object(&self->datei_kanal); + g_clear_object(&self->geheime_ablage); G_OBJECT_CLASS(my_application_parent_class)->dispose(object); } diff --git a/secure-messenger/app/test/core/geheime_ablage_test.dart b/secure-messenger/app/test/core/geheime_ablage_test.dart new file mode 100644 index 0000000..a784e44 --- /dev/null +++ b/secure-messenger/app/test/core/geheime_ablage_test.dart @@ -0,0 +1,234 @@ +// geheime_ablage_test.dart — wann die Zwischenablage nach dem Kopieren der +// zwoelf Woerter wieder leer wird, und wer sie leert. +// +// Die Zwischenablage ist hier eine Attrappe hinter SystemChannels.platform +// (dorthin schicken Clipboard.setData/getData), die Gegenstelle "bitdm/ablage" +// eine hinter ihrem Kanal. Die Zeit ist die Testzeit von testWidgets: eine +// Minute vergeht mit einem pump. + +import 'package:bitdm/core/geheime_ablage.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _kanal = MethodChannel('bitdm/ablage'); + +void main() { + /// Was in der Attrappe der Zwischenablage steht. + String? ablage; + + /// Was Flutter selbst hineingeschrieben hat (Clipboard.setData). + final perFlutter = []; + + /// Was bei der Gegenstelle ankam. + final aufrufe = []; + + TestDefaultBinaryMessenger bote() => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + /// Haengt eine Gegenstelle an "bitdm/ablage". Ohne Aufruf gibt es keine — + /// wie im Browser. + /// + /// "KEINE" HEISST HIER EINE ATTRAPPE, DIE MissingPluginException WIRFT, und + /// nicht ein Kanal ohne Attrappe. Ohne sie ginge der Aufruf an die echte + /// Maschine, deren Antwort ausserhalb der Testzeit ankommt — der Fall hing + /// dann fuer immer (beim ersten Lauf am 09.11.2026 so gesehen). + void gegenstelle(Object? Function(MethodCall) antwort) { + bote().setMockMethodCallHandler(_kanal, (c) async { + aufrufe.add(c); + return antwort(c); + }); + } + + setUp(() { + ablage = null; + perFlutter.clear(); + aufrufe.clear(); + gegenstelle((c) => throw MissingPluginException()); + bote().setMockMethodCallHandler(SystemChannels.platform, (c) async { + if (c.method == 'Clipboard.setData') { + final text = (c.arguments as Map)['text'] as String?; + perFlutter.add(text); + ablage = text; + return null; + } + if (c.method == 'Clipboard.getData') { + return ablage == null ? null : {'text': ablage}; + } + return null; + }); + }); + + tearDown(() { + GeheimeAblage.vergiss(); + bote().setMockMethodCallHandler(SystemChannels.platform, null); + bote().setMockMethodCallHandler(_kanal, null); + }); + + group('ohne Gegenstelle (Browser, Test)', () { + testWidgets('kopiert ueber Flutter und leert nach 60 s', (tester) async { + expect(await GeheimeAblage.kopiere('eins zwei drei'), isTrue); + expect(ablage, 'eins zwei drei'); + + await tester.pump(const Duration(seconds: 59)); + expect(ablage, 'eins zwei drei', reason: 'vor der Frist geleert'); + + await tester.pump(const Duration(seconds: 2)); + expect(ablage, '', reason: 'nach der Frist stand noch alles da'); + expect(GeheimeAblage.wartet, isFalse); + }); + + testWidgets('was inzwischen selbst kopiert wurde, bleibt', (tester) async { + await GeheimeAblage.kopiere('eins zwei drei'); + ablage = 'eine Adresse'; + + await tester.pump(const Duration(seconds: 61)); + expect(ablage, 'eine Adresse', + reason: 'die Frist hat fremden Inhalt geloescht'); + }); + + testWidgets('die Frist laesst sich kuerzen (Teilgeheimnisse: 30 s)', + (tester) async { + await GeheimeAblage.kopiere('teil', frist: const Duration(seconds: 30)); + await tester.pump(const Duration(seconds: 31)); + expect(ablage, ''); + }); + + testWidgets('ein zweites Kopieren beobachtet nur noch das zweite', + (tester) async { + await GeheimeAblage.kopiere('erstes'); + await tester.pump(const Duration(seconds: 40)); + await GeheimeAblage.kopiere('zweites'); + + // Nach 61 s seit dem ERSTEN darf noch nichts passiert sein. + await tester.pump(const Duration(seconds: 21)); + expect(ablage, 'zweites'); + await tester.pump(const Duration(seconds: 40)); + expect(ablage, ''); + }); + + testWidgets('raeumeJetzt leert sofort, aber nur das Eigene', + (tester) async { + await GeheimeAblage.kopiere('eins zwei drei'); + await GeheimeAblage.raeumeJetzt(); + expect(ablage, ''); + expect(GeheimeAblage.wartet, isFalse); + + await GeheimeAblage.kopiere('eins zwei drei'); + ablage = 'fremd'; + await GeheimeAblage.raeumeJetzt(); + expect(ablage, 'fremd'); + }); + }); + + /// Ein Fall auf [plattform]. Die Weiche wird IM Fall zurueckgesetzt und + /// nicht in tearDown: flutter_test prueft vorher, ob sie stehen blieb + /// (siehe fenster_abfolge_test.dart). + void auf(TargetPlatform plattform, String name, + Future Function(WidgetTester) koerper) { + testWidgets('$name — ${plattform.name}', (tester) async { + debugDefaultTargetPlatformOverride = plattform; + try { + await koerper(tester); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + } + + group('Android', () { + // Im Widget-Test meldet Flutter ohnehin android; hier steht es trotzdem, + // damit der Fall nicht still an einer Vorgabe des Testrahmens haengt. + auf(TargetPlatform.android, + 'die Gegenstelle kopiert UND raeumt selbst — kein Dart-Wecker', + (tester) async { + gegenstelle((c) => true); + expect(await GeheimeAblage.kopiere('eins zwei drei'), isTrue); + + expect(aufrufe.single.method, 'kopiere'); + expect(aufrufe.single.arguments, + {'text': 'eins zwei drei', 'sekunden': 60}); + expect(perFlutter, isEmpty, + reason: 'zusaetzlich ueber Flutter kopiert — ohne Sensibel-Merkmal'); + + // Die Frist laeuft in GeheimeAblage.kt. Dart darf danach nichts tun: + // im Hintergrund laese es null und koennte nichts entscheiden. + await tester.pump(const Duration(seconds: 61)); + expect(aufrufe, hasLength(1)); + expect(perFlutter, isEmpty); + + await GeheimeAblage.raeumeJetzt(); + expect(aufrufe.last.method, 'raeume'); + }); + + auf(TargetPlatform.android, + 'scheitert die Gegenstelle, kopiert Flutter und Dart raeumt', + (tester) async { + gegenstelle((c) => throw PlatformException(code: 'kaputt')); + expect(await GeheimeAblage.kopiere('eins zwei drei'), isTrue); + expect(ablage, 'eins zwei drei'); + + await tester.pump(const Duration(seconds: 61)); + expect(ablage, ''); + }); + }); + + group('am Rechner (Windows, Linux)', () { + for (final plattform in [TargetPlatform.windows, TargetPlatform.linux]) { + auf(plattform, + 'die Gegenstelle kopiert, Dart vergleicht und laesst sie leeren', + (tester) async { + gegenstelle((c) { + switch (c.method) { + case 'kopiere': + ablage = (c.arguments as Map)['text'] as String; + return true; + case 'leere': + ablage = null; + return true; + } + return null; + }); + await GeheimeAblage.kopiere('eins zwei drei'); + expect(ablage, 'eins zwei drei'); + + await tester.pump(const Duration(seconds: 61)); + expect(aufrufe.map((c) => c.method), ['kopiere', 'leere']); + expect(ablage, isNull); + // Clipboard.setData('') legte auf Windows einen leeren Eintrag in den + // Verlauf — die Gegenstelle leert die Ablage ganz. + expect(perFlutter, isEmpty); + }); + } + + auf(TargetPlatform.linux, 'kennt die Gegenstelle "leere" nicht, leert Flutter', + (tester) async { + gegenstelle((c) { + if (c.method == 'kopiere') { + ablage = (c.arguments as Map)['text'] as String; + return true; + } + return false; + }); + await GeheimeAblage.kopiere('eins zwei drei'); + await tester.pump(const Duration(seconds: 61)); + expect(perFlutter, ['']); + expect(ablage, ''); + }); + + auf(TargetPlatform.windows, 'fremder Inhalt bleibt auch hier', + (tester) async { + gegenstelle((c) { + if (c.method == 'kopiere') { + ablage = (c.arguments as Map)['text'] as String; + } + return true; + }); + await GeheimeAblage.kopiere('eins zwei drei'); + ablage = 'fremd'; + await tester.pump(const Duration(seconds: 61)); + expect(aufrufe.map((c) => c.method), ['kopiere']); + expect(ablage, 'fremd'); + }); + }); +} diff --git a/secure-messenger/app/test/oberflaeche/woerter_kopieren_test.dart b/secure-messenger/app/test/oberflaeche/woerter_kopieren_test.dart new file mode 100644 index 0000000..ff35034 --- /dev/null +++ b/secure-messenger/app/test/oberflaeche/woerter_kopieren_test.dart @@ -0,0 +1,255 @@ +// woerter_kopieren_test.dart — der Knopf "Kopieren" unter den zwoelf Woertern, +// nach dem Anlegen ('phrase') und in den Einstellungen. +// +// Die Woerter sind die des Entwurfskerns (FakeMessengerCore, der +// BIP39-Testvektor "abandon … about"); kein echtes Geheimnis. +// +// Was die Zwischenablage DANACH tut (Frist, nur Eigenes leeren, Android raeumt +// selbst), prueft test/core/geheime_ablage_test.dart. Hier geht es darum, dass +// der Knopf da ist, das Richtige weitergibt, den Hinweis zeigt und am +// Screenshot-Schutz nichts aendert. + +import 'package:bitdm/app_state.dart'; +import 'package:bitdm/core/fake_messenger_core.dart'; +import 'package:bitdm/core/geheime_ablage.dart'; +import 'package:bitdm/main.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _woerter = 'abandon abandon abandon abandon abandon abandon ' + 'abandon abandon abandon abandon abandon about'; + +void main() { + late FakeMessengerCore kern; + late AppState st; + + /// Was bei "bitdm/ablage" ankam. + final aufrufe = []; + + /// Die Attrappe der Zwischenablage — gebraucht im Fenster-Zweig, wo Dart + /// nach der Frist nachsieht. Ohne sie ginge die Frage an die echte Maschine, + /// und deren Antwort kaeme ausserhalb der Testzeit an. + String? ablage; + + /// Was die Oberflaeche zuletzt an den Screenshot-Kanal geschickt hat. + final schutz = []; + + TestDefaultBinaryMessenger bote() => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + aufrufe.clear(); + schutz.clear(); + ablage = null; + bote().setMockMethodCallHandler(SystemChannels.platform, (c) async { + if (c.method == 'Clipboard.setData') { + ablage = (c.arguments as Map)['text'] as String?; + } else if (c.method == 'Clipboard.getData') { + return ablage == null ? null : {'text': ablage}; + } + return null; + }); + bote().setMockMethodCallHandler(const MethodChannel('bitdm/fenster'), + (c) async { + if (c.method == 'setzeScreenshotSperre') { + schutz.add((c.arguments as Map)['an'] as bool); + } + return true; + }); + bote().setMockMethodCallHandler(const MethodChannel('bitdm/ablage'), + (c) async { + aufrufe.add(c); + // Wie die Gegenstellen: "kopiere" legt den Text ab, "leere" raeumt. + if (c.method == 'kopiere') ablage = (c.arguments as Map)['text'] as String; + if (c.method == 'leere') ablage = null; + return true; + }); + kern = FakeMessengerCore(); + st = AppState(kern); + }); + + tearDown(() { + GeheimeAblage.vergiss(); + bote().setMockMethodCallHandler(const MethodChannel('bitdm/fenster'), null); + bote().setMockMethodCallHandler(const MethodChannel('bitdm/ablage'), null); + bote().setMockMethodCallHandler(SystemChannels.platform, null); + st.dispose(); + }); + + Future warte(WidgetTester tester, [int ms = 400]) async { + for (var i = 0; i < 4; i++) { + await tester.runAsync(() async => Future.delayed(Duration.zero)); + await tester.pump(Duration(milliseconds: ms ~/ 2)); + } + } + + Future starte(WidgetTester tester) async { + await tester.runAsync(() async => st.boot()); + await tester.pumpWidget(BitApp(state: st)); + await tester.pump(const Duration(milliseconds: 400)); + } + + /// onboard → 'phrase'. + Future bisZuDenWoertern(WidgetTester tester) async { + await starte(tester); + await tester.tap(find.text('CREATE IDENTITY')); + await warte(tester, 600); + expect(find.text('YOUR 12 WORDS'), findsOneWidget); + } + + final knopf = find.byKey(const ValueKey('woerter-kopieren')); + final hinweis = find.byKey(const ValueKey('woerter-kopiert')); + + /// Was der Hinweis unter dem Knopf sagt. + String hinweisText(WidgetTester tester) => + tester.widget(hinweis).data ?? ''; + + testWidgets('NACH DEM ANLEGEN: Kopieren gibt die zwoelf Woerter weiter und ' + 'sagt, wann sie verschwinden', (tester) async { + await bisZuDenWoertern(tester); + expect(knopf, findsOneWidget, reason: 'kein Kopierknopf unter den Woertern'); + expect(find.descendant(of: knopf, matching: find.text('COPY')), + findsOneWidget); + expect(hinweis, findsNothing, reason: 'Hinweis vor dem Kopieren'); + + await tester.tap(knopf); + await warte(tester); + + expect(aufrufe, hasLength(1)); + expect(aufrufe.single.method, 'kopiere'); + expect(aufrufe.single.arguments, + {'text': _woerter, 'sekunden': GeheimeAblage.frist.inSeconds}); + expect(st.frischePhrase!.join(' '), _woerter); + + expect(hinweis, findsOneWidget, reason: 'kein Hinweis nach dem Kopieren'); + expect(hinweisText(tester), contains('60 s')); + expect(hinweisText(tester), contains('chats or cloud notes')); + + // DER SCREENSHOT-SCHUTZ BLEIBT: 'phrase' erzwingt ihn, und das Kopieren + // aendert daran nichts. + expect(schutz.last, isTrue); + + // Der Weg weiter ist derselbe wie vorher. + await tester.tap(find.text('I WROTE THEM DOWN')); + await warte(tester); + expect(find.text('SKIP FOR NOW'), findsOneWidget); + }); + + testWidgets('IM FENSTER: Knopf und Hinweis stehen bei den Woertern, und ' + '"I WROTE THEM DOWN" bleibt im Bild', (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + try { + tester.view.physicalSize = const Size(1600, 1000); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + await bisZuDenWoertern(tester); + + await tester.tap(knopf); + await warte(tester); + expect(aufrufe.single.arguments, containsPair('text', _woerter)); + expect(hinweis, findsOneWidget); + + // Unter den Woertern, nicht irgendwo am Rand: der Knopf liegt zwischen + // dem letzten Wort und dem Hauptknopf. + final wortUnten = tester.getBottomLeft(find.text('about')).dy; + final knopfOben = tester.getTopLeft(knopf).dy; + final weiter = tester.getTopLeft(find.text('I WROTE THEM DOWN')).dy; + expect(knopfOben, greaterThan(wortUnten)); + expect(weiter, greaterThan(tester.getBottomLeft(hinweis).dy)); + expect(weiter, lessThan(1000), reason: 'Hauptknopf unter den Fensterrand gerutscht'); + + // Am Rechner sieht Dart nach der Frist nach und laesst die Gegenstelle + // leeren. + expect(ablage, _woerter); + await tester.pump(const Duration(seconds: 61)); + expect(GeheimeAblage.wartet, isFalse); + expect(aufrufe.map((c) => c.method), ['kopiere', 'leere']); + expect(ablage, isNull); + + await tester.tap(find.text('I WROTE THEM DOWN')); + await warte(tester); + expect(find.text('SKIP FOR NOW'), findsOneWidget); + } finally { + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('IN DEN EINSTELLUNGEN: Kopieren im Dialog, Hinweis im Dialog, ' + 'Schliessen unveraendert', (tester) async { + kern.simulateExistingIdentity = true; + await tester.binding.setSurfaceSize(const Size(420, 1400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await starte(tester); + // Wie in sperre_oberflaeche_test: in den Einstellungen AUS, damit sich + // zeigt, dass der Dialog den Schutz erzwingt und das Kopieren ihn nicht + // stehen laesst. + await tester.runAsync(() => st.setzeEinstellungen( + st.einstellungen.copyWith(blockScreenshots: false))); + await tester.tap(find.text('SETTINGS')); + await warte(tester); + expect(schutz.last, isFalse); + + Future oeffne() async { + await tester.ensureVisible(find.byKey(const ValueKey('woerter-zeigen'))); + await tester.tap(find.byKey(const ValueKey('woerter-zeigen'))); + await warte(tester); + expect(find.byKey(const ValueKey('woerter-dialog')), findsOneWidget); + } + + await oeffne(); + final imDialog = find.descendant( + of: find.byKey(const ValueKey('woerter-dialog')), matching: knopf); + expect(imDialog, findsOneWidget, reason: 'kein Kopierknopf im Dialog'); + expect(schutz.last, isTrue); + + await tester.tap(imDialog); + await warte(tester); + expect(aufrufe.single.method, 'kopiere'); + expect(aufrufe.single.arguments, + {'text': _woerter, 'sekunden': GeheimeAblage.frist.inSeconds}); + expect( + find.descendant( + of: find.byKey(const ValueKey('woerter-dialog')), matching: hinweis), + findsOneWidget, + reason: 'der Hinweis muss IM Dialog stehen, nicht hinter dessen ' + 'Abdunklung'); + expect(schutz.last, isTrue, reason: 'Kopieren hat den Schutz geloest'); + + // Der Schliessen-Knopf ist weiterhin der einzige TextButton im Dialog — + // sperre_oberflaeche_test findet ihn genau so. + await tester.tap(find.descendant( + of: find.byKey(const ValueKey('woerter-dialog')), + matching: find.byType(TextButton))); + await warte(tester); + expect(find.byKey(const ValueKey('woerter-dialog')), findsNothing); + expect(schutz.last, isFalse); + + // Neu geoeffnet: ohne den alten Hinweis. + await oeffne(); + expect(hinweis, findsNothing); + await tester.tap(find.descendant( + of: find.byKey(const ValueKey('woerter-dialog')), + matching: find.byType(TextButton))); + await warte(tester); + }); + + testWidgets('IDENTITAET GELOESCHT: die Woerter gehen aus der Zwischenablage', + (tester) async { + await bisZuDenWoertern(tester); + await tester.tap(knopf); + await warte(tester); + expect(GeheimeAblage.wartet, isTrue); + + await tester.runAsync(() => st.allesLoeschen()); + await warte(tester); + + expect(st.hatIdentitaet, isFalse); + expect(aufrufe.last.method, 'raeume', + reason: 'nach dem Loeschen standen die Woerter weiter in der Ablage'); + expect(GeheimeAblage.wartet, isFalse); + // Der Wiederverbindungs-Wecker des Kerns, den das Loeschen anstoesst. + await tester.pump(const Duration(seconds: 2)); + }); +} diff --git a/secure-messenger/app/windows/runner/CMakeLists.txt b/secure-messenger/app/windows/runner/CMakeLists.txt index 80dc498..7095189 100644 --- a/secure-messenger/app/windows/runner/CMakeLists.txt +++ b/secure-messenger/app/windows/runner/CMakeLists.txt @@ -9,6 +9,7 @@ project(runner LANGUAGES CXX) add_executable(${BINARY_NAME} WIN32 "datei_kanal.cpp" "flutter_window.cpp" + "geheime_ablage.cpp" "main.cpp" "utils.cpp" "win32_window.cpp" diff --git a/secure-messenger/app/windows/runner/flutter_window.cpp b/secure-messenger/app/windows/runner/flutter_window.cpp index 4702597..a7bb3cc 100644 --- a/secure-messenger/app/windows/runner/flutter_window.cpp +++ b/secure-messenger/app/windows/runner/flutter_window.cpp @@ -73,6 +73,9 @@ bool FlutterWindow::OnCreate() { datei_kanal_ = std::make_unique( flutter_controller_->engine()->messenger(), [this]() { return GetHandle(); }); + geheime_ablage_ = std::make_unique( + flutter_controller_->engine()->messenger(), [this]() { return GetHandle(); }); + flutter_controller_->engine()->SetNextFrameCallback([&]() { this->Show(); }); diff --git a/secure-messenger/app/windows/runner/flutter_window.h b/secure-messenger/app/windows/runner/flutter_window.h index 3dbf85c..507e444 100644 --- a/secure-messenger/app/windows/runner/flutter_window.h +++ b/secure-messenger/app/windows/runner/flutter_window.h @@ -9,6 +9,7 @@ #include #include "datei_kanal.h" +#include "geheime_ablage.h" #include "win32_window.h" // A window that does nothing but host a Flutter view. @@ -38,6 +39,10 @@ class FlutterWindow : public Win32Window { // Kanal "bitdm/dateien" — Dateiauswahl und Oeffnen, siehe datei_kanal.cpp. std::unique_ptr datei_kanal_; + + // Kanal "bitdm/ablage" — die zwoelf Woerter ohne Verlauf und Cloud in die + // Zwischenablage, siehe geheime_ablage.cpp. + std::unique_ptr geheime_ablage_; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/secure-messenger/app/windows/runner/geheime_ablage.cpp b/secure-messenger/app/windows/runner/geheime_ablage.cpp new file mode 100644 index 0000000..e727e07 --- /dev/null +++ b/secure-messenger/app/windows/runner/geheime_ablage.cpp @@ -0,0 +1,132 @@ +#include "geheime_ablage.h" + +#include + +#include +#include + +// SEIT 09.11.2026, mit dem Kopierknopf fuer die zwoelf Woerter. +// +// Ohne diese Datei kopierte Windows ueber Flutters eigene Zwischenablage, und +// die nimmt jeder Verlauf mit: Win+V zeigt die Woerter noch nach dem Leeren, +// und mit "Geraeteuebergreifend synchronisieren" lagen sie im Microsoft-Konto. +// Dagegen gibt es drei Formate, die Passwortmanager (KeePass, KeePassXC, +// 1Password) neben den Text legen: +// +// ExcludeClipboardContentFromMonitorProcessing — irgendein Inhalt; dann +// uebernehmen Verlauf und Ablage-Ueberwacher den Eintrag nicht. +// CanIncludeInClipboardHistory = 0 — nicht in den Verlauf (Win+V). +// CanUploadToCloudClipboard = 0 — nicht in die Cloud-Zwischenablage. +// +// Beschrieben unter "Clipboard Formats" in der Win32-Doku +// (dataxchg/clipboard-formats, Abschnitt "Cloud Clipboard and Clipboard +// History Formats"). + +using flutter::EncodableMap; +using flutter::EncodableValue; + +namespace { + +std::wstring Utf16AusUtf8(const std::string& text) { + if (text.empty()) return std::wstring(); + const int laenge = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, + text.data(), + static_cast(text.size()), nullptr, 0); + if (laenge <= 0) return std::wstring(); + std::wstring ergebnis(laenge, L'\0'); + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, text.data(), + static_cast(text.size()), ergebnis.data(), laenge); + return ergebnis; +} + +const EncodableValue* Feld(const EncodableValue* argumente, const char* name) { + const auto* karte = std::get_if(argumente); + if (karte == nullptr) return nullptr; + auto it = karte->find(EncodableValue(name)); + return it == karte->end() ? nullptr : &it->second; +} + +// Oeffnet die Ablage fuer [fenster]. Haelt sie gerade ein anderes Programm +// offen (Ablage-Ueberwacher tun das kurz nach jeder Aenderung), schlaegt +// OpenClipboard fehl — dann ein paar Mal kurz nachfassen statt aufzugeben. +bool Oeffne(HWND fenster) { + for (int versuch = 0; versuch < 5; ++versuch) { + if (OpenClipboard(fenster)) return true; + Sleep(20); + } + return false; +} + +// Legt [groesse] Bytes ab [daten] im Format [format] ab. Die Ablage muss +// offen sein. Danach gehoert der Speicher dem System. +bool Lege(UINT format, const void* daten, size_t groesse) { + if (format == 0) return false; + HGLOBAL speicher = GlobalAlloc(GMEM_MOVEABLE, groesse); + if (speicher == nullptr) return false; + void* ziel = GlobalLock(speicher); + if (ziel == nullptr) { + GlobalFree(speicher); + return false; + } + std::memcpy(ziel, daten, groesse); + GlobalUnlock(speicher); + if (SetClipboardData(format, speicher) == nullptr) { + GlobalFree(speicher); + return false; + } + return true; +} + +// Ein DWORD in einem registrierten Format. +bool LegeZahl(const wchar_t* name, DWORD wert) { + return Lege(RegisterClipboardFormatW(name), &wert, sizeof(wert)); +} + +} // namespace + +bool GeheimeAblage::Kopiere(HWND fenster, const std::wstring& text) { + if (text.empty() || !Oeffne(fenster)) return false; + bool ok = EmptyClipboard() != FALSE; + // ERST DIE AUSSCHLUESSE, DANN DER TEXT. Ausgewertet wird zwar erst nach + // CloseClipboard, aber so steht nie ein Augenblick lang Text ohne sie da. + ok = ok && + LegeZahl(L"ExcludeClipboardContentFromMonitorProcessing", 1) && + LegeZahl(L"CanIncludeInClipboardHistory", 0) && + LegeZahl(L"CanUploadToCloudClipboard", 0) && + Lege(CF_UNICODETEXT, text.c_str(), (text.size() + 1) * sizeof(wchar_t)); + // Ging etwas schief, lieber gar nichts in der Ablage als die Woerter ohne + // Schutz. Die Dart-Seite kopiert dann ueber Flutter — das ist eine + // bewusste Entscheidung dort, nicht ein Zufall hier. + if (!ok) EmptyClipboard(); + CloseClipboard(); + return ok; +} + +bool GeheimeAblage::Leere(HWND fenster) { + if (!Oeffne(fenster)) return false; + const bool ok = EmptyClipboard() != FALSE; + CloseClipboard(); + return ok; +} + +GeheimeAblage::GeheimeAblage(flutter::BinaryMessenger* messenger, + std::function fenster) + : fenster_(std::move(fenster)) { + kanal_ = std::make_unique>( + messenger, "bitdm/ablage", &flutter::StandardMethodCodec::GetInstance()); + kanal_->SetMethodCallHandler( + [this](const flutter::MethodCall& aufruf, + std::unique_ptr> ergebnis) { + const std::string& methode = aufruf.method_name(); + if (methode == "kopiere") { + const EncodableValue* wert = Feld(aufruf.arguments(), "text"); + const auto* text = wert ? std::get_if(wert) : nullptr; + ergebnis->Success(EncodableValue( + text != nullptr && Kopiere(fenster_(), Utf16AusUtf8(*text)))); + } else if (methode == "leere") { + ergebnis->Success(EncodableValue(Leere(fenster_()))); + } else { + ergebnis->NotImplemented(); + } + }); +} diff --git a/secure-messenger/app/windows/runner/geheime_ablage.h b/secure-messenger/app/windows/runner/geheime_ablage.h new file mode 100644 index 0000000..f7a8285 --- /dev/null +++ b/secure-messenger/app/windows/runner/geheime_ablage.h @@ -0,0 +1,38 @@ +#ifndef RUNNER_GEHEIME_ABLAGE_H_ +#define RUNNER_GEHEIME_ABLAGE_H_ + +#include +#include +#include +#include + +#include +#include +#include + +// Kanal "bitdm/ablage" auf Windows — dasselbe wie GeheimeAblage.kt unter +// Android. Die Gegenstelle ist lib/core/geheime_ablage.dart. +// +// "kopiere" legt den Text so in die Zwischenablage, dass der Verlauf (Win+V) +// und die Synchronisation ueber das Microsoft-Konto ihn nicht uebernehmen. +// "leere" leert sie ganz. Wann geleert wird, entscheidet die Dart-Seite: am +// Rechner darf die App die Ablage auch im Hintergrund lesen. +class GeheimeAblage { + public: + // [fenster] liefert das Fenster, dem die Ablage danach gehoert. + GeheimeAblage(flutter::BinaryMessenger* messenger, + std::function fenster); + + // Legt [text] mit den Ausschluss-Formaten in die Ablage. Oeffentlich, damit + // sich der Win32-Teil ohne Flutter pruefen laesst. + static bool Kopiere(HWND fenster, const std::wstring& text); + + // Leert die Ablage. + static bool Leere(HWND fenster); + + private: + std::unique_ptr> kanal_; + std::function fenster_; +}; + +#endif // RUNNER_GEHEIME_ABLAGE_H_