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.
This commit is contained in:
imSp4rky
2026-08-30 21:17:20 -06:00
parent f7b1446427
commit a01ff594da
3 changed files with 188 additions and 8 deletions
+5 -1
View File
@@ -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`.
@@ -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"}
+76 -7
View File
@@ -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")