Skip to content

Commit

Permalink
Fix #106: Leverage the autograph-utils lib
Browse files Browse the repository at this point in the history
  • Loading branch information
leplatrem committed Nov 12, 2019
1 parent 379e037 commit 91e4cab
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 146 deletions.
15 changes: 13 additions & 2 deletions checks/remotesettings/certificates_expiration.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,26 @@
import datetime
import logging

import cryptography
import cryptography.x509
from cryptography.hazmat.backends import default_backend as crypto_default_backend

from poucave.typings import CheckResult
from poucave.utils import run_parallel, utcnow
from poucave.utils import fetch_text, run_parallel, utcnow

from .utils import KintoClient
from .validate_signatures import fetch_cert

logger = logging.getLogger(__name__)


async def fetch_cert(x5u):
cert_pem = await fetch_text(x5u)
cert = cryptography.x509.load_pem_x509_certificate(
cert_pem.encode("utf-8"), crypto_default_backend()
)
return cert


async def fetch_collection_metadata(server_url, entry):
client = KintoClient(
server_url=server_url, bucket=entry["bucket"], collection=entry["collection"]
Expand Down
3 changes: 3 additions & 0 deletions checks/remotesettings/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,6 @@ six==1.12.0 \
soupsieve==1.9.5 \
--hash=sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5 \
--hash=sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda
autograph-utils==0.1.0 \
--hash=sha256:589690a4bb3267e6eddcf48b721d9d8196d3d29080d93d0abcce3e6fed862b10 \
--hash=sha256:d1f2d5b7d20e25197ce2ffdfd2a97e29554fb51f929b163a44653c7e3b232e90
112 changes: 34 additions & 78 deletions checks/remotesettings/validate_signatures.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,21 @@
The errors are returned for each concerned collection.
"""
import base64
import datetime
import hashlib
import logging
import time
from typing import Dict, List

import cryptography
import cryptography.x509
import ecdsa
from cryptography.hazmat.backends import default_backend as crypto_default_backend
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.x509.oid import NameOID
from typing import List

from kinto_signer.serializer import canonical_json

from autograph_utils import MemoryCache, SignatureVerifier
from poucave.typings import CheckResult
from poucave.utils import fetch_text, run_parallel, utcnow
from poucave.utils import ClientSession, run_parallel

from .utils import KintoClient

logger = logging.getLogger(__name__)


def unpem(pem):
# Join lines and strip -----BEGIN/END PUBLIC KEY----- header/footer
return b"".join(
[l.strip() for l in pem.split(b"\n") if l and not l.startswith(b"-----")]
)


async def download_collection_data(server_url, entry):
client = KintoClient(
server_url=server_url, bucket=entry["bucket"], collection=entry["collection"]
Expand All @@ -48,53 +33,18 @@ async def download_collection_data(server_url, entry):
return (metadata, records, timestamp)


async def fetch_cert(x5u):
cert_pem = await fetch_text(x5u)
cert = cryptography.x509.load_pem_x509_certificate(
cert_pem.encode("utf-8"), crypto_default_backend()
)
return cert


async def validate_signature(metadata, records, timestamp, checked_certificates):
async def validate_signature(verifier, metadata, records, timestamp):
signature = metadata.get("signature")
assert signature is not None, "Missing signature"
x5u = signature["x5u"]
signature = signature["signature"]

# Serialize as canonical JSON
serialized = canonical_json(records, timestamp)
data = b"Content-Signature:\x00" + serialized.encode("utf-8")
data = canonical_json(records, timestamp).encode("utf-8")

# Verify that the x5u certificate is valid (ie. that signature was well refreshed)
x5u = signature["x5u"]
if x5u not in checked_certificates:
cert = await fetch_cert(x5u)
assert (
cert.not_valid_before.replace(tzinfo=datetime.timezone.utc) < utcnow()
), "Certificate not yet valid"
assert (
cert.not_valid_after.replace(tzinfo=datetime.timezone.utc) > utcnow()
), "Certificate expired"
subject = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
# eg. ``onecrl.content-signature.mozilla.org``, or
# ``pinning-preload.content-signature.mozilla.org``
assert subject.endswith(
".content-signature.mozilla.org"
), "Invalid subject name"
checked_certificates[x5u] = cert

# Verify the signature with the public key
cert = checked_certificates[x5u]
cert_pubkey_pem = cert.public_key().public_bytes(
crypto_serialization.Encoding.PEM,
crypto_serialization.PublicFormat.SubjectPublicKeyInfo,
)
pubkey = unpem(cert_pubkey_pem)
verifier = ecdsa.VerifyingKey.from_pem(pubkey)
signature_bytes = base64.urlsafe_b64decode(signature["signature"])
verifier.verify(signature_bytes, data, hashfunc=hashlib.sha384)
return await verifier.verify(data, signature, x5u)


async def run(server: str, buckets: List[str]) -> CheckResult:
async def run(server: str, buckets: List[str], root_hash: str) -> CheckResult:
client = KintoClient(server_url=server, bucket="monitor", collection="changes")
entries = [
entry for entry in await client.get_records() if entry["bucket"] in buckets
Expand All @@ -107,23 +57,29 @@ async def run(server: str, buckets: List[str]) -> CheckResult:
elapsed_time = time.time() - start_time
logger.info(f"Downloaded all data in {elapsed_time:.2f}s")

# Validate signatures sequentially.
errors = {}
checked_certificates: Dict[str, object] = {}
for i, (entry, (metadata, records, timestamp)) in enumerate(zip(entries, results)):
cid = "{bucket}/{collection}".format(**entry)
message = "{:02d}/{:02d} {}: ".format(i + 1, len(entries), cid)
try:
start_time = time.time()
await validate_signature(metadata, records, timestamp, checked_certificates)
elapsed_time = time.time() - start_time

message += f"OK ({elapsed_time:.2f}s)"
logger.info(message)

except Exception as e:
message += "⚠ Signature Error ⚠ " + str(e)
logger.error(message)
errors[cid] = str(e)
cache = MemoryCache()

async with ClientSession() as session:
verifier = SignatureVerifier(session, cache, root_hash)

# Validate signatures sequentially.
errors = {}
for i, (entry, (metadata, records, timestamp)) in enumerate(
zip(entries, results)
):
cid = "{bucket}/{collection}".format(**entry)
message = "{:02d}/{:02d} {}: ".format(i + 1, len(entries), cid)
try:
start_time = time.time()
await validate_signature(verifier, metadata, records, timestamp)
elapsed_time = time.time() - start_time

message += f"OK ({elapsed_time:.2f}s)"
logger.info(message)

except Exception as e:
message += "⚠ Signature Error ⚠ " + str(e)
logger.error(message)
errors[cid] = str(e)

return len(errors) == 0, errors
31 changes: 24 additions & 7 deletions tests/checks/remotesettings/test_certificates_expiration.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@
from poucave.utils import utcnow
from tests.utils import patch_async

CERT = """-----BEGIN CERTIFICATE-----
MIIDBTCCAougAwIBAgIIFcbkDrCrHAkwCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
dGVudCBTaWduaW5nIEludGVybWVkaWF0ZS9lbWFpbEFkZHJlc3M9Zm94c2VjQG1v
emlsbGEuY29tMB4XDTE5MDgyMzIyNDQzMVoXDTE5MTExMTIyNDQzMVowgakxCzAJ
BgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFp
biBWaWV3MRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMRcwFQYDVQQLEw5D
bG91ZCBTZXJ2aWNlczE2MDQGA1UEAxMtcGlubmluZy1wcmVsb2FkLmNvbnRlbnQt
c2lnbmF0dXJlLm1vemlsbGEub3JnMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAEX6Zd
vZ32rj9rDdRInp0kckbMtAdxOQxJ7EVAEZB2KOLUyotQL6A/9YWrMB4Msb4hfvxj
Nw05CS5/J4qUmsTkKLXQskjRe9x96uOXxprWiVwR4OLYagkJJR7YG1mTXmFzo4GD
MIGAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDAzAfBgNVHSME
GDAWgBSgHUoXT4zCKzVF8WPx2nBwp8744TA4BgNVHREEMTAvgi1waW5uaW5nLXBy
ZWxvYWQuY29udGVudC1zaWduYXR1cmUubW96aWxsYS5vcmcwCgYIKoZIzj0EAwMD
aAAwZQIxAOi2Eusi6MtEPOARiU+kZIi1vPnzTI71cA2ZIpzZ9aYg740eoJml8Guz
3oC6yXiIDAIwSy4Eylf+/nSMA73DUclcCjZc2yfRYIogII+krXBxoLkbPJcGaitx
qvRy6gQ1oC/z
-----END CERTIFICATE-----
"""


def mock_http_calls(mock_responses, server_url):
changes_url = server_url + "/buckets/monitor/collections/changes/records"
Expand All @@ -25,7 +46,6 @@ def mock_http_calls(mock_responses, server_url):

async def test_positive(mock_responses):
server_url = "http://fake.local/v1"

mock_http_calls(mock_responses, server_url)

next_month = utcnow() + timedelta(days=30)
Expand All @@ -45,15 +65,12 @@ async def test_negative(mock_responses):

mock_http_calls(mock_responses, server_url)

next_month = utcnow() + timedelta(days=30)
fake_cert = mock.MagicMock(not_valid_after=next_month)

module = "checks.remotesettings.certificates_expiration"
with patch_async(f"{module}.fetch_cert", return_value=fake_cert) as mocked:
status, data = await run(server_url, min_remaining_days=31)
with patch_async(f"{module}.fetch_text", return_value=CERT) as mocked:
status, data = await run(server_url, min_remaining_days=30)
mocked.assert_called_with("http://fake-x5u")

assert status is False
assert data == {
"bid/cid": {"x5u": "http://fake-x5u", "expires": next_month.isoformat()}
"bid/cid": {"x5u": "http://fake-x5u", "expires": "2019-11-11T22:44:31+00:00"}
}
72 changes: 13 additions & 59 deletions tests/checks/remotesettings/test_validate_signatures.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import datetime
from unittest import mock

import ecdsa
import pytest

from checks.remotesettings.validate_signatures import run, validate_signature
Expand All @@ -10,8 +6,7 @@
MODULE = "checks.remotesettings.validate_signatures"
COLLECTION_URL = "/buckets/{}/collections/{}"
RECORDS_URL = COLLECTION_URL + "/records"

FAKE_CERT = """-----BEGIN CERTIFICATE-----
CERT = """-----BEGIN CERTIFICATE-----
MIIDBTCCAougAwIBAgIIFcbkDrCrHAkwCgYIKoZIzj0EAwMwgaMxCzAJBgNVBAYT
AlVTMRwwGgYDVQQKExNNb3ppbGxhIENvcnBvcmF0aW9uMS8wLQYDVQQLEyZNb3pp
bGxhIEFNTyBQcm9kdWN0aW9uIFNpZ25pbmcgU2VydmljZTFFMEMGA1UEAww8Q29u
Expand All @@ -33,12 +28,6 @@
"""


FAKE_SIGNATURE = (
"ZtN8SKGhuydx6vr7lmKKX7Erln-42ICCo192KqI54-1nloBMEm2-h6bytNtg7RzwUQ8"
"GkBpEAf6AlWmFT6G4REA6Zu8dp2eOjY9e5Oo2MkZ59iDySbbChNVaKu3jVb0h"
)


async def test_positive(mock_responses):
server_url = "http://fake.local/v1"
changes_url = server_url + RECORDS_URL.format("monitor", "changes")
Expand All @@ -63,14 +52,15 @@ async def test_positive(mock_responses):
)

with patch_async(f"{MODULE}.validate_signature"):
status, data = await run(server_url, ["bid"])
status, data = await run(server_url, ["bid"], root_hash="foo")

assert status is True
assert data == {}


async def test_negative(mock_responses):
async def test_negative(mock_responses, mock_aioresponses):
server_url = "http://fake.local/v1"
x5u_url = "http://fake-x5u-url/"
changes_url = server_url + RECORDS_URL.format("monitor", "changes")
mock_responses.get(
changes_url,
Expand All @@ -80,58 +70,22 @@ async def test_negative(mock_responses):
]
},
)
mock_aioresponses.get(x5u_url, body=CERT)

metadata = {"signature": {"x5u": x5u_url, "signature": ""}}

with patch_async(
f"{MODULE}.download_collection_data", return_value=({"signature": {}}, [], 42)
f"{MODULE}.download_collection_data", return_value=(metadata, [], 42),
):
with patch_async(
f"{MODULE}.validate_signature", side_effect=AssertionError("boom")
):

status, data = await run(server_url, ["bid"])
status, data = await run(server_url, ["bid"], root_hash="foo")

assert status is False
assert data == {"bid/cid": "boom"}
assert data == {
"bid/cid": "CertificateExpired(datetime.datetime(2019, 11, 11, 22, 44, 31))"
}


async def test_missing_signature():
with pytest.raises(AssertionError) as exc_info:
await validate_signature({}, [], 0, {})
await validate_signature(verifier=None, metadata={}, records=[], timestamp=42)
assert exc_info.value.args[0] == "Missing signature"


async def test_outdated_certificate(mock_aioresponses):
url = "http://some/cert"
mock_aioresponses.get(url, body=FAKE_CERT)
fake = {"signature": FAKE_SIGNATURE, "x5u": url}

fake_now = datetime.datetime(2021, 1, 1).replace(tzinfo=datetime.timezone.utc)
with mock.patch(f"{MODULE}.utcnow", return_value=fake_now):
with pytest.raises(AssertionError) as exc_info:
await validate_signature({"signature": fake}, [], 1485794868067, {})

assert exc_info.value.args[0] == "Certificate expired"


async def test_valid_signature(mock_aioresponses):
url = "http://some/cert"
mock_aioresponses.get(url, body=FAKE_CERT)
fake = {"signature": FAKE_SIGNATURE, "x5u": url}

fake_now = datetime.datetime(2019, 9, 9).replace(tzinfo=datetime.timezone.utc)
with mock.patch(f"{MODULE}.utcnow", return_value=fake_now):
# Not raising.
await validate_signature({"signature": fake}, [], 1485794868067, {})


async def test_invalid_signature(mock_aioresponses):
url = "http://some/cert"
mock_aioresponses.get(url, body=FAKE_CERT)
fake = {"signature": "_" + FAKE_SIGNATURE[1:], "x5u": url}

fake_now = datetime.datetime(2019, 9, 9).replace(tzinfo=datetime.timezone.utc)
with mock.patch(f"{MODULE}.utcnow", return_value=fake_now):
with pytest.raises(Exception) as exc_info:
await validate_signature({"signature": fake}, [], 1485794868067, {})

assert type(exc_info.value) == ecdsa.keys.BadSignatureError

0 comments on commit 91e4cab

Please sign in to comment.