102 lines
4.3 KiB
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;
|
|
}
|
|
}
|
|
}
|