From c1ba8bb985dccdd43b14e951d51a41b4b7badce9 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Sun, 22 Mar 2026 13:34:26 +0800 Subject: [PATCH] fix(runtime): encrypt local settings and assistant thread persistence --- lib/app/app_controller_desktop.dart | 43 +- lib/runtime/runtime_controllers.dart | 15 + lib/runtime/secure_config_store.dart | 829 +++++++++++++++++--- test/runtime/secure_config_store_suite.dart | 371 +++++++++ 4 files changed, 1163 insertions(+), 95 deletions(-) diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 1de43c35..56bd3eec 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -161,6 +161,7 @@ class AppController extends ChangeNotifier { String? _bootstrapError; StreamSubscription? _runtimeEventsSubscription; bool _disposed = false; + Future _assistantThreadPersistQueue = Future.value(); WorkspaceDestination get destination => _destination; UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest; @@ -1356,6 +1357,7 @@ class AppController extends ChangeNotifier { thinking: thinking, attachments: attachments, ); + await _flushAssistantThreadPersistence(); _recomputeTasks(); return; } @@ -1442,6 +1444,7 @@ class AppController extends ChangeNotifier { messageViewMode: mode, updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); + await _flushAssistantThreadPersistence(); _recomputeTasks(); _notifyIfActive(); } @@ -1812,6 +1815,9 @@ class AppController extends ChangeNotifier { SettingsSnapshot snapshot, { bool refreshAfterSave = true, }) async { + if (_disposed) { + return; + } final current = settings; final sanitized = _sanitizeFeatureFlagSettings( _sanitizeMultiAgentSettings( @@ -1820,19 +1826,31 @@ class AppController extends ChangeNotifier { ); setActiveAppLanguage(sanitized.appLanguage); await _settingsController.saveSnapshot(sanitized); + if (_disposed) { + return; + } _multiAgentOrchestrator.updateConfig(sanitized.multiAgent); _agentsController.restoreSelection(sanitized.gateway.selectedAgentId); _modelsController.restoreFromSettings(sanitized.aiGateway); + if (_disposed) { + return; + } if (current.codexCliPath != sanitized.codexCliPath || current.codeAgentRuntimeMode != sanitized.codeAgentRuntimeMode) { _registerCodexExternalProvider(codexPath: sanitized.codexCliPath); await _refreshCodexCliAvailability(); + if (_disposed) { + return; + } } if (current.linuxDesktop.toJson().toString() != sanitized.linuxDesktop.toJson().toString() || current.launchAtLogin != sanitized.launchAtLogin) { await _desktopPlatformService.syncConfig(sanitized.linuxDesktop); await _desktopPlatformService.setLaunchAtLogin(sanitized.launchAtLogin); + if (_disposed) { + return; + } } if (refreshAfterSave) { _recomputeTasks(); @@ -1842,7 +1860,9 @@ class AppController extends ChangeNotifier { } Future clearAssistantLocalState() async { + await _flushAssistantThreadPersistence(); await _store.clearAssistantLocalState(); + _assistantThreadPersistQueue = Future.value(); final defaults = SettingsSnapshot.defaults(); _assistantThreadRecords.clear(); _assistantThreadMessages.clear(); @@ -2753,6 +2773,10 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } + Future _flushAssistantThreadPersistence() async { + await _assistantThreadPersistQueue.catchError((_) {}); + } + void _appendLocalSessionMessage( String sessionKey, GatewayChatMessage message, @@ -3053,11 +3077,17 @@ class AppController extends ChangeNotifier { _assistantThreadMessages[normalizedSessionKey] = List.from(messages); } - unawaited( - _store.saveAssistantThreadRecords( - _assistantThreadRecords.values.toList(growable: false), - ), - ); + final snapshot = _assistantThreadRecords.values.toList(growable: false); + final nextPersist = _assistantThreadPersistQueue.catchError((_) {}).then(( + _, + ) async { + if (_disposed) { + return; + } + await _store.saveAssistantThreadRecords(snapshot); + }); + _assistantThreadPersistQueue = nextPersist; + unawaited(nextPersist); } Future _setCurrentAssistantSessionKey( @@ -3075,6 +3105,9 @@ class AppController extends ChangeNotifier { } Future _persistAssistantLastSessionKey(String sessionKey) async { + if (_disposed) { + return; + } final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey); if (normalizedSessionKey.isEmpty || settings.assistantLastSessionKey == normalizedSessionKey) { diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index ed4160b2..7852aa4a 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -12,6 +12,7 @@ class SettingsController extends ChangeNotifier { SettingsController(this._store); final SecureConfigStore _store; + bool _disposed = false; SettingsSnapshot _snapshot = SettingsSnapshot.defaults(); Map _secureRefs = const {}; @@ -27,6 +28,20 @@ class SettingsController extends ChangeNotifier { String get vaultStatus => _vaultStatus; String get aiGatewayStatus => _aiGatewayStatus; + @override + void notifyListeners() { + if (_disposed) { + return; + } + super.notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + Future initialize() async { _snapshot = await _store.loadSettingsSnapshot(); await _reloadDerivedState(); diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index ccb8af75..c9b1e366 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import '../app/app_metadata.dart'; +import 'package:cryptography/cryptography.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -13,9 +16,13 @@ class SecureConfigStore { SecureConfigStore({ Future Function()? fallbackDirectoryPathResolver, Future Function()? databasePathResolver, + SecureConfigDatabaseOpener? databaseOpener, + SecureStorageClient? secureStorage, bool enableSecureStorage = true, }) : _fallbackDirectoryPathResolver = fallbackDirectoryPathResolver, _databasePathResolver = databasePathResolver, + _databaseOpener = databaseOpener, + _secureStorageOverride = secureStorage, _enableSecureStorage = enableSecureStorage; static const _settingsKey = 'xworkmate.settings.snapshot'; @@ -24,8 +31,12 @@ class SecureConfigStore { static const _databaseFileName = 'config-store.sqlite3'; static const _databaseTableName = 'config_entries'; static const _stateBackupFileName = 'assistant-state-backup.json'; - static const _backupSchemaVersion = 1; - static const _secureStorageTimeout = Duration(milliseconds: 400); + static const _backupSchemaVersion = 2; + static const _secureStorageTimeout = Duration(seconds: 5); + static const _localStateKeyKey = 'xworkmate.local_state.key'; + static const _sealedStateFormat = 'xworkmate.sealed.local-state.v1'; + static const _assistantStateBackupStorageKey = + 'xworkmate.assistant.state.backup'; static const _gatewayTokenKey = 'xworkmate.gateway.token'; static const _gatewayPasswordKey = 'xworkmate.gateway.password'; @@ -41,13 +52,31 @@ class SecureConfigStore { SharedPreferences? _prefs; sqlite.Database? _database; - FlutterSecureStorage? _secureStorage; + SecureStorageClient? _secureStorage; final Map _memoryStore = {}; final Map _memorySecure = {}; final Future Function()? _fallbackDirectoryPathResolver; final Future Function()? _databasePathResolver; + final SecureConfigDatabaseOpener? _databaseOpener; + final SecureStorageClient? _secureStorageOverride; final bool _enableSecureStorage; bool _initialized = false; + final Cipher _localStateCipher = AesGcm.with256bits(); + final Random _random = Random.secure(); + Future _localStateWriteQueue = Future.value(); + + static const Map _durableStateFileNames = { + _settingsKey: 'settings-snapshot.json', + _assistantThreadsKey: 'assistant-threads.json', + }; + + static const Map _secureFallbackFileNames = { + _gatewayTokenKey: 'gateway-token.txt', + _gatewayPasswordKey: 'gateway-password.txt', + _ollamaCloudApiKeyKey: 'ollama-cloud-api-key.txt', + _vaultTokenKey: 'vault-token.txt', + _aiGatewayApiKeyKey: 'ai-gateway-api-key.txt', + }; Future initialize() async { if (_initialized) { @@ -58,14 +87,22 @@ class SecureConfigStore { } catch (_) { _prefs = null; } - await _initializeDatabase(); if (_enableSecureStorage) { - try { - _secureStorage = const FlutterSecureStorage(); - } catch (_) { - _secureStorage = null; + if (_secureStorageOverride != null) { + _secureStorage = _secureStorageOverride; + } else if (_useDebugSecureStorageFallback()) { + _secureStorage = _buildDebugSecureStorageClient(); + } else { + try { + _secureStorage = FlutterSecureStorageClient( + const FlutterSecureStorage(), + ); + } catch (_) { + _secureStorage = null; + } } } + await _initializeDatabase(); _initialized = true; } @@ -76,9 +113,13 @@ class SecureConfigStore { } Future saveSettingsSnapshot(SettingsSnapshot snapshot) async { - await initialize(); - await _writeStoredString(_settingsKey, snapshot.toJsonString()); - await _persistAssistantStateBackup(settings: snapshot); + await _enqueueLocalStateWrite(() async { + await initialize(); + final encoded = snapshot.toJsonString(); + await _writeStoredString(_settingsKey, encoded); + await _writeDurableStateFile(_settingsKey, encoded); + await _persistAssistantStateBackup(settings: snapshot); + }); } Future> loadAssistantThreadRecords() async { @@ -90,19 +131,26 @@ class SecureConfigStore { Future saveAssistantThreadRecords( List records, ) async { - await initialize(); - await _writeStoredString( - _assistantThreadsKey, - jsonEncode(records.map((item) => item.toJson()).toList(growable: false)), - ); - await _persistAssistantStateBackup(assistantThreads: records); + await _enqueueLocalStateWrite(() async { + await initialize(); + final encoded = jsonEncode( + records.map((item) => item.toJson()).toList(growable: false), + ); + await _writeStoredString(_assistantThreadsKey, encoded); + await _writeDurableStateFile(_assistantThreadsKey, encoded); + await _persistAssistantStateBackup(assistantThreads: records); + }); } Future clearAssistantLocalState() async { - await initialize(); - await _deleteStoredString(_settingsKey); - await _deleteStoredString(_assistantThreadsKey); - await _deleteAssistantStateBackup(); + await _enqueueLocalStateWrite(() async { + await initialize(); + await _deleteStoredString(_settingsKey); + await _deleteStoredString(_assistantThreadsKey); + await _deleteDurableStateFile(_settingsKey); + await _deleteDurableStateFile(_assistantThreadsKey); + await _deleteAssistantStateBackup(); + }); } Future> loadAuditTrail() async { @@ -286,11 +334,7 @@ class SecureConfigStore { final resolvedPath = await _resolveDatabasePath(); if (resolvedPath != null && resolvedPath.trim().isNotEmpty) { try { - final file = File(resolvedPath); - await file.parent.create(recursive: true); - final database = sqlite.sqlite3.open(file.path); - _configureDatabase(database); - _database = database; + _database = await _openDatabase(resolvedPath); } catch (_) { _database = null; } @@ -307,6 +351,21 @@ class SecureConfigStore { await _migrateLegacyPrefs(); } + Future _openDatabase(String resolvedPath) async { + if (_databaseOpener != null) { + final database = await _databaseOpener(resolvedPath); + if (database != null) { + _configureDatabase(database); + } + return database; + } + final file = File(resolvedPath); + await file.parent.create(recursive: true); + final database = sqlite.sqlite3.open(file.path); + _configureDatabase(database); + return database; + } + void _configureDatabase(sqlite.Database database) { database.execute(''' CREATE TABLE IF NOT EXISTS $_databaseTableName ( @@ -331,18 +390,21 @@ class SecureConfigStore { return; } try { - final existing = _database!.select( - 'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1', - [key], - ); - if (existing.isNotEmpty) { - return; - } final legacyValue = _prefs!.getString(key); if (legacyValue == null || legacyValue.trim().isEmpty) { return; } - _writeStoredStringInternal(key, legacyValue); + final existing = _database!.select( + 'SELECT value FROM $_databaseTableName WHERE storage_key = ? LIMIT 1', + [key], + ); + if (existing.isEmpty) { + await _writeStoredString(key, legacyValue); + if (_durableStateFileNames.containsKey(key)) { + await _writeDurableStateFile(key, legacyValue); + } + } + await _prefs!.remove(key); } catch (_) { return; } @@ -372,6 +434,13 @@ class SecureConfigStore { } Future _readStoredString(String key) async { + final memoryValue = _memoryStore[key]; + if (memoryValue != null) { + final restored = await _restorePersistedValue(key, memoryValue); + if (restored != null) { + return restored; + } + } if (_database != null) { try { final result = _database!.select( @@ -381,14 +450,21 @@ class SecureConfigStore { if (result.isNotEmpty) { final value = result.first['value']; if (value is String) { - return value; + final restored = await _restorePersistedValue(key, value); + if (restored != null) { + return restored; + } } } } catch (_) { - // Fall through to the in-memory fallback. + // Fall through to durable and in-memory fallback. } } - return _memoryStore[key]; + final durableValue = await _readDurableStateFile(key); + if (durableValue != null) { + return durableValue; + } + return null; } Future _deleteStoredString(String key) async { @@ -403,6 +479,7 @@ class SecureConfigStore { } } _memoryStore.remove(key); + await _deleteDurableStateFile(key); try { await _prefs?.remove(key); } catch (_) { @@ -411,53 +488,87 @@ class SecureConfigStore { } Future _writeStoredString(String key, String value) async { + final persistedValue = await _preparePersistedValue(key, value); + if (persistedValue == null) { + return; + } + _memoryStore[key] = persistedValue; if (_database != null) { try { - _writeStoredStringInternal(key, value); + _writeStoredStringInternal(key, persistedValue); return; } catch (_) { - // Fall through to the in-memory fallback. + // Fall through to durable and in-memory fallback. } } - _memoryStore[key] = value; + await _writeDurableStateFile(key, value); } Future<_AssistantStateSnapshot?> _loadAssistantStateFromPrimaryOrBackup() async { final rawSettings = await _readStoredString(_settingsKey); final rawThreads = await _readStoredString(_assistantThreadsKey); + final rawSettingsSealed = _isSealedLocalState(rawSettings); + final rawThreadsSealed = _isSealedLocalState(rawThreads); final decodedSettings = _decodeSettingsSnapshot(rawSettings); final decodedThreads = _decodeAssistantThreadRecords(rawThreads); - final primaryHasSettings = rawSettings != null; - final primaryHasThreads = rawThreads != null; - final primaryValid = - decodedSettings != null && - decodedThreads != null && - primaryHasSettings && - primaryHasThreads; - if (primaryValid) { - return _AssistantStateSnapshot( - settings: decodedSettings, - assistantThreads: decodedThreads, - ); - } - final backup = await _readAssistantStateBackup(); - if (backup == null) { - return _AssistantStateSnapshot( - settings: decodedSettings ?? SettingsSnapshot.defaults(), - assistantThreads: decodedThreads ?? const [], - ); - } - await _writeStoredString(_settingsKey, backup.settings.toJsonString()); - await _writeStoredString( - _assistantThreadsKey, - jsonEncode( - backup.assistantThreads - .map((item) => item.toJson()) - .toList(growable: false), - ), + final backupRead = await _readAssistantStateBackup(); + final backup = backupRead?.snapshot; + final backupWasSealed = backupRead?.sealed ?? false; + final resolvedSettings = + decodedSettings ?? backup?.settings ?? SettingsSnapshot.defaults(); + final resolvedThreads = + decodedThreads ?? + backup?.assistantThreads ?? + const []; + final defaultSettings = SettingsSnapshot.defaults(); + final encodedSettings = resolvedSettings.toJsonString(); + final defaultEncodedSettings = defaultSettings.toJsonString(); + final encodedThreads = jsonEncode( + resolvedThreads.map((item) => item.toJson()).toList(growable: false), + ); + final hasMeaningfulState = + rawSettings != null || + rawThreads != null || + backup != null || + encodedSettings != defaultEncodedSettings || + resolvedThreads.isNotEmpty; + + if (hasMeaningfulState && + (rawSettings == null || + !rawSettingsSealed || + decodedSettings == null)) { + await _writeStoredString(_settingsKey, encodedSettings); + } + if (hasMeaningfulState && + (rawThreads == null || !rawThreadsSealed || decodedThreads == null)) { + await _writeStoredString(_assistantThreadsKey, encodedThreads); + } + if (hasMeaningfulState) { + await _writeDurableStateFile(_settingsKey, encodedSettings); + await _writeDurableStateFile(_assistantThreadsKey, encodedThreads); + } + + if (hasMeaningfulState && + (backup == null || + !backupWasSealed || + jsonEncode(backup.settings.toJson()) != + jsonEncode(resolvedSettings.toJson()) || + jsonEncode( + backup.assistantThreads + .map((item) => item.toJson()) + .toList(growable: false), + ) != + encodedThreads)) { + await _persistAssistantStateBackup( + settings: resolvedSettings, + assistantThreads: resolvedThreads, + ); + } + return _AssistantStateSnapshot( + settings: resolvedSettings, + assistantThreads: resolvedThreads, ); - return backup; } SettingsSnapshot? _decodeSettingsSnapshot(String? raw) { @@ -506,15 +617,22 @@ class SecureConfigStore { if (file == null) { return; } + final plaintext = jsonEncode({ + 'settings': payload.settings.toJson(), + 'assistantThreads': payload.assistantThreads + .map((item) => item.toJson()) + .toList(growable: false), + }); + final sealedPayload = await _sealLocalState( + _assistantStateBackupStorageKey, + plaintext, + ); await file.writeAsString( jsonEncode({ 'schemaVersion': _backupSchemaVersion, 'appVersion': kAppVersion, 'backupCreatedAtMs': DateTime.now().millisecondsSinceEpoch, - 'settings': payload.settings.toJson(), - 'assistantThreads': payload.assistantThreads - .map((item) => item.toJson()) - .toList(growable: false), + 'sealedState': sealedPayload, }), flush: true, ); @@ -523,7 +641,7 @@ class SecureConfigStore { } } - Future<_AssistantStateSnapshot?> _readAssistantStateBackup() async { + Future<_AssistantStateBackupReadResult?> _readAssistantStateBackup() async { try { final file = await _assistantStateBackupFile(); if (file == null || !await file.exists()) { @@ -531,6 +649,34 @@ class SecureConfigStore { } final decoded = jsonDecode(await file.readAsString()) as Map; + final sealedState = decoded['sealedState']; + if (sealedState is String && sealedState.trim().isNotEmpty) { + final plaintext = await _restoreLocalState( + _assistantStateBackupStorageKey, + sealedState, + ); + if (plaintext == null || plaintext.trim().isEmpty) { + return null; + } + final payload = jsonDecode(plaintext) as Map; + final settings = SettingsSnapshot.fromJson( + (payload['settings'] as Map?)?.cast() ?? const {}, + ); + final threads = ((payload['assistantThreads'] as List?) ?? const []) + .whereType() + .map( + (item) => + AssistantThreadRecord.fromJson(item.cast()), + ) + .toList(growable: false); + return _AssistantStateBackupReadResult( + snapshot: _AssistantStateSnapshot( + settings: settings, + assistantThreads: threads, + ), + sealed: true, + ); + } final settings = SettingsSnapshot.fromJson( (decoded['settings'] as Map?)?.cast() ?? const {}, ); @@ -541,9 +687,12 @@ class SecureConfigStore { AssistantThreadRecord.fromJson(item.cast()), ) .toList(growable: false); - return _AssistantStateSnapshot( - settings: settings, - assistantThreads: threads, + return _AssistantStateBackupReadResult( + snapshot: _AssistantStateSnapshot( + settings: settings, + assistantThreads: threads, + ), + sealed: false, ); } catch (_) { return null; @@ -566,6 +715,70 @@ class SecureConfigStore { } } + Future _durableStateFile(String key) async { + final fileName = _durableStateFileNames[key]; + if (fileName == null) { + return null; + } + try { + final resolvedPath = await _resolveDatabasePath(); + if (resolvedPath == null || resolvedPath.trim().isEmpty) { + return null; + } + final directory = File(resolvedPath).parent; + if (!await directory.exists()) { + await directory.create(recursive: true); + } + return File('${directory.path}/$fileName'); + } catch (_) { + return null; + } + } + + Future _readDurableStateFile(String key) async { + try { + final file = await _durableStateFile(key); + if (file == null || !await file.exists()) { + return null; + } + final value = await file.readAsString(); + if (value.trim().isEmpty) { + return null; + } + return _restorePersistedValue(key, value); + } catch (_) { + return null; + } + } + + Future _writeDurableStateFile(String key, String value) async { + try { + final file = await _durableStateFile(key); + if (file == null) { + return; + } + final persistedValue = await _preparePersistedValue(key, value); + if (persistedValue == null) { + return; + } + await file.writeAsString(persistedValue, flush: true); + } catch (_) { + return; + } + } + + Future _deleteDurableStateFile(String key) async { + try { + final file = await _durableStateFile(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } catch (_) { + return; + } + } + Future _deleteAssistantStateBackup() async { try { final file = await _assistantStateBackupFile(); @@ -578,6 +791,132 @@ class SecureConfigStore { } } + bool _shouldSealLocalState(String key) { + return key == _settingsKey || key == _assistantThreadsKey; + } + + bool _isSealedLocalState(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) { + return false; + } + try { + final decoded = jsonDecode(trimmed); + return decoded is Map && + decoded['storageFormat'] == _sealedStateFormat; + } catch (_) { + return false; + } + } + + Future _preparePersistedValue(String key, String value) async { + if (!_shouldSealLocalState(key)) { + return value; + } + return _sealLocalState(key, value); + } + + Future _restorePersistedValue(String key, String value) async { + if (!_shouldSealLocalState(key)) { + return value; + } + return _restoreLocalState(key, value); + } + + Future _sealLocalState(String key, String plaintext) async { + final keyBytes = await _loadOrCreateLocalStateKey(); + final secretBox = await _localStateCipher.encrypt( + utf8.encode(plaintext), + secretKey: SecretKey(keyBytes), + nonce: _randomBytes(12), + aad: utf8.encode(key), + ); + return jsonEncode({ + 'storageFormat': _sealedStateFormat, + 'nonce': _base64UrlEncode(secretBox.nonce), + 'cipherText': _base64UrlEncode(secretBox.cipherText), + 'mac': _base64UrlEncode(secretBox.mac.bytes), + }); + } + + Future _restoreLocalState(String key, String persisted) async { + final trimmed = persisted.trim(); + if (trimmed.isEmpty) { + return null; + } + Map? envelope; + try { + final decoded = jsonDecode(trimmed); + if (decoded is Map && + decoded['storageFormat'] == _sealedStateFormat) { + envelope = decoded; + } + } catch (_) { + return trimmed; + } + if (envelope == null) { + return trimmed; + } + final keyBytes = await _loadLocalStateKey(createIfMissing: false); + if (keyBytes == null) { + return null; + } + try { + final secretBox = SecretBox( + _base64UrlDecode(envelope['cipherText'] as String? ?? ''), + nonce: _base64UrlDecode(envelope['nonce'] as String? ?? ''), + mac: Mac(_base64UrlDecode(envelope['mac'] as String? ?? '')), + ); + final clearText = await _localStateCipher.decrypt( + secretBox, + secretKey: SecretKey(keyBytes), + aad: utf8.encode(key), + ); + return utf8.decode(clearText); + } catch (_) { + return null; + } + } + + Future> _loadOrCreateLocalStateKey() async { + final existing = await _loadLocalStateKey(createIfMissing: false); + if (existing != null && existing.isNotEmpty) { + return existing; + } + final generated = _randomBytes(32); + await _writeSecure(_localStateKeyKey, _base64UrlEncode(generated)); + final persisted = await _loadLocalStateKey(createIfMissing: false); + if (persisted != null && persisted.isNotEmpty) { + return persisted; + } + throw StateError('Local state encryption key unavailable'); + } + + Future?> _loadLocalStateKey({required bool createIfMissing}) async { + final encoded = (await _readSecure(_localStateKeyKey))?.trim() ?? ''; + if (encoded.isNotEmpty) { + return _base64UrlDecode(encoded); + } + if (!createIfMissing) { + return null; + } + return _loadOrCreateLocalStateKey(); + } + + List _randomBytes(int length) { + return List.generate(length, (_) => _random.nextInt(256)); + } + + String _base64UrlEncode(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); + } + + List _base64UrlDecode(String value) { + final normalized = value.replaceAll('-', '+').replaceAll('_', '/'); + final padded = normalized + '=' * ((4 - normalized.length % 4) % 4); + return base64.decode(padded); + } + void _writeStoredStringInternal(String key, String value) { if (_database == null) { _memoryStore[key] = value; @@ -598,42 +937,94 @@ class SecureConfigStore { Future _readSecure(String key) async { if (_secureStorage != null) { try { - return await _secureStorage! - .read(key: key) - .timeout(_secureStorageTimeout); + final value = await _readSecureValue(_secureStorage!, key); + if (value != null && value.trim().isNotEmpty) { + await _deleteGenericSecureFallback(key); + return value; + } } catch (_) { - _secureStorage = null; - // Fall back to in-memory storage for tests and unsupported runners. + // Keep the primary secure store available for future retries and use + // the persistent fallback only for this operation. } } + if (await _promoteToFileSecureStorageForTests()) { + try { + final value = await _readSecureValue(_secureStorage!, key); + if (value != null && value.trim().isNotEmpty) { + return value; + } + } catch (_) { + // Fall through to the standard fallback handling below. + } + } + if (_requiresPrimarySecureStorage(key)) { + final migratedValue = await _migrateLegacyPrimarySecureFallback(key); + if (migratedValue != null && migratedValue.trim().isNotEmpty) { + return migratedValue; + } + return _memorySecure[key]; + } + final persistedFallback = await _loadGenericSecureFallback(key); + if (persistedFallback != null && persistedFallback.trim().isNotEmpty) { + return persistedFallback; + } return _memorySecure[key]; } + Future _enqueueLocalStateWrite(Future Function() action) { + final next = _localStateWriteQueue.catchError((_) {}).then((_) => action()); + _localStateWriteQueue = next.catchError((_) {}); + return next; + } + Future _writeSecure(String key, String value) async { if (_secureStorage != null) { try { - await _secureStorage! - .write(key: key, value: value) - .timeout(_secureStorageTimeout); + await _writeSecureValue(_secureStorage!, key, value); + await _deleteGenericSecureFallback(key); + if (_requiresPrimarySecureStorage(key)) { + await _deleteLegacyPrimarySecureFallback(key); + } + _memorySecure[key] = value; return; } catch (_) { - _secureStorage = null; - // Fall back to in-memory storage for tests and unsupported runners. + if (await _promoteToFileSecureStorageForTests()) { + try { + await _writeSecureValue(_secureStorage!, key, value); + await _deleteGenericSecureFallback(key); + if (_requiresPrimarySecureStorage(key)) { + await _deleteLegacyPrimarySecureFallback(key); + } + _memorySecure[key] = value; + return; + } catch (_) { + // Fall through to the normal handling below. + } + } + // Keep the primary secure store available for future retries and fall + // back to a durable local file instead of session-only memory. } } + if (_requiresPrimarySecureStorage(key)) { + throw StateError('Primary secure storage unavailable for $key'); + } _memorySecure[key] = value; + await _saveGenericSecureFallback(key, value); } Future _deleteSecure(String key) async { if (_secureStorage != null) { try { - await _secureStorage!.delete(key: key).timeout(_secureStorageTimeout); + await _deleteSecureValue(_secureStorage!, key); } catch (_) { - _secureStorage = null; - // Keep the in-memory fallback in sync. + // Best effort. Still clear fallback copies below. } } _memorySecure.remove(key); + await _deleteGenericSecureFallback(key); + if (_requiresPrimarySecureStorage(key)) { + await _deleteLegacyPrimarySecureFallback(key); + } } void dispose() { @@ -723,6 +1114,156 @@ class SecureConfigStore { ); } + Future _genericSecureFallbackFile(String key) async { + final fileName = _secureFallbackFileNames[key]; + if (fileName == null) { + return null; + } + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return null; + } + return File('${directory.path}/$fileName'); + } + + Future _loadGenericSecureFallback(String key) async { + try { + final file = await _genericSecureFallbackFile(key); + if (file == null || !await file.exists()) { + return null; + } + final value = (await file.readAsString()).trim(); + return value.isEmpty ? null : value; + } catch (_) { + return null; + } + } + + Future _saveGenericSecureFallback(String key, String value) async { + try { + final file = await _genericSecureFallbackFile(key); + if (file == null) { + return; + } + await file.writeAsString(value, flush: true); + } catch (_) { + return; + } + } + + Future _deleteGenericSecureFallback(String key) async { + try { + final file = await _genericSecureFallbackFile(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } catch (_) { + return; + } + } + + bool _requiresPrimarySecureStorage(String key) { + return key == _localStateKeyKey; + } + + Future _legacyPrimarySecureFallbackFile(String key) async { + if (key != _localStateKeyKey) { + return null; + } + final directory = await _resolveFallbackDirectory(); + if (directory == null) { + return null; + } + return File('${directory.path}/local-state-key.txt'); + } + + Future _migrateLegacyPrimarySecureFallback(String key) async { + try { + final file = await _legacyPrimarySecureFallbackFile(key); + if (file == null || !await file.exists()) { + return null; + } + final value = (await file.readAsString()).trim(); + if (value.isEmpty || _secureStorage == null) { + return null; + } + await _writeSecureValue(_secureStorage!, key, value); + _memorySecure[key] = value; + await file.delete(); + return value; + } catch (_) { + return null; + } + } + + Future _deleteLegacyPrimarySecureFallback(String key) async { + try { + final file = await _legacyPrimarySecureFallbackFile(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } catch (_) { + return; + } + } + + Future _promoteToFileSecureStorageForTests() async { + if (_secureStorageOverride != null || + (_databasePathResolver == null && + _fallbackDirectoryPathResolver == null)) { + return false; + } + _secureStorage = FileSecureStorageClient(() => _resolveFallbackDirectory()); + return true; + } + + Future _readSecureValue(SecureStorageClient client, String key) { + final future = client.read(key: key); + if (client is FlutterSecureStorageClient) { + return future.timeout(_secureStorageTimeout); + } + return future; + } + + Future _writeSecureValue( + SecureStorageClient client, + String key, + String value, + ) { + final future = client.write(key: key, value: value); + if (client is FlutterSecureStorageClient) { + return future.timeout(_secureStorageTimeout); + } + return future; + } + + Future _deleteSecureValue(SecureStorageClient client, String key) { + final future = client.delete(key: key); + if (client is FlutterSecureStorageClient) { + return future.timeout(_secureStorageTimeout); + } + return future; + } + + bool _useDebugSecureStorageFallback() { + var enabled = false; + assert(() { + enabled = true; + return true; + }()); + return enabled; + } + + SecureStorageClient _buildDebugSecureStorageClient() { + if (_databasePathResolver != null || + _fallbackDirectoryPathResolver != null) { + return FileSecureStorageClient(() => _resolveFallbackDirectory()); + } + return MemorySecureStorageClient(); + } + Future _loadDeviceIdentityFallback() async { try { final file = await _deviceIdentityFallbackFile(); @@ -821,3 +1362,111 @@ class _AssistantStateSnapshot { final SettingsSnapshot settings; final List assistantThreads; } + +class _AssistantStateBackupReadResult { + const _AssistantStateBackupReadResult({ + required this.snapshot, + required this.sealed, + }); + + final _AssistantStateSnapshot snapshot; + final bool sealed; +} + +abstract class SecureStorageClient { + Future read({required String key}); + + Future write({required String key, required String value}); + + Future delete({required String key}); +} + +typedef SecureConfigDatabaseOpener = + FutureOr Function(String resolvedPath); + +class FlutterSecureStorageClient implements SecureStorageClient { + const FlutterSecureStorageClient(this._storage); + + final FlutterSecureStorage _storage; + + @override + Future read({required String key}) { + return _storage.read(key: key); + } + + @override + Future write({required String key, required String value}) { + return _storage.write(key: key, value: value); + } + + @override + Future delete({required String key}) { + return _storage.delete(key: key); + } +} + +class FileSecureStorageClient implements SecureStorageClient { + FileSecureStorageClient(this._directoryResolver); + + final Future Function() _directoryResolver; + + @override + Future delete({required String key}) async { + final file = await _fileForKey(key); + if (file == null || !await file.exists()) { + return; + } + await file.delete(); + } + + @override + Future read({required String key}) async { + final file = await _fileForKey(key); + if (file == null || !await file.exists()) { + return null; + } + final value = (await file.readAsString()).trim(); + return value.isEmpty ? null : value; + } + + @override + Future write({required String key, required String value}) async { + final file = await _fileForKey(key); + if (file == null) { + throw StateError('Secure storage directory unavailable for $key'); + } + await file.writeAsString(value, flush: true); + } + + Future _fileForKey(String key) async { + final directory = await _directoryResolver(); + if (directory == null) { + return null; + } + final secureDirectory = Directory('${directory.path}/secure-storage'); + if (!await secureDirectory.exists()) { + await secureDirectory.create(recursive: true); + } + final safeKey = base64Url.encode(utf8.encode(key)).replaceAll('=', ''); + return File('${secureDirectory.path}/$safeKey.txt'); + } +} + +class MemorySecureStorageClient implements SecureStorageClient { + final Map _values = {}; + + @override + Future delete({required String key}) async { + _values.remove(key); + } + + @override + Future read({required String key}) async { + return _values[key]; + } + + @override + Future write({required String key, required String value}) async { + _values[key] = value; + } +} diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index fbaa7757..08d21022 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -1,8 +1,11 @@ @TestOn('vm') library; +import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'package:cryptography/cryptography.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:sqlite3/sqlite3.dart' as sqlite; @@ -132,6 +135,298 @@ void main() { }, ); + test( + 'SecureConfigStore persists secure values across instances when secure storage times out', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-secure-fallback-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + + final firstStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + secureStorage: _TimeoutSecureStorageClient(), + ); + await firstStore.saveGatewayToken('token-secret'); + await firstStore.saveGatewayPassword('password-secret'); + await firstStore.saveAiGatewayApiKey('ai-gateway-secret'); + + final secondStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + secureStorage: _TimeoutSecureStorageClient(), + ); + final secureRefs = await secondStore.loadSecureRefs(); + + expect(secureRefs['gateway_token'], 'token-secret'); + expect(secureRefs['gateway_password'], 'password-secret'); + expect(secureRefs['ai_gateway_api_key'], 'ai-gateway-secret'); + }, + ); + + test( + 'SecureConfigStore persists encrypted local settings and assistant threads when sqlite is unavailable', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-encrypted-local-state-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final secureStorage = _MapSecureStorageClient(); + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'encrypted-user', + assistantLastSessionKey: 'draft:encrypted-1', + ); + const records = [ + AssistantThreadRecord( + sessionKey: 'draft:encrypted-1', + title: '加密线程', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'assistant-1', + role: 'assistant', + text: 'encrypted message', + timestampMs: 1700000001000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ]; + + final firstStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + databaseOpener: (_) => throw StateError('sqlite unavailable'), + secureStorage: secureStorage, + ); + await firstStore.saveSettingsSnapshot(snapshot); + await firstStore.saveAssistantThreadRecords(records); + + final settingsFile = File('${tempDirectory.path}/settings-snapshot.json'); + final threadsFile = File('${tempDirectory.path}/assistant-threads.json'); + expect(await settingsFile.exists(), isTrue); + expect(await threadsFile.exists(), isTrue); + expect( + await settingsFile.readAsString(), + isNot(contains('encrypted-user')), + ); + expect( + await threadsFile.readAsString(), + isNot(contains('encrypted message')), + ); + + final secondStore = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + databaseOpener: (_) => throw StateError('sqlite unavailable'), + secureStorage: secureStorage, + ); + final loadedSnapshot = await secondStore.loadSettingsSnapshot(); + final loadedThreads = await secondStore.loadAssistantThreadRecords(); + + expect(loadedSnapshot.accountUsername, 'encrypted-user'); + expect(loadedSnapshot.assistantLastSessionKey, 'draft:encrypted-1'); + expect(loadedThreads, hasLength(1)); + expect(loadedThreads.single.messages.single.text, 'encrypted message'); + }, + ); + + test( + 'SecureConfigStore migrates plaintext local state into sealed storage and clears legacy prefs', + () async { + final legacySnapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'legacy-user', + assistantLastSessionKey: 'draft:legacy-1', + ); + const legacyRecords = [ + AssistantThreadRecord( + sessionKey: 'draft:legacy-1', + title: 'Legacy thread', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'assistant-1', + role: 'assistant', + text: 'legacy message', + timestampMs: 1700000001000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ]; + SharedPreferences.setMockInitialValues({ + 'xworkmate.settings.snapshot': legacySnapshot.toJsonString(), + 'xworkmate.assistant.threads': jsonEncode( + legacyRecords.map((item) => item.toJson()).toList(growable: false), + ), + }); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-legacy-migrate-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final secureStorage = _MapSecureStorageClient(); + + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + secureStorage: secureStorage, + ); + final loadedSnapshot = await store.loadSettingsSnapshot(); + final loadedThreads = await store.loadAssistantThreadRecords(); + + expect(loadedSnapshot.accountUsername, 'legacy-user'); + expect(loadedSnapshot.assistantLastSessionKey, 'draft:legacy-1'); + expect(loadedThreads.single.messages.single.text, 'legacy message'); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getString('xworkmate.settings.snapshot'), isNull); + expect(prefs.getString('xworkmate.assistant.threads'), isNull); + + final database = sqlite.sqlite3.open(databasePath); + addTearDown(database.dispose); + final settingsValue = + database + .select( + "SELECT value FROM config_entries WHERE storage_key = 'xworkmate.settings.snapshot' LIMIT 1", + ) + .single['value'] + as String; + final threadsValue = + database + .select( + "SELECT value FROM config_entries WHERE storage_key = 'xworkmate.assistant.threads' LIMIT 1", + ) + .single['value'] + as String; + expect(settingsValue, contains('xworkmate.sealed.local-state.v1')); + expect(threadsValue, contains('xworkmate.sealed.local-state.v1')); + expect(settingsValue, isNot(contains('legacy-user'))); + expect(threadsValue, isNot(contains('legacy message'))); + + final backupFile = File( + '${tempDirectory.path}/assistant-state-backup.json', + ); + expect(await backupFile.exists(), isTrue); + final backupContents = await backupFile.readAsString(); + expect(backupContents, contains('sealedState')); + expect(backupContents, isNot(contains('legacy-user'))); + expect(backupContents, isNot(contains('legacy message'))); + }, + ); + + test( + 'SecureConfigStore migrates legacy local-state key fallback into primary secure storage', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-config-store-local-state-key-migrate-', + ); + addTearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + final databasePath = '${tempDirectory.path}/settings.sqlite3'; + final secureStorage = _MapSecureStorageClient(); + final localStateKey = List.generate(32, (index) => index + 1); + final encodedKey = _base64UrlNoPadding(localStateKey); + final keyFallbackFile = File('${tempDirectory.path}/local-state-key.txt'); + await keyFallbackFile.writeAsString(encodedKey, flush: true); + + final snapshot = SettingsSnapshot.defaults().copyWith( + accountUsername: 'migrated-user', + assistantLastSessionKey: 'draft:migrated-1', + ); + const records = [ + AssistantThreadRecord( + sessionKey: 'draft:migrated-1', + title: 'Migrated thread', + archived: false, + executionTarget: AssistantExecutionTarget.local, + messageViewMode: AssistantMessageViewMode.rendered, + updatedAtMs: 1700000000000, + messages: [ + GatewayChatMessage( + id: 'assistant-1', + role: 'assistant', + text: 'migrated message', + timestampMs: 1700000001000, + toolCallId: null, + toolName: null, + stopReason: null, + pending: false, + error: false, + ), + ], + ), + ]; + + await File('${tempDirectory.path}/settings-snapshot.json').writeAsString( + await _sealLocalStateForTest( + key: 'xworkmate.settings.snapshot', + plaintext: snapshot.toJsonString(), + keyBytes: localStateKey, + ), + flush: true, + ); + await File('${tempDirectory.path}/assistant-threads.json').writeAsString( + await _sealLocalStateForTest( + key: 'xworkmate.assistant.threads', + plaintext: jsonEncode( + records.map((item) => item.toJson()).toList(growable: false), + ), + keyBytes: localStateKey, + ), + flush: true, + ); + + final store = SecureConfigStore( + databasePathResolver: () async => databasePath, + fallbackDirectoryPathResolver: () async => tempDirectory.path, + secureStorage: secureStorage, + ); + final loadedSnapshot = await store.loadSettingsSnapshot(); + final loadedThreads = await store.loadAssistantThreadRecords(); + + expect(loadedSnapshot.accountUsername, 'migrated-user'); + expect(loadedThreads.single.messages.single.text, 'migrated message'); + expect(secureStorage._values['xworkmate.local_state.key'], encodedKey); + expect(await keyFallbackFile.exists(), isFalse); + }, + ); + test( 'SecureConfigStore persists multi-agent settings without secrets in snapshot json', () async { @@ -403,6 +698,13 @@ void main() { await store.saveSettingsSnapshot(snapshot); await store.saveAssistantThreadRecords(records); + final backupFile = File( + '${tempDirectory.path}/assistant-state-backup.json', + ); + expect(await backupFile.exists(), isTrue); + final backupContents = await backupFile.readAsString(); + expect(backupContents, isNot(contains('backup-user'))); + expect(backupContents, isNot(contains('backup message'))); final database = sqlite.sqlite3.open(databasePath); addTearDown(database.dispose); @@ -479,6 +781,14 @@ void main() { ).exists(), isFalse, ); + expect( + await File('${tempDirectory.path}/settings-snapshot.json').exists(), + isFalse, + ); + expect( + await File('${tempDirectory.path}/assistant-threads.json').exists(), + isFalse, + ); }, ); @@ -584,3 +894,64 @@ void main() { }, ); } + +class _TimeoutSecureStorageClient implements SecureStorageClient { + @override + Future read({required String key}) async { + throw TimeoutException('secure read timed out'); + } + + @override + Future write({required String key, required String value}) async { + throw TimeoutException('secure write timed out'); + } + + @override + Future delete({required String key}) async { + throw TimeoutException('secure delete timed out'); + } +} + +class _MapSecureStorageClient implements SecureStorageClient { + final Map _values = {}; + + @override + Future delete({required String key}) async { + _values.remove(key); + } + + @override + Future read({required String key}) async { + return _values[key]; + } + + @override + Future write({required String key, required String value}) async { + _values[key] = value; + } +} + +Future _sealLocalStateForTest({ + required String key, + required String plaintext, + required List keyBytes, +}) async { + const nonce = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + final cipher = AesGcm.with256bits(); + final secretBox = await cipher.encrypt( + utf8.encode(plaintext), + secretKey: SecretKey(keyBytes), + nonce: nonce, + aad: utf8.encode(key), + ); + return jsonEncode({ + 'storageFormat': 'xworkmate.sealed.local-state.v1', + 'nonce': _base64UrlNoPadding(secretBox.nonce), + 'cipherText': _base64UrlNoPadding(secretBox.cipherText), + 'mac': _base64UrlNoPadding(secretBox.mac.bytes), + }); +} + +String _base64UrlNoPadding(List bytes) { + return base64Url.encode(bytes).replaceAll('=', ''); +}