mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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"}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user