From ad1c2bf76c170d0de9ace34aff836b58cca642b9 Mon Sep 17 00:00:00 2001 From: JUSN Date: Wed, 15 Jul 2026 15:30:19 +0200 Subject: [PATCH] =?UTF-8?q?Datenschutz-Ausbau:=20Archiv-Verschl=C3=BCsselu?= =?UTF-8?q?ng,=20App-Sperre,=20Auto-L=C3=B6schen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Brief-Archiv jetzt AES-256-GCM-verschlüsselt (expo-crypto, Schlüssel im SecureStore); Migration alter Klartext-Bestände beim ersten Laden; im Simulator auf der Platte verifiziert (nur Chiffrat, kein Klartext) - AppSchutz-Komponente: optionale App-Sperre (Face ID / Geräte-Code, expo-local-authentication) + Sichtschutz im App-Umschalter - Auto-Löschen alter Briefe (30/90 Tage, Einstellungen), pure Filter- Funktion mit Tests - UTF-8-Helfer (Umlaute/Arabisch/Emoji-sicher) mit Tests; gesamt 30 Tests - Einstellungen: neuer Abschnitt "Sicherheit" - Datenschutzerklärung (Web + Entwurf) um die neuen Maßnahmen ergänzt - Neu: rechtliches/datenschutz-factsheet.md für Kooperations-Pitches - EU-Verarbeitung geprüft: inference_geo bietet aktuell nur us/global, keine EU-Option (Haiku 4.5 gar nicht) — als Ausbaupfad dokumentiert Co-Authored-By: Claude Fable 5 --- App.tsx | 11 ++- app.json | 6 ++ package-lock.json | 23 +++++ package.json | 2 + rechtliches/datenschutz-factsheet.md | 74 +++++++++++++++ rechtliches/datenschutzerklaerung.md | 7 +- src/components/AppSchutz.tsx | 113 +++++++++++++++++++++++ src/screens/EinstellungenScreen.tsx | 111 +++++++++++++++++++++- src/services/krypto.ts | 61 ++++++++++++ src/services/storage.ts | 66 +++++++++++-- src/store/useAppStore.ts | 12 ++- src/utils/__tests__/aufbewahrung.test.ts | 61 ++++++++++++ src/utils/__tests__/utf8.test.ts | 42 +++++++++ src/utils/aufbewahrung.ts | 27 ++++++ src/utils/utf8.ts | 33 +++++++ webseite/datenschutz.html | 8 +- 16 files changed, 639 insertions(+), 18 deletions(-) create mode 100644 rechtliches/datenschutz-factsheet.md create mode 100644 src/components/AppSchutz.tsx create mode 100644 src/services/krypto.ts create mode 100644 src/utils/__tests__/aufbewahrung.test.ts create mode 100644 src/utils/__tests__/utf8.test.ts create mode 100644 src/utils/aufbewahrung.ts create mode 100644 src/utils/utf8.ts diff --git a/App.tsx b/App.tsx index 2326c41..7f1f8ed 100644 --- a/App.tsx +++ b/App.tsx @@ -9,6 +9,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import { RootStackParamList } from './src/types'; import { useAppStore } from './src/store/useAppStore'; import { initialisiereBenachrichtigungen } from './src/services/erinnerungen'; +import { AppSchutz } from './src/components/AppSchutz'; import { farben, schrift } from './src/theme'; import { HomeScreen } from './src/screens/HomeScreen'; import { ConsentScreen } from './src/screens/ConsentScreen'; @@ -29,8 +30,9 @@ export default function App() { }, [initialisiere]); return ( - - + + + - - + + + ); } diff --git a/app.json b/app.json index 55a77c5..2de4093 100644 --- a/app.json +++ b/app.json @@ -41,6 +41,12 @@ ], "expo-notifications", "expo-status-bar", + [ + "expo-local-authentication", + { + "faceIDPermission": "BehördenKlar nutzt Face ID, um Ihre gescannten Briefe vor fremdem Zugriff zu schützen." + } + ], [ "expo-splash-screen", { diff --git a/package-lock.json b/package-lock.json index 4be01fc..7ee14c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,10 +14,12 @@ "expo": "^57.0.0", "expo-calendar": "~57.0.0", "expo-camera": "~57.0.1", + "expo-crypto": "~57.0.1", "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-manipulator": "~57.0.2", "expo-image-picker": "~57.0.2", + "expo-local-authentication": "~57.0.1", "expo-network": "~57.0.0", "expo-notifications": "~57.0.3", "expo-print": "~57.0.0", @@ -4224,6 +4226,15 @@ "react-native": "*" } }, + "node_modules/expo-crypto": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.1.tgz", + "integrity": "sha512-xwegXQw3ATgeL1ZuqbSNrGzOeG+zNeh6Z6DSJk825Qpa3TEQQ1kG3ioE1p3g/SNF373BAVz2iBKUTSytlIbBRA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-document-picker": { "version": "57.0.0", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-57.0.0.tgz", @@ -4276,6 +4287,18 @@ "expo": "*" } }, + "node_modules/expo-local-authentication": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-local-authentication/-/expo-local-authentication-57.0.1.tgz", + "integrity": "sha512-FrXtuXVBmXmwmvDus0OsFWMdzjYTaLd/ZvvVMKnRjXDKDS+LFG2Sz3r9f3Go0goJdpb/hA09wGwGi8XfW3Aa0w==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "57.0.5", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.5.tgz", diff --git a/package.json b/package.json index 0b8e310..795b148 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,12 @@ "expo": "^57.0.0", "expo-calendar": "~57.0.0", "expo-camera": "~57.0.1", + "expo-crypto": "~57.0.1", "expo-document-picker": "~57.0.0", "expo-file-system": "~57.0.0", "expo-image-manipulator": "~57.0.2", "expo-image-picker": "~57.0.2", + "expo-local-authentication": "~57.0.1", "expo-network": "~57.0.0", "expo-notifications": "~57.0.3", "expo-print": "~57.0.0", diff --git a/rechtliches/datenschutz-factsheet.md b/rechtliches/datenschutz-factsheet.md new file mode 100644 index 0000000..af35926 --- /dev/null +++ b/rechtliches/datenschutz-factsheet.md @@ -0,0 +1,74 @@ +# BehördenKlar — Datenschutz auf einen Blick + +**Für Beratungsstellen, Sozialverbände und Kooperationspartner** +Stand: 15.07.2026 · Kontakt: behoerdenbriefhelfer@gmail.com · behoerdenklar.pages.dev + +> BehördenKlar hilft Menschen, Behördenbriefe zu verstehen: Brief fotografieren, +> Erklärung in einfacher Sprache erhalten — mit Fristen, Checkliste und +> Antwort-Hilfe, auf Wunsch in 9 weiteren Sprachen. +> +> Weil Behördenbriefe hochsensible Daten enthalten, wurde die App von Grund +> auf datensparsam gebaut. Dieses Dokument erklärt transparent, was mit den +> Daten passiert — einschließlich der Punkte, die noch offen sind. + +--- + +## Das Wichtigste in einem Satz + +**Es gibt kein Nutzerkonto und keinen zentralen Datenspeicher — die Briefe +Ihrer Klientinnen und Klienten liegen verschlüsselt auf deren eigenem Handy, +nicht bei uns.** + +## Der Datenfluss beim Scannen (vollständig) + +1. **Foto entsteht auf dem Gerät** und wird vor dem Versand verkleinert. +2. **Übertragung TLS-verschlüsselt** an unseren Vermittlungsserver + (Cloudflare Workers). Dieser leitet nur weiter — **er speichert keine + Briefinhalte** (gespeichert wird ausschließlich ein anonymer Tageszähler + pro Gerät für 48 h, als Missbrauchsschutz). +3. **KI-Analyse bei Anthropic PBC (USA)**, Betreiber des Modells „Claude": + Verarbeitung nur zur Analyse, **Löschung nach spätestens 30 Tagen, keine + Nutzung zum KI-Training.** +4. **Ergebnis zurück aufs Gerät** — dort endet der Weg. Keine Kopie bei uns. + +## Schutzmaßnahmen in der App (implementiert und überprüfbar) + +| Maßnahme | Detail | +|---|---| +| **Lokale Verschlüsselung** | Das gesamte Brief-Archiv ist mit AES-256-GCM verschlüsselt; der Schlüssel liegt im Sicherheitsspeicher des Geräts (iOS Keychain / Android Keystore) und verlässt es nie | +| **App-Sperre** (optional) | Face ID / Fingerabdruck / Geräte-Code beim Öffnen der App | +| **Sichtschutz** | Im App-Umschalter wird der Inhalt verdeckt — keine Brief-Vorschau | +| **Auto-Löschen** (optional) | Briefe werden nach 30 oder 90 Tagen automatisch entfernt | +| **„Alles löschen"** | Ein Tipp entfernt sämtliche Daten inklusive Verschlüsselungs-Schlüssel | +| **Kein Konto** | Keine Registrierung, keine E-Mail, kein Passwort nötig | +| **Kein Tracking** | Keine Analyse-/Werbe-SDKs, keine Werbung, keine Datenweitergabe an Dritte | +| **Einwilligung zuerst** | Vor dem ersten Scan wird verständlich erklärt und aktiv zugestimmt (Art. 6 Abs. 1 lit. a, Art. 9 Abs. 2 lit. a DSGVO) | +| **Server-Härtung** | API-Zugang nur serverseitig, Anfrage-Limits pro Gerät und IP-Adresse | + +## Woran wir transparent erinnern (die ehrlichen Punkte) + +- **Verarbeitung in den USA:** Die KI-Analyse läuft bei Anthropic (USA), auf + Grundlage des EU-U.S. Data Privacy Framework bzw. EU-Standardvertragsklauseln. + Eine Verarbeitung in der EU ist beim Anbieter derzeit nicht verfügbar; wir + beobachten das und prüfen als Ausbaupfad den Betrieb über EU-Rechenzentren + (AWS Bedrock, Region Frankfurt). +- **30-Tage-Fenster:** Anthropic hält Anfragen bis zu 30 Tage zur + Missbrauchserkennung vor — danach automatische Löschung. Wir sagen das offen, + statt „Ihre Daten verlassen nie das Handy" zu behaupten. +- **Keine Rechtsberatung:** Die App erklärt Briefe, ersetzt aber keine + Beratung — sie ist als *Ergänzung* Ihrer Arbeit gedacht, nicht als Ersatz. +- **Dieses Dokument ist eine Selbstauskunft**, kein Zertifikat. Die + vollständige Datenschutzerklärung ist öffentlich: + https://behoerdenklar.pages.dev/datenschutz + +## Warum das für Ihre Beratungsarbeit interessant ist + +- Klientinnen und Klienten verstehen Briefe **vor** dem Termin — Ihre + Beratungszeit geht in die Lösung, nicht ins Vorlesen. +- 9 Übersetzungssprachen (u. a. Türkisch, Arabisch, Ukrainisch, Farsi) — + die deutschen Fachbegriffe bleiben sichtbar und wiedererkennbar. +- Große Schrift, große Tasten, einfache Sprache (A2/B1), Vorlese-Funktion — + gebaut für Menschen, für die normale Apps zu kompliziert sind. + +**Sprechen Sie uns an — wir zeigen die App gern persönlich und beantworten +jede Datenschutz-Rückfrage:** behoerdenbriefhelfer@gmail.com diff --git a/rechtliches/datenschutzerklaerung.md b/rechtliches/datenschutzerklaerung.md index 425f2ca..261c637 100644 --- a/rechtliches/datenschutzerklaerung.md +++ b/rechtliches/datenschutzerklaerung.md @@ -45,8 +45,11 @@ Einwilligung jederzeit widerrufen, indem Sie in den Einstellungen ### b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe -Alle Ergebnisse werden **ausschließlich lokal auf Ihrem Gerät** gespeichert. -Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe. +Alle Ergebnisse werden **ausschließlich lokal auf Ihrem Gerät** gespeichert — +zusätzlich verschlüsselt (AES-256-GCM; Schlüssel im Sicherheitsspeicher des +Geräts). Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer +Briefe. Optional: App-Sperre (Face ID / Geräte-Code) und automatisches +Löschen nach 30/90 Tagen in den Einstellungen. ### c) Anonyme Geräte-Kennung diff --git a/src/components/AppSchutz.tsx b/src/components/AppSchutz.tsx new file mode 100644 index 0000000..63a6c17 --- /dev/null +++ b/src/components/AppSchutz.tsx @@ -0,0 +1,113 @@ +/** + * Schützt den App-Inhalt auf zwei Ebenen: + * 1. App-Sperre (optional, Einstellungen): Beim Öffnen und nach jedem + * Wechsel in den Hintergrund muss das Gerät entsperrt werden + * (Face ID / Touch ID / Geräte-Code). + * 2. Sichtschutz (immer): Im App-Umschalter wird der Inhalt verdeckt, + * damit Briefe dort nicht als Vorschau lesbar sind. + * + * Der Inhalt bleibt gemountet (Navigation geht nicht verloren) — die + * Schutzschichten legen sich als Overlay darüber. + */ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, AppStateStatus, StyleSheet, Text, View } from 'react-native'; +import * as LocalAuthentication from 'expo-local-authentication'; +import { holeAppSperre } from '../services/storage'; +import { GrossButton } from './GrossButton'; +import { farben, schrift, abstand } from '../theme'; + +export function AppSchutz({ children }: { children: React.ReactNode }) { + const [entsperrt, setEntsperrt] = useState(false); + const [bereit, setBereit] = useState(false); + const [verdeckt, setVerdeckt] = useState(false); + const authLaeuft = useRef(false); + + const entsperren = useCallback(async () => { + if (authLaeuft.current) return; + authLaeuft.current = true; + try { + const ergebnis = await LocalAuthentication.authenticateAsync({ + promptMessage: 'BehördenKlar entsperren', + cancelLabel: 'Abbrechen', + }); + if (ergebnis.success) setEntsperrt(true); + } finally { + authLaeuft.current = false; + } + }, []); + + // Beim Start: Sperre-Einstellung prüfen und ggf. direkt entsperren lassen + useEffect(() => { + holeAppSperre().then((aktiv) => { + if (!aktiv) { + setEntsperrt(true); + } else { + entsperren(); + } + setBereit(true); + }); + }, [entsperren]); + + // Hintergrund-Wechsel: Sichtschutz an/aus, Sperre ggf. neu scharf stellen. + // Die Einstellung wird hier frisch gelesen, damit ein Umschalten in den + // Einstellungen sofort wirkt. + useEffect(() => { + const abo = AppState.addEventListener('change', (status: AppStateStatus) => { + if (status === 'active') { + setVerdeckt(false); + } else { + setVerdeckt(true); + if (status === 'background') { + holeAppSperre().then((aktiv) => { + if (aktiv) setEntsperrt(false); + }); + } + } + }); + return () => abo.remove(); + }, []); + + if (!bereit) return ; + + return ( + + {children} + {!entsperrt && ( + + 🔒 + BehördenKlar ist gesperrt + Ihre Briefe sind geschützt. + + + )} + {entsperrt && verdeckt && ( + + 📬 + + )} + + ); +} + +const styles = StyleSheet.create({ + deckschicht: { flex: 1, backgroundColor: farben.hintergrund }, + sperrschirm: { + justifyContent: 'center', + padding: abstand.xl, + gap: abstand.m, + }, + sichtschutz: { justifyContent: 'center', alignItems: 'center' }, + symbol: { fontSize: 56, textAlign: 'center' }, + titel: { + fontSize: schrift.titel, + fontWeight: '700', + color: farben.text, + textAlign: 'center', + }, + text: { + fontSize: schrift.basis, + color: farben.textSekundaer, + textAlign: 'center', + marginBottom: abstand.m, + }, +}); diff --git a/src/screens/EinstellungenScreen.tsx b/src/screens/EinstellungenScreen.tsx index a4b32ee..c4f1615 100644 --- a/src/screens/EinstellungenScreen.tsx +++ b/src/screens/EinstellungenScreen.tsx @@ -4,16 +4,30 @@ * im Produktionsmodus läuft der KI-Zugang über den Backend-Proxy. */ import React, { useEffect, useState } from 'react'; -import { Alert, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import { + Alert, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; import * as Notifications from 'expo-notifications'; +import * as LocalAuthentication from 'expo-local-authentication'; import { NativeStackScreenProps } from '@react-navigation/native-stack'; import { RootStackParamList } from '../types'; import { BONUS_ANALYSEN, holeApiKey, + holeAppSperre, + holeAutoLoeschTage, holeBonusAktiv, loeseBonusCodeEin, loescheAlleDaten, + setzeAppSperre, + setzeAutoLoeschTage, speichereApiKey, } from '../services/storage'; import { NUTZT_PROXY } from '../services/claudeClient'; @@ -30,12 +44,45 @@ export function EinstellungenScreen({ navigation }: Props) { const [gespeichert, setGespeichert] = useState(false); const [bonusCode, setBonusCode] = useState(''); const [bonusAktiv, setBonusAktiv] = useState(false); + const [sperreAktiv, setSperreAktiv] = useState(false); + const [autoTage, setAutoTage] = useState(0); useEffect(() => { holeApiKey().then((k) => setKeyVorhanden(!!k)); holeBonusAktiv().then(setBonusAktiv); + holeAppSperre().then(setSperreAktiv); + holeAutoLoeschTage().then(setAutoTage); }, []); + /** App-Sperre umschalten — beide Richtungen erfordern eine Authentifizierung. */ + const sperreUmschalten = async (an: boolean) => { + if (an) { + const stufe = await LocalAuthentication.getEnrolledLevelAsync(); + if (stufe === LocalAuthentication.SecurityLevel.NONE) { + Alert.alert( + 'Keine Geräte-Sperre eingerichtet', + 'Bitte richten Sie zuerst in den Geräte-Einstellungen einen Code, Face ID oder Fingerabdruck ein.' + ); + return; + } + } + const ergebnis = await LocalAuthentication.authenticateAsync({ + promptMessage: an ? 'App-Sperre aktivieren' : 'App-Sperre deaktivieren', + cancelLabel: 'Abbrechen', + }); + if (!ergebnis.success) return; + await setzeAppSperre(an); + setSperreAktiv(an); + }; + + /** Aufbewahrungsdauer setzen und sofort anwenden. */ + const autoTageSetzen = async (tage: number) => { + await setzeAutoLoeschTage(tage); + setAutoTage(tage); + // Sofort anwenden statt erst beim nächsten App-Start + await useAppStore.getState().initialisiere(); + }; + const codeEinloesen = async () => { const erfolg = await loeseBonusCodeEin(bonusCode); if (erfolg) { @@ -155,13 +202,57 @@ export function EinstellungenScreen({ navigation }: Props) { + Sicherheit + + + App-Sperre + + Face ID / Geräte-Code beim Öffnen der App — schützt Ihre Briefe, + falls jemand Ihr Handy in die Hand bekommt. + + + + + + + Briefe automatisch löschen + + + Alte Briefe werden dann von selbst vom Gerät entfernt. + + {[ + { tage: 0, label: 'Nie (Standard)' }, + { tage: 30, label: 'Nach 30 Tagen' }, + { tage: 90, label: 'Nach 90 Tagen' }, + ].map(({ tage, label }) => ( + autoTageSetzen(tage)} + accessibilityRole="radio" + accessibilityState={{ selected: autoTage === tage }} + > + + {autoTage === tage ? '● ' : '○ '} + {label} + + + ))} + + + Datenschutz • Brieffotos werden nur zur Analyse an den KI-Dienst Anthropic (USA) gesendet und dort nach spätestens 30 Tagen gelöscht. Sie werden nicht zum Training der KI verwendet.{'\n\n'} - • Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät.{'\n\n'} + • Alle Ergebnisse liegen ausschließlich auf Ihrem Gerät — verschlüsselt + gespeichert (AES-256).{'\n\n'} • Diese App ersetzt keine Rechtsberatung. @@ -202,4 +293,20 @@ const styles = StyleSheet.create({ }, karte: { backgroundColor: farben.flaeche, borderRadius: 14, padding: abstand.m }, trenner: { height: 1, backgroundColor: farben.rand, marginVertical: abstand.l }, + schalterZeile: { + flexDirection: 'row', + alignItems: 'center', + gap: abstand.m, + }, + option: { + borderWidth: 1, + borderColor: farben.rand, + borderRadius: 12, + padding: abstand.s, + minHeight: 56, + justifyContent: 'center', + marginTop: abstand.s, + }, + optionAktiv: { borderColor: farben.primaer, backgroundColor: farben.flaeche }, + optionAktivText: { fontWeight: '700', color: farben.primaer }, }); diff --git a/src/services/krypto.ts b/src/services/krypto.ts new file mode 100644 index 0000000..9273845 --- /dev/null +++ b/src/services/krypto.ts @@ -0,0 +1,61 @@ +/** + * Verschlüsselung des Brief-Archivs (AES-256-GCM, nativ via expo-crypto). + * + * Warum: Die analysierten Briefe enthalten hochsensible Daten (Sozialleistungen, + * Gesundheit, Finanzen). AsyncStorage ist unverschlüsselter Klartext auf der + * Platte — deshalb wird das Archiv vor dem Speichern verschlüsselt. + * + * Schlüsselverwaltung: Ein 256-Bit-Schlüssel wird beim ersten Bedarf erzeugt + * und im Secure Store des Geräts abgelegt (iOS Keychain / Android Keystore). + * Er verlässt das Gerät nie. GCM liefert zusätzlich Integritätsschutz — + * manipulierte Daten fallen beim Entschlüsseln auf. + */ +import { + AESEncryptionKey, + AESSealedData, + aesDecryptAsync, + aesEncryptAsync, +} from 'expo-crypto'; +import * as SecureStore from 'expo-secure-store'; +import { utf8Dekodieren, utf8Kodieren } from '../utils/utf8'; + +const KEY_ARCHIV_SCHLUESSEL = 'behoerdenklar_archiv_schluessel'; + +/** Im Speicher gehaltener Schlüssel — spart SecureStore-Zugriffe pro Aufruf. */ +let schluesselCache: AESEncryptionKey | null = null; + +async function holeSchluessel(): Promise { + if (schluesselCache) return schluesselCache; + const hex = await SecureStore.getItemAsync(KEY_ARCHIV_SCHLUESSEL); + if (hex) { + schluesselCache = await AESEncryptionKey.import(hex, 'hex'); + } else { + schluesselCache = await AESEncryptionKey.generate(); // 256 Bit (Default) + await SecureStore.setItemAsync( + KEY_ARCHIV_SCHLUESSEL, + await schluesselCache.encoded('hex') + ); + } + return schluesselCache; +} + +/** Verschlüsselt Text -> base64-String (IV + Ciphertext + GCM-Tag kombiniert). */ +export async function verschluesseleText(klartext: string): Promise { + const schluessel = await holeSchluessel(); + const versiegelt = await aesEncryptAsync(utf8Kodieren(klartext), schluessel); + return (await versiegelt.combined('base64')) as string; +} + +/** Entschlüsselt einen base64-String zurück zu Text. Wirft bei Manipulation. */ +export async function entschluesseleText(chiffrat: string): Promise { + const schluessel = await holeSchluessel(); + const versiegelt = AESSealedData.fromCombined(chiffrat); + const bytes = (await aesDecryptAsync(versiegelt, schluessel)) as Uint8Array; + return utf8Dekodieren(bytes); +} + +/** Für "Alle Daten löschen": Schlüssel unwiderruflich entfernen. */ +export async function loescheArchivSchluessel(): Promise { + schluesselCache = null; + await SecureStore.deleteItemAsync(KEY_ARCHIV_SCHLUESSEL); +} diff --git a/src/services/storage.ts b/src/services/storage.ts index 6cc3d15..c8d7510 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,15 +1,21 @@ /** * Lokale Speicherung. * - API-Key: expo-secure-store (verschlüsselt, nie im Klartext auf der Platte) - * - Brief-Archiv + Einwilligung: AsyncStorage (bleibt auf dem Gerät) + * - Brief-Archiv: AsyncStorage, aber AES-256-GCM-verschlüsselt (siehe krypto.ts) + * - Einwilligung & Einstellungen: AsyncStorage (unkritische Flags) * * Datenschutz: Alle Daten bleiben lokal. Nichts wird an Server gesendet, * außer dem Briefinhalt zur KI-Analyse (nach expliziter Einwilligung). - * loescheAlleDaten() entfernt restlos alles. + * loescheAlleDaten() entfernt restlos alles — inklusive Archiv-Schlüssel. */ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import { BriefEintrag } from '../types'; +import { + entschluesseleText, + loescheArchivSchluessel, + verschluesseleText, +} from './krypto'; const KEY_API = 'anthropic_api_key'; const KEY_BRIEFE = 'behoerdenklar_briefe'; @@ -112,21 +118,65 @@ export async function speichereConsent(): Promise { await AsyncStorage.setItem(KEY_CONSENT, 'ja'); } -// ---- Brief-Archiv ---- +// ---- Brief-Archiv (verschlüsselt) ---- export async function ladeBriefe(): Promise { const roh = await AsyncStorage.getItem(KEY_BRIEFE); if (!roh) return []; try { - return JSON.parse(roh) as BriefEintrag[]; + // Migration: Bestände aus Versionen vor der Verschlüsselung liegen als + // Klartext-JSON-Array vor ('['), base64-Chiffrate nie. Einmalig + // verschlüsselt neu schreiben. + if (roh.startsWith('[')) { + const briefe = JSON.parse(roh) as BriefEintrag[]; + await speichereBriefe(briefe); + return briefe; + } + return JSON.parse(await entschluesseleText(roh)) as BriefEintrag[]; } catch { - // Korrupte Daten nicht crashen lassen — leeres Archiv liefern + // Korrupte/unlesbare Daten nicht crashen lassen — leeres Archiv liefern return []; } } export async function speichereBriefe(briefe: BriefEintrag[]): Promise { - await AsyncStorage.setItem(KEY_BRIEFE, JSON.stringify(briefe)); + await AsyncStorage.setItem( + KEY_BRIEFE, + await verschluesseleText(JSON.stringify(briefe)) + ); +} + +// ---- Sicherheits-Einstellungen ---- + +const KEY_APP_SPERRE = 'behoerdenklar_app_sperre'; +const KEY_AUTO_LOESCHEN = 'behoerdenklar_auto_loeschen_tage'; + +/** Ist die App-Sperre (Face ID / Geräte-Code beim Öffnen) aktiviert? */ +export async function holeAppSperre(): Promise { + return (await AsyncStorage.getItem(KEY_APP_SPERRE)) === 'ja'; +} + +export async function setzeAppSperre(aktiv: boolean): Promise { + if (aktiv) { + await AsyncStorage.setItem(KEY_APP_SPERRE, 'ja'); + } else { + await AsyncStorage.removeItem(KEY_APP_SPERRE); + } +} + +/** Nach wie vielen Tagen Briefe automatisch gelöscht werden (0 = nie). */ +export async function holeAutoLoeschTage(): Promise { + const roh = await AsyncStorage.getItem(KEY_AUTO_LOESCHEN); + const tage = roh ? parseInt(roh, 10) : 0; + return Number.isFinite(tage) && tage > 0 ? tage : 0; +} + +export async function setzeAutoLoeschTage(tage: number): Promise { + if (tage > 0) { + await AsyncStorage.setItem(KEY_AUTO_LOESCHEN, String(tage)); + } else { + await AsyncStorage.removeItem(KEY_AUTO_LOESCHEN); + } } // ---- Alles löschen (Datenschutz-Anforderung) ---- @@ -141,6 +191,10 @@ export async function loescheAlleDaten(): Promise { KEY_GERAETE_ID, KEY_SCAN_ANZAHL, KEY_BONUS, + KEY_APP_SPERRE, + KEY_AUTO_LOESCHEN, ]); await SecureStore.deleteItemAsync(KEY_API); + // Archiv-Schlüssel zuletzt: danach wären Restdaten ohnehin unlesbar + await loescheArchivSchluessel(); } diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index 836ecc6..9d9bcbd 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -4,7 +4,8 @@ */ import { create } from 'zustand'; import { BriefEintrag, Uebersetzung, AntwortTyp } from '../types'; -import { ladeBriefe, speichereBriefe } from '../services/storage'; +import { holeAutoLoeschTage, ladeBriefe, speichereBriefe } from '../services/storage'; +import { filtereAlteBriefe } from '../utils/aufbewahrung'; interface AppState { briefe: BriefEintrag[]; @@ -28,7 +29,14 @@ export const useAppStore = create((set, get) => ({ geladen: false, initialisiere: async () => { - const briefe = await ladeBriefe(); + let briefe = await ladeBriefe(); + // Auto-Löschen (Datensparsamkeit): abgelaufene Briefe beim Start entfernen + const aufbewahrungTage = await holeAutoLoeschTage(); + const gefiltert = filtereAlteBriefe(briefe, aufbewahrungTage); + if (gefiltert.length !== briefe.length) { + briefe = gefiltert; + await speichereBriefe(briefe); + } set({ briefe, geladen: true }); }, diff --git a/src/utils/__tests__/aufbewahrung.test.ts b/src/utils/__tests__/aufbewahrung.test.ts new file mode 100644 index 0000000..c4bb998 --- /dev/null +++ b/src/utils/__tests__/aufbewahrung.test.ts @@ -0,0 +1,61 @@ +/** + * Tests fürs Auto-Löschen — löschen ist unumkehrbar, deshalb müssen die + * Grenzfälle sitzen (nichts zu früh löschen, "nie" respektieren). + */ +import { filtereAlteBriefe } from '../aufbewahrung'; +import { BriefEintrag } from '../../types'; + +const JETZT = new Date('2026-07-15T12:00:00Z').getTime(); +const TAG = 86400000; + +function brief(id: string, alterInTagen: number): BriefEintrag { + return { + id, + erstelltAm: new Date(JETZT - alterInTagen * TAG).toISOString(), + analyse: { + brieftyp: 'Test', + absender: 'Testamt', + kernaussage: '', + erklaerung_einfach: '', + fachbegriffe: [], + frist: null, + termin: null, + checkliste: [], + antwort_noetig: false, + antwort_optionen: [], + }, + uebersetzungen: {}, + }; +} + +describe('filtereAlteBriefe', () => { + it('0 Tage = nie löschen, alles bleibt', () => { + const briefe = [brief('a', 1), brief('b', 500)]; + expect(filtereAlteBriefe(briefe, 0, JETZT)).toHaveLength(2); + }); + + it('löscht Briefe, die älter als die Frist sind', () => { + const briefe = [brief('neu', 5), brief('alt', 40)]; + const ergebnis = filtereAlteBriefe(briefe, 30, JETZT); + expect(ergebnis.map((b) => b.id)).toEqual(['neu']); + }); + + it('behält Briefe exakt am Grenzwert (30 Tage alt bei 30-Tage-Frist)', () => { + const briefe = [brief('grenze', 30)]; + expect(filtereAlteBriefe(briefe, 30, JETZT)).toHaveLength(1); + }); + + it('löscht bei 90-Tage-Frist erst nach 90 Tagen', () => { + const briefe = [brief('a', 89), brief('b', 91)]; + expect(filtereAlteBriefe(briefe, 90, JETZT).map((b) => b.id)).toEqual(['a']); + }); + + it('behält Briefe mit kaputtem Datum (lieber zu viel als zu wenig)', () => { + const kaputt = { ...brief('kaputt', 0), erstelltAm: 'kein-datum' }; + expect(filtereAlteBriefe([kaputt], 30, JETZT)).toHaveLength(1); + }); + + it('leeres Archiv bleibt leer', () => { + expect(filtereAlteBriefe([], 30, JETZT)).toEqual([]); + }); +}); diff --git a/src/utils/__tests__/utf8.test.ts b/src/utils/__tests__/utf8.test.ts new file mode 100644 index 0000000..353dd91 --- /dev/null +++ b/src/utils/__tests__/utf8.test.ts @@ -0,0 +1,42 @@ +/** + * Tests für die UTF-8-Helfer — kritisch für die Archiv-Verschlüsselung: + * Ein Kodierungsfehler würde gespeicherte Briefe unlesbar machen. + */ +import { utf8Kodieren, utf8Dekodieren } from '../utf8'; + +describe('utf8Kodieren / utf8Dekodieren', () => { + const rundreise = (text: string) => utf8Dekodieren(utf8Kodieren(text)); + + it('einfaches ASCII', () => { + expect(rundreise('Hallo Welt 123')).toBe('Hallo Welt 123'); + }); + + it('deutsche Umlaute und ß', () => { + expect(rundreise('Bescheid über Bürgergeld gemäß § 32 — Änderung')).toBe( + 'Bescheid über Bürgergeld gemäß § 32 — Änderung' + ); + }); + + it('arabisch, ukrainisch, türkisch (Übersetzungssprachen)', () => { + const text = 'العربية · Українська · Türkçe İĞÜŞÖÇ'; + expect(rundreise(text)).toBe(text); + }); + + it('Emoji (Surrogate-Paare)', () => { + expect(rundreise('Ampel: 🔴🟡🟢 📬')).toBe('Ampel: 🔴🟡🟢 📬'); + }); + + it('JSON-Sonderzeichen', () => { + const json = JSON.stringify({ a: 'Zeile\nUmbruch "Anführung" \\ Slash' }); + expect(rundreise(json)).toBe(json); + }); + + it('leerer String', () => { + expect(rundreise('')).toBe(''); + }); + + it('kodiert Umlaute als Mehrbyte-Sequenzen (echtes UTF-8)', () => { + // "ü" ist in UTF-8 die Bytefolge 0xC3 0xBC + expect(Array.from(utf8Kodieren('ü'))).toEqual([0xc3, 0xbc]); + }); +}); diff --git a/src/utils/aufbewahrung.ts b/src/utils/aufbewahrung.ts new file mode 100644 index 0000000..d996d4b --- /dev/null +++ b/src/utils/aufbewahrung.ts @@ -0,0 +1,27 @@ +/** + * Auto-Löschen alter Briefe (Datensparsamkeit, DSGVO-Grundsatz). + * Reine Funktion — die Anwendung passiert beim App-Start im Store. + */ +import { BriefEintrag } from '../types'; + +/** + * Filtert Briefe heraus, die älter als die Aufbewahrungsdauer sind. + * + * @param briefe - das aktuelle Archiv + * @param aufbewahrungTage - 0 = nie löschen (alles behalten) + * @param jetztMs - aktueller Zeitpunkt (injizierbar für Tests) + */ +export function filtereAlteBriefe( + briefe: BriefEintrag[], + aufbewahrungTage: number, + jetztMs: number = Date.now() +): BriefEintrag[] { + if (aufbewahrungTage <= 0) return briefe; + const grenzeMs = jetztMs - aufbewahrungTage * 86400000; + return briefe.filter((brief) => { + const erstellt = new Date(brief.erstelltAm).getTime(); + // Unlesbare Datumswerte lieber behalten als versehentlich löschen + if (!Number.isFinite(erstellt)) return true; + return erstellt >= grenzeMs; + }); +} diff --git a/src/utils/utf8.ts b/src/utils/utf8.ts new file mode 100644 index 0000000..1ef5963 --- /dev/null +++ b/src/utils/utf8.ts @@ -0,0 +1,33 @@ +/** + * UTF-8-Kodierung ohne Plattform-Abhängigkeiten. + * + * Warum selbst gebaut: expo-crypto akzeptiert Klartext nur als Uint8Array + * (oder base64-String), und TextEncoder/TextDecoder sind in Hermes nicht + * zuverlässig über alle Versionen verfügbar. Der encodeURIComponent-Trick + * ist korrekt für alle Unicode-Zeichen (Umlaute, Arabisch, Emoji — + * inklusive Surrogate-Paaren) und in Jest testbar. + */ + +/** JavaScript-String -> UTF-8-Bytes. */ +export function utf8Kodieren(text: string): Uint8Array { + const prozentKodiert = encodeURIComponent(text); + const bytes: number[] = []; + for (let i = 0; i < prozentKodiert.length; i++) { + if (prozentKodiert[i] === '%') { + bytes.push(parseInt(prozentKodiert.slice(i + 1, i + 3), 16)); + i += 2; + } else { + bytes.push(prozentKodiert.charCodeAt(i)); + } + } + return new Uint8Array(bytes); +} + +/** UTF-8-Bytes -> JavaScript-String. Wirft bei ungültigem UTF-8 (URIError). */ +export function utf8Dekodieren(bytes: Uint8Array): string { + let prozentKodiert = ''; + for (const byte of bytes) { + prozentKodiert += '%' + byte.toString(16).padStart(2, '0'); + } + return decodeURIComponent(prozentKodiert); +} diff --git a/webseite/datenschutz.html b/webseite/datenschutz.html index c99edfa..e3bec15 100644 --- a/webseite/datenschutz.html +++ b/webseite/datenschutz.html @@ -76,8 +76,12 @@

b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe

- Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert. - Wir betreiben keine Nutzerkonten und keine Cloud-Speicherung Ihrer Briefe. + Alle Ergebnisse werden ausschließlich lokal auf Ihrem Gerät gespeichert — + zusätzlich verschlüsselt (AES-256-GCM; der Schlüssel liegt im + Sicherheitsspeicher Ihres Geräts). Wir betreiben keine Nutzerkonten und + keine Cloud-Speicherung Ihrer Briefe. Optional können Sie in den + Einstellungen eine App-Sperre (Face ID / Geräte-Code) aktivieren und + Briefe nach 30 oder 90 Tagen automatisch löschen lassen.

c) Anonyme Geräte-Kennung