focus-flow/packages/scheduler_backup/lib/src/encrypted_backup.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';
}