Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC][WIP] Use cryptography cert validation instead of PyOpenSSL #246

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
WIP: Use cryptography cert validation instead of PyOpenSSL
bluetech committed Feb 15, 2025
commit e6cfd95cab91e2ea9d1f1a63c6428a56cd8d7afa
3 changes: 0 additions & 3 deletions mypy.ini
Original file line number Diff line number Diff line change
@@ -6,6 +6,3 @@ ignore_missing_imports = True

[mypy-cbor2.*]
ignore_missing_imports = True

[mypy-OpenSSL.*]
ignore_missing_imports = True
3 changes: 1 addition & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@ black==24.8.0
cbor2==5.6.5
cffi==1.17.1
click==8.1.7
cryptography==43.0.3
cryptography==45.0.0
mccabe==0.7.0
mypy==1.11.2
mypy-extensions==1.0.0
@@ -12,7 +12,6 @@ platformdirs==4.3.6
pycodestyle==2.12.1
pycparser==2.22
pyflakes==3.2.0
pyOpenSSL==24.2.1
regex==2024.11.6
six==1.16.0
toml==0.10.2
3 changes: 1 addition & 2 deletions setup.py
Original file line number Diff line number Diff line change
@@ -49,7 +49,6 @@ def find_version(*file_paths):
install_requires=[
"asn1crypto>=1.5.1",
"cbor2>=5.6.5",
"cryptography>=43.0.3",
"pyOpenSSL>=24.2.1",
"cryptography>=45.0.0",
],
)
32 changes: 9 additions & 23 deletions tests/test_validate_certificate_chain.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import datetime
from unittest import TestCase
from unittest.mock import MagicMock, patch

from cryptography.hazmat.backends import default_backend
from cryptography.x509 import load_pem_x509_certificate
from webauthn.helpers.exceptions import InvalidCertificateChain
from webauthn.helpers.known_root_certs import (
apple_webauthn_root_ca,
@@ -11,8 +9,9 @@
from webauthn.helpers.validate_certificate_chain import (
validate_certificate_chain,
)
from OpenSSL.crypto import X509, X509StoreContextError

# Not Valid Before: 2021-08-31 23:02:07+00:00
# Not Valid After: 2021-09-03 23:02:07+00:00
apple_x5c_certs = [
bytes.fromhex(
"30820243308201c9a0030201020206017ba3992221300a06082a8648ce3d0403023048311c301a06035504030c134170706c6520576562417574686e204341203131133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e6961301e170d3231303833313233303230375a170d3231303930333233303230375a3081913149304706035504030c4062313066373138626335646437353838383661316438636662356238623633313732396634643765346261303639616230613939326331633038343738616639311a3018060355040b0c114141412043657274696669636174696f6e31133011060355040a0c0a4170706c6520496e632e3113301106035504080c0a43616c69666f726e69613059301306072a8648ce3d020106082a8648ce3d03010703420004d124b0e9ff8192723c9ee2fa4f8170d373e03286cf880aeec7008a14cdea64724963e05bb8c44a9f980ded12aa8a33795cf81d31e74116ced6f1f4c5eb0c358fa3553053300c0603551d130101ff04023000300e0603551d0f0101ff0404030204f0303306092a864886f76364080204263024a1220420e457e5bc292f1635210248ed2e776ba129c7cc469524a75356836caef2f058a0300a06082a8648ce3d0403020368003065023065c6e7075ddacb50879a8412904759013d0da78726408759a01f1994c1795a69c2c1d11306c2d1bc97be6141627b8677023100ab0b9e7d97ca2b603b1edb6e264c49bf1971380c2afa5d37f8c4ff5a5de6d457a19cb80c02b2edf94b0853e0482f8686"
@@ -24,39 +23,26 @@


class TestValidateCertificateChain(TestCase):
# TODO: Revisit these tests when we figure out how to generate dynamic certs that
# won't start failing tests 72 hours after creation...
@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
def test_validates_certificate_chain(self, mock_verify_certificate: MagicMock) -> None:
# Mocked because these certs actually expired and started failing this test
mock_verify_certificate.return_value = True

def test_validates_certificate_chain(self) -> None:
time = datetime.datetime(2021, 9, 1, 13, 5, 3, 5353, datetime.timezone.utc)
try:
validate_certificate_chain(
x5c=apple_x5c_certs,
pem_root_certs_bytes=[apple_webauthn_root_ca],
time=time,
)
except Exception as err:
print(err)
self.fail("validate_certificate_chain failed when it should have succeeded")

@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
def test_throws_on_bad_root_cert(self, mock_verify_certificate: MagicMock) -> None:
# Mocked because these certs actually expired and started failing this test
root_cert_x509 = X509().from_cryptography(
load_pem_x509_certificate(globalsign_root_ca, default_backend())
)
mock_verify_certificate.side_effect = X509StoreContextError(
"bad root cert",
[],
root_cert_x509,
)

def test_throws_on_bad_root_cert(self) -> None:
time = datetime.datetime(2021, 9, 1, 13, 5, 3, 5353, datetime.timezone.utc)
with self.assertRaises(InvalidCertificateChain):
validate_certificate_chain(
x5c=apple_x5c_certs,
# An obviously invalid root cert for these x5c certs
pem_root_certs_bytes=[globalsign_root_ca],
time=time,
)

def test_passes_on_no_root_certs(self):
13 changes: 4 additions & 9 deletions tests/test_verify_registration_response_android_key.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import datetime
from unittest import TestCase
from unittest.mock import MagicMock, patch

from webauthn.helpers import base64url_to_bytes
from webauthn.helpers.structs import AttestationFormat
from webauthn import verify_registration_response


class TestVerifyRegistrationResponseAndroidKey(TestCase):
@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
def test_verify_attestation_android_key_hardware_authority(
self, mock_verify_certificate: MagicMock
):
# Mocked because these certs actually expired and started failing this test
mock_verify_certificate.return_value = True

def test_verify_attestation_android_key_hardware_authority(self):
"""
This android-key attestation was generated on a Pixel 8a in January 2025 via an origin
trial. Google will be sunsetting android-safetynet attestation for android-key attestations
@@ -35,7 +29,7 @@ def test_verify_attestation_android_key_hardware_authority(
},
"authenticatorAttachment": "platform"
}"""

time = datetime.datetime(2025, 1, 30, 17, 8, 43, 5353, datetime.timezone.utc)
challenge = base64url_to_bytes("t4LWI0iYJSTWPl9WXUdNhdHAnrPDLF9eWAP9lHgmHP8")
rp_id = "localhost"
expected_origin = "http://localhost:8000"
@@ -45,6 +39,7 @@ def test_verify_attestation_android_key_hardware_authority(
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=rp_id,
time=time,
)

assert verification.fmt == AttestationFormat.ANDROID_KEY
23 changes: 8 additions & 15 deletions tests/test_verify_registration_response_android_safetynet.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
from unittest import TestCase
from unittest.mock import MagicMock, patch

@@ -10,15 +11,7 @@


class TestVerifyRegistrationResponseAndroidSafetyNet(TestCase):
# TODO: Revisit these tests when we figure out how to generate dynamic certs that
# won't start failing tests 72 hours after creation...
@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
def test_verify_attestation_android_safetynet(
self, mock_verify_certificate: MagicMock
) -> None:
# Mocked because these certs actually expired and started failing this test
mock_verify_certificate.return_value = True

def test_verify_attestation_android_safetynet(self) -> None:
credential = parse_registration_credential_json(
"""{
"id": "AePltP2wAoNYwG5XGc9sfleGgDxQRHdkX8vphNIHv3HylIj_nZo9ncs7bLL65AGmVAc69pS4l64hgOBJU9o2jCQ",
@@ -32,6 +25,7 @@ def test_verify_attestation_android_safetynet(
}
"""
)
time = datetime.datetime(2021, 9, 4, 0, 39, 28, 5353, datetime.timezone.utc)

parsed_attestation_object = parse_attestation_object(
credential.response.attestation_object
@@ -44,26 +38,24 @@ def test_verify_attestation_android_safetynet(
client_data_json=credential.response.client_data_json,
pem_root_certs_bytes=[],
verify_timestamp_ms=False,
time=time,
)

assert verified is True

@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
@patch("base64.b64encode")
@patch("cbor2.loads")
def test_verify_attestation_android_safetynet_basic_integrity_true_cts_profile_match_false(
self,
mock_cbor2_loads: MagicMock,
mock_b64encode: MagicMock,
mock_verify_certificate: MagicMock,
):
"""
We're not working with a full WebAuthn response here so we have to mock out some values
because all we really want to test is that such a response is allowed through
"""
mock_cbor2_loads.return_value = {"authData": bytes()}
mock_b64encode.return_value = "3N7YJmISsFM0cdvMAYcHcw==".encode("utf-8")
mock_verify_certificate.return_value = True

# basicIntegrity: True, ctsProfileMatch: False
jws_result_only_fail_cts_check = (
@@ -101,6 +93,7 @@ def test_verify_attestation_android_safetynet_basic_integrity_true_cts_profile_m
"S2W1MzvpXwq1KMFvrcka7C4t5vyOhMMYwY6BWEnAGcx5_tpJsqegXTgTHSrr4TFQJzsa-H8wb1"
"YaxlMcRVSqOew"
)
time = datetime.datetime(2021, 9, 4, 0, 39, 28, 5353, datetime.timezone.utc)

attestation_statement = AttestationStatement(
ver="0",
@@ -113,26 +106,24 @@ def test_verify_attestation_android_safetynet_basic_integrity_true_cts_profile_m
client_data_json=bytes(),
pem_root_certs_bytes=[],
verify_timestamp_ms=False,
time=time,
)

assert verified is True

@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
@patch("base64.b64encode")
@patch("cbor2.loads")
def test_raise_attestation_android_safetynet_basic_integrity_false_cts_profile_match_false(
self,
mock_cbor2_loads: MagicMock,
mock_b64encode: MagicMock,
mock_verify_certificate: MagicMock,
):
"""
We're not working with a full WebAuthn response here so we have to mock out some values
because all we really want to test is that a response fails the basicIntegrity check
"""
mock_cbor2_loads.return_value = {"authData": bytes()}
mock_b64encode.return_value = "NumMA+QH27ik6Mu737RgWg==".encode("utf-8")
mock_verify_certificate.return_value = True

# basicIntegrity: False, ctsProfileMatch: False
jws_result_fail = (
@@ -170,6 +161,7 @@ def test_raise_attestation_android_safetynet_basic_integrity_false_cts_profile_m
"4OVdwMd5seh5483VEpqAmzX7NcZ0aoiMl5PhLGgzHZTrsd1Mc-RZqgc3hAYjnubxONN8vOWGzP"
"gI2Vzgr4VzLOZsWfYwKSR5g"
)
time = datetime.datetime(2019, 10, 20, 0, 39, 28, 5353, datetime.timezone.utc)

attestation_statement = AttestationStatement(
ver="0",
@@ -186,4 +178,5 @@ def test_raise_attestation_android_safetynet_basic_integrity_false_cts_profile_m
client_data_json=bytes(),
pem_root_certs_bytes=[],
verify_timestamp_ms=False,
time=time,
)
12 changes: 4 additions & 8 deletions tests/test_verify_registration_response_apple.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
import datetime
from unittest import TestCase
from unittest.mock import MagicMock, patch

from webauthn.helpers import base64url_to_bytes
from webauthn.helpers.structs import AttestationFormat
from webauthn import verify_registration_response


class TestVerifyRegistrationResponseApple(TestCase):
# TODO: Revisit these tests when we figure out how to generate dynamic certs that
# won't start failing tests 72 hours after creation...
@patch("OpenSSL.crypto.X509StoreContext.verify_certificate")
def test_verify_attestation_apple_passkey(self, mock_verify_certificate: MagicMock) -> None:
# Mocked because these certs actually expired and started failing this test
mock_verify_certificate.return_value = True

def test_verify_attestation_apple_passkey(self) -> None:
credential = """{
"id": "0yhsKG_gCzynIgNbvXWkqJKL8Uc",
"rawId": "0yhsKG_gCzynIgNbvXWkqJKL8Uc",
@@ -29,12 +23,14 @@ def test_verify_attestation_apple_passkey(self, mock_verify_certificate: MagicMo
)
rp_id = "dev2.dontneeda.pw"
expected_origin = "https://dev2.dontneeda.pw:5000"
time = datetime.datetime(2021, 9, 1, 0, 39, 28, 5353, datetime.timezone.utc)

verification = verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_origin=expected_origin,
expected_rp_id=rp_id,
time=time,
)

assert verification.fmt == AttestationFormat.APPLE
37 changes: 13 additions & 24 deletions tests/test_verify_safetynet_timestamp.py
Original file line number Diff line number Diff line change
@@ -1,59 +1,48 @@
import datetime
from unittest import TestCase
from unittest.mock import MagicMock, patch

from webauthn.helpers import verify_safetynet_timestamp


class TestVerifySafetyNetTimestamp(TestCase):
mock_time: MagicMock
# time.time() returns time in microseconds
mock_now = 1636589648

def setUp(self) -> None:
super().setUp()
time_patch = patch("time.time")
self.mock_time = time_patch.start()
self.mock_time.return_value = self.mock_now

def tearDown(self) -> None:
super().tearDown()
patch.stopall()
now_ms = 1636589648000
now_dt = datetime.datetime.fromtimestamp(now_ms / 1000, datetime.timezone.utc)

def test_does_not_raise_on_timestamp_slightly_in_future(self):
# Put timestamp just a bit in the future
timestamp_ms = (self.mock_now * 1000) + 600
verify_safetynet_timestamp(timestamp_ms)
timestamp_ms = self.now_ms + 600
verify_safetynet_timestamp(timestamp_ms, time=self.now_dt)

assert True

def test_does_not_raise_on_timestamp_slightly_in_past(self):
# Put timestamp just a bit in the past
timestamp_ms = (self.mock_now * 1000) - 600
verify_safetynet_timestamp(timestamp_ms)
timestamp_ms = self.now_ms - 600
verify_safetynet_timestamp(timestamp_ms, time=self.now_dt)

assert True

def test_raises_on_timestamp_too_far_in_future(self):
# Put timestamp 20 seconds in the future
timestamp_ms = (self.mock_now * 1000) + 20000
timestamp_ms = self.now_ms + 20000
self.assertRaisesRegex(
ValueError,
"was later than",
lambda: verify_safetynet_timestamp(timestamp_ms),
lambda: verify_safetynet_timestamp(timestamp_ms, time=self.now_dt)
)

def test_raises_on_timestamp_too_far_in_past(self):
# Put timestamp 20 seconds in the past
timestamp_ms = (self.mock_now * 1000) - 20000
timestamp_ms = self.now_ms - 20000
self.assertRaisesRegex(
ValueError,
"expired",
lambda: verify_safetynet_timestamp(timestamp_ms),
lambda: verify_safetynet_timestamp(timestamp_ms, time=self.now_dt)
)

def test_does_not_raise_on_last_possible_millisecond(self):
# Timestamp is verified at the exact last millisecond
timestamp_ms = (self.mock_now * 1000) + 10000
verify_safetynet_timestamp(timestamp_ms)
timestamp_ms = self.now_ms + 10000
verify_safetynet_timestamp(timestamp_ms, time=self.now_dt)

assert True
12 changes: 0 additions & 12 deletions webauthn/helpers/pem_cert_bytes_to_open_ssl_x509.py

This file was deleted.

Loading