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 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 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> _encryptBytes({ required String passphrase, required List 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 = { '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> _decryptBytes({ required String passphrase, required List encoded, }) async { try { final envelope = jsonDecode(utf8.decode(encoded)) as Map; 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 _deriveKey({ required String passphrase, required List salt, }) { final algorithm = Pbkdf2( macAlgorithm: Hmac.sha256(), iterations: _kdfIterations, bits: _keyLength * 8, ); return algorithm.deriveKey( secretKey: SecretKey(utf8.encode(passphrase)), nonce: salt, ); } List _randomBytes(int length) { final random = Random.secure(); return Uint8List.fromList( List.generate(length, (_) => random.nextInt(256)), ); } Future _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'; }