Cold start on the target TV box reaches its first frame 21 ms after `dart_main`, so nothing Dart-side gates the splash. Everything below is on the path to the first *useful* frame, which is a strictly serial chain and where the viewer actually waits. - `monoTheme` is a pure function of two bools that builds a full `ColorScheme`, an applied-and-copied 15-style `TextTheme`, ~14 sub-themes and then clones the whole `ThemeData` again. It was rebuilt five times per cold start, two of them before `runApp`, and twice more per app-shell rebuild. It is memoized now, keyed by palette plus `TargetPlatform` -- the platform matters because `ThemeData()` derives tap target size, visual density and typography from `defaultTargetPlatform`, so a palette-only key would be wrong under a debug or test platform override. - `initializeDateFormatting` ignores its locale argument and builds CLDR symbols and patterns for all 121 locales synchronously. It blocked the gate ahead of the database open for data that only content screens use. - `DownloadStorageService.initialize` ended in a `path_provider` round trip plus mkdir at the tail of the gate, contradicting the comment above it that already explained offline artwork is not a launch requirement. - `recoverInterruptedDownloads()` and `TrackerCoordinator.initialize()` ran from `initState` of the widget whose first build produces the first app frame, and the RSS watchdog installed a periodic timer there whose first useful sample is 15 s away regardless. - `CredentialVault` decrypted every token with pure-Dart AES-GCM on the main isolate, uncached, on every registry read and on every Drift re-emit -- and the binder writes tokens during the startup sweep, so writes re-triggered reads. Decryption is memoized by ciphertext, with `invalidateCache()` wired into the preference-store repair path so a repaired install cannot serve stale plaintext. - `reloadFromStorage` now coalesces in-flight callers. The two serial awaits around the legacy migration are deliberately not merged; only genuinely concurrent callers share a snapshot. - `_sameConnections` ran two `jsonEncode` calls per connection on every Drift emit purely to compare, allocating two maps and two strings each time. - The splash rendered one `CircularProgressIndicator` per pending server on top of the aggregate one, so N+1 tickers scheduled a frame every vsync for the whole of `awaitBindingSettle` -- competing with the startup work they were reporting on. Measured on device in a settled dexopt state: time to `main_screen` 1495 -> 1400 ms, `credentials_loaded` 1238 -> 1128 ms, `database_ready` 501 -> 443 ms. First frame is unchanged at ~18 ms, as expected.
214 lines
8.4 KiB
Dart
214 lines
8.4 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:cryptography/cryptography.dart';
|
|
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
|
|
/// decrypt at their boundaries and rewrite legacy plaintext values on read.
|
|
///
|
|
/// Security model: the key is stored in SharedPreferences, so this is
|
|
/// obfuscation-at-rest against casual database inspection/export rather than
|
|
/// OS-backed Keychain/Keystore protection. Anyone with full access to both app
|
|
/// prefs and the database can recover the tokens.
|
|
class CredentialVault {
|
|
CredentialVault._();
|
|
|
|
static const String _keyPref = credentialVaultKeyPref;
|
|
static const String _prefix = 'enc:v1:';
|
|
static final AesGcm _algorithm = AesGcm.with256bits();
|
|
static final Map<String, Future<String?>> _decryptionCache = {};
|
|
static Future<SecretKey>? _secretKey;
|
|
static int _cacheGeneration = 0;
|
|
|
|
/// Invalidates the memoized key and decrypted credentials after the
|
|
/// underlying preference store is repaired or replaced.
|
|
static void invalidateCache() {
|
|
_cacheGeneration++;
|
|
_secretKey = null;
|
|
_decryptionCache.clear();
|
|
}
|
|
|
|
/// Drops memoized vault state so tests can simulate key loss/divergence.
|
|
@visibleForTesting
|
|
static void resetKeyForTesting() {
|
|
invalidateCache();
|
|
}
|
|
|
|
static bool isProtected(String? value) => value != null && value.startsWith(_prefix);
|
|
|
|
static Future<String> protect(String value) async {
|
|
if (value.isEmpty || isProtected(value)) return value;
|
|
while (true) {
|
|
final generation = _cacheGeneration;
|
|
final key = await _getSecretKey();
|
|
final box = await _algorithm.encrypt(utf8.encode(value), secretKey: key);
|
|
if (generation != _cacheGeneration) continue;
|
|
|
|
final payload = {
|
|
'n': base64Encode(box.nonce),
|
|
'c': base64Encode(box.cipherText),
|
|
'm': base64Encode(box.mac.bytes),
|
|
};
|
|
final ciphertext = '$_prefix${jsonEncode(payload)}';
|
|
_decryptionCache[ciphertext] = Future.value(value);
|
|
return ciphertext;
|
|
}
|
|
}
|
|
|
|
/// Decrypts a protected value, or returns it unchanged when it isn't
|
|
/// protected. Returns null when decryption fails — a failed MAC check
|
|
/// (key/ciphertext divergence: restored backup, clobbered prefs, racing
|
|
/// key generation) or a corrupt payload means the credential is *lost*,
|
|
/// never a reason to crash; callers treat null as "re-acquire the token".
|
|
static Future<String?> reveal(String value) async {
|
|
if (!isProtected(value)) return value;
|
|
while (true) {
|
|
final generation = _cacheGeneration;
|
|
final clear = await _decryptionCache.putIfAbsent(value, () => _decrypt(value, generation));
|
|
if (generation == _cacheGeneration) return clear;
|
|
}
|
|
}
|
|
|
|
static Future<String?> _decrypt(String value, int generation) async {
|
|
try {
|
|
final payload = jsonDecode(value.substring(_prefix.length)) as Map<String, dynamic>;
|
|
final box = SecretBox(
|
|
base64Decode(payload['c'] as String),
|
|
nonce: base64Decode(payload['n'] as String),
|
|
mac: Mac(base64Decode(payload['m'] as String)),
|
|
);
|
|
final clear = await _algorithm.decrypt(box, secretKey: await _getSecretKey());
|
|
return utf8.decode(clear);
|
|
} catch (e) {
|
|
if (generation != _cacheGeneration) return null;
|
|
appLogger.w('CredentialVault: failed to decrypt stored credential, treating as lost', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
static Future<Map<String, Object?>> protectConnectionConfig(String kind, Map<String, Object?> config) async {
|
|
final copy = Map<String, Object?>.from(config);
|
|
final tokenKey = _tokenKeyForKind(kind);
|
|
final token = tokenKey == null ? null : copy[tokenKey];
|
|
if (token is String) copy[tokenKey!] = await protect(token);
|
|
if (kind == 'plex') {
|
|
copy['servers'] = await _protectPlexServers(copy['servers']);
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
static Future<({Map<String, dynamic> config, bool migrated})> revealConnectionConfig(
|
|
String kind,
|
|
Map<String, dynamic> config,
|
|
) async {
|
|
final copy = Map<String, dynamic>.from(config);
|
|
final tokenKey = _tokenKeyForKind(kind);
|
|
var migrated = false;
|
|
final token = tokenKey == null ? null : copy[tokenKey];
|
|
if (token is String && token.isNotEmpty) {
|
|
final revealed = await reveal(token);
|
|
// An undecryptable token becomes the empty string — the shared
|
|
// "no credential, re-auth" shape — and must not be rewritten back.
|
|
migrated = revealed != null && !isProtected(token);
|
|
copy[tokenKey!] = revealed ?? '';
|
|
}
|
|
if (kind == 'plex') {
|
|
final result = await _revealPlexServers(copy['servers']);
|
|
copy['servers'] = result.servers;
|
|
migrated = migrated || result.migrated;
|
|
}
|
|
return (config: copy, migrated: migrated);
|
|
}
|
|
|
|
/// Config key holding the long-lived credential for a `connections.kind`
|
|
/// value. Returning `null` means "nothing to encrypt", so every new kind MUST
|
|
/// be listed here — an omission silently persists the token in plaintext.
|
|
static String? _tokenKeyForKind(String kind) => switch (kind) {
|
|
'plex' => 'accountToken',
|
|
'jellyfin' || 'emby' => 'accessToken',
|
|
_ => null,
|
|
};
|
|
|
|
static Future<Object?> _protectPlexServers(Object? rawServers) async {
|
|
if (rawServers is! List) return rawServers;
|
|
final servers = <Object?>[];
|
|
for (final raw in rawServers) {
|
|
if (raw is! Map) {
|
|
servers.add(raw);
|
|
continue;
|
|
}
|
|
final server = Map<String, Object?>.from(raw);
|
|
final token = server['accessToken'];
|
|
if (token is String) server['accessToken'] = await protect(token);
|
|
servers.add(server);
|
|
}
|
|
return servers;
|
|
}
|
|
|
|
static Future<({Object? servers, bool migrated})> _revealPlexServers(Object? rawServers) async {
|
|
if (rawServers is! List) return (servers: rawServers, migrated: false);
|
|
var migrated = false;
|
|
final servers = <Object?>[];
|
|
for (final raw in rawServers) {
|
|
if (raw is! Map) {
|
|
servers.add(raw);
|
|
continue;
|
|
}
|
|
final server = Map<String, dynamic>.from(raw);
|
|
final token = server['accessToken'];
|
|
if (token is String && token.isNotEmpty) {
|
|
final revealed = await reveal(token);
|
|
migrated = migrated || (revealed != null && !isProtected(token));
|
|
server['accessToken'] = revealed ?? '';
|
|
}
|
|
servers.add(server);
|
|
}
|
|
return (servers: servers, migrated: migrated);
|
|
}
|
|
|
|
static Future<SecretKey> _getSecretKey() {
|
|
return _secretKey ??= () async {
|
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
|
// The cached snapshot can predate a key written by another isolate
|
|
// (background downloader, first-run migration); generating "fresh" over
|
|
// it would clobber the real key and orphan every stored ciphertext.
|
|
// Reload before deciding, and after writing re-read and adopt whatever
|
|
// actually landed so all isolates converge on a single key.
|
|
try {
|
|
await prefs.reloadCache();
|
|
} catch (e) {
|
|
appLogger.d('CredentialVault: prefs reload before key check failed', error: e);
|
|
}
|
|
// 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));
|
|
}
|
|
final bytes = List<int>.generate(32, (_) => Random.secure().nextInt(256));
|
|
await prefs.setString(_keyPref, base64Encode(bytes));
|
|
try {
|
|
await prefs.reloadCache();
|
|
} 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);
|
|
}();
|
|
}
|
|
}
|