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

162 lines
7.0 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// Application-wide secure vault (SQLCipher master key + private integration
/// credentials). Kept in `core` so both the database bootstrap and feature
/// controllers share one instance configuration.
final cryptoManagerProvider = Provider<CryptoManager>((ref) => CryptoManager());
/// 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';
// ---------------------------------------------------------------------------
// Private recipe provider keys (Phase 4B — Mealie self-hosted instance)
// ---------------------------------------------------------------------------
/// Vault key for the Mealie server base URL (architecture §4.9.2: the
/// pairing never lands in plain `SharedPreferences`).
static const String mealieBaseUrlKey = 'mealie_base_url';
/// Vault key for the Mealie API bearer token (Android Keystore / Apple
/// Keychain / Windows DPAPI — see the class documentation table).
static const String mealieApiTokenKey = 'mealie_api_token';
/// 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;
}
// ---------------------------------------------------------------------------
// Private recipe provider credentials (Phase 4B — Mealie)
// ---------------------------------------------------------------------------
/// Persists the Mealie pairing (server URL + API bearer token) in the
/// platform vault. Returns `false` when the vault rejected the write so the
/// caller can surface a storage failure instead of pretending to be saved.
Future<bool> writeMealieCredentials({
required String baseUrl,
required String apiToken,
}) async {
final stored = await _tryOrNull(() async {
await _secureStorage.write(key: mealieBaseUrlKey, value: baseUrl);
await _secureStorage.write(key: mealieApiTokenKey, value: apiToken);
return true;
});
return stored == true;
}
/// Reads the stored Mealie pairing, or `null` when the integration has not
/// been configured (or the vault is unavailable).
Future<({String baseUrl, String apiToken})?> readMealieCredentials() async {
final values = await _tryOrNull(() async {
final baseUrl = await _secureStorage.read(key: mealieBaseUrlKey);
final apiToken = await _secureStorage.read(key: mealieApiTokenKey);
return (baseUrl: baseUrl, apiToken: apiToken);
});
final baseUrl = values?.baseUrl?.trim() ?? '';
final apiToken = values?.apiToken?.trim() ?? '';
if (baseUrl.isEmpty || apiToken.isEmpty) return null;
return (baseUrl: baseUrl, apiToken: apiToken);
}
/// Removes the stored Mealie pairing (Settings → Disconnect). Never throws.
Future<void> deleteMealieCredentials() async {
await _tryOrNull(() async {
await _secureStorage.delete(key: mealieBaseUrlKey);
await _secureStorage.delete(key: mealieApiTokenKey);
return true;
});
}
/// 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;
}
}
}