Datenschutz-Ausbau: Archiv-Verschlüsselung, App-Sperre, Auto-Löschen

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 15:30:19 +02:00
parent 12ebf514fb
commit ad1c2bf76c
16 changed files with 639 additions and 18 deletions

View File

@@ -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,6 +30,7 @@ export default function App() {
}, [initialisiere]);
return (
<AppSchutz>
<NavigationContainer>
<StatusBar style="dark" />
<Stack.Navigator
@@ -49,5 +51,6 @@ export default function App() {
<Stack.Screen name="Einstellungen" component={EinstellungenScreen} options={{ title: 'Einstellungen' }} />
</Stack.Navigator>
</NavigationContainer>
</AppSchutz>
);
}

View File

@@ -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",
{

23
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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 <View style={styles.deckschicht} />;
return (
<View style={{ flex: 1 }}>
{children}
{!entsperrt && (
<View style={[StyleSheet.absoluteFill, styles.deckschicht, styles.sperrschirm]}>
<Text style={styles.symbol}>🔒</Text>
<Text style={styles.titel}>BehördenKlar ist gesperrt</Text>
<Text style={styles.text}>Ihre Briefe sind geschützt.</Text>
<GrossButton titel="Entsperren" onPress={entsperren} />
</View>
)}
{entsperrt && verdeckt && (
<View style={[StyleSheet.absoluteFill, styles.deckschicht, styles.sichtschutz]}>
<Text style={styles.symbol}>📬</Text>
</View>
)}
</View>
);
}
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,
},
});

View File

@@ -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) {
<View style={styles.trenner} />
<Text style={styles.abschnittTitel}>Sicherheit</Text>
<View style={styles.schalterZeile}>
<View style={{ flex: 1 }}>
<Text style={styles.text}>App-Sperre</Text>
<Text style={styles.hinweis}>
Face ID / Geräte-Code beim Öffnen der App schützt Ihre Briefe,
falls jemand Ihr Handy in die Hand bekommt.
</Text>
</View>
<Switch
value={sperreAktiv}
onValueChange={sperreUmschalten}
accessibilityLabel="App-Sperre umschalten"
/>
</View>
<Text style={[styles.text, { marginTop: abstand.m, fontWeight: '700' }]}>
Briefe automatisch löschen
</Text>
<Text style={styles.hinweis}>
Alte Briefe werden dann von selbst vom Gerät entfernt.
</Text>
{[
{ tage: 0, label: 'Nie (Standard)' },
{ tage: 30, label: 'Nach 30 Tagen' },
{ tage: 90, label: 'Nach 90 Tagen' },
].map(({ tage, label }) => (
<TouchableOpacity
key={tage}
style={[styles.option, autoTage === tage && styles.optionAktiv]}
onPress={() => autoTageSetzen(tage)}
accessibilityRole="radio"
accessibilityState={{ selected: autoTage === tage }}
>
<Text style={[styles.text, autoTage === tage && styles.optionAktivText]}>
{autoTage === tage ? '● ' : '○ '}
{label}
</Text>
</TouchableOpacity>
))}
<View style={styles.trenner} />
<Text style={styles.abschnittTitel}>Datenschutz</Text>
<View style={styles.karte}>
<Text style={styles.text}>
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.
</Text>
</View>
@@ -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 },
});

61
src/services/krypto.ts Normal file
View File

@@ -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<AESEncryptionKey> {
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<string> {
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<string> {
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<void> {
schluesselCache = null;
await SecureStore.deleteItemAsync(KEY_ARCHIV_SCHLUESSEL);
}

View File

@@ -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<void> {
await AsyncStorage.setItem(KEY_CONSENT, 'ja');
}
// ---- Brief-Archiv ----
// ---- Brief-Archiv (verschlüsselt) ----
export async function ladeBriefe(): Promise<BriefEintrag[]> {
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<void> {
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<boolean> {
return (await AsyncStorage.getItem(KEY_APP_SPERRE)) === 'ja';
}
export async function setzeAppSperre(aktiv: boolean): Promise<void> {
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<number> {
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<void> {
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<void> {
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();
}

View File

@@ -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<AppState>((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 });
},

View File

@@ -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([]);
});
});

View File

@@ -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]);
});
});

27
src/utils/aufbewahrung.ts Normal file
View File

@@ -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;
});
}

33
src/utils/utf8.ts Normal file
View File

@@ -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);
}

View File

@@ -76,8 +76,12 @@
<h3>b) Analyse-Ergebnisse, Übersetzungen, Antwort-Entwürfe</h3>
<p>
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.
</p>
<h3>c) Anonyme Geräte-Kennung</h3>