glp-companion/lib/core/security/crypto_manager.dart

102 lines
4.3 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Platform-hardened vault for the SQLCipher AES-256 master key.
///
/// Every target stores the key in its native secret store with safe
/// defaults:
///
/// | Platform | Backend | Hardening |
/// |-----------|----------------------------------|----------------------------------------|
/// | Android | Jetpack Crypto `EncryptedSharedPreferences` | `encryptedSharedPreferences: true` |
/// | iOS | Keychain | `KeychainAccessibility.first_unlock` |
/// | macOS | Keychain | `KeychainAccessibility.first_unlock` |
/// | Windows | DPAPI-encrypted JSON file (`CryptProtectData`) | plugin backend default |
/// | Linux | libsecret / Secret Service | plugin backend default |
/// | Web | Browser-scoped obfuscated store | never used for the SQLCipher key |
///
/// `first_unlock` (rather than `when_unlocked`) lets background services
/// (health sync, dose reminders) open the encrypted database after a reboot
/// without requiring the device to be unlocked again.
///
/// When the platform vault is unavailable — headless Linux without a Secret
/// Service, locked-down CI runners, exotic embedders — the manager degrades
/// to an ephemeral in-memory key instead of crashing: the database stays
/// encrypted for the current process run and re-keys (re-syncs) on the next
/// launch.
class CryptoManager {
static const _databaseKeyName = 'glp1_sqlcipher_master_key';
/// Platform-safe vault configuration (see the class documentation).
static const FlutterSecureStorage _platformVault = FlutterSecureStorage(
// Android: AES-256 EncryptedSharedPreferences (API 23+).
aOptions: AndroidOptions(encryptedSharedPreferences: true),
// iOS: Keychain readable after the first post-boot unlock.
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
// macOS: same Keychain accessibility contract as iOS.
mOptions: MacOsOptions(accessibility: KeychainAccessibility.first_unlock),
// Windows: DPAPI-sealed file managed by the plugin's FFI backend.
wOptions: WindowsOptions.defaultOptions,
// Linux: libsecret (Secret Service / GNOME Keyring) backend.
lOptions: LinuxOptions.defaultOptions,
);
CryptoManager({FlutterSecureStorage? secureStorage})
: _secureStorage = secureStorage ?? _platformVault;
final FlutterSecureStorage _secureStorage;
/// Session-scoped replacement key used only when the platform vault is
/// unavailable (see the class documentation).
String? _ephemeralKey;
/// Returns the persistent SQLCipher master key, generating and vaulting it
/// on first run. Never throws: a vault failure falls back to an ephemeral
/// in-memory key (database usable until the process exits).
Future<String> getOrCreateDatabaseEncryptionKey() async {
final existingKey =
await _tryOrNull(() => _secureStorage.read(key: _databaseKeyName));
if (existingKey != null && existingKey.isNotEmpty) {
return existingKey;
}
final newKey = _generateKey();
final stored = await _tryOrNull(() async {
await _secureStorage.write(key: _databaseKeyName, value: newKey);
return true;
});
if (stored == true) return newKey;
debugPrintStack(
label: 'Secure vault unavailable - using an ephemeral in-memory '
'SQLCipher key for this session (data re-keys on next launch).',
);
return _ephemeralKey ??= newKey;
}
/// 256-bit cryptographically random key, base64url-encoded for safe
/// embedding in `PRAGMA key`.
static String _generateKey() {
final random = Random.secure();
final values = List<int>.generate(32, (i) => random.nextInt(256));
return base64UrlEncode(values);
}
/// Runs a vault operation, returning `null` (instead of throwing) on any
/// platform failure so callers can degrade gracefully.
Future<T?> _tryOrNull<T>(Future<T> Function() operation) async {
try {
return await operation();
} catch (error, stackTrace) {
debugPrintStack(
stackTrace: stackTrace,
label: 'Secure vault access failed (non-fatal): $error',
);
return null;
}
}
}