forked from eva/focus-flow
262 lines
7.8 KiB
Dart
262 lines
7.8 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:cryptography/cryptography.dart';
|
|
|
|
/// Default scheduler data directory name under the user's home directory.
|
|
const defaultSchedulerDirectoryName = 'ADHD_Scheduler';
|
|
|
|
/// Default SQLite database file name used by local scheduler persistence.
|
|
const defaultSchedulerDatabaseFileName = 'scheduler.sqlite';
|
|
|
|
/// Default encrypted backup directory name under the scheduler data directory.
|
|
const defaultSchedulerBackupDirectoryName = 'backups';
|
|
|
|
const _formatMagic = 'ADHD_SCHEDULER_BACKUP_V1';
|
|
const _fileExtension = '.db.enc';
|
|
const _kdfIterations = 200000;
|
|
const _saltLength = 16;
|
|
const _nonceLength = 12;
|
|
const _keyLength = 32;
|
|
|
|
final _utf8 = utf8.encoder;
|
|
|
|
/// Thrown when an encrypted backup cannot be decrypted with the passphrase.
|
|
final class BackupDecryptionException implements Exception {
|
|
/// Creates a decryption failure with optional diagnostic [message].
|
|
const BackupDecryptionException([
|
|
this.message = 'Backup could not be decrypted.',
|
|
]);
|
|
|
|
/// Diagnostic text for logs and tests.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'BackupDecryptionException: $message';
|
|
}
|
|
|
|
/// Thrown when backup input paths or file contents are invalid.
|
|
final class BackupException implements Exception {
|
|
/// Creates a backup exception with diagnostic [message].
|
|
const BackupException(this.message);
|
|
|
|
/// Diagnostic text for logs and tests.
|
|
final String message;
|
|
|
|
@override
|
|
String toString() => 'BackupException: $message';
|
|
}
|
|
|
|
/// Create an encrypted backup of the scheduler SQLite file.
|
|
///
|
|
/// Defaults:
|
|
/// - Source DB: `~/ADHD_Scheduler/scheduler.sqlite`
|
|
/// - Backup dir: `~/ADHD_Scheduler/backups`
|
|
/// - File name: `yyyymmdd_hhmm.db.enc`
|
|
Future<File> createEncryptedBackup({
|
|
required String passphrase,
|
|
File? sqliteFile,
|
|
Directory? backupDirectory,
|
|
DateTime? now,
|
|
}) async {
|
|
final source = sqliteFile ?? defaultSchedulerSqliteFile();
|
|
if (!await source.exists()) {
|
|
throw BackupException('SQLite file does not exist: ${source.path}');
|
|
}
|
|
|
|
final destinationDirectory =
|
|
backupDirectory ?? defaultSchedulerBackupDirectory();
|
|
await destinationDirectory.create(recursive: true);
|
|
|
|
final timestamp = _backupTimestamp(now ?? DateTime.now());
|
|
final destination = await _nextAvailableBackupFile(
|
|
destinationDirectory,
|
|
'$timestamp$_fileExtension',
|
|
);
|
|
|
|
final plaintext = await source.readAsBytes();
|
|
final encoded = await _encryptBytes(
|
|
passphrase: passphrase,
|
|
plaintext: plaintext,
|
|
);
|
|
return destination.writeAsBytes(encoded, flush: true);
|
|
}
|
|
|
|
/// Restore an encrypted backup over the scheduler SQLite file.
|
|
///
|
|
/// Defaults to restoring into `~/ADHD_Scheduler/scheduler.sqlite`. Tests and
|
|
/// tools can pass [targetFile] to restore into a temp or alternate database.
|
|
Future<void> restoreEncryptedBackup(
|
|
File backup,
|
|
String passphrase, {
|
|
File? targetFile,
|
|
}) async {
|
|
if (!await backup.exists()) {
|
|
throw BackupException('Backup file does not exist: ${backup.path}');
|
|
}
|
|
|
|
final plaintext = await _decryptBytes(
|
|
passphrase: passphrase,
|
|
encoded: await backup.readAsBytes(),
|
|
);
|
|
final target = targetFile ?? defaultSchedulerSqliteFile();
|
|
await target.parent.create(recursive: true);
|
|
|
|
final temporary = File('${target.path}.restore.tmp');
|
|
await temporary.writeAsBytes(plaintext, flush: true);
|
|
if (await target.exists()) {
|
|
await target.delete();
|
|
}
|
|
await temporary.rename(target.path);
|
|
}
|
|
|
|
/// Default scheduler SQLite database path.
|
|
File defaultSchedulerSqliteFile() {
|
|
return File(_joinPath(
|
|
_homeDirectory().path,
|
|
defaultSchedulerDirectoryName,
|
|
defaultSchedulerDatabaseFileName,
|
|
));
|
|
}
|
|
|
|
/// Default encrypted backup output directory.
|
|
Directory defaultSchedulerBackupDirectory() {
|
|
return Directory(_joinPath(
|
|
_homeDirectory().path,
|
|
defaultSchedulerDirectoryName,
|
|
defaultSchedulerBackupDirectoryName,
|
|
));
|
|
}
|
|
|
|
Future<List<int>> _encryptBytes({
|
|
required String passphrase,
|
|
required List<int> plaintext,
|
|
}) async {
|
|
final salt = _randomBytes(_saltLength);
|
|
final nonce = _randomBytes(_nonceLength);
|
|
final key = await _deriveKey(passphrase: passphrase, salt: salt);
|
|
final algorithm = AesGcm.with256bits();
|
|
final secretBox = await algorithm.encrypt(
|
|
plaintext,
|
|
secretKey: key,
|
|
nonce: nonce,
|
|
);
|
|
final envelope = <String, Object?>{
|
|
'magic': _formatMagic,
|
|
'kdf': 'pbkdf2-hmac-sha256',
|
|
'iterations': _kdfIterations,
|
|
'cipher': 'aes-256-gcm',
|
|
'salt': base64Encode(salt),
|
|
'nonce': base64Encode(secretBox.nonce),
|
|
'ciphertext': base64Encode(secretBox.cipherText),
|
|
'mac': base64Encode(secretBox.mac.bytes),
|
|
};
|
|
return _utf8.convert(jsonEncode(envelope));
|
|
}
|
|
|
|
Future<List<int>> _decryptBytes({
|
|
required String passphrase,
|
|
required List<int> encoded,
|
|
}) async {
|
|
try {
|
|
final envelope = jsonDecode(utf8.decode(encoded)) as Map<String, Object?>;
|
|
if (envelope['magic'] != _formatMagic ||
|
|
envelope['kdf'] != 'pbkdf2-hmac-sha256' ||
|
|
envelope['iterations'] != _kdfIterations ||
|
|
envelope['cipher'] != 'aes-256-gcm') {
|
|
throw const BackupDecryptionException('Unsupported backup format.');
|
|
}
|
|
|
|
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 key = await _deriveKey(passphrase: passphrase, salt: salt);
|
|
final algorithm = AesGcm.with256bits();
|
|
return await algorithm.decrypt(
|
|
SecretBox(cipherText, nonce: nonce, mac: mac),
|
|
secretKey: key,
|
|
);
|
|
} on BackupDecryptionException {
|
|
rethrow;
|
|
} on Object {
|
|
throw const BackupDecryptionException();
|
|
}
|
|
}
|
|
|
|
Future<SecretKey> _deriveKey({
|
|
required String passphrase,
|
|
required List<int> salt,
|
|
}) {
|
|
final algorithm = Pbkdf2(
|
|
macAlgorithm: Hmac.sha256(),
|
|
iterations: _kdfIterations,
|
|
bits: _keyLength * 8,
|
|
);
|
|
return algorithm.deriveKey(
|
|
secretKey: SecretKey(utf8.encode(passphrase)),
|
|
nonce: salt,
|
|
);
|
|
}
|
|
|
|
List<int> _randomBytes(int length) {
|
|
final random = Random.secure();
|
|
return Uint8List.fromList(
|
|
List<int>.generate(length, (_) => random.nextInt(256)),
|
|
);
|
|
}
|
|
|
|
Future<File> _nextAvailableBackupFile(Directory directory, String name) async {
|
|
final preferred = File(_joinPath(directory.path, name));
|
|
if (!await preferred.exists()) {
|
|
return preferred;
|
|
}
|
|
final dot = name.lastIndexOf('.');
|
|
final stem = dot == -1 ? name : name.substring(0, dot);
|
|
final extension = dot == -1 ? '' : name.substring(dot);
|
|
var counter = 2;
|
|
while (true) {
|
|
final candidate =
|
|
File(_joinPath(directory.path, '$stem-$counter$extension'));
|
|
if (!await candidate.exists()) {
|
|
return candidate;
|
|
}
|
|
counter += 1;
|
|
}
|
|
}
|
|
|
|
String _backupTimestamp(DateTime value) {
|
|
final local = value.toLocal();
|
|
return '${_four(local.year)}${_two(local.month)}${_two(local.day)}_'
|
|
'${_two(local.hour)}${_two(local.minute)}';
|
|
}
|
|
|
|
String _four(int value) => value.toString().padLeft(4, '0');
|
|
|
|
String _two(int value) => value.toString().padLeft(2, '0');
|
|
|
|
Directory _homeDirectory() {
|
|
final home = Platform.environment['HOME'] ??
|
|
Platform.environment['USERPROFILE'] ??
|
|
Platform.environment['HOMEDRIVE'];
|
|
if (home == null || home.trim().isEmpty) {
|
|
throw const BackupException('Home directory could not be resolved.');
|
|
}
|
|
return Directory(home);
|
|
}
|
|
|
|
String _joinPath(String first, String second, [String? third]) {
|
|
final separator = Platform.pathSeparator;
|
|
final left = first.endsWith(separator)
|
|
? first.substring(0, first.length - separator.length)
|
|
: first;
|
|
final middle = second.startsWith(separator) ? second.substring(1) : second;
|
|
final joined = '$left$separator$middle';
|
|
if (third == null) {
|
|
return joined;
|
|
}
|
|
final right = third.startsWith(separator) ? third.substring(1) : third;
|
|
return '$joined$separator$right';
|
|
}
|