162 lines
7.0 KiB
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;
|
|
}
|
|
}
|
|
}
|