fix(runtime): encrypt local settings and assistant thread persistence
This commit is contained in:
parent
f3a2426ba7
commit
c1ba8bb985
@ -161,6 +161,7 @@ class AppController extends ChangeNotifier {
|
||||
String? _bootstrapError;
|
||||
StreamSubscription<GatewayPushEvent>? _runtimeEventsSubscription;
|
||||
bool _disposed = false;
|
||||
Future<void> _assistantThreadPersistQueue = Future<void>.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<void> clearAssistantLocalState() async {
|
||||
await _flushAssistantThreadPersistence();
|
||||
await _store.clearAssistantLocalState();
|
||||
_assistantThreadPersistQueue = Future<void>.value();
|
||||
final defaults = SettingsSnapshot.defaults();
|
||||
_assistantThreadRecords.clear();
|
||||
_assistantThreadMessages.clear();
|
||||
@ -2753,6 +2773,10 @@ class AppController extends ChangeNotifier {
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<void> _flushAssistantThreadPersistence() async {
|
||||
await _assistantThreadPersistQueue.catchError((_) {});
|
||||
}
|
||||
|
||||
void _appendLocalSessionMessage(
|
||||
String sessionKey,
|
||||
GatewayChatMessage message,
|
||||
@ -3053,11 +3077,17 @@ class AppController extends ChangeNotifier {
|
||||
_assistantThreadMessages[normalizedSessionKey] =
|
||||
List<GatewayChatMessage>.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<void> _setCurrentAssistantSessionKey(
|
||||
@ -3075,6 +3105,9 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _persistAssistantLastSessionKey(String sessionKey) async {
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
final normalizedSessionKey = _normalizedAssistantSessionKey(sessionKey);
|
||||
if (normalizedSessionKey.isEmpty ||
|
||||
settings.assistantLastSessionKey == normalizedSessionKey) {
|
||||
|
||||
@ -12,6 +12,7 @@ class SettingsController extends ChangeNotifier {
|
||||
SettingsController(this._store);
|
||||
|
||||
final SecureConfigStore _store;
|
||||
bool _disposed = false;
|
||||
|
||||
SettingsSnapshot _snapshot = SettingsSnapshot.defaults();
|
||||
Map<String, String> _secureRefs = const <String, String>{};
|
||||
@ -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<void> initialize() async {
|
||||
_snapshot = await _store.loadSettingsSnapshot();
|
||||
await _reloadDerivedState();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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(<String, Object>{});
|
||||
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(<String, Object>{});
|
||||
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>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:encrypted-1',
|
||||
title: '加密线程',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
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>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:legacy-1',
|
||||
title: 'Legacy thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
GatewayChatMessage(
|
||||
id: 'assistant-1',
|
||||
role: 'assistant',
|
||||
text: 'legacy message',
|
||||
timestampMs: 1700000001000,
|
||||
toolCallId: null,
|
||||
toolName: null,
|
||||
stopReason: null,
|
||||
pending: false,
|
||||
error: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'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(<String, Object>{});
|
||||
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<int>.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>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'draft:migrated-1',
|
||||
title: 'Migrated thread',
|
||||
archived: false,
|
||||
executionTarget: AssistantExecutionTarget.local,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
updatedAtMs: 1700000000000,
|
||||
messages: <GatewayChatMessage>[
|
||||
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<String?> read({required String key}) async {
|
||||
throw TimeoutException('secure read timed out');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value}) async {
|
||||
throw TimeoutException('secure write timed out');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async {
|
||||
throw TimeoutException('secure delete timed out');
|
||||
}
|
||||
}
|
||||
|
||||
class _MapSecureStorageClient implements SecureStorageClient {
|
||||
final Map<String, String> _values = <String, String>{};
|
||||
|
||||
@override
|
||||
Future<void> delete({required String key}) async {
|
||||
_values.remove(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> read({required String key}) async {
|
||||
return _values[key];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write({required String key, required String value}) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
Future<String> _sealLocalStateForTest({
|
||||
required String key,
|
||||
required String plaintext,
|
||||
required List<int> keyBytes,
|
||||
}) async {
|
||||
const nonce = <int>[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(<String, dynamic>{
|
||||
'storageFormat': 'xworkmate.sealed.local-state.v1',
|
||||
'nonce': _base64UrlNoPadding(secretBox.nonce),
|
||||
'cipherText': _base64UrlNoPadding(secretBox.cipherText),
|
||||
'mac': _base64UrlNoPadding(secretBox.mac.bytes),
|
||||
});
|
||||
}
|
||||
|
||||
String _base64UrlNoPadding(List<int> bytes) {
|
||||
return base64Url.encode(bytes).replaceAll('=', '');
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user