Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
416 lines
14 KiB
Dart
416 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. The
|
|
/// MediaBrowser backends (Jellyfin and Emby) use 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 MediaBrowser server/user identity
|
|
/// — Jellyfin or Emby.
|
|
///
|
|
/// Both peers of a pair are signed into the same server and user, so both
|
|
/// derive the same secret regardless of dialect. The `jellyfin-secret` HKDF
|
|
/// `info` label and the `jellyfin:` cache prefix are therefore fixed domain
|
|
/// separators, not dialect discriminators: changing either would invalidate
|
|
/// every existing pairing for zero benefit.
|
|
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 MediaBrowser 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);
|
|
}
|