diff --git a/.isort.cfg b/.isort.cfg index 00aee0a..5eb22d0 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,2 +1,4 @@ [settings] -known_third_party = click,setuptools +line_length=88 +include_trailing_comma=true +multi_line_output=3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9b529a8..1085a4c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,12 +19,11 @@ rev: v1.3.0 hooks: - id: blacken-docs -- repo: https://github.com/asottile/seed-isort-config - rev: v1.9.3 - hooks: - - id: seed-isort-config -- repo: https://github.com/pre-commit/mirrors-isort - rev: v4.3.20 +- repo: https://github.com/timothycrosley/isort + rev: 4.3.21-2 hooks: - id: isort exclude: ^docs/conf\.py$ + types: [python] + additional_dependencies: [pipreqs, pip-api, six] + verbose: true diff --git a/Makefile b/Makefile index 5767f1d..c5ed9bc 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ clean-test: ## remove test and coverage artifacts rm -fr .pytest_cache lint: ## check style with flake8 - flake8 python_autograph_utils tests + flake8 autograph_utils tests test: ## run tests quickly with the default Python pytest @@ -60,15 +60,15 @@ test-all: ## run tests on every Python version with tox tox coverage: ## check code coverage quickly with the default Python - coverage run --source python_autograph_utils -m pytest + coverage run --source autograph_utils -m pytest coverage report -m coverage html $(BROWSER) htmlcov/index.html docs: ## generate Sphinx HTML documentation, including API docs - rm -f docs/python_autograph_utils.rst + rm -f docs/autograph_utils.rst rm -f docs/modules.rst - sphinx-apidoc -o docs/ python_autograph_utils + sphinx-apidoc -o docs/ autograph_utils $(MAKE) -C docs clean $(MAKE) -C docs html $(BROWSER) docs/_build/html/index.html diff --git a/README.rst b/README.rst index 7b08619..336e4cc 100644 --- a/README.rst +++ b/README.rst @@ -3,8 +3,8 @@ Python Autograph Utilities ========================== -.. image:: https://img.shields.io/pypi/v/python_autograph_utils.svg - :target: https://pypi.python.org/pypi/python_autograph_utils +.. image:: https://img.shields.io/pypi/v/autograph_utils.svg + :target: https://pypi.python.org/pypi/autograph_utils .. image:: https://img.shields.io/travis/glasserc/python_autograph_utils.svg :target: https://travis-ci.org/glasserc/python_autograph_utils @@ -26,7 +26,15 @@ A library to simplify use of Autograph Features -------- -* TODO +SignatureVerifier +================= + +The canonical implementation of certificate chain validation. Although +some other implementations seem to exist (such as +https://github.com/river2sea/X509Validation, +https://github.com/alex/x509-validator, and +https://github.com/openstack/cursive), all are marked as +pre-production and/or needing work, so just do it ourselves. Credits ------- diff --git a/autograph_utils/__init__.py b/autograph_utils/__init__.py new file mode 100644 index 0000000..ddd6b1d --- /dev/null +++ b/autograph_utils/__init__.py @@ -0,0 +1,325 @@ +# -*- coding: utf-8 -*- + +"""Top-level package for Python Autograph Utilities.""" + +__author__ = """Ethan Glasser-Camp""" +__email__ = "eglassercamp@mozilla.com" +__version__ = "0.1.0" + + +import base64 +import binascii +import re +from abc import ABC +from datetime import datetime + +import cryptography +import ecdsa.util +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import ec as cryptography_ec +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature +from cryptography.hazmat.primitives.hashes import SHA256, SHA384 +from cryptography.x509.oid import NameOID + + +class Cache(ABC): + """An interface for caching x5u validity checks.""" + + def get(self, url): + pass + + def set(self, url, result): + pass + + +class MemoryCache: + """A simple Cache implementation that just uses a dictionary. + + This cache does not expire data and can therefore grow without + bound. This may make it vulnerable to a denial-of-service attack. + + """ + + def __init__(self): + self.data = {} + + def get(self, url): + return self.data.get(url) + + def set(self, url, result): + self.data[url] = result + + +Cache.register(MemoryCache) + + +class SubjectNameCheck(ABC): + """An interface for predicates that verify the subject name.""" + + def check(self, subject_name): + pass + + def describe(self): + pass + + +class EndsWith: + def __init__(self, domain): + self.domain = domain + + def check(self, subject_name): + return subject_name.endswith(self.domain) + + def describe(self): + return f"ends with {self.domain!r}" + + +class ExactMatch: + def __init__(self, domain): + self.domain = domain + + def check(self, subject_name): + return subject_name == self.domain + + def describe(self): + return f"matches exactly {self.domain!r}" + + +BASE64_WRONG_LENGTH_RE = re.compile( + r"Invalid base64-encoded string: number of data characters \(\d+\) cannot " + r"be [123] more than a multiple of 4" +) + + +class BadCertificate(Exception): + def __init__(self, extra): + self.extra = extra + + @property + def detail(self): + return f"Bad certificate: {self.extra}" + + +class CertificateParseError(BadCertificate): + def __init__(self, extra): + self.extra = extra + + @property + def detail(self): + return f"Could not parse certificate: {self.extra}" + + +class CertificateNotYetValid(BadCertificate): + def __init__(self, not_before): + self.not_before = not_before + + @property + def detail(self): + return f"Certificate is not valid until {self.not_before}" + + +class CertificateExpired(BadCertificate): + def __init__(self, not_after): + self.not_after = not_after + + @property + def detail(self): + return f"Certificate expired on {self.not_after}" + + +class CertificateHasWrongSubject(BadCertificate): + def __init__(self, actual, check_description): + self.check_description = check_description + self.actual = actual + + @property + def detail(self): + return ( + f"Certificate does not have the expected subject. " + f"Got {self.actual!r}, checking for {self.check_description}" + ) + + +class BadSignature(Exception): + detail = "Unknown signature problem" + + +class SignatureDoesNotMatch(BadSignature): + detail = "Signature does not correspond to this data" + + +class WrongSignatureSize(BadSignature): + detail = "Signature is not the right number of bytes" + + +class SignatureVerifier: + """A utility to verify the provenance of data. + + This class lets you verify that data is signed by a collection of + certificates that chains back up to some well-known root + hash. Certificate chains are identified by x5u. x5us are assumed + to be static and so can be cached to save network traffic. + + :params ClientSession session: An aiohttp session, used to retrieve x5us. + :params Cache cache: A cache used to store results for x5u verification. + :params bytes root_hash: The expected hash for the first + certificate in a chain. This should not be encoded in any + way. Hashes can be decoded using decode_mozilla_hash. + :params SubjectNameCheck subject_name_check: Predicate to use to + validate cert subject names. Defaults to + EndsWith(".content-signature.mozilla.org"). + + """ + + def __init__(self, session, cache, root_hash, subject_name_check=None): + self.session = session + self.cache = cache + self.root_hash = root_hash + self.subject_name_check = subject_name_check or EndsWith( + ".content-signature.mozilla.org" + ) + + algorithm = cryptography_ec.ECDSA(SHA384()) + + async def verify(self, data, signature, x5u): + """Verify that the data is signed by certs that chain up to the root hash. + + Returns True if the signature checks out and raises an + exception otherwise. + + :param bytes data: Data that was signed. + :param signature: Signature in Autograph format (described in + Autograph docs as "DL/ECSSA representation of the R and S + values (IEEE Std 1363-2000)"). This can be bytes or str. + :param str x5u: URL of a certificate chain which was allegedly + used to sign the data. + :raises: BadCertificate if the certificate is bad; + BadSignature if signature verification fails + + """ + cert = await self.verify_x5u(x5u) + # Decode signature into the (r, s) components + try: + signature = base64.urlsafe_b64decode(signature) + except binascii.Error as e: + if BASE64_WRONG_LENGTH_RE.match(e.args[0]): + raise WrongSignatureSize( + "Base64 encoded signature was not a multiple of 4" + ) + raise + + try: + r, s = ecdsa.util.sigdecode_string( + signature, order=ecdsa.curves.NIST384p.order + ) + except ecdsa.util.MalformedSignature: + raise WrongSignatureSize() + + # Encode as DER for cryptography + signature = encode_dss_signature(r, s) + + # Content signature implicitly adds a prefix to signed data + data = b"Content-Signature:\x00" + data + + try: + cert.public_key().verify(signature, data, self.algorithm) + except cryptography.exceptions.InvalidSignature: + raise SignatureDoesNotMatch() + + return True + + async def verify_x5u(self, url): + cached = self.cache.get(url) + if cached: + return cached + + async with self.session.get(url) as response: + response.raise_for_status() + content = await response.read() + pems = split_pem(content) + certs = [ + x509.load_pem_x509_certificate(pem, backend=default_backend()) + for pem in pems + ] + + now = _now() + for cert in certs: + if cert.not_valid_before > cert.not_valid_after: + raise BadCertificate( + f"not_before ({cert.not_valid_before}) after " + f"not_after ({cert.not_valid_after})" + ) + if now < cert.not_valid_before: + raise CertificateNotYetValid(cert.not_valid_before) + if now > cert.not_valid_after: + raise CertificateExpired(cert.not_valid_after) + + leaf_subject_name = ( + certs[0].subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + ) + if not self.subject_name_check.check(leaf_subject_name): + raise CertificateHasWrongSubject( + leaf_subject_name, check_description=self.subject_name_check.describe() + ) + + root_hash = certs[-1].fingerprint(SHA256()) + assert root_hash == self.root_hash + + return certs[0] + + +def split_pem(s): + """Split a string containing many ASCII-armored PEM structures. + + No validation is performed on the PEM structures (even to the + point of verifying that the BEGIN lines match the END lines). + + :param bytes s: bytes containing a list of PEM-encoded things + :returns: List of bytes, each representing a single PEM-encoded thing + """ + out = [] + acc = [] + state = "PRE" + for line in s.split(b"\n"): + if state == "PRE" and line.startswith(b"-----BEGIN "): + acc.append(line) + state = "BODY_OR_META" + elif state == "PRE" and not line: + pass + elif state == "BODY_OR_META" and b":" in line: + state = "META" + elif state == "BODY" and line.startswith(b"-----END "): + acc.append(line) + out.append(b"\n".join(acc)) + acc = [] + state = "PRE" + elif state == "META" and not line: + state = "BODY" + elif state == "BODY" or state == "BODY_OR_META": + acc.append(line) + state = "BODY" + else: + raise CertificateParseError(f'Unexpected input "{line}" in state "{state}"') + + if acc: + raise CertificateParseError(f"Unexpected end of input. Leftover: {acc}") + + return out + + +def decode_mozilla_hash(s): + """Convert a hash from pseudo-base16 colon-separated format. + + >>> decode_mozilla_hash('4C:35:B1:C3') + b'\x4c\x35\xb1\xc3') + """ + return bytes.fromhex(s.replace(":", " ")) + + +def _now(self): + """Mockable function to get "now". + + :returns: naive datetime representing a UTC timestamp + """ + return datetime.utcnow() diff --git a/autograph_utils/__main__.py b/autograph_utils/__main__.py new file mode 100644 index 0000000..0cb58b8 --- /dev/null +++ b/autograph_utils/__main__.py @@ -0,0 +1,5 @@ +import sys + +from autograph_utils.main import main + +sys.exit(main()) diff --git a/autograph_utils/main.py b/autograph_utils/main.py new file mode 100644 index 0000000..051c03b --- /dev/null +++ b/autograph_utils/main.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +"""Console script for autograph_utils.""" + +import click + + +@click.command() +def main(args=None): + """Console script for autograph_utils.""" + click.echo( + "Replace this message by putting your code into autograph_utils.cli.main" + ) + click.echo("See click documentation at https://click.palletsprojects.com/") + return 0 diff --git a/docs/Makefile b/docs/Makefile index 27d1383..19bfb3c 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -4,7 +4,7 @@ # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = python -msphinx -SPHINXPROJ = python_autograph_utils +SPHINXPROJ = autograph_utils SOURCEDIR = . BUILDDIR = _build diff --git a/docs/conf.py b/docs/conf.py index ef86422..dd8226c 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# python_autograph_utils documentation build configuration file, created by +# autograph_utils documentation build configuration file, created by # sphinx-quickstart on Fri Jun 9 13:47:02 2017. # # This file is execfile()d with the current directory set to its @@ -23,7 +23,7 @@ sys.path.insert(0, os.path.abspath("..")) -import python_autograph_utils +import autograph_utils # -- General configuration --------------------------------------------- @@ -57,9 +57,9 @@ # the built documents. # # The short X.Y version. -version = python_autograph_utils.__version__ +version = autograph_utils.__version__ # The full version, including alpha/beta/rc tags. -release = python_autograph_utils.__version__ +release = autograph_utils.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -102,7 +102,7 @@ # -- Options for HTMLHelp output --------------------------------------- # Output file base name for HTML help builder. -htmlhelp_basename = "python_autograph_utilsdoc" +htmlhelp_basename = "autograph_utilsdoc" # -- Options for LaTeX output ------------------------------------------ @@ -128,7 +128,7 @@ latex_documents = [ ( master_doc, - "python_autograph_utils.tex", + "autograph_utils.tex", u"Python Autograph Utilities Documentation", u"Ethan Glasser-Camp", "manual", @@ -143,7 +143,7 @@ man_pages = [ ( master_doc, - "python_autograph_utils", + "autograph_utils", u"Python Autograph Utilities Documentation", [author], 1, @@ -159,10 +159,10 @@ texinfo_documents = [ ( master_doc, - "python_autograph_utils", + "autograph_utils", u"Python Autograph Utilities Documentation", author, - "python_autograph_utils", + "autograph_utils", "One line description of project.", "Miscellaneous", ) diff --git a/python_autograph_utils/__init__.py b/python_autograph_utils/__init__.py deleted file mode 100644 index 69fd79f..0000000 --- a/python_autograph_utils/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Top-level package for Python Autograph Utilities.""" - -__author__ = """Ethan Glasser-Camp""" -__email__ = "eglassercamp@mozilla.com" -__version__ = "0.1.0" diff --git a/python_autograph_utils/cli.py b/python_autograph_utils/cli.py deleted file mode 100644 index 41d15c4..0000000 --- a/python_autograph_utils/cli.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: utf-8 -*- - -"""Console script for python_autograph_utils.""" -import sys - -import click - - -@click.command() -def main(args=None): - """Console script for python_autograph_utils.""" - click.echo( - "Replace this message by putting your code into " - "python_autograph_utils.cli.main" - ) - click.echo("See click documentation at https://click.palletsprojects.com/") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) # pragma: no cover diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bdb8185 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +aiohttp==3.6.2 +cryptography==2.8 +ecdsa==0.13.3 diff --git a/requirements_dev.txt b/requirements_dev.txt index 80ec7c7..0772f27 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,8 +1,10 @@ +aioresponses==0.6.1 Click==7.0 coverage==4.5.4 flake8==3.7.8 pip==19.2.3 pytest==4.6.5 +pytest-aiohttp==0.3.0 pytest-runner==5.1 Sphinx==1.8.5 tox==3.14.0 diff --git a/setup.py b/setup.py index 1072860..9415a74 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ with open("HISTORY.rst") as history_file: history = history_file.read() -requirements = ["Click>=7.0"] +requirements = ["Click>=7.0", "aiohttp>=3.6", "cryptography>=2.8", "ecdsa>=0.13"] setup_requirements = ["pytest-runner"] @@ -34,18 +34,14 @@ "Programming Language :: Python :: 3.7", ], description="A library to simplify use of Autograph", - entry_points={ - "console_scripts": ["python_autograph_utils=python_autograph_utils.cli:main"] - }, + entry_points={"console_scripts": ["autograph_utils=autograph_utils.cli:main"]}, install_requires=requirements, license="Apache Software License 2.0", long_description=readme + "\n\n" + history, include_package_data=True, - keywords="python_autograph_utils", - name="python_autograph_utils", - packages=find_packages( - include=["python_autograph_utils", "python_autograph_utils.*"] - ), + keywords="autograph_utils", + name="autograph_utils", + packages=find_packages(include=["autograph_utils", "autograph_utils.*"]), setup_requires=setup_requirements, test_suite="tests", tests_require=test_requirements, diff --git a/tests/__init__.py b/tests/__init__.py index a4ed574..99f60de 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- -"""Unit test package for python_autograph_utils.""" +"""Unit test package for autograph_utils.""" diff --git a/tests/normandy.content-signature.mozilla.org-20210705.dev.chain b/tests/normandy.content-signature.mozilla.org-20210705.dev.chain new file mode 100644 index 0000000..5bf5378 --- /dev/null +++ b/tests/normandy.content-signature.mozilla.org-20210705.dev.chain @@ -0,0 +1,123 @@ +-----BEGIN CERTIFICATE----- +MIIGRTCCBC2gAwIBAgIEAQAABTANBgkqhkiG9w0BAQwFADBrMQswCQYDVQQGEwJV +UzEQMA4GA1UEChMHQWxsaXpvbTEXMBUGA1UECxMOQ2xvdWQgU2VydmljZXMxMTAv +BgNVBAMTKERldnppbGxhIFNpZ25pbmcgU2VydmljZXMgSW50ZXJtZWRpYXRlIDEw +HhcNMTYwNzA2MjE1NzE1WhcNMjEwNzA1MjE1NzE1WjCBrzELMAkGA1UEBhMCVVMx +EzARBgNVBAgTCkNhbGlmb3JuaWExHDAaBgNVBAoTE01vemlsbGEgQ29ycG9yYXRp +b24xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMS8wLQYDVQQDEyZub3JtYW5keS5j +b250ZW50LXNpZ25hdHVyZS5tb3ppbGxhLm9yZzEjMCEGCSqGSIb3DQEJARYUc2Vj +dXJpdHlAbW96aWxsYS5vcmcwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARUQqIIAiTB +GDVUWw/wk5h1IXpreq+BtE+gQr15O4tusHpCLGjOxwpHiJYnxk45fpE8JGAV19UO +hmqMUEU0k31C1EGTSZW0ducSvHrh3a8wXShZ6dxLWHItbbCGA6A7PumjggJYMIIC +VDAdBgNVHQ4EFgQUVfksSjlZ0i1TBiS1vcoObaMeXn0wge8GA1UdIwSB5zCB5IAU +/YboUIXAovChEpudDBuodHKbjUuhgcWkgcIwgb8xCzAJBgNVBAYTAlVTMQswCQYD +VQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVu +dCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNp +Z25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290 +Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIEAQAABDAMBgNVHRMBAf8EAjAA +MA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggrBgEFBQcDAzBEBgNVHR8E +PTA7MDmgN6A1hjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3Mu +bmV0L2NhL2NybC5wZW0wQgYJYIZIAYb4QgEEBDUWM2h0dHBzOi8vY29udGVudC1z +aWduYXR1cmUuZGV2Lm1vemF3cy5uZXQvY2EvY3JsLnBlbTBOBggrBgEFBQcBAQRC +MEAwPgYIKwYBBQUHMAKGMmh0dHBzOi8vY29udGVudC1zaWduYXR1cmUuZGV2Lm1v +emF3cy5uZXQvY2EvY2EucGVtMDEGA1UdEQQqMCiCJm5vcm1hbmR5LmNvbnRlbnQt +c2lnbmF0dXJlLm1vemlsbGEub3JnMA0GCSqGSIb3DQEBDAUAA4ICAQCwb+8JTAB7 +ZfQmFqPUIV2cQQv696AaDPQCtA9YS4zmUfcLMvfZVAbK397zFr0RMDdLiTUQDoeq +rBEmPXhJRPiv6JAK4n7Jf6Y6XfXcNxx+q3garR09Vm/0CnEq/iV+ZAtPkoKIO9kr +Nkzecd894yQCF4hIuPQ5qtMySeqJmH3Dp13eq4T0Oew1Bu32rNHuBJh2xYBkWdun +aAw/YX0I5EqZBP/XA6gbiA160tTK+hnpnlMtw/ljkvfhHbWpICD4aSiTL8L3vABQ +j7bqjMKR5xDkuGWshZfcmonpvQhGTye/RZ1vz5IzA3VOJt1mz5bdZlitpaOm/Yv0 +x6aODz8GP/PiRWFQ5CW8Uf/7pGc5rSyvnfZV2ix8EzFlo8cUtuN1fjrPFPOFOLvG +iiB6S9nlXiKBGYIDdd8V8iC5xJpzjiAWJQigwSNzuc2K30+iPo3w0zazkwe5V8jW +gj6gItYxh5xwVQTPHD0EOd9HvV1ou42+rH5Y+ISFUm25zz02UtUHEK0BKtL0lmdt +DwVq5jcHn6bx2/iwUtlKvPXtfM/6JjTJlkLZLtS7U5/pwcS0owo9zAL0qg3bdm16 ++v/olmPqQFLUHmamJTzv3rojj5X/uVdx1HMM3wBjV9tRYoYaZw9RIInRmM8Z1pHv +JJ+CIZgCyd5vgp57BKiodRZcgHoCH+BkOQ== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIHijCCBXKgAwIBAgIEAQAABDANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMC +VVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQK +Ex1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNv +bnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2Vj +K2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjIx +NDkyNloXDTIxMDcwNTIxNDkyNlowazELMAkGA1UEBhMCVVMxEDAOBgNVBAoTB0Fs +bGl6b20xFzAVBgNVBAsTDkNsb3VkIFNlcnZpY2VzMTEwLwYDVQQDEyhEZXZ6aWxs +YSBTaWduaW5nIFNlcnZpY2VzIEludGVybWVkaWF0ZSAxMIICIjANBgkqhkiG9w0B +AQEFAAOCAg8AMIICCgKCAgEAypIfUURYgWzGw8G/1Pz9zW+Tsjirx2owThiv2gys +wJiWL/9/2gzKOrYDEqlDUudfA/BjVRtT9+NbYgnhaCkNfADOAacWS83aMhedAqhP +bVd5YhGQdpijI7f1AVTSb0ehrU2nhOZHvHX5Tk2fbRx3ryefIazNTLFGpiMBbsNv +tSI/+fjW8s0MhKNqlLnk6a9mZKo0mEy7HjGYV8nzsgI17rKLx/s2HG4TFG0+JQzc +UGlum3Tg58ritDzWdyKIkmKNZ48oLBX99Qc8j8B1UyiLv6TZmjVX0I+Ds7eSWHZk +0axLEpTyf2r7fHvN4iDNCPajw+ZpuuBfbs80Ha8b8MHvnpf9fbwiirodNQOVpY4c +t5E3Us3eYwBKdqDEbECWxCKGAS2/iVVUCNKHsg0sSxgqcwxrxyrddQRUQ0EM38DZ +F/vHt+vTdHt07kezbjJe0Kklel59uSpghA0iL4vxzbTns1fuwYOgVrNGs3acTkiB +GhFOxRXUPGtpdYmv+AaR9WlWJQY1GIEoVrinPVH7bcCwyh1CcUbHL+oAFTcmc6kZ +7azNg21tWILIRL7R0IZYQm0tF5TTwCsjVC7FuHaBtkxtVrrZqeKjvVXQ8TK5VoI0 +BUQ6BKHGeTtm+0HBpheYBDy3wkOsEGbGHLEM6cMeiz6PyCXF8wXli8Qb/TjN3LHZ +e30CAwEAAaOCAd8wggHbMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGG +MBYGA1UdJQEB/wQMMAoGCCsGAQUFBwMDMB0GA1UdDgQWBBT9huhQhcCi8KESm50M +G6h0cpuNSzCB7AYDVR0jBIHkMIHhgBSDx8s0qJaMyQCehKcuzgzVNRA75qGBxaSB +wjCBvzELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFp +biBWaWV3MSYwJAYDVQQKEx1Db250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEm +MCQGA1UEAxMdZGV2LmNvbnRlbnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG +9w0BCQEWLGNsb3Vkc2VjK2RldnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEu +Y29tggEBMEIGCWCGSAGG+EIBBAQ1FjNodHRwczovL2NvbnRlbnQtc2lnbmF0dXJl +LmRldi5tb3phd3MubmV0L2NhL2NybC5wZW0wTgYIKwYBBQUHAQEEQjBAMD4GCCsG +AQUFBzAChjJodHRwczovL2NvbnRlbnQtc2lnbmF0dXJlLmRldi5tb3phd3MubmV0 +L2NhL2NhLnBlbTANBgkqhkiG9w0BAQwFAAOCAgEAbum0z0ccqI1Wp49VtsGmUPHA +gjPPy2Xa5NePmqY87WrGdhjN3xbLVb3hx8T2N6pqDjMY2rEynXKEOek3oJkQ3C6J +8AFP6Y93gaAlNz6EA0J0mqdW5TMI8YEYsu2ma+dQQ8wm9f/5b+/Y8qwfhztP06W5 +H6IG04/RvgUwYMnSR4QvT309fu5UmCRUDzsO53ZmQCfmN94u3NxHc4S6n0Q6AKAM +kh5Ld9SQnlqqqDykzn7hYDi8nTLWc7IYqkGfNMilDEKbAl4CjnSfyEvpdFAJ9sPR +UL+kaWFSMvaqIPNpxS5OpoPZjmxEc9HHnCHxtfDHWdXTJILjijPrCdMaxOCHfIqV +5roOJggI4RZ0YM68IL1MfN4IEVOrHhKjDHtd1gcmy2KU4jfj9LQq9YTnyvZ2z1yS +lS310HG3or1K8Nnu5Utfe7T6ppX8bLRMkS1/w0p7DKxHaf4D/GJcCtM9lcSt9JpW +6ACKFikjWR4ZxczYKgApc0wcjd7XBuO5777xtOeyEUDHdDft3jiXA91dYM5UAzc3 +69z/3zmaELzo0gWcrjLXh7fU9AvbU4EUF6rwzxbPGF78jJcGK+oBf8uWUCkBykDt +VsAEZI1u4EDg8e/C1nFqaH9gNMArAgquYIB9rve+hdprIMnva0S147pflWopBWcb +jwzgpfquuYnnxe0CNBA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIH3DCCBcSgAwIBAgIBATANBgkqhkiG9w0BAQwFADCBvzELMAkGA1UEBhMCVVMx +CzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MSYwJAYDVQQKEx1D +b250ZW50IFNpZ25hdHVyZSBEZXYgU2lnbmluZzEmMCQGA1UEAxMdZGV2LmNvbnRl +bnQtc2lnbmF0dXJlLnJvb3QuY2ExOzA5BgkqhkiG9w0BCQEWLGNsb3Vkc2VjK2Rl +dnJvb3Rjb250ZW50c2lnbmF0dXJlQG1vemlsbGEuY29tMB4XDTE2MDcwNjE4MTUy +MloXDTI2MDcwNDE4MTUyMlowgb8xCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEW +MBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UEChMdQ29udGVudCBTaWduYXR1 +cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5jb250ZW50LXNpZ25hdHVyZS5y +b290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNlYytkZXZyb290Y29udGVudHNp +Z25hdHVyZUBtb3ppbGxhLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC +ggIBAJcPcXhD8MzF6OTn5qZ0L7lX1+PEgLKhI9g1HxxDYDVup4Zm0kZhPGmFSlml +6eVO99OvvHdAlHhQGCIG7h+w1cp66mWjfpcvtQH23uRoKZfiW3jy1jUWrvdXolxR +t1taZosjzo+9OP8TvG6LpJj7AvqUiYD4wYnQJtt0jNRN4d6MUfQwiavSS5uTBuxd +ZJ4TsPvEI+Iv4A4PSobSzxkg79LTMvsGtDLQv7nN5hMs9T18EL5GnIKoqnSQCU0d +n2CN7S3QiQ+cbORWsSYqCTj1gUbFh6X3duEB/ypLcyWFbqeJmPHikjY8q8pLjZSB +IYiTJYLyvYlKdM5QleC/xuBNnMPCftrwwLHjWE4Dd7C9t7k0R5xyOetuiHLCwOcQ +tuckp7RgFKoviMNv3gdkzwVapOklcsaRkRscv6OMTKJNsdJVIDLrPF1wMABhbEQB +64BL0uL4lkFtpXXbJzQ6mgUNQveJkfUWOoB+cA/6GtI4J0aQfvQgloCYI6jxNn/e +Nvk5OV9KFOhXS2dnDft3wHU46sg5yXOuds1u6UrOoATBNFlkS95m4zIX1Svu091+ +CKTiLK85+ZiFtAlU2bPr3Bk3GhL3Z586ae6a4QUEx6SPQVXc18ezB4qxKqFc+avI +ylccYMRhVP+ruADxtUM5Vy6x3U8BwBK5RLdecRx2FEBDImY1AgMBAAGjggHfMIIB +2zAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAWBgNVHSUBAf8EDDAK +BggrBgEFBQcDAzAdBgNVHQ4EFgQUg8fLNKiWjMkAnoSnLs4M1TUQO+YwgewGA1Ud +IwSB5DCB4YAUg8fLNKiWjMkAnoSnLs4M1TUQO+ahgcWkgcIwgb8xCzAJBgNVBAYT +AlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEmMCQGA1UE +ChMdQ29udGVudCBTaWduYXR1cmUgRGV2IFNpZ25pbmcxJjAkBgNVBAMTHWRldi5j +b250ZW50LXNpZ25hdHVyZS5yb290LmNhMTswOQYJKoZIhvcNAQkBFixjbG91ZHNl +YytkZXZyb290Y29udGVudHNpZ25hdHVyZUBtb3ppbGxhLmNvbYIBATBCBglghkgB +hvhCAQQENRYzaHR0cHM6Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5l +dC9jYS9jcmwucGVtME4GCCsGAQUFBwEBBEIwQDA+BggrBgEFBQcwAoYyaHR0cHM6 +Ly9jb250ZW50LXNpZ25hdHVyZS5kZXYubW96YXdzLm5ldC9jYS9jYS5wZW0wDQYJ +KoZIhvcNAQEMBQADggIBAAAQ+fotZE79FfZ8Lz7eiTUzlwHXCdSE2XD3nMROu6n6 +uLTBPrf2C+k+U1FjKVvL5/WCUj6hIzP2X6Sb8+o0XHX9mKN0yoMORTEYJOnazYPK +KSUUFnE4vGgQkr6k/31gGRMTICdnf3VOOAlUCQ5bOmGIuWi401E3sbd85U+TJe0A +nHlU+XjtfzlqcBvQivdbA0s+GEG55uRPvn952aTBEMHfn+2JqKeLShl4AtUAfu+h +6md3Z2HrEC7B3GK8ekWPu0G/ZuWTuFvOimZ+5C8IPRJXcIR/siPQl1x6dpTCew6t +lPVcVuvg6SQwzvxetkNrGUe2Wb2s9+PK2PUvfOS8ee25SNmfG3XK9qJpqGUhzSBX +T8QQgyxd0Su5G7Wze7aaHZd/fqIm/G8YFR0HiC2xni/lnDTXFDPCe+HCnSk0bH6U +wpr6I1yK8oZ2IdnNVfuABGMmGOhvSQ8r7//ea9WKhCsGNQawpVWVioY7hpyNAJ0O +Vn4xqG5f6allz8lgpwAQ+AeEEClHca6hh6mj9KhD1Of1CC2Vx52GHNh/jMYEc3/g +zLKniencBqn3Y2XH2daITGJddcleN09+a1NaTkT3hgr7LumxM8EVssPkC+z9j4Vf +Gbste+8S5QCMhh00g5vR9QF8EaFqdxCdSxrsA4GmpCa5UQl8jtCnpp2DLKXuOh72 +-----END CERTIFICATE----- diff --git a/tests/test_autograph_utils.py b/tests/test_autograph_utils.py new file mode 100755 index 0000000..d77b873 --- /dev/null +++ b/tests/test_autograph_utils.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Tests for `autograph_utils` package.""" + +import datetime +import os.path +from unittest import mock + +import aiohttp +import cryptography.x509 +import pytest +from aioresponses import aioresponses +from click.testing import CliRunner +from cryptography.hazmat.backends import default_backend + +import autograph_utils +from autograph_utils import ( + ExactMatch, + MemoryCache, + SignatureVerifier, + decode_mozilla_hash, + main, +) + +TESTS_BASE = os.path.dirname(__file__) + + +SAMPLE_SIGNATURE = ( + "z7vcSigd9fKX-H8RrL2YBmji6bgmoaRfymtVLFyRcjbhCuXzTpexm2dQfKT-ru9K" + + "D42sKXxZ9ZZmW2wnAy_yoj6nGXaDa35AyYSrQav602s3n4vJ4tYsJi3y0utsz6aD" +) + + +SIGNED_DATA = b"".join( + [ + b'{"action":"console-log","arguments":{"message":"A recipe that was', + b' used to generate a signature"},"capabilities":["action.console-l', + b'og"],"filter_expression":"normandy.channel in [\\"default\\"]","i', + b'd":10,"name":"python-autograph-utils-sample","revision_id":"16"}', + ] +) + + +CERT_PATH = os.path.join( + TESTS_BASE, "normandy.content-signature.mozilla.org-20210705.dev.chain" +) + +FAKE_CERT_URL = ( + "https://example.com/normandy.content-signature.mozilla.org-20210705.dev.chain" +) + +CERT_CHAIN = open(CERT_PATH, "rb").read() + +CERT_LIST = autograph_utils.split_pem(CERT_CHAIN) + +DEV_ROOT_HASH = decode_mozilla_hash( + "4C:35:B1:C3:E3:12:D9:55:E7:78:ED:D0:A7:E7:8A:38:" + + "83:04:EF:01:BF:FA:03:29:B2:46:9F:3C:C5:EC:36:04" +) + + +@pytest.fixture +def mock_aioresponses(): + with aioresponses() as m: + yield m + + +@pytest.fixture +def mock_with_x5u(mock_aioresponses): + mock_aioresponses.get(FAKE_CERT_URL, status=200, body=CERT_CHAIN) + return mock_aioresponses + + +@pytest.fixture +def cache(): + return MemoryCache() + + +@pytest.fixture +def now_fixed(): + with mock.patch("autograph_utils._now") as m: + # A common static time used in a lot of tests. + m.return_value = datetime.datetime(2019, 10, 23, 16, 16) + # Yield the mock so someone can change the time if they want + yield m + + +@pytest.fixture +async def aiohttp_session(loop): + async with aiohttp.ClientSession() as s: + yield s + + +def test_decode_mozilla_hash(): + assert decode_mozilla_hash("4C:35:B1:C3") == b"\x4c\x35\xb1\xc3" + + +async def test_verify_x5u(aiohttp_session, mock_with_x5u, cache, now_fixed): + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + await s.verify_x5u(FAKE_CERT_URL) + + +async def test_verify_signature(aiohttp_session, mock_with_x5u, cache, now_fixed): + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + +async def test_verify_signature_bad_base64( + aiohttp_session, mock_with_x5u, cache, now_fixed +): + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + with pytest.raises(autograph_utils.WrongSignatureSize): + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE[:-3], FAKE_CERT_URL) + + +async def test_verify_signature_bad_numbers( + aiohttp_session, mock_with_x5u, cache, now_fixed +): + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + with pytest.raises(autograph_utils.WrongSignatureSize): + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE[:-4], FAKE_CERT_URL) + + +async def test_verify_x5u_expired(aiohttp_session, mock_with_x5u, cache, now_fixed): + now_fixed.return_value = datetime.datetime(2022, 10, 23, 16, 16, 16) + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + with pytest.raises(autograph_utils.CertificateExpired) as excinfo: + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + assert excinfo.value.detail == "Certificate expired on 2021-07-05 21:57:15" + + +async def test_verify_x5u_too_soon(aiohttp_session, mock_with_x5u, cache, now_fixed): + now_fixed.return_value = datetime.datetime(2010, 10, 23, 16, 16, 16) + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + with pytest.raises(autograph_utils.CertificateNotYetValid) as excinfo: + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + assert excinfo.value.detail == "Certificate is not valid until 2016-07-06 21:57:15" + + +async def test_verify_x5u_screwy_dates( + aiohttp_session, mock_with_x5u, cache, now_fixed +): + now_fixed.return_value = datetime.datetime(2010, 10, 23, 16, 16, 16) + s = SignatureVerifier(aiohttp_session, cache, DEV_ROOT_HASH) + leaf_cert = cryptography.x509.load_pem_x509_certificate( + CERT_LIST[0], backend=default_backend() + ) + bad_cert = mock.Mock(spec=leaf_cert) + bad_cert.not_valid_before = leaf_cert.not_valid_after + bad_cert.not_valid_after = leaf_cert.not_valid_before + with mock.patch("autograph_utils.x509.load_pem_x509_certificate") as x509: + x509.return_value = bad_cert + with pytest.raises(autograph_utils.BadCertificate) as excinfo: + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + assert excinfo.value.detail == ( + "Bad certificate: not_before (2021-07-05 21:57:15) " + "after not_after (2016-07-06 21:57:15)" + ) + + +async def test_verify_x5u_name_exact_match( + aiohttp_session, mock_with_x5u, cache, now_fixed +): + s = SignatureVerifier( + aiohttp_session, + cache, + DEV_ROOT_HASH, + subject_name_check=ExactMatch("normandy.content-signature.mozilla.org"), + ) + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + +async def test_verify_x5u_name_exact_doesnt_match( + aiohttp_session, mock_with_x5u, cache, now_fixed +): + s = SignatureVerifier( + aiohttp_session, + cache, + DEV_ROOT_HASH, + subject_name_check=ExactMatch("remote-settings.content-signature.mozilla.org"), + ) + with pytest.raises(autograph_utils.CertificateHasWrongSubject) as excinfo: + await s.verify(SIGNED_DATA, SAMPLE_SIGNATURE, FAKE_CERT_URL) + + assert excinfo.value.detail == ( + "Certificate does not have the expected subject. " + "Got 'normandy.content-signature.mozilla.org', " + "checking for matches exactly 'remote-settings.content-signature.mozilla.org'" + ) + + +def test_command_line_interface(): + """Test the CLI.""" + runner = CliRunner() + result = runner.invoke(main.main) + assert result.exit_code == 0 + assert "autograph_utils.cli.main" in result.output + help_result = runner.invoke(main.main, ["--help"]) + assert help_result.exit_code == 0 + assert "--help Show this message and exit." in help_result.output diff --git a/tox.ini b/tox.ini index 33d8bd8..1c3a6b6 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ python = [testenv:flake8] basepython = python deps = flake8 -commands = flake8 python_autograph_utils +commands = flake8 autograph_utils [testenv] setenv =