1006 lines
31 KiB
Python
1006 lines
31 KiB
Python
"""Synthetic security and wire-contract tests for certificate profiles."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import asdict
|
|
from datetime import UTC, datetime, timedelta
|
|
from types import SimpleNamespace
|
|
from uuid import UUID
|
|
|
|
import pytest
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.asymmetric import ec
|
|
from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
|
|
from OpenSSL import SSL, crypto
|
|
|
|
import smartthings_local.protocol.auth as auth_module
|
|
from smartthings_local.protocol.auth import (
|
|
CertificateAuth,
|
|
SamsungServerProfile,
|
|
SamsungServerRole,
|
|
ServerCertificateAuth,
|
|
)
|
|
|
|
_IDENTITY = UUID(bytes=b"\xab" * 16)
|
|
_OTHER_IDENTITY = UUID(bytes=b"\xcd" * 16)
|
|
|
|
|
|
def _build_certificate(
|
|
*,
|
|
subject: x509.Name,
|
|
issuer: x509.Name,
|
|
public_key,
|
|
issuer_key,
|
|
serial_number: int,
|
|
is_ca: bool | None,
|
|
) -> x509.Certificate:
|
|
now = datetime.now(UTC)
|
|
builder = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(subject)
|
|
.issuer_name(issuer)
|
|
.public_key(public_key)
|
|
.serial_number(serial_number)
|
|
.not_valid_before(now - timedelta(minutes=1))
|
|
.not_valid_after(now + timedelta(hours=1))
|
|
)
|
|
if is_ca is not None:
|
|
builder = builder.add_extension(
|
|
x509.BasicConstraints(ca=is_ca, path_length=None),
|
|
critical=True,
|
|
).add_extension(
|
|
x509.KeyUsage(
|
|
digital_signature=True,
|
|
content_commitment=False,
|
|
key_encipherment=False,
|
|
data_encipherment=False,
|
|
key_agreement=False,
|
|
key_cert_sign=is_ca,
|
|
crl_sign=is_ca,
|
|
encipher_only=None,
|
|
decipher_only=None,
|
|
),
|
|
critical=True,
|
|
)
|
|
if is_ca is False:
|
|
builder = builder.add_extension(
|
|
x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]),
|
|
critical=False,
|
|
)
|
|
return builder.sign(issuer_key, hashes.SHA256())
|
|
|
|
|
|
def _encode_der_length(length: int) -> bytes:
|
|
if length < 0x80:
|
|
return bytes([length])
|
|
encoded = length.to_bytes((length.bit_length() + 7) // 8, "big")
|
|
return bytes([0x80 | len(encoded)]) + encoded
|
|
|
|
|
|
def _encode_der_element(tag: int, contents: bytes) -> bytes:
|
|
return bytes([tag]) + _encode_der_length(len(contents)) + contents
|
|
|
|
|
|
def _read_der_element(
|
|
encoded: bytes,
|
|
offset: int,
|
|
) -> tuple[int, int, int, int]:
|
|
tag = encoded[offset]
|
|
first_length_octet = encoded[offset + 1]
|
|
if first_length_octet < 0x80:
|
|
length_octets = 0
|
|
length = first_length_octet
|
|
else:
|
|
length_octets = first_length_octet & 0x7F
|
|
assert 0 < length_octets <= 4
|
|
length = int.from_bytes(
|
|
encoded[offset + 2 : offset + 2 + length_octets],
|
|
"big",
|
|
)
|
|
contents_start = offset + 2 + length_octets
|
|
contents_end = contents_start + length
|
|
assert contents_end <= len(encoded)
|
|
return tag, contents_start, contents_end, contents_end
|
|
|
|
|
|
def _add_tbs_signature_algorithm_trailing_null(
|
|
certificate: x509.Certificate,
|
|
issuer_key,
|
|
) -> bytes:
|
|
"""Make a signed synthetic leaf with Samsung-style algorithm extra data."""
|
|
encoded = certificate.public_bytes(serialization.Encoding.DER)
|
|
outer_tag, outer_start, _outer_end, certificate_end = _read_der_element(
|
|
encoded,
|
|
0,
|
|
)
|
|
assert outer_tag == 0x30
|
|
assert certificate_end == len(encoded)
|
|
|
|
tbs_tag, tbs_start, tbs_end, tbs_next = _read_der_element(
|
|
encoded,
|
|
outer_start,
|
|
)
|
|
assert tbs_tag == 0x30
|
|
signature_algorithm_offset = tbs_start
|
|
for _ in range(2):
|
|
_, _, _, signature_algorithm_offset = _read_der_element(
|
|
encoded,
|
|
signature_algorithm_offset,
|
|
)
|
|
(
|
|
signature_algorithm_tag,
|
|
signature_algorithm_start,
|
|
signature_algorithm_end,
|
|
_,
|
|
) = _read_der_element(encoded, signature_algorithm_offset)
|
|
assert signature_algorithm_tag == 0x30
|
|
|
|
malformed_signature_algorithm = _encode_der_element(
|
|
0x30,
|
|
encoded[signature_algorithm_start:signature_algorithm_end]
|
|
+ b"\x05\x00",
|
|
)
|
|
malformed_tbs = _encode_der_element(
|
|
0x30,
|
|
encoded[tbs_start:signature_algorithm_offset]
|
|
+ malformed_signature_algorithm
|
|
+ encoded[signature_algorithm_end:tbs_end],
|
|
)
|
|
|
|
(
|
|
outer_signature_tag,
|
|
outer_signature_start,
|
|
outer_signature_end,
|
|
_,
|
|
) = _read_der_element(
|
|
encoded,
|
|
tbs_next,
|
|
)
|
|
assert outer_signature_tag == 0x30
|
|
malformed_outer_signature_algorithm = _encode_der_element(
|
|
0x30,
|
|
encoded[outer_signature_start:outer_signature_end] + b"\x05\x00",
|
|
)
|
|
signature = issuer_key.sign(malformed_tbs, ec.ECDSA(hashes.SHA256()))
|
|
return _encode_der_element(
|
|
0x30,
|
|
malformed_tbs
|
|
+ malformed_outer_signature_algorithm
|
|
+ _encode_der_element(0x03, b"\x00" + signature),
|
|
)
|
|
|
|
|
|
def _make_generated_chain(
|
|
identity: UUID,
|
|
*,
|
|
organizational_unit: str = "OCF HA Device",
|
|
intermediate_has_constraints: bool = True,
|
|
leaf_signature_algorithm_trailing_null: bool = False,
|
|
):
|
|
"""Create a throwaway three-level chain unrelated to real devices."""
|
|
root_key = ec.generate_private_key(ec.SECP256R1())
|
|
root_name = x509.Name(
|
|
[x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic profile root")]
|
|
)
|
|
root = _build_certificate(
|
|
subject=root_name,
|
|
issuer=root_name,
|
|
public_key=root_key.public_key(),
|
|
issuer_key=root_key,
|
|
serial_number=101,
|
|
is_ca=True,
|
|
)
|
|
|
|
intermediate_key = ec.generate_private_key(ec.SECP256R1())
|
|
intermediate_name = x509.Name(
|
|
[
|
|
x509.NameAttribute(
|
|
NameOID.COMMON_NAME,
|
|
"Synthetic profile intermediate",
|
|
)
|
|
]
|
|
)
|
|
intermediate = _build_certificate(
|
|
subject=intermediate_name,
|
|
issuer=root_name,
|
|
public_key=intermediate_key.public_key(),
|
|
issuer_key=root_key,
|
|
serial_number=102,
|
|
is_ca=True if intermediate_has_constraints else None,
|
|
)
|
|
|
|
leaf_key = ec.generate_private_key(ec.SECP256R1())
|
|
leaf_name = x509.Name(
|
|
[
|
|
x509.NameAttribute(NameOID.COUNTRY_NAME, "KR"),
|
|
x509.NameAttribute(
|
|
NameOID.ORGANIZATION_NAME,
|
|
"Samsung Electronics",
|
|
),
|
|
x509.NameAttribute(
|
|
NameOID.ORGANIZATIONAL_UNIT_NAME,
|
|
organizational_unit,
|
|
),
|
|
x509.NameAttribute(
|
|
NameOID.COMMON_NAME,
|
|
f"OCF Device: Test ({identity})",
|
|
),
|
|
]
|
|
)
|
|
leaf = _build_certificate(
|
|
subject=leaf_name,
|
|
issuer=intermediate_name,
|
|
public_key=leaf_key.public_key(),
|
|
issuer_key=intermediate_key,
|
|
serial_number=103,
|
|
is_ca=False,
|
|
)
|
|
|
|
root_pem = root.public_bytes(serialization.Encoding.PEM).decode()
|
|
intermediate_pem = intermediate.public_bytes(serialization.Encoding.PEM).decode()
|
|
if leaf_signature_algorithm_trailing_null:
|
|
leaf_der = _add_tbs_signature_algorithm_trailing_null(
|
|
leaf,
|
|
intermediate_key,
|
|
)
|
|
openssl_leaf = crypto.load_certificate(crypto.FILETYPE_ASN1, leaf_der)
|
|
leaf_pem = crypto.dump_certificate(
|
|
crypto.FILETYPE_PEM,
|
|
openssl_leaf,
|
|
).decode()
|
|
else:
|
|
leaf_pem = leaf.public_bytes(serialization.Encoding.PEM).decode()
|
|
openssl_leaf = crypto.load_certificate(crypto.FILETYPE_PEM, leaf_pem)
|
|
key_pem = leaf_key.private_bytes(
|
|
serialization.Encoding.PEM,
|
|
serialization.PrivateFormat.PKCS8,
|
|
serialization.NoEncryption(),
|
|
).decode()
|
|
return SimpleNamespace(
|
|
root_pem=root_pem,
|
|
certificate_pem=leaf_pem + intermediate_pem,
|
|
private_key_pem=key_pem,
|
|
leaf=openssl_leaf,
|
|
intermediate=crypto.load_certificate(
|
|
crypto.FILETYPE_PEM,
|
|
intermediate_pem,
|
|
),
|
|
)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def generated_chain():
|
|
return _make_generated_chain(_IDENTITY)
|
|
|
|
|
|
def _configured_context(chain, profile=None, *, server_only=False) -> SSL.Context:
|
|
context = SSL.Context(SSL.DTLS_METHOD)
|
|
if server_only:
|
|
ServerCertificateAuth(server_profile=profile).configure_context(context)
|
|
else:
|
|
CertificateAuth.from_memory(
|
|
chain.certificate_pem,
|
|
chain.private_key_pem,
|
|
server_profile=profile,
|
|
).configure_context(context)
|
|
return context
|
|
|
|
|
|
def _configured_certificate_server_context(chain) -> SSL.Context:
|
|
context = SSL.Context(SSL.DTLS_METHOD)
|
|
certificates = auth_module._PEM_CERT_RE.findall(
|
|
chain.certificate_pem.encode()
|
|
)
|
|
context.use_certificate(
|
|
crypto.load_certificate(crypto.FILETYPE_PEM, certificates[0])
|
|
)
|
|
for certificate in certificates[1:]:
|
|
context.add_extra_chain_cert(
|
|
crypto.load_certificate(crypto.FILETYPE_PEM, certificate)
|
|
)
|
|
context.use_privatekey(
|
|
crypto.load_privatekey(
|
|
crypto.FILETYPE_PEM,
|
|
chain.private_key_pem.encode(),
|
|
)
|
|
)
|
|
context.check_privatekey()
|
|
# Request a client certificate but permit an empty certificate message,
|
|
# matching a server-authenticated manufacturer-certificate OTM carrier.
|
|
context.set_verify(SSL.VERIFY_PEER, lambda *_args: True)
|
|
return context
|
|
|
|
|
|
def _first_client_hello(context: SSL.Context) -> bytes:
|
|
connection = SSL.Connection(context, None)
|
|
connection.set_connect_state()
|
|
with pytest.raises(SSL.WantReadError):
|
|
connection.do_handshake()
|
|
chunks = []
|
|
while True:
|
|
try:
|
|
chunks.append(connection.bio_read(65535))
|
|
except SSL.WantReadError:
|
|
break
|
|
assert len(chunks) == 1
|
|
return chunks[0]
|
|
|
|
|
|
def _parse_client_hello(datagram: bytes):
|
|
"""Return cipher suites and extensions from one DTLS ClientHello."""
|
|
assert datagram[0] == 22
|
|
record_length = int.from_bytes(datagram[11:13], "big")
|
|
handshake = datagram[13 : 13 + record_length]
|
|
assert handshake[0] == 1
|
|
body = handshake[12:]
|
|
|
|
offset = 2 + 32
|
|
session_id_length = body[offset]
|
|
offset += 1 + session_id_length
|
|
cookie_length = body[offset]
|
|
offset += 1 + cookie_length
|
|
|
|
cipher_length = int.from_bytes(body[offset : offset + 2], "big")
|
|
offset += 2
|
|
ciphers = [
|
|
int.from_bytes(body[index : index + 2], "big")
|
|
for index in range(offset, offset + cipher_length, 2)
|
|
]
|
|
offset += cipher_length
|
|
|
|
compression_length = body[offset]
|
|
offset += 1 + compression_length
|
|
extensions_length = int.from_bytes(body[offset : offset + 2], "big")
|
|
offset += 2
|
|
extensions_end = offset + extensions_length
|
|
extensions = {}
|
|
while offset < extensions_end:
|
|
extension_type = int.from_bytes(body[offset : offset + 2], "big")
|
|
extension_length = int.from_bytes(
|
|
body[offset + 2 : offset + 4],
|
|
"big",
|
|
)
|
|
offset += 4
|
|
assert extension_type not in extensions
|
|
extensions[extension_type] = body[offset : offset + extension_length]
|
|
offset += extension_length
|
|
assert offset == extensions_end == len(body)
|
|
return ciphers, extensions
|
|
|
|
|
|
def _vector_values(extension: bytes) -> list[int]:
|
|
vector_length = int.from_bytes(extension[:2], "big")
|
|
assert vector_length == len(extension) - 2
|
|
return [
|
|
int.from_bytes(extension[index : index + 2], "big")
|
|
for index in range(2, len(extension), 2)
|
|
]
|
|
|
|
|
|
def _drive_memory_bio_handshake(
|
|
client_context: SSL.Context,
|
|
server_context: SSL.Context,
|
|
) -> tuple[SSL.Connection, SSL.Connection]:
|
|
client = SSL.Connection(client_context, None)
|
|
client.set_connect_state()
|
|
client.set_ciphertext_mtu(1200)
|
|
server = SSL.Connection(server_context, None)
|
|
server.set_accept_state()
|
|
server.set_ciphertext_mtu(1200)
|
|
client_done = False
|
|
server_done = False
|
|
|
|
for _ in range(20):
|
|
if not client_done:
|
|
try:
|
|
client.do_handshake()
|
|
client_done = True
|
|
except (SSL.WantReadError, SSL.WantWriteError):
|
|
pass
|
|
while True:
|
|
try:
|
|
server.bio_write(client.bio_read(65535))
|
|
except SSL.WantReadError:
|
|
break
|
|
|
|
if not server_done:
|
|
try:
|
|
server.do_handshake()
|
|
server_done = True
|
|
except (SSL.WantReadError, SSL.WantWriteError):
|
|
pass
|
|
while True:
|
|
try:
|
|
client.bio_write(server.bio_read(65535))
|
|
except SSL.WantReadError:
|
|
break
|
|
|
|
if client_done and server_done:
|
|
return client, server
|
|
|
|
raise AssertionError("synthetic DTLS handshake did not complete")
|
|
|
|
|
|
def _verify_chain(context: SSL.Context, chain) -> None:
|
|
crypto.X509StoreContext(
|
|
context.get_cert_store(),
|
|
chain.leaf,
|
|
[chain.intermediate],
|
|
).verify_certificate()
|
|
|
|
|
|
def test_profile_accepts_only_canonical_nonzero_identity():
|
|
assert repr(SamsungServerProfile.bound_device(_IDENTITY)) == (
|
|
"SamsungServerProfile()"
|
|
)
|
|
assert repr(SamsungServerProfile.bound_device(str(_IDENTITY))) == (
|
|
"SamsungServerProfile()"
|
|
)
|
|
|
|
for invalid in (
|
|
UUID(int=0),
|
|
str(UUID(int=0)),
|
|
str(_IDENTITY).upper(),
|
|
"{" + str(_IDENTITY) + "}",
|
|
"not-an-identity",
|
|
):
|
|
with pytest.raises(ValueError, match="canonical non-zero UUID"):
|
|
SamsungServerProfile.bound_device(invalid)
|
|
for invalid in (True, 1, b"not-an-identity"):
|
|
with pytest.raises(TypeError, match="UUID or string"):
|
|
SamsungServerProfile.bound_device(invalid)
|
|
|
|
|
|
def test_profile_roles_are_explicit_and_fail_closed():
|
|
home_chain = _make_generated_chain(_IDENTITY)
|
|
video_chain = _make_generated_chain(
|
|
_IDENTITY,
|
|
organizational_unit="OCF VD Device",
|
|
)
|
|
mismatched_video_chain = _make_generated_chain(
|
|
_OTHER_IDENTITY,
|
|
organizational_unit="OCF VD Device",
|
|
)
|
|
home_profile = SamsungServerProfile.bound_device(_IDENTITY)
|
|
video_profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
)
|
|
|
|
assert home_profile._verify_peer(None, home_chain.leaf, 0, 0, True) is True
|
|
assert home_profile._verify_peer(None, video_chain.leaf, 0, 0, True) is False
|
|
assert video_profile._verify_peer(None, video_chain.leaf, 0, 0, True) is True
|
|
assert video_profile._verify_peer(None, home_chain.leaf, 0, 0, True) is False
|
|
assert (
|
|
video_profile._verify_peer(
|
|
None,
|
|
mismatched_video_chain.leaf,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is False
|
|
)
|
|
|
|
for invalid in ("OCF VD Device", "video_device", None, object()):
|
|
with pytest.raises(TypeError, match="SamsungServerRole"):
|
|
SamsungServerProfile.bound_device(_IDENTITY, role=invalid)
|
|
|
|
|
|
def test_profile_additional_ca_input_is_bounded_and_parsed(generated_chain):
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
assert repr(profile) == "SamsungServerProfile()"
|
|
|
|
with pytest.raises(TypeError, match="must be a string"):
|
|
SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem.encode(),
|
|
)
|
|
for invalid in (
|
|
"",
|
|
generated_chain.root_pem + "unexpected trailing material",
|
|
generated_chain.root_pem * 5,
|
|
generated_chain.root_pem * 2,
|
|
generated_chain.root_pem + (" " * (32 * 1024)),
|
|
generated_chain.certificate_pem,
|
|
"-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----",
|
|
"non-ascii-\N{SNOWMAN}",
|
|
):
|
|
with pytest.raises(ValueError):
|
|
SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=invalid,
|
|
)
|
|
|
|
|
|
def test_profile_is_immutable_and_has_no_public_identity_or_ca_surface(
|
|
generated_chain,
|
|
):
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
rendered = repr(profile)
|
|
assert str(_IDENTITY) not in rendered
|
|
assert "BEGIN CERTIFICATE" not in rendered
|
|
assert not hasattr(profile, "expected_certificate_identity")
|
|
assert not hasattr(profile, "additional_ca_pem")
|
|
with pytest.raises(TypeError):
|
|
vars(profile)
|
|
with pytest.raises(TypeError):
|
|
asdict(profile)
|
|
with pytest.raises(AttributeError, match="immutable"):
|
|
profile.expected_certificate_identity = _OTHER_IDENTITY
|
|
with pytest.raises(AttributeError, match="immutable"):
|
|
del profile._expected_certificate_identity
|
|
|
|
|
|
def test_server_certificate_auth_is_explicit_immutable_and_redacted(
|
|
generated_chain,
|
|
):
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
provider = ServerCertificateAuth(server_profile=profile)
|
|
|
|
assert repr(provider) == "ServerCertificateAuth()"
|
|
assert str(_IDENTITY) not in repr(provider)
|
|
assert generated_chain.root_pem not in repr(provider)
|
|
with pytest.raises(TypeError):
|
|
vars(provider)
|
|
with pytest.raises(TypeError):
|
|
asdict(provider)
|
|
with pytest.raises(AttributeError, match="immutable"):
|
|
provider.server_profile = profile
|
|
with pytest.raises(AttributeError, match="immutable"):
|
|
del provider._server_profile
|
|
for invalid in (None, object()):
|
|
with pytest.raises(TypeError, match="SamsungServerProfile"):
|
|
ServerCertificateAuth(server_profile=invalid)
|
|
|
|
|
|
def test_certificate_auth_requires_the_exact_profile_type(generated_chain):
|
|
with pytest.raises(TypeError, match="SamsungServerProfile"):
|
|
CertificateAuth.from_memory(
|
|
generated_chain.certificate_pem,
|
|
generated_chain.private_key_pem,
|
|
server_profile=object(),
|
|
)
|
|
|
|
|
|
def test_profile_emits_the_exact_client_hello_and_reuses_cold(
|
|
generated_chain,
|
|
):
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
|
|
first = _parse_client_hello(
|
|
_first_client_hello(_configured_context(generated_chain, profile))
|
|
)
|
|
second = _parse_client_hello(
|
|
_first_client_hello(_configured_context(generated_chain, profile))
|
|
)
|
|
assert first == second
|
|
|
|
ciphers, extensions = first
|
|
# Older OpenSSL appends the non-negotiable renegotiation SCSV (0x00ff).
|
|
# No other negotiable cipher may enter the profile.
|
|
assert ciphers[0] == 0xC02B
|
|
assert set(ciphers) <= {0xC02B, 0x00FF}
|
|
assert _vector_values(extensions[10]) == [23]
|
|
assert _vector_values(extensions[13]) == [
|
|
0x0401,
|
|
0x0403,
|
|
0x0201,
|
|
0x0203,
|
|
]
|
|
assert 35 not in extensions
|
|
|
|
|
|
def test_server_only_profile_emits_the_same_client_hello_without_credentials(
|
|
generated_chain,
|
|
):
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
|
|
certificate_hello = _parse_client_hello(
|
|
_first_client_hello(_configured_context(generated_chain, profile))
|
|
)
|
|
server_only_hello = _parse_client_hello(
|
|
_first_client_hello(
|
|
_configured_context(
|
|
generated_chain,
|
|
profile,
|
|
server_only=True,
|
|
)
|
|
)
|
|
)
|
|
|
|
assert server_only_hello == certificate_hello
|
|
|
|
|
|
def test_server_only_profile_completes_without_a_client_certificate():
|
|
video_chain = _make_generated_chain(
|
|
_IDENTITY,
|
|
organizational_unit="OCF VD Device",
|
|
)
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
additional_ca_pem=video_chain.root_pem,
|
|
)
|
|
client_context = _configured_context(
|
|
video_chain,
|
|
profile,
|
|
server_only=True,
|
|
)
|
|
|
|
client, server = _drive_memory_bio_handshake(
|
|
client_context,
|
|
_configured_certificate_server_context(video_chain),
|
|
)
|
|
|
|
assert client.get_peer_certificate() is not None
|
|
assert server.get_peer_certificate() is None
|
|
|
|
|
|
def test_profile_verifies_openssl_accepted_non_der_vd_leaf():
|
|
video_chain = _make_generated_chain(
|
|
_IDENTITY,
|
|
organizational_unit="OCF VD Device",
|
|
leaf_signature_algorithm_trailing_null=True,
|
|
)
|
|
try:
|
|
video_chain.leaf.to_cryptography()
|
|
except ValueError as error:
|
|
# cryptography 50 rejects this shape; the dependency floor accepts it.
|
|
assert "TbsCertificate" in str(error)
|
|
assert "signature_alg" in str(error)
|
|
|
|
video_profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
additional_ca_pem=video_chain.root_pem,
|
|
)
|
|
client_context = _configured_context(
|
|
video_chain,
|
|
video_profile,
|
|
server_only=True,
|
|
)
|
|
_verify_chain(client_context, video_chain)
|
|
|
|
client, server = _drive_memory_bio_handshake(
|
|
client_context,
|
|
_configured_certificate_server_context(video_chain),
|
|
)
|
|
|
|
assert client.get_peer_certificate() is not None
|
|
assert server.get_peer_certificate() is None
|
|
assert (
|
|
SamsungServerProfile.bound_device(
|
|
_OTHER_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
)._verify_peer(None, video_chain.leaf, 0, 0, True)
|
|
is False
|
|
)
|
|
assert (
|
|
SamsungServerProfile.bound_device(_IDENTITY)._verify_peer(
|
|
None,
|
|
video_chain.leaf,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_python_floor_curve_fallback_has_the_same_wire_contract(
|
|
monkeypatch,
|
|
generated_chain,
|
|
):
|
|
signature_setter = auth_module._util.lib.SSL_CTX_set1_sigalgs_list
|
|
monkeypatch.setattr(
|
|
auth_module,
|
|
"_util",
|
|
SimpleNamespace(
|
|
lib=SimpleNamespace(
|
|
SSL_CTX_set1_sigalgs_list=signature_setter,
|
|
)
|
|
),
|
|
)
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
|
|
ciphers, extensions = _parse_client_hello(
|
|
_first_client_hello(_configured_context(generated_chain, profile))
|
|
)
|
|
|
|
assert ciphers[0] == 0xC02B
|
|
assert set(ciphers) <= {0xC02B, 0x00FF}
|
|
assert _vector_values(extensions[10]) == [23]
|
|
assert _vector_values(extensions[13]) == [
|
|
0x0401,
|
|
0x0403,
|
|
0x0201,
|
|
0x0203,
|
|
]
|
|
assert 35 not in extensions
|
|
|
|
|
|
def test_profile_fails_closed_when_exact_openssl_support_is_unavailable(
|
|
monkeypatch,
|
|
):
|
|
class FallbackContext:
|
|
_context = object()
|
|
|
|
def set_tmp_ecdh(self, _curve):
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
auth_module,
|
|
"_util",
|
|
SimpleNamespace(lib=SimpleNamespace()),
|
|
)
|
|
profile = SamsungServerProfile.bound_device(_IDENTITY)
|
|
|
|
with pytest.raises(RuntimeError, match="rejected"):
|
|
profile._configure_context(FallbackContext())
|
|
|
|
|
|
def test_default_and_profile_verification_are_selected_per_provider(
|
|
monkeypatch,
|
|
):
|
|
valid_chain = _make_generated_chain(_IDENTITY)
|
|
mismatch_chain = _make_generated_chain(_OTHER_IDENTITY)
|
|
|
|
class RecordingContext:
|
|
def __init__(self):
|
|
self._context = object()
|
|
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",))
|
|
|
|
def set_options(self, options):
|
|
self.calls.append(("set_options", options))
|
|
|
|
profile_calls = []
|
|
monkeypatch.setattr(
|
|
auth_module,
|
|
"_util",
|
|
SimpleNamespace(
|
|
lib=SimpleNamespace(
|
|
SSL_CTX_set1_curves_list=(
|
|
lambda handle, value: (
|
|
profile_calls.append(("curves", handle, value)) or 1
|
|
)
|
|
),
|
|
SSL_CTX_set1_sigalgs_list=(
|
|
lambda handle, value: (
|
|
profile_calls.append(("signature_algorithms", handle, value))
|
|
or 1
|
|
)
|
|
),
|
|
)
|
|
),
|
|
)
|
|
|
|
default_context = RecordingContext()
|
|
CertificateAuth.from_files("/synthetic/cert", "/synthetic/key").configure_context(
|
|
default_context
|
|
)
|
|
assert default_context.verify_callback(None, None, 0, 0, True) is True
|
|
assert default_context.verify_callback(None, None, 0, 0, False) is False
|
|
assert not profile_calls
|
|
assert all(call[0] != "set_options" for call in default_context.calls)
|
|
|
|
profiled_context = RecordingContext()
|
|
profile = SamsungServerProfile.bound_device(_IDENTITY)
|
|
CertificateAuth.from_files(
|
|
"/synthetic/cert",
|
|
"/synthetic/key",
|
|
server_profile=profile,
|
|
).configure_context(profiled_context)
|
|
|
|
assert [call[0] for call in profile_calls] == [
|
|
"curves",
|
|
"signature_algorithms",
|
|
]
|
|
assert ("set_options", SSL.OP_NO_TICKET) in profiled_context.calls
|
|
callback = profiled_context.verify_callback
|
|
assert callback(None, valid_chain.leaf, 0, 0, True) is True
|
|
assert callback(None, mismatch_chain.leaf, 0, 0, True) is False
|
|
assert callback(None, valid_chain.leaf, 0, 0, False) is False
|
|
|
|
profile_calls.clear()
|
|
video_chain = _make_generated_chain(
|
|
_IDENTITY,
|
|
organizational_unit="OCF VD Device",
|
|
)
|
|
server_only_context = RecordingContext()
|
|
video_profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
role=SamsungServerRole.VD_DEVICE,
|
|
)
|
|
ServerCertificateAuth(server_profile=video_profile).configure_context(
|
|
server_only_context
|
|
)
|
|
assert [call[0] for call in profile_calls] == [
|
|
"curves",
|
|
"signature_algorithms",
|
|
]
|
|
assert all(
|
|
call[0]
|
|
not in {
|
|
"use_certificate_chain_file",
|
|
"use_privatekey_file",
|
|
"check_privatekey",
|
|
}
|
|
for call in server_only_context.calls
|
|
)
|
|
assert (
|
|
server_only_context.verify_callback(
|
|
None,
|
|
video_chain.leaf,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
server_only_context.verify_callback(
|
|
None,
|
|
valid_chain.leaf,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_profile_identity_verification_rejects_malformed_subjects(caplog):
|
|
profile = SamsungServerProfile.bound_device(_IDENTITY)
|
|
wrong_role = _make_generated_chain(
|
|
_IDENTITY,
|
|
organizational_unit="Unexpected Device",
|
|
)
|
|
|
|
assert profile._verify_peer(None, wrong_role.leaf, 0, 0, True) is False
|
|
assert profile._verify_peer(None, None, 0, 0, True) is False
|
|
assert profile._verify_peer(None, wrong_role.leaf, 0, -1, True) is False
|
|
assert profile._verify_peer(None, object(), 0, 1, True) is True
|
|
|
|
duplicate_common_name = SimpleNamespace(
|
|
get_subject=lambda: SimpleNamespace(
|
|
get_components=lambda: [
|
|
(b"CN", f"OCF Device: First ({_IDENTITY})".encode()),
|
|
(b"CN", f"OCF Device: Other ({_IDENTITY})".encode()),
|
|
(b"OU", b"OCF HA Device"),
|
|
(b"O", b"Samsung Electronics"),
|
|
(b"C", b"KR"),
|
|
]
|
|
)
|
|
)
|
|
assert (
|
|
profile._verify_peer(
|
|
None,
|
|
duplicate_common_name,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is False
|
|
)
|
|
|
|
invalid_encoding = SimpleNamespace(
|
|
get_subject=lambda: SimpleNamespace(
|
|
get_components=lambda: [(b"CN", b"\xff")]
|
|
)
|
|
)
|
|
caplog.clear()
|
|
with caplog.at_level(logging.WARNING, logger=auth_module.__name__):
|
|
assert profile._verify_peer(None, object(), 0, 0, True) is False
|
|
assert (
|
|
profile._verify_peer(
|
|
None,
|
|
invalid_encoding,
|
|
0,
|
|
0,
|
|
True,
|
|
)
|
|
is False
|
|
)
|
|
assert caplog.messages == [
|
|
"Unable to parse Samsung server certificate subject",
|
|
"Unable to parse Samsung server certificate subject",
|
|
]
|
|
|
|
caplog.clear()
|
|
assert profile._verify_peer(None, wrong_role.leaf, 0, 0, True) is False
|
|
assert not caplog.records
|
|
|
|
|
|
def test_additional_ca_is_scoped_and_invalid_intermediate_is_rejected(
|
|
generated_chain,
|
|
):
|
|
default_context = _configured_context(generated_chain)
|
|
with pytest.raises(crypto.X509StoreContextError):
|
|
_verify_chain(default_context, generated_chain)
|
|
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
profiled_context = _configured_context(generated_chain, profile)
|
|
_verify_chain(profiled_context, generated_chain)
|
|
|
|
missing_constraints = _make_generated_chain(
|
|
_IDENTITY,
|
|
intermediate_has_constraints=False,
|
|
)
|
|
missing_constraints_profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=missing_constraints.root_pem,
|
|
)
|
|
missing_constraints_context = _configured_context(
|
|
missing_constraints,
|
|
missing_constraints_profile,
|
|
)
|
|
with pytest.raises(crypto.X509StoreContextError):
|
|
_verify_chain(missing_constraints_context, missing_constraints)
|
|
|
|
|
|
def test_profile_errors_and_provider_repr_do_not_echo_inputs(generated_chain):
|
|
marker = "private" + "-profile-marker"
|
|
with pytest.raises(ValueError) as captured:
|
|
SamsungServerProfile.bound_device(marker)
|
|
assert marker not in str(captured.value)
|
|
|
|
profile = SamsungServerProfile.bound_device(
|
|
_IDENTITY,
|
|
additional_ca_pem=generated_chain.root_pem,
|
|
)
|
|
provider = CertificateAuth.from_memory(
|
|
generated_chain.certificate_pem,
|
|
generated_chain.private_key_pem,
|
|
server_profile=profile,
|
|
)
|
|
rendered = repr(provider) + repr(profile)
|
|
assert str(_IDENTITY) not in rendered
|
|
assert generated_chain.root_pem not in rendered
|
|
assert generated_chain.private_key_pem not in rendered
|