109 lines
3.4 KiB
Dart
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)));
|
|
}
|