From a01ff594dac5c4b10f224032e162cbbe7eaea118 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Sun, 30 Aug 2026 21:17:20 -0600 Subject: [PATCH] fix(remote): send only the active profile's cache files to the server The client withheld nothing before: every profile's tokens went to any remote server. Filter by active-credential digests and profile names, keeping service-global files; worst case for a withheld file is a server-side re-auth. --- docs/dev/rest-api/remote-sessions.md | 6 +- .../unit/test_remote_service_helpers.py | 107 ++++++++++++++++++ unshackle/core/remote_service.py | 83 ++++++++++++-- 3 files changed, 188 insertions(+), 8 deletions(-) diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index e49b132f..9c0fb31c 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -124,7 +124,11 @@ hood the client walks a remote session through its lifecycle. the client region itself - Track-selection hints (`range_`, `vcodec`, `quality`, `best_available`) so the server fetches the right manifests - - Your local per-service **cache files** (e.g. refreshed tokens) + - Your local per-service **cache files** (e.g. refreshed tokens), but only the + files for the active profile. The client withholds a file whose name embeds + a hash of another credential, or the name of another profile. One profile's + tokens therefore never reach the server while you use a different profile. + At worst, a withheld file makes the server authenticate again The server uses its own accounts only when the operator lists the service in `serve.server_accounts` and gives your API key `server_accounts` in `serve.users`. diff --git a/tests/remote/unit/test_remote_service_helpers.py b/tests/remote/unit/test_remote_service_helpers.py index f496dc60..46ffa2ef 100644 --- a/tests/remote/unit/test_remote_service_helpers.py +++ b/tests/remote/unit/test_remote_service_helpers.py @@ -172,3 +172,110 @@ def test_build_title_episode() -> None: def test_build_title_falls_back_to_id_when_missing() -> None: title = build_title({"type": "movie", "name": "x"}, "ATV", "fallback-id") assert title.id == "fallback-id" + + +def test_credential_cache_digests_cover_common_key_schemes() -> None: + import hashlib + + from unshackle.core.credential import Credential + from unshackle.core.remote_service import credential_cache_digests + + cred = Credential("user@example.com", "hunter2") + digests = credential_cache_digests(cred) + assert cred.sha1 in digests + assert hashlib.sha1(cred.username.encode()).hexdigest() in digests + assert hashlib.md5(cred.username.encode()).hexdigest() in digests + + +def test_cache_stem_relevance_filters_foreign_digests_and_profiles() -> None: + import hashlib + + from unshackle.core.credential import Credential + from unshackle.core.remote_service import cache_stem_is_relevant, credential_cache_digests + + active = Credential("us@example.com", "pw1") + other = Credential("uk@example.com", "pw2") + allowed = credential_cache_digests(active) + foreign = {"uk"} + + assert cache_stem_is_relevant(f"tokens_{active.sha1}", allowed, "us", foreign) + assert not cache_stem_is_relevant(f"tokens_{other.sha1}", allowed, "us", foreign) + username_hash = hashlib.sha1(active.username.encode()).hexdigest() + assert cache_stem_is_relevant(f"device_id_{username_hash}", allowed, "us", foreign) + # Service-global state carries no identity marker, so the client sends it + assert cache_stem_is_relevant("session_guid", allowed, "us", foreign) + assert cache_stem_is_relevant("tokens", allowed, "us", foreign) + # Files keyed by profile name: only the active profile's file goes + assert cache_stem_is_relevant("tokens_us", allowed, "us", foreign) + assert not cache_stem_is_relevant("tokens_uk", allowed, "us", foreign) + # Region prefix plus a matching digest (the region is not a profile name here) + assert cache_stem_is_relevant(f"tokens_intl_{active.sha1}", allowed, "us", foreign) + # An active-credential digest takes priority over a colliding foreign-profile token + assert cache_stem_is_relevant(f"tokens_uk_{active.sha1}", allowed, "us", foreign) + + +def test_cache_stem_relevance_active_profile_beats_region_collision() -> None: + from unshackle.core.remote_service import cache_stem_is_relevant + + # tokens_{profile}_{region}_{device}_{6hex} style key, where the region "us" + # collides with a foreign profile named "us" + assert cache_stem_is_relevant("tokens_default_us_androidtv_abc123", set(), "default", {"us", "de"}) + assert not cache_stem_is_relevant("tokens_us_us_androidtv_abc123", set(), "default", {"us", "de"}) + assert cache_stem_is_relevant("web_device_id_default_us", set(), "default", {"us"}) + + +def test_cache_stem_relevance_without_credential_withholds_hex_stems() -> None: + from unshackle.core.credential import Credential + from unshackle.core.remote_service import cache_stem_is_relevant + + stray = Credential("someone@example.com", "pw") + assert not cache_stem_is_relevant(f"tokens_{stray.sha1}", set(), "default", set()) + assert cache_stem_is_relevant("tokens_guest", set(), "default", set()) + + +def test_cache_stem_relevance_separator_and_case_profile_names() -> None: + from unshackle.core.remote_service import cache_stem_is_relevant + + # A foreign profile name with separators still matches + assert not cache_stem_is_relevant("tokens_us-east", set(), "eu", {"us-east"}) + assert not cache_stem_is_relevant("tokens_us_east", set(), "eu", {"us-east"}) + # Case variants of a foreign profile name still match + assert not cache_stem_is_relevant("tokens_UK", set(), "us", {"uk"}) + # The active profile matches through case and separator variants too + assert cache_stem_is_relevant("tokens_US-EAST", set(), "us-east", {"eu"}) + # A longer matching foreign name beats an active name that is its prefix + assert not cache_stem_is_relevant("tokens_us-east", set(), "us", {"us-east"}) + # ...but a shorter foreign match does not override the active name + assert cache_stem_is_relevant("tokens_default_us_androidtv_abc123", set(), "default", {"us"}) + + +def test_load_cache_files_wiring(tmp_path, monkeypatch) -> None: + import json + import logging + from types import SimpleNamespace + + from unshackle.core.config import config + from unshackle.core.credential import Credential + from unshackle.core.remote_service import RemoteService + + tag = "TESTSVC" + cache_dir = tmp_path / tag + cache_dir.mkdir() + other = Credential("other@example.com", "pw2") + for stem in ("tokens_us", "tokens_uk", "tokens_default", "session_guid", f"tokens_{other.sha1}", "titles_abc"): + (cache_dir / f"{stem}.json").write_text(json.dumps({"k": stem})) + + monkeypatch.setattr(config.directories, "cache", tmp_path) + monkeypatch.setattr( + config, + "credentials", + {tag: {"default": "a@example.com:pw", "us": "b@example.com:pw", "uk": "c@example.com:pw"}}, + ) + stub = SimpleNamespace(service_tag=tag, log=logging.getLogger("test")) + + sent = RemoteService.load_cache_files(stub, "us") + assert set(sent) == {"tokens_us", "session_guid"} + + # A profile with no credentials entry falls back to the default credential + sent = RemoteService.load_cache_files(stub, "missing") + assert set(sent) == {"tokens_default", "session_guid"} diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 271347b5..9ea9cc8b 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -8,6 +8,7 @@ Everything else (track selection, download, decrypt, mux) runs locally. from __future__ import annotations import base64 +import hashlib import logging import re import time @@ -610,6 +611,53 @@ def resolve_proxy_arg(proxy_arg: Optional[str]) -> Optional[str]: raise click.ClickException(str(e)) +_CACHE_HEX_ID_RE = re.compile(r"[0-9a-f]{32,}", re.IGNORECASE) + + +def credential_cache_digests(credential: Credential) -> set[str]: + """Hex digests a service may embed in cache filenames for this credential.""" + digests = set() + for text in (credential.dumps(), credential.username, credential.password): + for algo in ("md5", "sha1", "sha256"): + digests.add(hashlib.new(algo, text.encode()).hexdigest()) + return digests + + +def _contains_name(stem: str, name: str) -> bool: + """True when the stem contains the profile name as a separator-delimited run. + + Both sides casefold and normalise separators, so a profile named "us-east" + matches "tokens_us-east" and "tokens_US_EAST". + """ + norm = re.sub(r"[._-]", "_", stem.casefold()) + return f"_{re.sub(r'[._-]', '_', name.casefold())}_" in f"_{norm}_" + + +def cache_stem_is_relevant( + stem: str, allowed_digests: set[str], active_profile: str, foreign_profiles: set[str] +) -> bool: + """True when a cache filename provably belongs to the active remote session. + + A stem fails the check when it embeds a hex digest that the active credential + does not produce, or another configured profile's name. A digest or profile + name that matches the active credential takes priority over a foreign profile + name, because region codes in cache filenames can collide with profile names. + The exception is a longer foreign name that also matches, since the active + name is then likely a prefix of it. A stem with no identity marker at all holds + service-global state, so it passes. The check does not detect a digest cut + to fewer than 32 characters. + """ + digests = [d.lower() for d in _CACHE_HEX_ID_RE.findall(stem)] + if any(d not in allowed_digests for d in digests): + return False + if digests: + return True + matched_foreign = [n for n in foreign_profiles if _contains_name(stem, n)] + if _contains_name(stem, active_profile): + return not any(len(n) > len(active_profile) for n in matched_foreign) + return not matched_foreign + + class RemoteService: """Service adapter that proxies to a remote unshackle server. @@ -794,7 +842,7 @@ class RemoteService: create_data["cdm_type"] = "playready" if is_playready_cdm(cdm) else "widevine" - cache_data = self.load_cache_files() if self._server_accounts is None else None + cache_data = self.load_cache_files(profile) if self._server_accounts is None else None if cache_data: create_data["cache"] = cache_data @@ -1215,17 +1263,38 @@ class RemoteService: self.log.info(f"Saved {len(cache_data)} cache file(s) from server") - def load_cache_files(self) -> Dict[str, str]: + def load_cache_files(self, profile: Optional[str] = None) -> Dict[str, str]: + """Collect the cache files to forward, and withhold other profiles' files. + + The client cannot rely on the server to filter, so it sends only the files + it can tie to the active credential or profile, plus service-global state. + At worst, a withheld file makes the server authenticate again. + """ import zlib + from unshackle.commands.dl import dl + cache_dir = config.directories.cache / self.service_tag if not cache_dir.is_dir(): return {} - return { - f.stem: base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii") - for f in cache_dir.glob("*.json") - if not f.stem.startswith("titles_") - } + + credential = dl.get_credentials(self.service_tag, profile) + allowed = credential_cache_digests(credential) if credential else set() + active = profile or "default" + profiles = config.credentials.get(self.service_tag) + if isinstance(profiles, dict) and profile and profile not in profiles: + active = "default" + foreign = set(profiles) - {active} if isinstance(profiles, dict) else set() + + files: Dict[str, str] = {} + for f in cache_dir.glob("*.json"): + if f.stem.startswith("titles_"): + continue + if not cache_stem_is_relevant(f.stem, allowed, active, foreign): + self.log.debug(f"Withholding cache file from the remote server: {f.stem}") + continue + files[f.stem] = base64.b64encode(zlib.compress(f.read_bytes())).decode("ascii") + return files __all__ = ("RemoteClient", "RemoteService", "resolve_server")