Files
plezy/lib/services/sensitive_prefs.dart
T
edde746 66549e3a67 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.
2026-07-31 21:45:32 +02:00

77 lines
3.5 KiB
Dart

/// Preference keys whose stored values are credentials.
///
/// `shared_preferences` is the most credential-dense artifact in a Plezy
/// installation. On the desktop platforms it is a single plaintext JSON file
/// next to the database, and it holds:
///
/// * [credentialVaultKeyPref] — the AES-256 key that `CredentialVault` uses to
/// protect every server/profile token stored in the Drift
/// `connections.config_json` and `profile_connections.user_token` columns.
/// Losing it orphans every one of those ciphertexts permanently.
/// * tracker sessions — `TrackerAccountStore` persists `TrackerSession.encode()`
/// verbatim, so raw OAuth `access_token`/`refresh_token` pairs for MAL,
/// AniList, Simkl and Trakt live here in plaintext.
/// * Seerr sessions — `SeerrSessionStore` persists a raw `connect.sid` cookie
/// alongside a vault-protected password.
/// * [legacyPlexTokenPref] — the pre-connection-registry Plex token slot. It is
/// drained by the connection migration but can linger on old installs.
///
/// Two subsystems consult this list, both added for #1732:
///
/// * the tolerant preference reads in `BaseSharedPreferencesService` must never
/// silently drop one of these keys — an unreadable credential has to surface
/// as an explicit repair prompt, not as a silent re-authentication;
/// * the corrupt-store repair in `PrefsRecovery` salvages exactly these keys
/// out of a damaged store before quarantining it.
///
/// Keep this list exhaustive. A credential slot that is missing here is
/// silently dropped on a type mismatch and silently lost on a repair.
///
/// This lives apart from `CredentialVault`, `TrackerAccountStore` and
/// `SeerrSessionStore` so `BaseSharedPreferencesService` can depend on it
/// without an import cycle.
library;
/// Key holding the base64 `CredentialVault` AES-256 key.
const String credentialVaultKeyPref = 'credential_vault_key_v1';
/// Legacy single-slot Plex token, superseded by the connection registry.
const String legacyPlexTokenPref = 'plex_token';
/// Unscoped base keys used by `TrackerAccountStore`, one per tracker service.
const List<String> trackerSessionBaseKeys = <String>[
'mal_session',
'anilist_session',
'simkl_session',
'trakt_session',
];
/// Unscoped base key used by `SeerrSessionStore`.
const String seerrSessionBaseKey = 'seerr_session';
/// Every credential slot that is profile-scoped through `profileScopedPrefsKey`,
/// so a stored key is either the bare base key or `user_{scope}_{baseKey}`.
const List<String> profileScopedCredentialBaseKeys = <String>[...trackerSessionBaseKeys, seerrSessionBaseKey];
final RegExp _profileScopedCredentialPattern = RegExp(
'^(?:user_.+_)?(?:${profileScopedCredentialBaseKeys.join('|')})\$',
);
/// The unscoped base key [key] resolves to, or null when [key] is not a
/// profile-scoped credential slot.
String? profileScopedCredentialBaseKey(String key) {
if (!_profileScopedCredentialPattern.hasMatch(key)) return null;
for (final base in profileScopedCredentialBaseKeys) {
if (key == base || key.endsWith('_$base')) return base;
}
return null;
}
/// Whether [key] is a profile-scoped or global Seerr session slot.
bool isSeerrSessionPrefKey(String key) => profileScopedCredentialBaseKey(key) == seerrSessionBaseKey;
/// Whether [key] holds a credential and must never be dropped or exported
/// without an explicit, informed user decision.
bool isSensitivePrefKey(String key) =>
key == credentialVaultKeyPref || key == legacyPlexTokenPref || profileScopedCredentialBaseKey(key) != null;