generated from knso/webklar-preview-template
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:
3
App.tsx
3
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,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>
|
||||
);
|
||||
}
|
||||
|
||||
6
app.json
6
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",
|
||||
{
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
74
rechtliches/datenschutz-factsheet.md
Normal file
74
rechtliches/datenschutz-factsheet.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
113
src/components/AppSchutz.tsx
Normal file
113
src/components/AppSchutz.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -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
61
src/services/krypto.ts
Normal 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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
|
||||
61
src/utils/__tests__/aufbewahrung.test.ts
Normal file
61
src/utils/__tests__/aufbewahrung.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
42
src/utils/__tests__/utf8.test.ts
Normal file
42
src/utils/__tests__/utf8.test.ts
Normal 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
27
src/utils/aufbewahrung.ts
Normal 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
33
src/utils/utf8.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user