Drops dead code across services, models, utils and widgets, including the connection auth service, which had no implementer, and the Live TV DVR provisioning models, which had no caller. Tests that only covered deleted behaviour are removed or trimmed. No behaviour change.
408 lines
14 KiB
Dart
408 lines
14 KiB
Dart
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:crypto/crypto.dart' as crypto;
|
|
import 'package:cryptography/cryptography.dart';
|
|
|
|
import '../../models/plex/plex_home.dart';
|
|
import '../../utils/app_logger.dart';
|
|
|
|
/// Cryptographic authentication service for companion remote.
|
|
///
|
|
/// Proves same-account membership via a backend-derived shared secret.
|
|
///
|
|
/// Plex uses the Plex Home metadata available to signed-in devices. Jellyfin
|
|
/// uses the stable server/user identity available after sign-in, matching the
|
|
/// same local-LAN trust model: peers that know the same backend identity can
|
|
/// discover and authenticate each other without a central pairing round-trip.
|
|
class RemoteAuthService {
|
|
RemoteAuthService._();
|
|
static final instance = RemoteAuthService._();
|
|
|
|
// Reusable crypto algorithm instances
|
|
static final _hkdf = Hkdf(hmac: Hmac(Sha256()), outputLength: 32);
|
|
static final _aesGcm = AesGcm.with256bits();
|
|
|
|
// Cached account secret — derived in memory, never persisted.
|
|
List<int>? _cachedSecret;
|
|
String? _cachedSecretKey;
|
|
|
|
/// Build canonical IKM bytes from home data.
|
|
/// Format: [4-byte BE len][utf8 bytes] for each field, in fixed order.
|
|
static Uint8List _canonicalIkm(int homeId, String adminUUID) {
|
|
final homeIdBytes = utf8.encode(homeId.toString());
|
|
final uuidBytes = utf8.encode(adminUUID.toLowerCase());
|
|
|
|
final buf = BytesWriter();
|
|
buf.writeUint32BE(homeIdBytes.length);
|
|
buf.writeBytes(homeIdBytes);
|
|
buf.writeUint32BE(uuidBytes.length);
|
|
buf.writeBytes(uuidBytes);
|
|
return buf.toBytes();
|
|
}
|
|
|
|
/// Derive the long-term home secret via HKDF-SHA256.
|
|
/// This is derived in memory from cached Plex data — never persisted.
|
|
Future<List<int>> deriveHomeSecret(int homeId, String adminUUID) async {
|
|
// Return cached if inputs unchanged
|
|
final cacheKey = 'plex:$homeId:${adminUUID.toLowerCase()}';
|
|
if (_cachedSecret != null && _cachedSecretKey == cacheKey) {
|
|
return _cachedSecret!;
|
|
}
|
|
|
|
final hkdf = _hkdf;
|
|
final ikm = _canonicalIkm(homeId, adminUUID);
|
|
|
|
final secretKey = await hkdf.deriveKey(
|
|
secretKey: SecretKey(ikm),
|
|
nonce: utf8.encode('plezy-remote-v1'),
|
|
info: utf8.encode('home-secret'),
|
|
);
|
|
|
|
_cachedSecret = await secretKey.extractBytes();
|
|
_cachedSecretKey = cacheKey;
|
|
|
|
appLogger.d('RemoteAuth: Derived home secret');
|
|
return _cachedSecret!;
|
|
}
|
|
|
|
/// Derive the long-term home secret from a PlexHome object.
|
|
Future<List<int>> deriveHomeSecretFromHome(PlexHome home) async {
|
|
final admin = home.adminUser;
|
|
if (admin == null) {
|
|
throw StateError('PlexHome has no admin user');
|
|
}
|
|
return deriveHomeSecret(home.id, admin.uuid);
|
|
}
|
|
|
|
/// Derive a companion remote secret from a Jellyfin server/user identity.
|
|
Future<List<int>> deriveJellyfinSecret({required String serverMachineId, required String userId}) async {
|
|
final normalizedServerId = serverMachineId.toLowerCase();
|
|
final normalizedUserId = userId.toLowerCase();
|
|
final cacheKey = 'jellyfin:$normalizedServerId:$normalizedUserId';
|
|
if (_cachedSecret != null && _cachedSecretKey == cacheKey) {
|
|
return _cachedSecret!;
|
|
}
|
|
|
|
final buf = BytesWriter();
|
|
_writeLengthPrefixed(buf, utf8.encode(normalizedServerId));
|
|
_writeLengthPrefixed(buf, utf8.encode(normalizedUserId));
|
|
|
|
final secretKey = await _hkdf.deriveKey(
|
|
secretKey: SecretKey(buf.toBytes()),
|
|
nonce: utf8.encode('plezy-remote-v1'),
|
|
info: utf8.encode('jellyfin-secret'),
|
|
);
|
|
|
|
_cachedSecret = await secretKey.extractBytes();
|
|
_cachedSecretKey = cacheKey;
|
|
|
|
appLogger.d('RemoteAuth: Derived Jellyfin secret');
|
|
return _cachedSecret!;
|
|
}
|
|
|
|
/// Derive per-session encryption key from homeSecret + both nonces.
|
|
Future<List<int>> deriveSessionEncKey(List<int> homeSecret, List<int> hostNonce, List<int> clientNonce) async {
|
|
final hkdf = _hkdf;
|
|
|
|
final salt = Uint8List(hostNonce.length + clientNonce.length);
|
|
salt.setAll(0, hostNonce);
|
|
salt.setAll(hostNonce.length, clientNonce);
|
|
|
|
// First derive session secret
|
|
final sessionSecretKey = await hkdf.deriveKey(
|
|
secretKey: SecretKey(homeSecret),
|
|
nonce: salt,
|
|
info: utf8.encode('plezy-session-v1'),
|
|
);
|
|
final sessionSecret = await sessionSecretKey.extractBytes();
|
|
|
|
// Then derive encryption key from session secret
|
|
final encKeyResult = await hkdf.deriveKey(
|
|
secretKey: SecretKey(sessionSecret),
|
|
nonce: const <int>[],
|
|
info: utf8.encode('encryption'),
|
|
);
|
|
|
|
return encKeyResult.extractBytes();
|
|
}
|
|
|
|
/// Derive discovery key from homeSecret.
|
|
Future<List<int>> deriveDiscoveryKey(List<int> homeSecret) async {
|
|
final hkdf = _hkdf;
|
|
final key = await hkdf.deriveKey(
|
|
secretKey: SecretKey(homeSecret),
|
|
nonce: const <int>[],
|
|
info: utf8.encode('discovery'),
|
|
);
|
|
return key.extractBytes();
|
|
}
|
|
|
|
/// Stable, non-secret identifier used to select the matching auth context
|
|
/// during the WebSocket handshake without broadcasting it over UDP.
|
|
String computeAuthContextId(List<int> homeSecret) {
|
|
final hmac = crypto.Hmac(crypto.sha256, homeSecret);
|
|
final bytes = hmac.convert(utf8.encode('auth-context-id')).bytes.take(16).toList();
|
|
return 'v1.${base64UrlEncode(bytes).replaceAll('=', '')}';
|
|
}
|
|
|
|
/// Compute rotating discovery tag for beacon filtering.
|
|
/// Uses 5-minute epoch windows to reduce cross-network tracking.
|
|
String computeDiscoveryTag(List<int> discoveryKey, {DateTime? now}) {
|
|
final epochSeconds = ((now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000);
|
|
final window = epochSeconds ~/ 300; // 5-minute windows
|
|
final msg = utf8.encode('identify|$window');
|
|
final hmac = crypto.Hmac(crypto.sha256, discoveryKey);
|
|
return hmac.convert(msg).toString();
|
|
}
|
|
|
|
/// Check if a received homeHash matches any of the ±1 epoch windows.
|
|
bool matchesDiscoveryTag(String receivedHash, List<int> discoveryKey, {DateTime? now}) {
|
|
final epochSeconds = ((now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000);
|
|
final currentWindow = epochSeconds ~/ 300;
|
|
|
|
for (final window in [currentWindow - 1, currentWindow, currentWindow + 1]) {
|
|
final msg = utf8.encode('identify|$window');
|
|
final hmac = crypto.Hmac(crypto.sha256, discoveryKey);
|
|
final tag = hmac.convert(msg).toString();
|
|
if (_constantTimeEquals(tag, receivedHash)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// Generate 32 cryptographically random bytes.
|
|
List<int> generateNonce() {
|
|
final random = Random.secure();
|
|
return List<int>.generate(32, (_) => random.nextInt(256));
|
|
}
|
|
|
|
/// Compute auth tag (HMAC-SHA256) over the full handshake transcript.
|
|
/// Domain-separated with "plezy-auth-v1|" prefix.
|
|
String computeAuthTag({
|
|
required List<int> homeSecret,
|
|
required List<int> hostNonce,
|
|
required List<int> clientNonce,
|
|
required String hostClientId,
|
|
required String userUUID,
|
|
required String clientIdentifier,
|
|
required String deviceName,
|
|
required String platform,
|
|
}) {
|
|
final msg = _buildAuthTranscript(
|
|
hostNonce: hostNonce,
|
|
clientNonce: clientNonce,
|
|
hostClientId: hostClientId,
|
|
userUUID: userUUID,
|
|
clientIdentifier: clientIdentifier,
|
|
deviceName: deviceName,
|
|
platform: platform,
|
|
);
|
|
final hmac = crypto.Hmac(crypto.sha256, homeSecret);
|
|
return hmac.convert(msg).toString();
|
|
}
|
|
|
|
/// Verify auth tag with constant-time comparison.
|
|
bool verifyAuthTag({
|
|
required String authTag,
|
|
required List<int> homeSecret,
|
|
required List<int> hostNonce,
|
|
required List<int> clientNonce,
|
|
required String hostClientId,
|
|
required String userUUID,
|
|
required String clientIdentifier,
|
|
required String deviceName,
|
|
required String platform,
|
|
}) {
|
|
final expected = computeAuthTag(
|
|
homeSecret: homeSecret,
|
|
hostNonce: hostNonce,
|
|
clientNonce: clientNonce,
|
|
hostClientId: hostClientId,
|
|
userUUID: userUUID,
|
|
clientIdentifier: clientIdentifier,
|
|
deviceName: deviceName,
|
|
platform: platform,
|
|
);
|
|
return _constantTimeEquals(expected, authTag);
|
|
}
|
|
|
|
/// Build the auth transcript message bytes.
|
|
List<int> _buildAuthTranscript({
|
|
required List<int> hostNonce,
|
|
required List<int> clientNonce,
|
|
required String hostClientId,
|
|
required String userUUID,
|
|
required String clientIdentifier,
|
|
required String deviceName,
|
|
required String platform,
|
|
}) {
|
|
final buf = BytesWriter();
|
|
buf.writeBytes(utf8.encode('plezy-auth-v1|'));
|
|
// Fixed-length nonces (32 bytes each) — no prefix needed
|
|
buf.writeBytes(hostNonce);
|
|
buf.writeBytes(clientNonce);
|
|
// Variable-length strings — length-prefixed to prevent field boundary shifting
|
|
_writeLengthPrefixed(buf, utf8.encode(hostClientId));
|
|
_writeLengthPrefixed(buf, utf8.encode(userUUID));
|
|
_writeLengthPrefixed(buf, utf8.encode(clientIdentifier));
|
|
_writeLengthPrefixed(buf, utf8.encode(deviceName));
|
|
_writeLengthPrefixed(buf, utf8.encode(platform));
|
|
return buf.toBytes();
|
|
}
|
|
|
|
/// Compute HMAC for discovery beacon over canonical binary layout.
|
|
/// Fields in fixed order: v, homeHash, name, platform, clientId, port, ips (sorted, comma-joined).
|
|
String computeBeaconHmac({
|
|
required List<int> discoveryKey,
|
|
required int version,
|
|
required String homeHash,
|
|
required String name,
|
|
required String platform,
|
|
required String clientId,
|
|
required int port,
|
|
required List<String> ips,
|
|
}) {
|
|
final buf = BytesWriter();
|
|
_writeLengthPrefixed(buf, utf8.encode(version.toString()));
|
|
_writeLengthPrefixed(buf, utf8.encode(homeHash));
|
|
_writeLengthPrefixed(buf, utf8.encode(name));
|
|
_writeLengthPrefixed(buf, utf8.encode(platform));
|
|
_writeLengthPrefixed(buf, utf8.encode(clientId));
|
|
_writeLengthPrefixed(buf, utf8.encode(port.toString()));
|
|
final sortedIps = List<String>.from(ips)..sort();
|
|
_writeLengthPrefixed(buf, utf8.encode(sortedIps.join(',')));
|
|
|
|
final hmac = crypto.Hmac(crypto.sha256, discoveryKey);
|
|
return hmac.convert(buf.toBytes()).toString();
|
|
}
|
|
|
|
/// Verify a beacon's HMAC.
|
|
bool verifyBeaconHmac({
|
|
required String receivedHmac,
|
|
required List<int> discoveryKey,
|
|
required int version,
|
|
required String homeHash,
|
|
required String name,
|
|
required String platform,
|
|
required String clientId,
|
|
required int port,
|
|
required List<String> ips,
|
|
}) {
|
|
final expected = computeBeaconHmac(
|
|
discoveryKey: discoveryKey,
|
|
version: version,
|
|
homeHash: homeHash,
|
|
name: name,
|
|
platform: platform,
|
|
clientId: clientId,
|
|
port: port,
|
|
ips: ips,
|
|
);
|
|
return _constantTimeEquals(expected, receivedHmac);
|
|
}
|
|
|
|
// ── AES-256-GCM Encryption ──
|
|
|
|
static const int _directionHost = 0x01;
|
|
static const int _directionClient = 0x02;
|
|
|
|
/// Build the 12-byte GCM nonce from direction + counter.
|
|
static Uint8List buildNonce(int direction, int counter) {
|
|
final nonce = Uint8List(12);
|
|
// 4-byte direction (big-endian)
|
|
nonce[0] = (direction >> 24) & 0xFF;
|
|
nonce[1] = (direction >> 16) & 0xFF;
|
|
nonce[2] = (direction >> 8) & 0xFF;
|
|
nonce[3] = direction & 0xFF;
|
|
// 8-byte counter (big-endian)
|
|
nonce[4] = (counter >> 56) & 0xFF;
|
|
nonce[5] = (counter >> 48) & 0xFF;
|
|
nonce[6] = (counter >> 40) & 0xFF;
|
|
nonce[7] = (counter >> 32) & 0xFF;
|
|
nonce[8] = (counter >> 24) & 0xFF;
|
|
nonce[9] = (counter >> 16) & 0xFF;
|
|
nonce[10] = (counter >> 8) & 0xFF;
|
|
nonce[11] = counter & 0xFF;
|
|
return nonce;
|
|
}
|
|
|
|
/// Encrypt plaintext with AES-256-GCM.
|
|
/// Returns ciphertext + auth tag (nonce is implicit from counters).
|
|
Future<List<int>> encrypt(
|
|
List<int> sessionEncKey,
|
|
List<int> plaintext, {
|
|
required bool isHost,
|
|
required int counter,
|
|
}) async {
|
|
final algo = _aesGcm;
|
|
final nonce = buildNonce(isHost ? _directionHost : _directionClient, counter);
|
|
|
|
final secretBox = await algo.encrypt(plaintext, secretKey: SecretKey(sessionEncKey), nonce: nonce);
|
|
|
|
// Return ciphertext + mac (nonce is implicit)
|
|
return [...secretBox.cipherText, ...secretBox.mac.bytes];
|
|
}
|
|
|
|
/// Decrypt ciphertext + auth tag with AES-256-GCM.
|
|
/// Receiver reconstructs the nonce from its own expected counter.
|
|
Future<List<int>> decrypt(
|
|
List<int> data,
|
|
List<int> sessionEncKey, {
|
|
required bool fromHost,
|
|
required int expectedCounter,
|
|
}) async {
|
|
final algo = _aesGcm;
|
|
final nonce = buildNonce(fromHost ? _directionHost : _directionClient, expectedCounter);
|
|
|
|
if (data.length < 16) {
|
|
throw ArgumentError('Encrypted data too short');
|
|
}
|
|
|
|
final cipherText = data.sublist(0, data.length - 16);
|
|
final mac = Mac(data.sublist(data.length - 16));
|
|
|
|
final secretBox = SecretBox(cipherText, nonce: nonce, mac: mac);
|
|
return algo.decrypt(secretBox, secretKey: SecretKey(sessionEncKey));
|
|
}
|
|
|
|
// ── Utility ──
|
|
|
|
static void _writeLengthPrefixed(BytesWriter buf, List<int> bytes) {
|
|
buf.writeUint32BE(bytes.length);
|
|
buf.writeBytes(bytes);
|
|
}
|
|
|
|
/// Constant-time string comparison to prevent timing attacks.
|
|
static bool _constantTimeEquals(String a, String b) {
|
|
if (a.length != b.length) return false;
|
|
var result = 0;
|
|
for (var i = 0; i < a.length; i++) {
|
|
result |= a.codeUnitAt(i) ^ b.codeUnitAt(i);
|
|
}
|
|
return result == 0;
|
|
}
|
|
|
|
/// Clear cached home secret (e.g. on logout).
|
|
void clearCache() {
|
|
_cachedSecret = null;
|
|
_cachedSecretKey = null;
|
|
}
|
|
}
|
|
|
|
/// Helper for building byte arrays.
|
|
class BytesWriter {
|
|
final _bytes = <int>[];
|
|
|
|
void writeBytes(List<int> data) => _bytes.addAll(data);
|
|
|
|
void writeUint32BE(int value) {
|
|
_bytes.add((value >> 24) & 0xFF);
|
|
_bytes.add((value >> 16) & 0xFF);
|
|
_bytes.add((value >> 8) & 0xFF);
|
|
_bytes.add(value & 0xFF);
|
|
}
|
|
|
|
Uint8List toBytes() => Uint8List.fromList(_bytes);
|
|
}
|