From e087575022ccadfcc15c2d1c8afbf7265cfb98a2 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 8 Oct 2026 21:59:49 -0500 Subject: [PATCH 1/2] Fix native updater and support signed, version-independent MSI acquisition --- packaging/flatpak/io.github.PTCGLLinux.yml | 12 ++ src/ptcgl_linux/installer.py | 144 ++++++++++++++++++--- src/ptcgl_linux/installer_security.py | 89 +++++++++++++ src/ptcgl_linux/launcher.py | 1 + tests/test_installer.py | 47 +++++++ tests/test_installer_download_security.py | 143 ++++++++++++++++++++ tests/test_installer_interrupted.py | 57 ++++++++ tests/test_installer_lock.py | 75 +++++++++++ tests/test_installer_security.py | 105 +++++++++++++++ tests/test_launcher.py | 5 + 10 files changed, 659 insertions(+), 19 deletions(-) create mode 100644 src/ptcgl_linux/installer_security.py create mode 100644 tests/test_installer_download_security.py create mode 100644 tests/test_installer_interrupted.py create mode 100644 tests/test_installer_lock.py create mode 100644 tests/test_installer_security.py diff --git a/packaging/flatpak/io.github.PTCGLLinux.yml b/packaging/flatpak/io.github.PTCGLLinux.yml index e815d67..01b1adb 100644 --- a/packaging/flatpak/io.github.PTCGLLinux.yml +++ b/packaging/flatpak/io.github.PTCGLLinux.yml @@ -87,6 +87,18 @@ modules: url: https://github.com/Open-Wine-Components/umu-launcher/releases/download/1.4.4/umu-launcher-1.4.4-zipapp.tar sha256: eb590691841f7fad3fc3ad8fd5db4ccb87849fe7948e62b28ece7a4ee48cc851 + - name: osslsigncode + buildsystem: cmake-ninja + config-opts: + - -DCMAKE_BUILD_TYPE=Release + - -DCMAKE_INSTALL_PREFIX=/app + - -DCMAKE_INSTALL_DATADIR=share + - -DBASH_COMPLETION_USER_DIR=/app/share + sources: + - type: archive + url: https://github.com/mtrojnar/osslsigncode/archive/refs/tags/2.14.tar.gz + sha256: 0f033fd6069387d2e489fbd2187e62f624764eb8c2758ee94e3e793e5150b5c5 + - name: ptcgl-linux buildsystem: simple diff --git a/src/ptcgl_linux/installer.py b/src/ptcgl_linux/installer.py index 49ce4ea..7a95360 100644 --- a/src/ptcgl_linux/installer.py +++ b/src/ptcgl_linux/installer.py @@ -2,12 +2,17 @@ from __future__ import annotations +import fcntl import os import subprocess +import tempfile +import urllib.error +import urllib.request +from contextlib import contextmanager from pathlib import Path -from .artifacts import ArtifactError, download_verified, verify_sha256 from .paths import cache_home +from .installer_security import SignatureError, verify_pokemon_installer from .proton import install_proton from .runtime import RuntimePaths, runtime_paths, vc_runtime_paths from .umu import install_umu @@ -18,10 +23,7 @@ "https://installer.studio-prod.pokemon.com/installer/" "PokemonTCGLiveInstaller.msi" ) -PTCGL_INSTALLER_SHA256 = ( - "cf970aded232fffc52263932bb0dc5627" - "642a8f489c73485f3d7b5d0c1e5127b" -) +PTCGL_INSTALLER_MAX_BYTES = 1024 * 1024 * 1024 class InstallError(RuntimeError): @@ -163,11 +165,34 @@ def install_vcrun2019(paths: RuntimePaths) -> None: ) +@contextmanager +def _installer_cache_lock(cache: Path): + """Serialize installer acquisition across processes.""" + + lock_path = cache / ".pokemon-installer.lock" + + with lock_path.open("a+b") as lock: + os.chmod(lock_path, 0o600) + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + + try: + yield + finally: + fcntl.flock(lock.fileno(), fcntl.LOCK_UN) + + +class _RejectRedirects(urllib.request.HTTPRedirectHandler): + """Never follow an installer download to another URL.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise InstallError("official installer endpoint redirected") + + def acquire_ptcgl_installer( *, artifact_cache: Path | None = None, ) -> Path: - """Acquire the pinned official Pokémon TCG Live MSI.""" + """Download and authenticate the current official Pokémon MSI.""" if artifact_cache is None: artifact_cache = cache_home() / "artifacts" @@ -175,26 +200,107 @@ def acquire_ptcgl_installer( artifact_cache.mkdir(parents=True, exist_ok=True) os.chmod(artifact_cache, 0o700) - installer = artifact_cache / PTCGL_INSTALLER_NAME + with _installer_cache_lock(artifact_cache): + return _acquire_ptcgl_installer_locked(artifact_cache) - if installer.exists(): - if verify_sha256(installer, PTCGL_INSTALLER_SHA256): - os.chmod(installer, 0o600) - return installer - installer.unlink() +def _acquire_ptcgl_installer_locked( + artifact_cache: Path, +) -> Path: + """Acquire and publish the MSI while holding the cache lock.""" + + installer = artifact_cache / PTCGL_INSTALLER_NAME + temporary: Path | None = None try: - return download_verified( - PTCGL_INSTALLER_URL, - installer, - PTCGL_INSTALLER_SHA256, - ) - except ArtifactError as exc: + # Only the configured HTTPS endpoint is permitted. + if PTCGL_INSTALLER_URL != ( + "https://installer.studio-prod.pokemon.com/installer/" + "PokemonTCGLiveInstaller.msi" + ): + raise InstallError("invalid official installer endpoint") + + opener = urllib.request.build_opener(_RejectRedirects()) + + with tempfile.NamedTemporaryFile( + mode="wb", + dir=artifact_cache, + prefix=".pokemon-installer-", + suffix=".tmp", + delete=False, + ) as target: + temporary = Path(target.name) + os.chmod(temporary, 0o600) + + with opener.open( + PTCGL_INSTALLER_URL, + timeout=60, + ) as response: + total = 0 + content_length = response.headers.get("Content-Length") + expected = ( + int(content_length) + if content_length and content_length.isdigit() + else None + ) + + if expected is not None and expected > PTCGL_INSTALLER_MAX_BYTES: + raise InstallError( + "official installer exceeds maximum size" + ) + + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + + total += len(chunk) + + if total > PTCGL_INSTALLER_MAX_BYTES: + raise InstallError( + "official installer exceeds maximum size" + ) + + target.write(chunk) + + if expected is not None and total != expected: + raise InstallError( + "official installer download is incomplete" + ) + + target.flush() + os.fsync(target.fileno()) + + if total < 65536: + raise InstallError("official installer is unexpectedly small") + + # MSI uses the OLE Compound File binary container. + with temporary.open("rb") as stream: + if stream.read(8) != bytes.fromhex("D0CF11E0A1B11AE1"): + raise InstallError("download is not a valid MSI container") + + verify_pokemon_installer(temporary) + + # Publish only after every verification succeeds. + os.replace(temporary, installer) + temporary = None + os.chmod(installer, 0o600) + + return installer + + except ( + urllib.error.URLError, + OSError, + SignatureError, + ) as exc: raise InstallError( - "unable to acquire Pokémon TCG Live installer" + f"unable to acquire verified Pokémon installer: {exc}" ) from exc + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + def install_game( paths: RuntimePaths, diff --git a/src/ptcgl_linux/installer_security.py b/src/ptcgl_linux/installer_security.py new file mode 100644 index 0000000..2de1ab9 --- /dev/null +++ b/src/ptcgl_linux/installer_security.py @@ -0,0 +1,89 @@ +"""Verify the authenticity of Pokémon TCG Live installers.""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from pathlib import Path + + +class SignatureError(RuntimeError): + """Installer authenticity could not be established.""" + + +EXPECTED_PUBLISHER = r"The Pokemon Company International\, Inc." + + +def verify_pokemon_installer(path: Path) -> None: + """Require a trusted Authenticode signature from Pokémon's publisher.""" + + verifier = shutil.which("osslsigncode") + + if verifier is None: + raise SignatureError( + "osslsigncode is unavailable; cannot authenticate installer" + ) + + try: + result = subprocess.run( + [verifier, "verify", "-in", str(path)], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SignatureError( + "installer signature verification could not complete" + ) from exc + + output = result.stdout + "\n" + result.stderr + + if result.returncode != 0: + raise SignatureError( + "installer signature or certificate validation failed" + ) + + required = ( + "Signature verification: ok", + "Signature CRL verification: ok", + "Timestamp Server Signature verification: ok", + "Timestamp Server Signature CRL verification: ok", + "Number of verified signatures: 1", + "Succeeded", + ) + + if not all(item in output for item in required): + raise SignatureError( + "installer verification did not confirm all security checks" + ) + + # Read the primary signer's certificate, not the chain certificates + # or the timestamp authority's certificate. + match = re.search( + r"^Signer's certificate:\s*\n" + r"\s*-+\s*\n" + r"\s*Signer #0:\s*\n" + r"\s*Subject:\s*(.+)$", + output, + re.MULTILINE, + ) + + if match is None: + raise SignatureError("installer signer identity is unavailable") + + subject = match.group(1).strip() + + # Certificate DN commas may be escaped. Match complete CN/O fields, + # not arbitrary text elsewhere in the verification output. + fields = re.split(r"(? None: class InstallerTests(unittest.TestCase): + def test_acquire_accepts_changed_official_installer(self) -> None: + from ptcgl_linux.installer import acquire_ptcgl_installer + + class FakeResponse: + def __init__(self, payload): + self.payload = payload + self.position = 0 + self.headers = {"Content-Length": str(len(payload))} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size): + chunk = self.payload[self.position:self.position + size] + self.position += len(chunk) + return chunk + + magic = bytes.fromhex("D0CF11E0A1B11AE1") + first = magic + b"A" * 65536 + second = magic + b"B" * 65536 + + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + + for payload in (first, second): + with ( + patch( + "ptcgl_linux.installer.urllib.request.build_opener" + ) as build_opener, + patch( + "ptcgl_linux.installer.verify_pokemon_installer" + ) as verify, + ): + build_opener.return_value.open.return_value = ( + FakeResponse(payload) + ) + + result = acquire_ptcgl_installer( + artifact_cache=cache, + ) + + verify.assert_called_once() + self.assertEqual(result.read_bytes(), payload) + def test_initialize_prefix_uses_umu(self) -> None: with tempfile.TemporaryDirectory() as tmp: paths = make_paths(Path(tmp)) diff --git a/tests/test_installer_download_security.py b/tests/test_installer_download_security.py new file mode 100644 index 0000000..a9284ce --- /dev/null +++ b/tests/test_installer_download_security.py @@ -0,0 +1,143 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from ptcgl_linux.installer import ( + InstallError, + PTCGL_INSTALLER_NAME, + acquire_ptcgl_installer, +) + + +MAGIC = bytes.fromhex("D0CF11E0A1B11AE1") + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + self.offset = 0 + self.headers = {"Content-Length": str(len(payload))} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size): + chunk = self.payload[self.offset:self.offset + size] + self.offset += len(chunk) + return chunk + + +class InstallerDownloadSecurityTests(unittest.TestCase): + def check_rejected(self, payload, *, signature_valid=False): + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + existing = cache / PTCGL_INSTALLER_NAME + existing.write_bytes(b"previous installer") + + with ( + patch( + "ptcgl_linux.installer.urllib.request.build_opener" + ) as opener, + patch( + "ptcgl_linux.installer.verify_pokemon_installer" + ) as verify, + ): + opener.return_value.open.return_value = ( + FakeResponse(payload) + ) + + if not signature_valid: + from ptcgl_linux.installer_security import SignatureError + verify.side_effect = SignatureError( + "invalid signature" + ) + + with self.assertRaises(InstallError): + acquire_ptcgl_installer(artifact_cache=cache) + + self.assertEqual( + existing.read_bytes(), + b"previous installer", + ) + self.assertEqual( + sorted(p.name for p in cache.iterdir()), + [".pokemon-installer.lock", PTCGL_INSTALLER_NAME], + ) + + def test_invalid_msi_header(self): + self.check_rejected(b"X" * 65544) + + def test_invalid_signature(self): + self.check_rejected(MAGIC + b"A" * 65536) + + def test_oversized_download(self): + with patch( + "ptcgl_linux.installer.PTCGL_INSTALLER_MAX_BYTES", + 65536, + ): + self.check_rejected( + MAGIC + b"A" * 65536, + signature_valid=True, + ) + + def test_truncated_download_preserves_cache(self): + payload = MAGIC + b"A" * 65536 + + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + existing = cache / PTCGL_INSTALLER_NAME + existing.write_bytes(b"previous installer") + + response = FakeResponse(payload) + response.headers = { + "Content-Length": str(len(payload) + 100000) + } + + with ( + patch( + "ptcgl_linux.installer.urllib.request.build_opener" + ) as opener, + patch( + "ptcgl_linux.installer.verify_pokemon_installer" + ) as verify, + ): + opener.return_value.open.return_value = response + + with self.assertRaisesRegex( + InstallError, "download is incomplete" + ): + acquire_ptcgl_installer(artifact_cache=cache) + + verify.assert_not_called() + + self.assertEqual( + existing.read_bytes(), + b"previous installer", + ) + self.assertEqual( + sorted(path.name for path in cache.iterdir()), + [".pokemon-installer.lock", PTCGL_INSTALLER_NAME], + ) + + def test_redirect_is_rejected(self): + from ptcgl_linux.installer import _RejectRedirects + + handler = _RejectRedirects() + + with self.assertRaises(InstallError): + handler.redirect_request( + None, + None, + 302, + "Found", + {}, + "https://untrusted.example/installer.msi", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer_interrupted.py b/tests/test_installer_interrupted.py new file mode 100644 index 0000000..af5ef36 --- /dev/null +++ b/tests/test_installer_interrupted.py @@ -0,0 +1,57 @@ +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from ptcgl_linux.installer import ( + InstallError, + PTCGL_INSTALLER_NAME, + acquire_ptcgl_installer, +) + + +class InterruptedResponse: + headers = {"Content-Length": "196512768"} + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, size): + if not hasattr(self, "started"): + self.started = True + return bytes.fromhex("D0CF11E0A1B11AE1") + b"A" * 65536 + + raise OSError("connection lost during download") + + +class InterruptedDownloadTests(unittest.TestCase): + def test_interrupted_download_preserves_cache(self): + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + existing = cache / PTCGL_INSTALLER_NAME + existing.write_bytes(b"previous installer") + + with patch( + "ptcgl_linux.installer.urllib.request.build_opener" + ) as opener: + opener.return_value.open.return_value = InterruptedResponse() + + with self.assertRaises(InstallError): + acquire_ptcgl_installer(artifact_cache=cache) + + self.assertEqual( + existing.read_bytes(), + b"previous installer", + ) + + self.assertEqual( + sorted(path.name for path in cache.iterdir()), + [".pokemon-installer.lock", PTCGL_INSTALLER_NAME], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer_lock.py b/tests/test_installer_lock.py new file mode 100644 index 0000000..66f7d81 --- /dev/null +++ b/tests/test_installer_lock.py @@ -0,0 +1,75 @@ +import multiprocessing +import tempfile +import time +import unittest +from pathlib import Path + +from ptcgl_linux.installer import _installer_cache_lock + + +def hold_lock(cache, entered, release): + with _installer_cache_lock(cache): + entered.set() + release.wait(timeout=10) + + +def wait_for_lock(cache, acquired): + with _installer_cache_lock(cache): + acquired.set() + + +class InstallerLockTests(unittest.TestCase): + def test_lock_serializes_processes(self): + ctx = multiprocessing.get_context("spawn") + + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + entered = ctx.Event() + release = ctx.Event() + acquired = ctx.Event() + + first = ctx.Process( + target=hold_lock, + args=(cache, entered, release), + ) + second = ctx.Process( + target=wait_for_lock, + args=(cache, acquired), + ) + + try: + first.start() + self.assertTrue( + entered.wait(timeout=5), + "First process did not acquire lock", + ) + + second.start() + + self.assertFalse( + acquired.wait(timeout=0.5), + "Second process acquired lock prematurely", + ) + + release.set() + + self.assertTrue( + acquired.wait(timeout=5), + "Second process never acquired released lock", + ) + finally: + release.set() + + for process in (first, second): + if process.pid is not None: + process.join(timeout=3) + if process.is_alive(): + process.terminate() + process.join(timeout=3) + + self.assertEqual(first.exitcode, 0) + self.assertEqual(second.exitcode, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_installer_security.py b/tests/test_installer_security.py new file mode 100644 index 0000000..7c65b4f --- /dev/null +++ b/tests/test_installer_security.py @@ -0,0 +1,105 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from ptcgl_linux.installer_security import ( + SignatureError, + verify_pokemon_installer, +) +from ptcgl_linux.installer import ( + InstallError, + acquire_ptcgl_installer, +) + + +class InstallerSecurityTests(unittest.TestCase): + def test_missing_verifier_is_rejected(self): + with patch( + "ptcgl_linux.installer_security.shutil.which", + return_value=None, + ): + with self.assertRaises(SignatureError): + verify_pokemon_installer(Path("/tmp/example.msi")) + + def test_failed_signature_is_rejected(self): + with ( + patch( + "ptcgl_linux.installer_security.shutil.which", + return_value="/app/bin/osslsigncode", + ), + patch( + "ptcgl_linux.installer_security.subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + returncode=1, + stdout="Signature verification: failed\nFailed", + stderr="", + ), + ), + ): + with self.assertRaises(SignatureError): + verify_pokemon_installer(Path("/tmp/example.msi")) + + def test_wrong_publisher_is_rejected(self): + output = ( + "Signer's certificate:\n" + "\t------------------\n" + "\tSigner #0:\n" + "\t\tSubject: CN=Other Company,O=Other Company,C=US\n" + "Timestamp Server Signature CRL verification: ok\n" + "Timestamp Server Signature verification: ok\n" + "Signature CRL verification: ok\n" + "Signature verification: ok\n" + "Number of verified signatures: 1\n" + "Succeeded\n" + ) + + with ( + patch( + "ptcgl_linux.installer_security.shutil.which", + return_value="/app/bin/osslsigncode", + ), + patch( + "ptcgl_linux.installer_security.subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + returncode=0, + stdout=output, + stderr="", + ), + ), + ): + with self.assertRaises(SignatureError): + verify_pokemon_installer(Path("/tmp/example.msi")) + + def test_download_failure_preserves_cache(self): + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) + existing = cache / "PokemonTCGLiveInstaller.msi" + existing.write_bytes(b"previously validated installer") + + with patch( + "ptcgl_linux.installer.urllib.request.build_opener" + ) as opener: + opener.return_value.open.side_effect = OSError( + "network unavailable" + ) + + with self.assertRaises(InstallError): + acquire_ptcgl_installer(artifact_cache=cache) + + self.assertEqual( + existing.read_bytes(), + b"previously validated installer", + ) + + self.assertEqual( + sorted(p.name for p in cache.iterdir()), + [".pokemon-installer.lock", "PokemonTCGLiveInstaller.msi"], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 570bc41..e88849a 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -66,6 +66,11 @@ def test_launch_uses_managed_runtime(self) -> None: ], ) + self.assertEqual( + kwargs["cwd"], + paths.game.parent, + ) + env = kwargs["env"] self.assertEqual(env["GAMEID"], "0") From ff6d7377fcf5f325c2804bd6e9f945040e7babe0 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 8 Oct 2026 22:17:55 -0500 Subject: [PATCH 2/2] Release PTCGL-Linux 0.1.4 --- .../flatpak/files/io.github.PTCGLLinux.metainfo.xml | 9 ++++++--- pyproject.toml | 2 +- src/ptcgl_linux/__init__.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packaging/flatpak/files/io.github.PTCGLLinux.metainfo.xml b/packaging/flatpak/files/io.github.PTCGLLinux.metainfo.xml index 9124de6..0ffd941 100644 --- a/packaging/flatpak/files/io.github.PTCGLLinux.metainfo.xml +++ b/packaging/flatpak/files/io.github.PTCGLLinux.metainfo.xml @@ -45,11 +45,14 @@ https://github.com/mattmcbeardface/ptcgl-linux/issues - + -

Metadata update.

+

Installer and automatic update compatibility fixes.

    -
  • Update the displayed developer name.
  • +
  • Fix the working directory used by the game's built-in updater.
  • +
  • Support updated official Pokémon installers without hardcoded MSI checksums.
  • +
  • Verify installer signatures and publisher identity before installation.
  • +
  • Improve download validation, interrupted-download handling, and installer cache safety.
diff --git a/pyproject.toml b/pyproject.toml index 5dc479d..b970e21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ptcgl-linux" -version = "0.1.3" +version = "0.1.4" description = "Linux launcher and compatibility environment for Pokémon TCG Live" requires-python = ">=3.11" authors = [ diff --git a/src/ptcgl_linux/__init__.py b/src/ptcgl_linux/__init__.py index f2d47f2..bb8f867 100644 --- a/src/ptcgl_linux/__init__.py +++ b/src/ptcgl_linux/__init__.py @@ -1,3 +1,3 @@ """ptcgl-linux.""" -__version__ = "0.1.3" +__version__ = "0.1.4"