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((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 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 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 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.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 _tryOrNull(Future Function() operation) async { try { return await operation(); } catch (error, stackTrace) { debugPrintStack( stackTrace: stackTrace, label: 'Secure vault access failed (non-fatal): $error', ); return null; } } }