fix(prefs): route every credential read through the tolerant path

The wrong-type recovery only covered reads that went through a
BaseSharedPreferencesService instance. The three stores that hold
credentials read the shared cache directly, so a mistyped value there
still threw a raw TypeError or, for Seerr, was swallowed by a catch-all
and reported as "no session" — the registry documented protection it did
not actually provide.

readPreferenceTolerantly now takes the cache, so CredentialVault,
TrackerAccountStore and SeerrSessionStore get the same classification as
the settings layer. CredentialVault's post-write re-read moves outside
its catch: a wrong-typed value written by another isolate was swallowed
there, and the process then returned a key that never durably landed,
making every ciphertext written under it unreadable on the next launch.

Those stores are consulted long after startup, where a throw is an
unhandled provider error rather than a repair prompt, so SettingsService
initialization now walks the cached key set once and reads every
sensitive key. That puts the failure inside a fatal gate step while the
store is still open and a surgical single-key repair is possible.

The remaining direct reads in settings and storage are routed too; the
only ones left are the library-density dual-type migration, which probes
both types deliberately, and an untyped switch that is type-safe by
construction.
This commit is contained in:
edde746
2026-07-31 21:45:32 +02:00
parent 7f0cad339c
commit 66549e3a67
8 changed files with 259 additions and 49 deletions
+14 -6
View File
@@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart' show visibleForTesting;
import '../utils/app_logger.dart';
import 'base_shared_preferences_service.dart';
import 'sensitive_prefs.dart';
/// Encrypts credentials before they are persisted in Drift config/token
/// columns. The database no longer stores raw server tokens; registries
@@ -18,7 +19,7 @@ import 'base_shared_preferences_service.dart';
class CredentialVault {
CredentialVault._();
static const String _keyPref = 'credential_vault_key_v1';
static const String _keyPref = credentialVaultKeyPref;
static const String _prefix = 'enc:v1:';
static final AesGcm _algorithm = AesGcm.with256bits();
static Future<SecretKey>? _secretKey;
@@ -152,7 +153,10 @@ class CredentialVault {
} catch (e) {
appLogger.d('CredentialVault: prefs reload before key check failed', error: e);
}
final stored = prefs.getString(_keyPref);
// Tolerant read: a wrong-typed key must surface as a repairable
// failure, not be mistaken for 'no key yet' and silently replaced —
// that would orphan every ciphertext in the database (#1732).
final stored = readTolerantString(prefs, _keyPref);
if (stored != null && stored.isNotEmpty) {
return SecretKey(base64Decode(stored));
}
@@ -160,13 +164,17 @@ class CredentialVault {
await prefs.setString(_keyPref, base64Encode(bytes));
try {
await prefs.reloadCache();
final settled = prefs.getString(_keyPref);
if (settled != null && settled.isNotEmpty) {
return SecretKey(base64Decode(settled));
}
} catch (e) {
appLogger.d('CredentialVault: prefs re-read after key write failed', error: e);
}
// Outside the catch: if another isolate raced us and left a wrong-typed
// value, swallowing it here would return a key that never durably
// landed, and every ciphertext written under it would be unreadable on
// the next launch. Surface it for repair instead (#1732).
final settled = readTolerantString(prefs, _keyPref);
if (settled != null && settled.isNotEmpty) {
return SecretKey(base64Decode(settled));
}
return SecretKey(bytes);
}();
}