diff --git a/dikte/integrate.py b/dikte/integrate.py index ddda8d6..f96a118 100644 --- a/dikte/integrate.py +++ b/dikte/integrate.py @@ -14,6 +14,12 @@ Everything is written from the path Dikte is running as, which is why it is also run again on every start rather than once: an AppImage that was moved out of ~/Downloads leaves behind a menu entry naming a file that is no longer there, and the run after the move is the only moment that can be noticed. + +The rest of the module is the other half of the same meeting. A build carries +the libraries and the OpenSSL of the machine it was built on, and both of them +have to be reconciled with the machine it is running on before anything else +happens: what it hands to the programs it starts, and where it looks for the +certificates that say who it is talking to. """ import os @@ -89,6 +95,53 @@ def restore_library_path(): return moved +# Where the distributions keep the trust store. One list rather than a guess +# per distribution, in the order curl and Go try them. +CA_FILES = ( + "/etc/ssl/certs/ca-certificates.crt", # Debian, Ubuntu, Arch, Gentoo + "/etc/pki/tls/certs/ca-bundle.crt", # Fedora, RHEL + "/etc/ssl/ca-bundle.pem", # openSUSE + "/etc/ssl/cert.pem", # Alpine, and macOS +) +CA_DIRECTORIES = ("/etc/ssl/certs", "/etc/pki/tls/certs") + + +def use_system_certificates(): + """Point the OpenSSL in this build at the machine's trust store. What it found. + + A build carries the OpenSSL of the machine it was built on, and that + OpenSSL has one directory compiled into it as the only place it will look: + /usr/lib/ssl for an AppImage built on Ubuntu, which does not exist on Arch, + Fedora or openSUSE. Every HTTPS request then fails with + CERTIFICATE_VERIFY_FAILED, which reads like a rejected API key rather than + a packaging fault, and takes the model downloads down with it. + + The machine's own store rather than a copy carried along: a copy goes stale + as roots are rotated, and it would ignore a certificate somebody added + themselves, which is how a network that inspects its own traffic is made to + work. Anybody who has already said where to look is not argued with. + """ + if not packaged(): + return None + if os.environ.get("SSL_CERT_FILE") or os.environ.get("SSL_CERT_DIR"): + return None + + import ssl + # Both of these are None unless the path they name is really there, so this + # asks whether the build's idea of where certificates live survived the trip. + defaults = ssl.get_default_verify_paths() + if defaults.cafile or defaults.capath: + return None + + for name, candidates, exists in (("SSL_CERT_FILE", CA_FILES, os.path.isfile), + ("SSL_CERT_DIR", CA_DIRECTORIES, os.path.isdir)): + for candidate in candidates: + if exists(candidate): + os.environ[name] = candidate + return candidate + return None + + def bundled_bin(): """Where a build keeps the helper programs it carries, if it carries any. diff --git a/packaging/entry.py b/packaging/entry.py index e82d70a..94c2a04 100644 --- a/packaging/entry.py +++ b/packaging/entry.py @@ -7,8 +7,9 @@ The Finder hands a double-clicked application a -psn_0_… argument naming the process serial number, which argparse reads as a flag it has never heard of and exits over, and no one clicking an icon would ever find out why. -The two environment lines have to run before anything starts another process, -and before is easier to be sure of here than anywhere further in. +The three environment lines have to run before anything starts a process, +opens a connection or reaches for ffmpeg, and before is easier to be sure of +here than anywhere further in. """ import sys @@ -18,6 +19,7 @@ from dikte.app import main if __name__ == "__main__": integrate.restore_library_path() + integrate.use_system_certificates() integrate.add_bundled_tools() sys.argv[1:] = [arg for arg in sys.argv[1:] if not arg.startswith("-psn_")] sys.exit(main()) diff --git a/tests/test_integrate.py b/tests/test_integrate.py index a71744c..6bc43b6 100644 --- a/tests/test_integrate.py +++ b/tests/test_integrate.py @@ -158,6 +158,61 @@ class LibraryPath(unittest.TestCase): self.assertFalse(integrate.restore_library_path()) +class Certificates(unittest.TestCase): + """Where a build looks for the certificates that say who it is talking to. + + An AppImage built on Ubuntu carries an OpenSSL with /usr/lib/ssl compiled + into it, and Arch, Fedora and openSUSE have no such directory. Left alone + it is every HTTPS request failing at once, reported as a rejected key. + """ + + def paths(self, cafile=None, capath=None): + """ssl.get_default_verify_paths(), which reports only what really exists.""" + import ssl + return mock.patch("ssl.get_default_verify_paths", + return_value=ssl.DefaultVerifyPaths( + cafile, capath, "SSL_CERT_FILE", "/usr/lib/ssl/cert.pem", + "SSL_CERT_DIR", "/usr/lib/ssl/certs")) + + def test_a_store_the_build_cannot_find_is_looked_up(self): + with Frozen("/tmp/.mount_x/usr/bin/dikte"), \ + mock.patch.dict(os.environ, {}, clear=True), \ + self.paths(), \ + mock.patch("os.path.isfile", lambda p: p == "/etc/ssl/cert.pem"): + self.assertEqual(integrate.use_system_certificates(), "/etc/ssl/cert.pem") + self.assertEqual(os.environ["SSL_CERT_FILE"], "/etc/ssl/cert.pem") + + def test_a_directory_will_do_when_no_bundle_is_there(self): + with Frozen("/tmp/.mount_x/usr/bin/dikte"), \ + mock.patch.dict(os.environ, {}, clear=True), \ + self.paths(), \ + mock.patch("os.path.isfile", return_value=False), \ + mock.patch("os.path.isdir", lambda p: p == "/etc/ssl/certs"): + self.assertEqual(integrate.use_system_certificates(), "/etc/ssl/certs") + self.assertEqual(os.environ["SSL_CERT_DIR"], "/etc/ssl/certs") + + def test_a_build_that_can_already_find_them_is_left_alone(self): + """Which is the AppImage running on the distribution it was built on.""" + with Frozen("/tmp/.mount_x/usr/bin/dikte"), \ + mock.patch.dict(os.environ, {}, clear=True), \ + self.paths(capath="/usr/lib/ssl/certs"): + self.assertIsNone(integrate.use_system_certificates()) + self.assertNotIn("SSL_CERT_FILE", os.environ) + + def test_somebody_who_has_said_where_is_not_argued_with(self): + """A network that inspects its own traffic is made to work this way.""" + with Frozen("/tmp/.mount_x/usr/bin/dikte"), \ + mock.patch.dict(os.environ, {"SSL_CERT_FILE": "/opt/work/ca.pem"}, + clear=True), \ + self.paths(): + self.assertIsNone(integrate.use_system_certificates()) + self.assertEqual(os.environ["SSL_CERT_FILE"], "/opt/work/ca.pem") + + def test_a_checkout_uses_the_python_it_was_installed_against(self): + with mock.patch.dict(os.environ, {}, clear=True): + self.assertIsNone(integrate.use_system_certificates()) + + class Linux(Home): def install(self, appimage, force=False): with Frozen("/tmp/.mount_x/usr/bin/dikte", appimage=str(appimage),