Files
OpenParcelBox/mobile-app/app/lib/backup_service.dart
T
2026-07-16 17:36:28 +02:00

109 lines
3.4 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
import 'package:file_picker/file_picker.dart';
import 'models.dart';
class BackupService {
static const _format = 'openparcelbox-backup-v1';
final Random _random = Random.secure();
Future<bool> exportBoxes(
List<SavedBox> boxes,
String password, {
String? initialDirectory,
}) async {
if (password.isEmpty) {
throw ArgumentError('A backup password is required.');
}
final salt = _randomBytes(16);
final nonce = _randomBytes(12);
final key = await _deriveKey(password, salt);
final clearText = utf8.encode(
jsonEncode(<String, Object?>{
'format': _format,
'boxes': boxes.map((box) => box.toJson()).toList(),
}),
);
final secretBox = await AesGcm.with256bits().encrypt(
clearText,
secretKey: key,
nonce: nonce,
);
final envelope = utf8.encode(
jsonEncode(<String, Object?>{
'format': _format,
'salt': base64Encode(salt),
'nonce': base64Encode(secretBox.nonce),
'ciphertext': base64Encode(secretBox.cipherText),
'mac': base64Encode(secretBox.mac.bytes),
}),
);
final path = await FilePicker.saveFile(
dialogTitle: 'OpenParcelBox backup',
fileName: 'openparcelbox-backup.opb',
initialDirectory: initialDirectory,
type: FileType.custom,
allowedExtensions: const <String>['opb'],
bytes: Uint8List.fromList(envelope),
);
return path != null;
}
Future<Uint8List?> pickBackupFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: const <String>['opb'],
withData: true,
);
return result?.files.single.bytes;
}
Future<List<SavedBox>> importBoxes(
Uint8List bytes,
String password,
) async {
if (password.isEmpty) {
throw ArgumentError('A backup password is required.');
}
final envelope = jsonDecode(utf8.decode(bytes));
if (envelope is! Map<String, Object?> || envelope['format'] != _format) {
throw const FormatException('Unsupported OpenParcelBox backup.');
}
final salt = base64Decode(envelope['salt']! as String);
final nonce = base64Decode(envelope['nonce']! as String);
final cipherText = base64Decode(envelope['ciphertext']! as String);
final mac = Mac(base64Decode(envelope['mac']! as String));
final clearText = await AesGcm.with256bits().decrypt(
SecretBox(cipherText, nonce: nonce, mac: mac),
secretKey: await _deriveKey(password, salt),
);
final decoded = jsonDecode(utf8.decode(clearText));
if (decoded is! Map<String, Object?> || decoded['format'] != _format) {
throw const FormatException('Invalid OpenParcelBox backup.');
}
final boxes = decoded['boxes'];
if (boxes is! List<Object?>) {
throw const FormatException('The backup does not contain boxes.');
}
return boxes
.whereType<Map<String, dynamic>>()
.map((item) => SavedBox.fromJson(item.cast<String, Object?>()))
.toList();
}
Future<SecretKey> _deriveKey(String password, List<int> salt) {
return Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: 210000,
bits: 256,
).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt);
}
Uint8List _randomBytes(int length) =>
Uint8List.fromList(List<int>.generate(length, (_) => _random.nextInt(256)));
}