refactor(protocol): add certificate authentication provider
This commit is contained in:
@@ -1,31 +1,69 @@
|
||||
import gc
|
||||
import traceback
|
||||
import weakref
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from OpenSSL import SSL, crypto
|
||||
|
||||
from smartthings_local.protocol.auth import CertificateAuth
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession, _load_pem_chain
|
||||
|
||||
|
||||
def _make_self_signed_pem_pair():
|
||||
"""A throwaway self-signed cert + key, just to exercise PEM loading —
|
||||
not meant to resemble a real Samsung client cert."""
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, 2048)
|
||||
def _make_generated_pem_chain():
|
||||
"""Create a throwaway leaf + root chain unrelated to Samsung devices."""
|
||||
now = datetime.now(timezone.utc)
|
||||
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
root_name = x509.Name(
|
||||
[x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic test root")]
|
||||
)
|
||||
root_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(root_name)
|
||||
.issuer_name(root_name)
|
||||
.public_key(root_key.public_key())
|
||||
.serial_number(1)
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(hours=1))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
cert = crypto.X509()
|
||||
cert.get_subject().CN = "test"
|
||||
cert.set_serial_number(1)
|
||||
cert.gmtime_adj_notBefore(0)
|
||||
cert.gmtime_adj_notAfter(3600)
|
||||
cert.set_issuer(cert.get_subject())
|
||||
cert.set_pubkey(key)
|
||||
cert.sign(key, "sha256")
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_name = x509.Name(
|
||||
[x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic test client")]
|
||||
)
|
||||
leaf_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(leaf_name)
|
||||
.issuer_name(root_name)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(2)
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(hours=1))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), True)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
|
||||
key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode()
|
||||
cert_pem = (
|
||||
leaf_cert.public_bytes(serialization.Encoding.PEM)
|
||||
+ root_cert.public_bytes(serialization.Encoding.PEM)
|
||||
).decode()
|
||||
key_pem = leaf_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def test_load_pem_chain_loads_cert_and_key_in_memory():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
_load_pem_chain(ctx, cert_pem, key_pem)
|
||||
ctx.check_privatekey() # raises if cert/key don't match
|
||||
@@ -37,7 +75,7 @@ def test_load_pem_chain_rejects_cert_pem_with_no_certificates():
|
||||
|
||||
|
||||
def test_session_requires_exactly_one_cert_source():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234) # neither pair given
|
||||
@@ -49,10 +87,226 @@ def test_session_requires_exactly_one_cert_source():
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234, cert_pem=cert_pem) # key_pem missing
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234, cert_path="/a") # key_path missing
|
||||
|
||||
|
||||
def test_session_rejects_provider_with_legacy_certificate_arguments():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
with pytest.raises(ValueError, match="auth or legacy certificate"):
|
||||
DtlsCoapSession(
|
||||
"host",
|
||||
1234,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
||||
def test_session_rejects_object_that_is_not_an_authentication_provider():
|
||||
with pytest.raises(TypeError, match="AuthenticationProvider"):
|
||||
DtlsCoapSession("host", 1234, auth=object())
|
||||
|
||||
|
||||
def test_session_accepts_explicit_certificate_provider():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
session = DtlsCoapSession("host", 1234, auth=auth)
|
||||
|
||||
assert session.auth is auth
|
||||
assert session.cert_path is None
|
||||
assert session.key_path is None
|
||||
assert session.cert_pem is None
|
||||
assert session.key_pem is None
|
||||
|
||||
|
||||
def test_session_retains_authentication_provider_for_its_lifetime():
|
||||
class RetainedProvider:
|
||||
def configure_context(self, _context):
|
||||
return None
|
||||
|
||||
auth = RetainedProvider()
|
||||
reference = weakref.ref(auth)
|
||||
session = DtlsCoapSession("host", 1234, auth=auth)
|
||||
|
||||
del auth
|
||||
gc.collect()
|
||||
assert reference() is session.auth
|
||||
|
||||
|
||||
def test_session_accepts_pem_pair():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
sess = DtlsCoapSession("host", 1234, cert_pem=cert_pem, key_pem=key_pem)
|
||||
assert sess.cert_path is None
|
||||
assert sess.key_path is None
|
||||
assert sess.cert_pem == cert_pem
|
||||
assert isinstance(sess.auth, CertificateAuth)
|
||||
|
||||
|
||||
def test_session_routes_legacy_file_pair_through_certificate_auth(tmp_path):
|
||||
cert_path = tmp_path / "client.pem"
|
||||
key_path = tmp_path / "client-key.pem"
|
||||
session = DtlsCoapSession(
|
||||
"host",
|
||||
1234,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
)
|
||||
|
||||
assert isinstance(session.auth, CertificateAuth)
|
||||
assert session.cert_path == str(cert_path)
|
||||
assert session.key_path == str(key_path)
|
||||
|
||||
|
||||
def test_certificate_auth_loads_generated_chain_from_memory_and_files(tmp_path):
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
|
||||
memory_context = SSL.Context(SSL.DTLS_METHOD)
|
||||
CertificateAuth.from_memory(cert_pem, key_pem).configure_context(
|
||||
memory_context
|
||||
)
|
||||
memory_context.check_privatekey()
|
||||
|
||||
cert_path = tmp_path / "client.pem"
|
||||
key_path = tmp_path / "client-key.pem"
|
||||
cert_path.write_text(cert_pem)
|
||||
key_path.write_text(key_pem)
|
||||
file_context = SSL.Context(SSL.DTLS_METHOD)
|
||||
CertificateAuth.from_files(cert_path, key_path).configure_context(file_context)
|
||||
file_context.check_privatekey()
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_invalid_memory_material():
|
||||
auth = CertificateAuth.from_memory("not a certificate", "not a key")
|
||||
with pytest.raises(ValueError, match="No certificates found"):
|
||||
auth.configure_context(SSL.Context(SSL.DTLS_METHOD))
|
||||
|
||||
|
||||
def test_invalid_certificate_error_does_not_include_credential_material():
|
||||
marker = "credential" + "-marker"
|
||||
certificate_blob = (
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
f"{marker}\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
)
|
||||
key_blob = "invalid-" + marker
|
||||
auth = CertificateAuth.from_memory(certificate_blob, key_blob)
|
||||
|
||||
with pytest.raises(crypto.Error) as captured:
|
||||
auth.configure_context(SSL.Context(SSL.DTLS_METHOD))
|
||||
rendered = (
|
||||
str(captured.value)
|
||||
+ repr(captured.value)
|
||||
+ "".join(traceback.format_exception(captured.value))
|
||||
)
|
||||
assert marker not in rendered
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_invalid_file_material(tmp_path):
|
||||
cert_path = tmp_path / "invalid.pem"
|
||||
key_path = tmp_path / "invalid-key.pem"
|
||||
cert_path.write_text("invalid")
|
||||
key_path.write_text("invalid")
|
||||
|
||||
with pytest.raises(SSL.Error):
|
||||
CertificateAuth.from_files(cert_path, key_path).configure_context(
|
||||
SSL.Context(SSL.DTLS_METHOD)
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_incomplete_or_mixed_sources():
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth()
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(certificate_path="/synthetic/client.pem")
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(certificate_pem="certificate")
|
||||
|
||||
certificate_path = "/synthetic/client.pem"
|
||||
key_path = "/synthetic/client-key.pem"
|
||||
certificate_data = "certificate"
|
||||
key_data = "key"
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(
|
||||
certificate_path=certificate_path,
|
||||
private_key_path=key_path,
|
||||
certificate_pem=certificate_data,
|
||||
private_key_pem=key_data,
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_auth_is_immutable_and_has_secret_safe_repr():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
rendered = repr(auth)
|
||||
assert rendered == "CertificateAuth()"
|
||||
assert cert_pem not in rendered
|
||||
assert key_pem not in rendered
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
auth.certificate_pem = None
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
del auth._certificate_pem
|
||||
|
||||
|
||||
def test_certificate_auth_has_no_public_or_dataclass_credential_surface():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
assert not hasattr(auth, "certificate_pem")
|
||||
assert not hasattr(auth, "private_key_pem")
|
||||
with pytest.raises(TypeError):
|
||||
vars(auth)
|
||||
with pytest.raises(TypeError):
|
||||
asdict(auth)
|
||||
|
||||
|
||||
def test_certificate_auth_context_setup_matches_legacy_happy_path():
|
||||
class RecordingContext:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.verify_callback = None
|
||||
|
||||
def load_verify_locations(self, path):
|
||||
self.calls.append(("load_verify_locations", path))
|
||||
|
||||
def set_verify(self, mode, callback):
|
||||
self.calls.append(("set_verify", mode))
|
||||
self.verify_callback = callback
|
||||
|
||||
def set_cipher_list(self, ciphers):
|
||||
self.calls.append(("set_cipher_list", ciphers))
|
||||
|
||||
def use_certificate_chain_file(self, path):
|
||||
self.calls.append(("use_certificate_chain_file", path))
|
||||
|
||||
def use_privatekey_file(self, path):
|
||||
self.calls.append(("use_privatekey_file", path))
|
||||
|
||||
def check_privatekey(self):
|
||||
self.calls.append(("check_privatekey",))
|
||||
|
||||
context = RecordingContext()
|
||||
CertificateAuth.from_files(
|
||||
"/synthetic/client.pem",
|
||||
"/synthetic/client-key.pem",
|
||||
).configure_context(context)
|
||||
|
||||
assert [call[0] for call in context.calls] == [
|
||||
"load_verify_locations",
|
||||
"set_verify",
|
||||
"set_cipher_list",
|
||||
"use_certificate_chain_file",
|
||||
"use_privatekey_file",
|
||||
"check_privatekey",
|
||||
]
|
||||
assert context.calls[1] == ("set_verify", SSL.VERIFY_PEER)
|
||||
assert context.calls[2] == (
|
||||
"set_cipher_list",
|
||||
b"ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0",
|
||||
)
|
||||
callback = context.verify_callback
|
||||
assert callback(None, None, 0, 0, True) is True
|
||||
assert callback(None, None, 1, 0, False) is False
|
||||
|
||||
@@ -6,6 +6,7 @@ import inspect
|
||||
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.protocol.auth import AuthenticationProvider, CertificateAuth
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
|
||||
@@ -40,6 +41,14 @@ def test_dtls_session_constructor_keeps_file_memory_and_local_port_inputs():
|
||||
"local_port",
|
||||
],
|
||||
)
|
||||
auth_parameter = inspect.signature(DtlsCoapSession).parameters["auth"]
|
||||
assert auth_parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert auth_parameter.default is None
|
||||
|
||||
|
||||
def test_certificate_auth_is_a_public_authentication_provider():
|
||||
provider = CertificateAuth.from_files("/synthetic/cert.pem", "/synthetic/key")
|
||||
assert isinstance(provider, AuthenticationProvider)
|
||||
|
||||
|
||||
def test_dtls_session_keeps_current_consumer_methods():
|
||||
|
||||
Reference in New Issue
Block a user