Skip to content

Commit 8670cce

Browse files
sprobst76claude
andcommitted
fix(security): S1 encrypted backup, S2 mounted guard, P1 holidays out of build
- S1: AES-256-GCM + PBKDF2-SHA256 passphrase backup (200k iter), .enc envelope, password dialog with confirm field; auto-detect on restore - S2: if (!mounted) return; guard at start of _syncPendingEvents() - P1: _loadHolidays() moved out of build() — initState postFrameCallback + ref.listen - Lint: remove unused headerStyle in pdf_export_service.dart Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ec1f8b8 commit 8670cce

File tree

7 files changed

+260
-7
lines changed

7 files changed

+260
-7
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,16 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
1414

1515
---
1616

17+
## [0.1.0-beta.70] - 2026-03-22
18+
19+
### Sicherheit & Fixes
20+
- **S1 — Verschlüsseltes Backup**: Neues "Verschlüsseltes Backup"-Feature mit AES-256-GCM + PBKDF2-SHA256 (200.000 Iterationen); Passwort-Dialog mit Bestätigungsfeld; `.enc`-Format mit JSON-Envelope (salt, nonce, mac, ciphertext)
21+
- **S2 — mounted-Guard**: `_syncPendingEvents()` in `home_screen.dart` prüft nun `if (!mounted) return;` vor async-Operationen (verhindert Fehler nach Widget-Dispose)
22+
- **P1 — _loadHolidays aus build()**: Initialer Holiday-Load in `initState` via `addPostFrameCallback`; Bundesland-Änderungen über `ref.listen` — kein async-Aufruf mehr direkt in `build()`
23+
- **Lint**: Ungenutzten `headerStyle` in `pdf_export_service.dart` entfernt
24+
25+
---
26+
1727
## [0.1.0-beta.69] - 2026-03-22
1828

1929
### Tests

lib/screens/home_screen.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,7 @@ class _HomeState extends ConsumerState<HomeScreen> with WidgetsBindingObserver {
678678
}
679679

680680
Future<void> _syncPendingEvents() async {
681+
if (!mounted) return;
681682
final processedCount = await _syncService.syncPendingEvents();
682683
if (processedCount > 0) {
683684
ref.invalidate(workListProvider);

lib/screens/report_screen.dart

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
4141
void initState() {
4242
super.initState();
4343
_tabController = TabController(length: 4, vsync: this);
44+
WidgetsBinding.instance.addPostFrameCallback((_) {
45+
final settings = ref.read(settingsProvider);
46+
_loadHolidays(settings.bundesland);
47+
});
4448
}
4549

4650
@override
@@ -317,9 +321,11 @@ class _ReportScreenState extends ConsumerState<ReportScreen> with SingleTickerPr
317321
final periodsNotifier = ref.watch(weeklyHoursPeriodsProvider.notifier);
318322

319323
// Lade Feiertage wenn sich das Bundesland ändert
320-
if (_loadedBundesland != settings.bundesland) {
321-
_loadHolidays(settings.bundesland);
322-
}
324+
ref.listen(settingsProvider, (prev, next) {
325+
if (prev?.bundesland != next.bundesland) {
326+
_loadHolidays(next.bundesland);
327+
}
328+
});
323329

324330
return Scaffold(
325331
appBar: AppBar(

lib/screens/settings_screen.dart

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,15 @@ class SettingsScreen extends ConsumerWidget {
14861486
],
14871487
),
14881488
const SizedBox(height: 8),
1489+
SizedBox(
1490+
width: double.infinity,
1491+
child: OutlinedButton.icon(
1492+
onPressed: () => _createEncryptedBackup(context),
1493+
icon: const Icon(Icons.lock_outline),
1494+
label: const Text('Verschlüsseltes Backup'),
1495+
),
1496+
),
1497+
const SizedBox(height: 8),
14891498
Container(
14901499
padding: const EdgeInsets.all(8),
14911500
decoration: BoxDecoration(
@@ -1590,6 +1599,84 @@ class SettingsScreen extends ConsumerWidget {
15901599
}
15911600
}
15921601

1602+
Future<void> _createEncryptedBackup(BuildContext context) async {
1603+
final controller = TextEditingController();
1604+
final confirmController = TextEditingController();
1605+
final passphrase = await showDialog<String>(
1606+
context: context,
1607+
builder: (ctx) => AlertDialog(
1608+
title: const Text('Verschlüsseltes Backup'),
1609+
content: Column(
1610+
mainAxisSize: MainAxisSize.min,
1611+
children: [
1612+
const Text('Backup wird mit einem Passwort verschlüsselt (AES-256-GCM). '
1613+
'Bewahre das Passwort sicher auf — ohne es kann das Backup nicht wiederhergestellt werden.'),
1614+
const SizedBox(height: 16),
1615+
TextField(
1616+
controller: controller,
1617+
obscureText: true,
1618+
decoration: const InputDecoration(
1619+
labelText: 'Passwort',
1620+
border: OutlineInputBorder(),
1621+
),
1622+
),
1623+
const SizedBox(height: 8),
1624+
TextField(
1625+
controller: confirmController,
1626+
obscureText: true,
1627+
decoration: const InputDecoration(
1628+
labelText: 'Passwort bestätigen',
1629+
border: OutlineInputBorder(),
1630+
),
1631+
),
1632+
],
1633+
),
1634+
actions: [
1635+
TextButton(
1636+
onPressed: () => Navigator.pop(ctx),
1637+
child: const Text('Abbrechen'),
1638+
),
1639+
TextButton(
1640+
onPressed: () {
1641+
if (controller.text.isEmpty) return;
1642+
if (controller.text != confirmController.text) {
1643+
ScaffoldMessenger.of(ctx).showSnackBar(
1644+
const SnackBar(content: Text('Passwörter stimmen nicht überein')),
1645+
);
1646+
return;
1647+
}
1648+
Navigator.pop(ctx, controller.text);
1649+
},
1650+
child: const Text('Erstellen'),
1651+
),
1652+
],
1653+
),
1654+
);
1655+
if (passphrase == null || !context.mounted) return;
1656+
1657+
showDialog(
1658+
context: context,
1659+
barrierDismissible: false,
1660+
builder: (_) => const Center(child: CircularProgressIndicator()),
1661+
);
1662+
try {
1663+
final backupService = BackupService();
1664+
await backupService.shareEncryptedBackup(passphrase);
1665+
if (context.mounted) Navigator.pop(context);
1666+
} catch (e) {
1667+
if (context.mounted) {
1668+
Navigator.pop(context);
1669+
ScaffoldMessenger.of(context).showSnackBar(
1670+
SnackBar(
1671+
content: Text('Verschlüsseltes Backup fehlgeschlagen: $e'),
1672+
backgroundColor: Colors.red,
1673+
behavior: SnackBarBehavior.floating,
1674+
),
1675+
);
1676+
}
1677+
}
1678+
}
1679+
15931680
void _showImportInfo(BuildContext context, WidgetRef ref) {
15941681
showDialog(
15951682
context: context,

lib/services/backup_service.dart

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import 'dart:convert';
22
import 'dart:io';
3+
import 'dart:math';
4+
import 'dart:typed_data';
35
import 'package:archive/archive.dart';
6+
import 'package:cryptography/cryptography.dart';
47
import 'package:hive/hive.dart';
58
import 'package:path_provider/path_provider.dart';
69
import 'package:share_plus/share_plus.dart';
@@ -89,6 +92,154 @@ class BackupService {
8992
);
9093
}
9194

95+
/// Erstellt ein passphrase-verschlüsseltes Backup (AES-256-GCM + PBKDF2-SHA256)
96+
Future<File> createEncryptedBackup(String passphrase) async {
97+
final archive = Archive();
98+
99+
final metadata = {
100+
'version': backupVersion,
101+
'createdAt': DateTime.now().toIso8601String(),
102+
'appVersion': '0.1.0',
103+
};
104+
archive.addFile(_createJsonFile('metadata.json', metadata));
105+
106+
final workBox = Hive.box<WorkEntry>('work');
107+
archive.addFile(_createJsonFile('work_entries.json',
108+
workBox.values.map(_workEntryToJson).toList()));
109+
110+
final vacationBox = Hive.box<Vacation>('vacations');
111+
archive.addFile(_createJsonFile('vacations.json',
112+
vacationBox.values.map(_vacationToJson).toList()));
113+
114+
final quotaBox = Hive.box<VacationQuota>('vacation_quotas');
115+
archive.addFile(_createJsonFile('vacation_quotas.json',
116+
quotaBox.values.map(_quotaToJson).toList()));
117+
118+
final settingsBox = Hive.box<Settings>('settings');
119+
if (settingsBox.isNotEmpty) {
120+
archive.addFile(_createJsonFile('settings.json',
121+
_settingsToJson(settingsBox.getAt(0)!)));
122+
}
123+
124+
final projectBox = Hive.box<Project>('projects');
125+
archive.addFile(_createJsonFile('projects.json',
126+
projectBox.values.map(_projectToJson).toList()));
127+
128+
final periodsBox = Hive.box<WeeklyHoursPeriod>('weekly_hours_periods');
129+
archive.addFile(_createJsonFile('weekly_hours_periods.json',
130+
periodsBox.values.map(_periodToJson).toList()));
131+
132+
final zonesBox = Hive.box<GeofenceZone>('geofence_zones');
133+
archive.addFile(_createJsonFile('geofence_zones.json',
134+
zonesBox.values.map(_zoneToJson).toList()));
135+
136+
final zipData = ZipEncoder().encode(archive);
137+
if (zipData == null) throw Exception('Failed to create ZIP archive');
138+
139+
// PBKDF2-SHA256 key derivation
140+
final rng = Random.secure();
141+
final salt = Uint8List.fromList(List.generate(16, (_) => rng.nextInt(256)));
142+
final nonce = Uint8List.fromList(List.generate(12, (_) => rng.nextInt(256)));
143+
144+
const kdfIterations = 200000;
145+
final pbkdf2 = Pbkdf2(
146+
macAlgorithm: Hmac.sha256(),
147+
iterations: kdfIterations,
148+
bits: 256,
149+
);
150+
final secretKey = await pbkdf2.deriveKey(
151+
secretKey: SecretKey(utf8.encode(passphrase)),
152+
nonce: salt,
153+
);
154+
155+
// AES-256-GCM encryption
156+
final algorithm = AesGcm.with256bits();
157+
final secretBox = await algorithm.encrypt(
158+
zipData,
159+
secretKey: secretKey,
160+
nonce: nonce,
161+
);
162+
163+
// JSON envelope
164+
final envelope = jsonEncode({
165+
'v': 1,
166+
'alg': 'AES-256-GCM',
167+
'kdf': 'PBKDF2-SHA256',
168+
'iter': kdfIterations,
169+
'salt': base64Encode(salt),
170+
'nonce': base64Encode(secretBox.nonce),
171+
'mac': base64Encode(secretBox.mac.bytes),
172+
'data': base64Encode(secretBox.cipherText),
173+
});
174+
175+
final dir = await getApplicationDocumentsDirectory();
176+
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first;
177+
final file = File('${dir.path}/vibedtracker_backup_$timestamp.enc');
178+
await file.writeAsString(envelope);
179+
return file;
180+
}
181+
182+
/// Teilt das verschlüsselte Backup über Share-Dialog
183+
Future<void> shareEncryptedBackup(String passphrase) async {
184+
final file = await createEncryptedBackup(passphrase);
185+
await Share.shareXFiles(
186+
[XFile(file.path)],
187+
subject: 'VibedTracker Backup (verschlüsselt)',
188+
text: 'VibedTracker Daten-Backup (verschlüsselt)',
189+
);
190+
}
191+
192+
/// Stellt Daten aus einem verschlüsselten Backup wieder her
193+
Future<BackupRestoreResult> restoreFromEncryptedFile(
194+
File encFile, String passphrase) async {
195+
try {
196+
final envelope = jsonDecode(await encFile.readAsString()) as Map<String, dynamic>;
197+
198+
if (envelope['v'] != 1 || envelope['alg'] != 'AES-256-GCM') {
199+
return BackupRestoreResult(
200+
success: false, error: 'Unbekanntes Backup-Format');
201+
}
202+
203+
final salt = base64Decode(envelope['salt'] as String);
204+
final nonce = base64Decode(envelope['nonce'] as String);
205+
final mac = base64Decode(envelope['mac'] as String);
206+
final cipherText = base64Decode(envelope['data'] as String);
207+
final iterations = (envelope['iter'] as int?) ?? 200000;
208+
209+
final pbkdf2 = Pbkdf2(
210+
macAlgorithm: Hmac.sha256(),
211+
iterations: iterations,
212+
bits: 256,
213+
);
214+
final secretKey = await pbkdf2.deriveKey(
215+
secretKey: SecretKey(utf8.encode(passphrase)),
216+
nonce: salt,
217+
);
218+
219+
final algorithm = AesGcm.with256bits();
220+
final List<int> zipData;
221+
try {
222+
zipData = await algorithm.decrypt(
223+
SecretBox(cipherText, nonce: nonce, mac: Mac(mac)),
224+
secretKey: secretKey,
225+
);
226+
} on SecretBoxAuthenticationError {
227+
return BackupRestoreResult(
228+
success: false, error: 'Falsches Passwort oder beschädigtes Backup');
229+
}
230+
231+
// Temporäre ZIP-Datei schreiben und restore aufrufen
232+
final dir = await getApplicationDocumentsDirectory();
233+
final tmpFile = File('${dir.path}/_restore_tmp.zip');
234+
await tmpFile.writeAsBytes(zipData);
235+
final result = await restoreFromFile(tmpFile);
236+
await tmpFile.delete();
237+
return result;
238+
} catch (e) {
239+
return BackupRestoreResult(success: false, error: e.toString());
240+
}
241+
}
242+
92243
/// Stellt Daten aus einem Backup wieder her
93244
Future<BackupRestoreResult> restoreFromFile(File zipFile) async {
94245
try {

lib/services/pdf_export_service.dart

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ class PdfExportService {
145145
// ── Tabelle ──────────────────────────────────────────────────────────────────
146146

147147
pw.Widget _buildTable(List<WorkEntry> sorted, List<Project> projects) {
148-
const headerStyle = pw.TextStyle(fontSize: 9, color: PdfColors.white);
149148
const cellStyle = pw.TextStyle(fontSize: 9);
150149
const headerBg = PdfColors.blueGrey700;
151150

@@ -163,8 +162,7 @@ class PdfExportService {
163162
return pw.TableHelper.fromTextArray(
164163
columnWidths: {for (var i = 0; i < widths.length; i++) i: widths[i]},
165164
headers: headers,
166-
headerStyle: pw.TextStyle(
167-
fontSize: 9, fontWeight: pw.FontWeight.bold, color: PdfColors.white),
165+
headerStyle: const pw.TextStyle(fontSize: 9, color: PdfColors.white),
168166
headerDecoration: const pw.BoxDecoration(color: headerBg),
169167
cellStyle: cellStyle,
170168
cellAlignments: {

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: time_tracker
22
description: Zeiterfassung mit Geofence, Urlaub, Feiertagen & ICS-Sync
3-
version: 0.1.0-beta.69+69
3+
version: 0.1.0-beta.70+70
44
publish_to: 'none'
55

66
environment:

0 commit comments

Comments
 (0)