feat(protocol): add bound server certificate profile
This commit is contained in:
4 files changed
+1453
-9
No files matched your search
@@ -56,6 +56,76 @@ auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
|||||||
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Some newer OCF-PKI devices require an exact Samsung DTLS offer and present a
|
||||||
|
hardware certificate whose subject contains a certificate UUID. That UUID can
|
||||||
|
be distinct from the runtime OCF device UUID reported by `/oic/d`, so callers
|
||||||
|
must obtain and verify the certificate identity independently. When the caller
|
||||||
|
already has an authorized client certificate and a previously verified
|
||||||
|
hardware-certificate UUID, opt in to both requirements explicitly:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smartthings_local.protocol.auth import (
|
||||||
|
CertificateAuth,
|
||||||
|
SamsungServerProfile,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_profile = SamsungServerProfile.bound_device(
|
||||||
|
expected_certificate_uuid,
|
||||||
|
additional_ca_pem=additional_samsung_ca_pem,
|
||||||
|
)
|
||||||
|
auth = CertificateAuth.from_memory(
|
||||||
|
cert_pem,
|
||||||
|
key_pem,
|
||||||
|
server_profile=server_profile,
|
||||||
|
)
|
||||||
|
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
The default profile is restricted to Samsung home-appliance leaves with
|
||||||
|
`OU=OCF HA Device`. The profile limits the ClientHello to P-256,
|
||||||
|
`ECDHE-ECDSA-AES128-GCM-SHA256`, and the observed SHA-256/SHA-1 RSA/ECDSA
|
||||||
|
signature set, disables session tickets, preserves certificate-chain
|
||||||
|
verification, and requires the exact subject role
|
||||||
|
`C=KR, O=Samsung Electronics, OU=OCF HA Device` with a common name ending in
|
||||||
|
the expected certificate UUID. `additional_ca_pem` is optional and accepts
|
||||||
|
only a bounded PEM CA-certificate chain; it is applied only to this profiled
|
||||||
|
context. Without a profile, `CertificateAuth` retains its existing verification
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
Samsung VD-family devices can present the same wire profile with the distinct
|
||||||
|
`OU=OCF VD Device` role. Select that role explicitly; profiles never fall back
|
||||||
|
between device classes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smartthings_local.protocol.auth import (
|
||||||
|
SamsungServerProfile,
|
||||||
|
SamsungServerRole,
|
||||||
|
ServerCertificateAuth,
|
||||||
|
)
|
||||||
|
|
||||||
|
server_profile = SamsungServerProfile.bound_device(
|
||||||
|
expected_certificate_uuid,
|
||||||
|
role=SamsungServerRole.VD_DEVICE,
|
||||||
|
)
|
||||||
|
auth = ServerCertificateAuth(server_profile=server_profile)
|
||||||
|
sess = DtlsCoapSession("192.0.2.100", 5684, auth=auth)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ServerCertificateAuth` is for a server-authenticated channel that does not
|
||||||
|
send a client certificate, such as the initial DTLS carrier used by
|
||||||
|
manufacturer-certificate OTM. It still verifies the CA chain, exact selected
|
||||||
|
subject role, and pinned certificate UUID. It does not learn an identity from
|
||||||
|
the first endpoint it reaches and cannot be combined with client credentials.
|
||||||
|
|
||||||
|
This API deliberately does not discover, mint, authorize, provision, rotate,
|
||||||
|
or persist credentials, and it performs no ownership transfer or OCF security
|
||||||
|
resource writes. In particular, the server-only provider can authenticate the
|
||||||
|
initial manufacturer-certificate channel, but it does not implement the OTM
|
||||||
|
that follows. The already-owned new-PKI case in
|
||||||
|
[issue #16](https://github.com/QuiteYellow/SmartThings-Local/issues/16) still
|
||||||
|
requires an authorized client identity before ordinary protected resources
|
||||||
|
can be used.
|
||||||
|
|
||||||
For compatibility, the existing `cert_path` / `key_path` and `cert_pem` /
|
For compatibility, the existing `cert_path` / `key_path` and `cert_pem` /
|
||||||
`key_pem` session arguments remain supported without a deprecation warning.
|
`key_pem` session arguments remain supported without a deprecation warning.
|
||||||
They are routed through `CertificateAuth` internally. Do not combine `auth`
|
They are routed through `CertificateAuth` internally. Do not combine `auth`
|
||||||
|
|||||||
@@ -2,16 +2,33 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import warnings
|
||||||
|
from enum import Enum
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Protocol, runtime_checkable
|
from typing import Protocol, runtime_checkable
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from cryptography.x509.oid import ExtensionOID
|
||||||
from OpenSSL import SSL, _util, crypto
|
from OpenSSL import SSL, _util, crypto
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_OCF_ROOT_CA = str(Path(__file__).with_name("ocf_root_ca.pem"))
|
_OCF_ROOT_CA = str(Path(__file__).with_name("ocf_root_ca.pem"))
|
||||||
_DTLS_CIPHERS = b"ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0"
|
_DTLS_CIPHERS = b"ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0"
|
||||||
_DTLS_PSK_CIPHERS = b"ECDHE-PSK-AES128-CBC-SHA256:@SECLEVEL=0"
|
_DTLS_PSK_CIPHERS = b"ECDHE-PSK-AES128-CBC-SHA256:@SECLEVEL=0"
|
||||||
|
_SAMSUNG_SERVER_CURVES = b"prime256v1"
|
||||||
|
_SAMSUNG_SERVER_SIGNATURE_ALGORITHMS = (
|
||||||
|
b"RSA+SHA256:ECDSA+SHA256:RSA+SHA1:ECDSA+SHA1"
|
||||||
|
)
|
||||||
|
_SAMSUNG_SERVER_CN_RE = re.compile(
|
||||||
|
r"\AOCF Device: [^()\r\n]{1,96} "
|
||||||
|
r"\((?P<device_identity>[0-9a-f]{8}-[0-9a-f]{4}-"
|
||||||
|
r"[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\Z",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
_PSK_CLIENT_CALLBACK_CDEF = (
|
_PSK_CLIENT_CALLBACK_CDEF = (
|
||||||
"unsigned int (*)(SSL *, char *, char *, unsigned int, "
|
"unsigned int (*)(SSL *, char *, char *, unsigned int, "
|
||||||
"unsigned char *, unsigned int)"
|
"unsigned char *, unsigned int)"
|
||||||
@@ -53,6 +70,299 @@ class AuthenticationProvider(Protocol):
|
|||||||
"""Configure a context while this provider remains session-owned."""
|
"""Configure a context while this provider remains session-owned."""
|
||||||
|
|
||||||
|
|
||||||
|
class SamsungServerRole(Enum):
|
||||||
|
"""Known Samsung OCF hardware-certificate subject roles."""
|
||||||
|
|
||||||
|
HOME_APPLIANCE = "OCF HA Device"
|
||||||
|
VD_DEVICE = "OCF VD Device"
|
||||||
|
|
||||||
|
|
||||||
|
class SamsungServerProfile:
|
||||||
|
"""Opt-in Samsung hardware-certificate verification profile."""
|
||||||
|
|
||||||
|
__slots__ = (
|
||||||
|
"_additional_ca_certificates",
|
||||||
|
"_expected_certificate_identity",
|
||||||
|
"_role",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
expected_certificate_identity: UUID | str,
|
||||||
|
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
|
||||||
|
additional_ca_pem: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
if type(expected_certificate_identity) is UUID:
|
||||||
|
parsed_identity = expected_certificate_identity
|
||||||
|
elif type(expected_certificate_identity) is str:
|
||||||
|
try:
|
||||||
|
parsed_identity = UUID(expected_certificate_identity)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
"expected_certificate_identity must be a canonical "
|
||||||
|
"non-zero UUID"
|
||||||
|
) from None
|
||||||
|
if expected_certificate_identity != str(parsed_identity):
|
||||||
|
raise ValueError(
|
||||||
|
"expected_certificate_identity must be a canonical "
|
||||||
|
"non-zero UUID"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
"expected_certificate_identity must be a UUID or string"
|
||||||
|
)
|
||||||
|
if parsed_identity.int == 0:
|
||||||
|
raise ValueError(
|
||||||
|
"expected_certificate_identity must be a canonical "
|
||||||
|
"non-zero UUID"
|
||||||
|
)
|
||||||
|
if type(role) is not SamsungServerRole:
|
||||||
|
raise TypeError("role must be a SamsungServerRole")
|
||||||
|
|
||||||
|
certificates: tuple[bytes, ...] = ()
|
||||||
|
if additional_ca_pem is not None:
|
||||||
|
if type(additional_ca_pem) is not str:
|
||||||
|
raise TypeError("additional_ca_pem must be a string")
|
||||||
|
try:
|
||||||
|
raw_ca_pem = additional_ca_pem.encode("ascii")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
raise ValueError(
|
||||||
|
"additional_ca_pem must contain ASCII PEM certificates"
|
||||||
|
) from None
|
||||||
|
parsed_certificates = tuple(_PEM_CERT_RE.findall(raw_ca_pem))
|
||||||
|
if (
|
||||||
|
not 1 <= len(parsed_certificates) <= 4
|
||||||
|
or len(raw_ca_pem) > 32 * 1024
|
||||||
|
or _PEM_CERT_RE.sub(b"", raw_ca_pem).strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"additional_ca_pem must contain one to four PEM certificates"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
loaded_certificates = [
|
||||||
|
crypto.load_certificate(crypto.FILETYPE_PEM, certificate)
|
||||||
|
for certificate in parsed_certificates
|
||||||
|
]
|
||||||
|
basic_constraints = [
|
||||||
|
[
|
||||||
|
extension
|
||||||
|
for extension in certificate.to_cryptography().extensions
|
||||||
|
if extension.oid == ExtensionOID.BASIC_CONSTRAINTS
|
||||||
|
]
|
||||||
|
for certificate in loaded_certificates
|
||||||
|
]
|
||||||
|
except (crypto.Error, ValueError):
|
||||||
|
raise ValueError(
|
||||||
|
"additional_ca_pem contains an invalid certificate"
|
||||||
|
) from None
|
||||||
|
if any(
|
||||||
|
len(constraints) != 1 or not constraints[0].value.ca
|
||||||
|
for constraints in basic_constraints
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"additional_ca_pem must contain only CA certificates"
|
||||||
|
)
|
||||||
|
fingerprints = {
|
||||||
|
crypto.dump_certificate(crypto.FILETYPE_ASN1, certificate)
|
||||||
|
for certificate in loaded_certificates
|
||||||
|
}
|
||||||
|
if len(fingerprints) != len(loaded_certificates):
|
||||||
|
raise ValueError(
|
||||||
|
"additional_ca_pem must not contain duplicate certificates"
|
||||||
|
)
|
||||||
|
certificates = parsed_certificates
|
||||||
|
|
||||||
|
object.__setattr__(
|
||||||
|
self,
|
||||||
|
"_expected_certificate_identity",
|
||||||
|
parsed_identity,
|
||||||
|
)
|
||||||
|
object.__setattr__(self, "_role", role)
|
||||||
|
object.__setattr__(self, "_additional_ca_certificates", certificates)
|
||||||
|
|
||||||
|
def __setattr__(self, _name: str, _value: object) -> None:
|
||||||
|
raise AttributeError("SamsungServerProfile is immutable")
|
||||||
|
|
||||||
|
def __delattr__(self, _name: str) -> None:
|
||||||
|
raise AttributeError("SamsungServerProfile is immutable")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def bound_device(
|
||||||
|
cls,
|
||||||
|
expected_certificate_identity: UUID | str,
|
||||||
|
*,
|
||||||
|
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
|
||||||
|
additional_ca_pem: str | None = None,
|
||||||
|
) -> SamsungServerProfile:
|
||||||
|
"""Bind a verified Samsung hardware leaf to its certificate UUID."""
|
||||||
|
return cls(
|
||||||
|
expected_certificate_identity=expected_certificate_identity,
|
||||||
|
role=role,
|
||||||
|
additional_ca_pem=additional_ca_pem,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Return a representation without device or trust-chain details."""
|
||||||
|
return "SamsungServerProfile()"
|
||||||
|
|
||||||
|
def _configure_context(self, context: SSL.Context) -> None:
|
||||||
|
curve_setter = getattr(_util.lib, "SSL_CTX_set1_curves_list", None)
|
||||||
|
if curve_setter is not None:
|
||||||
|
if curve_setter(context._context, _SAMSUNG_SERVER_CURVES) != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OpenSSL rejected the Samsung server certificate profile"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# pyOpenSSL 23.1 does not expose SSL_CTX_set1_curves_list. Its
|
||||||
|
# public set_tmp_ecdh fallback produces the same single P-256
|
||||||
|
# supported-groups ClientHello extension; wire-level tests protect
|
||||||
|
# that compatibility path. Newer pyOpenSSL uses the exact setter.
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
curve = crypto.get_elliptic_curve("prime256v1")
|
||||||
|
context.set_tmp_ecdh(curve)
|
||||||
|
except (AttributeError, TypeError, ValueError, SSL.Error):
|
||||||
|
raise RuntimeError(
|
||||||
|
"OpenSSL rejected the Samsung server certificate profile"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
signature_setter = getattr(
|
||||||
|
_util.lib,
|
||||||
|
"SSL_CTX_set1_sigalgs_list",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
signature_setter is None
|
||||||
|
or signature_setter(
|
||||||
|
context._context,
|
||||||
|
_SAMSUNG_SERVER_SIGNATURE_ALGORITHMS,
|
||||||
|
)
|
||||||
|
!= 1
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"OpenSSL rejected the Samsung server certificate profile"
|
||||||
|
)
|
||||||
|
context.set_options(SSL.OP_NO_TICKET)
|
||||||
|
|
||||||
|
if self._additional_ca_certificates:
|
||||||
|
store = context.get_cert_store()
|
||||||
|
try:
|
||||||
|
for certificate in self._additional_ca_certificates:
|
||||||
|
store.add_cert(
|
||||||
|
crypto.load_certificate(
|
||||||
|
crypto.FILETYPE_PEM,
|
||||||
|
certificate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except crypto.Error:
|
||||||
|
raise RuntimeError(
|
||||||
|
"OpenSSL rejected the Samsung server trust profile"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
def _verify_peer(
|
||||||
|
self,
|
||||||
|
_connection,
|
||||||
|
certificate,
|
||||||
|
_error,
|
||||||
|
depth,
|
||||||
|
ok,
|
||||||
|
) -> bool:
|
||||||
|
if not ok or certificate is None or depth < 0:
|
||||||
|
return False
|
||||||
|
if depth > 0:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
# pyOpenSSL deprecates this API in favor of cryptography, but
|
||||||
|
# reparsing Samsung's non-DER factory leaves with cryptography
|
||||||
|
# rejects certificates that OpenSSL has already verified.
|
||||||
|
warnings.simplefilter("ignore", DeprecationWarning)
|
||||||
|
components = [
|
||||||
|
(name.decode("ascii"), value.decode("ascii"))
|
||||||
|
for name, value in certificate.get_subject().get_components()
|
||||||
|
]
|
||||||
|
except (
|
||||||
|
AttributeError,
|
||||||
|
TypeError,
|
||||||
|
UnicodeDecodeError,
|
||||||
|
ValueError,
|
||||||
|
crypto.Error,
|
||||||
|
):
|
||||||
|
logger.warning("Unable to parse Samsung server certificate subject")
|
||||||
|
return False
|
||||||
|
|
||||||
|
common_names = [value for name, value in components if name == "CN"]
|
||||||
|
organizational_units = [
|
||||||
|
value for name, value in components if name == "OU"
|
||||||
|
]
|
||||||
|
organizations = [value for name, value in components if name == "O"]
|
||||||
|
countries = [value for name, value in components if name == "C"]
|
||||||
|
# This deliberately pins the complete Samsung subject role. The OCF
|
||||||
|
# reference implementation reads only the UUID-bearing CN, but
|
||||||
|
# relaxing C/O/OU here could accept a different certificate cohort.
|
||||||
|
if (
|
||||||
|
len(common_names) != 1
|
||||||
|
or organizational_units != [self._role.value]
|
||||||
|
or organizations != ["Samsung Electronics"]
|
||||||
|
or countries != ["KR"]
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
match = _SAMSUNG_SERVER_CN_RE.fullmatch(common_names[0])
|
||||||
|
return (
|
||||||
|
match is not None
|
||||||
|
and UUID(match.group("device_identity"))
|
||||||
|
== self._expected_certificate_identity
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_certificate_server(
|
||||||
|
context: SSL.Context,
|
||||||
|
server_profile: SamsungServerProfile | None,
|
||||||
|
) -> None:
|
||||||
|
"""Configure certificate-server verification for one DTLS context."""
|
||||||
|
context.load_verify_locations(_OCF_ROOT_CA)
|
||||||
|
if server_profile is None:
|
||||||
|
context.set_verify(SSL.VERIFY_PEER, _verify_peer)
|
||||||
|
else:
|
||||||
|
server_profile._configure_context(context)
|
||||||
|
context.set_verify(
|
||||||
|
SSL.VERIFY_PEER,
|
||||||
|
server_profile._verify_peer,
|
||||||
|
)
|
||||||
|
# @SECLEVEL=0 permits SHA-1 in Samsung's server cert chain (AC14K_M
|
||||||
|
# intermediate is SHA-1 signed). This is the only channel that reaches
|
||||||
|
# the OpenSSL instance cryptography bundles; ctypes and cffi bindings
|
||||||
|
# do not expose SSL_CTX_set_security_level on this build.
|
||||||
|
context.set_cipher_list(_DTLS_CIPHERS)
|
||||||
|
|
||||||
|
|
||||||
|
class ServerCertificateAuth:
|
||||||
|
"""Verify a pinned Samsung server without a client certificate."""
|
||||||
|
|
||||||
|
__slots__ = ("_server_profile",)
|
||||||
|
|
||||||
|
def __init__(self, *, server_profile: SamsungServerProfile) -> None:
|
||||||
|
if type(server_profile) is not SamsungServerProfile:
|
||||||
|
raise TypeError("server_profile must be a SamsungServerProfile")
|
||||||
|
object.__setattr__(self, "_server_profile", server_profile)
|
||||||
|
|
||||||
|
def __setattr__(self, _name: str, _value: object) -> None:
|
||||||
|
raise AttributeError("ServerCertificateAuth is immutable")
|
||||||
|
|
||||||
|
def __delattr__(self, _name: str) -> None:
|
||||||
|
raise AttributeError("ServerCertificateAuth is immutable")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
"""Return a representation without server identity or trust details."""
|
||||||
|
return "ServerCertificateAuth()"
|
||||||
|
|
||||||
|
def configure_context(self, context: SSL.Context) -> None:
|
||||||
|
"""Verify the selected server profile without loading client material."""
|
||||||
|
_configure_certificate_server(context, self._server_profile)
|
||||||
|
|
||||||
|
|
||||||
class CertificateAuth:
|
class CertificateAuth:
|
||||||
"""Certificate authentication loaded from files or in-memory PEM data.
|
"""Certificate authentication loaded from files or in-memory PEM data.
|
||||||
|
|
||||||
@@ -65,6 +375,7 @@ class CertificateAuth:
|
|||||||
"_certificate_pem",
|
"_certificate_pem",
|
||||||
"_private_key_path",
|
"_private_key_path",
|
||||||
"_private_key_pem",
|
"_private_key_pem",
|
||||||
|
"_server_profile",
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -74,6 +385,7 @@ class CertificateAuth:
|
|||||||
private_key_path: str | PathLike[str] | None = None,
|
private_key_path: str | PathLike[str] | None = None,
|
||||||
certificate_pem: str | None = None,
|
certificate_pem: str | None = None,
|
||||||
private_key_pem: str | None = None,
|
private_key_pem: str | None = None,
|
||||||
|
server_profile: SamsungServerProfile | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
file_supplied = (
|
file_supplied = (
|
||||||
certificate_path is not None or private_key_path is not None
|
certificate_path is not None or private_key_path is not None
|
||||||
@@ -101,6 +413,11 @@ class CertificateAuth:
|
|||||||
"must pass either certificate_path/private_key_path or "
|
"must pass either certificate_path/private_key_path or "
|
||||||
"certificate_pem/private_key_pem"
|
"certificate_pem/private_key_pem"
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
server_profile is not None
|
||||||
|
and type(server_profile) is not SamsungServerProfile
|
||||||
|
):
|
||||||
|
raise TypeError("server_profile must be a SamsungServerProfile")
|
||||||
object.__setattr__(
|
object.__setattr__(
|
||||||
self,
|
self,
|
||||||
"_certificate_path",
|
"_certificate_path",
|
||||||
@@ -113,6 +430,7 @@ class CertificateAuth:
|
|||||||
)
|
)
|
||||||
object.__setattr__(self, "_certificate_pem", certificate_pem)
|
object.__setattr__(self, "_certificate_pem", certificate_pem)
|
||||||
object.__setattr__(self, "_private_key_pem", private_key_pem)
|
object.__setattr__(self, "_private_key_pem", private_key_pem)
|
||||||
|
object.__setattr__(self, "_server_profile", server_profile)
|
||||||
|
|
||||||
def __setattr__(self, _name: str, _value: object) -> None:
|
def __setattr__(self, _name: str, _value: object) -> None:
|
||||||
raise AttributeError("CertificateAuth is immutable")
|
raise AttributeError("CertificateAuth is immutable")
|
||||||
@@ -125,11 +443,14 @@ class CertificateAuth:
|
|||||||
cls,
|
cls,
|
||||||
certificate_path: str | PathLike[str],
|
certificate_path: str | PathLike[str],
|
||||||
private_key_path: str | PathLike[str],
|
private_key_path: str | PathLike[str],
|
||||||
|
*,
|
||||||
|
server_profile: SamsungServerProfile | None = None,
|
||||||
) -> CertificateAuth:
|
) -> CertificateAuth:
|
||||||
"""Create a provider backed by certificate-chain and key files."""
|
"""Create a provider backed by certificate-chain and key files."""
|
||||||
return cls(
|
return cls(
|
||||||
certificate_path=certificate_path,
|
certificate_path=certificate_path,
|
||||||
private_key_path=private_key_path,
|
private_key_path=private_key_path,
|
||||||
|
server_profile=server_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -137,11 +458,14 @@ class CertificateAuth:
|
|||||||
cls,
|
cls,
|
||||||
certificate_pem: str,
|
certificate_pem: str,
|
||||||
private_key_pem: str,
|
private_key_pem: str,
|
||||||
|
*,
|
||||||
|
server_profile: SamsungServerProfile | None = None,
|
||||||
) -> CertificateAuth:
|
) -> CertificateAuth:
|
||||||
"""Create a provider backed by an in-memory PEM chain and key."""
|
"""Create a provider backed by an in-memory PEM chain and key."""
|
||||||
return cls(
|
return cls(
|
||||||
certificate_pem=certificate_pem,
|
certificate_pem=certificate_pem,
|
||||||
private_key_pem=private_key_pem,
|
private_key_pem=private_key_pem,
|
||||||
|
server_profile=server_profile,
|
||||||
)
|
)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
@@ -150,13 +474,7 @@ class CertificateAuth:
|
|||||||
|
|
||||||
def configure_context(self, context: SSL.Context) -> None:
|
def configure_context(self, context: SSL.Context) -> None:
|
||||||
"""Apply the existing certificate authentication profile to a context."""
|
"""Apply the existing certificate authentication profile to a context."""
|
||||||
context.load_verify_locations(_OCF_ROOT_CA)
|
_configure_certificate_server(context, self._server_profile)
|
||||||
context.set_verify(SSL.VERIFY_PEER, _verify_peer)
|
|
||||||
# @SECLEVEL=0 permits SHA-1 in Samsung's server cert chain (AC14K_M
|
|
||||||
# intermediate is SHA-1 signed). This is the only channel that reaches
|
|
||||||
# the OpenSSL instance cryptography bundles; ctypes and cffi bindings
|
|
||||||
# do not expose SSL_CTX_set_security_level on this build.
|
|
||||||
context.set_cipher_list(_DTLS_CIPHERS)
|
|
||||||
if self._certificate_pem is not None:
|
if self._certificate_pem is not None:
|
||||||
_load_pem_chain(
|
_load_pem_chain(
|
||||||
context,
|
context,
|
||||||
@@ -240,7 +558,14 @@ class PskAuth:
|
|||||||
"the installed OpenSSL binding does not support DTLS PSK"
|
"the installed OpenSSL binding does not support DTLS PSK"
|
||||||
)
|
)
|
||||||
context.set_cipher_list(_DTLS_PSK_CIPHERS)
|
context.set_cipher_list(_DTLS_PSK_CIPHERS)
|
||||||
setter(context._context, self._callback) # noqa: SLF001
|
setter(context._context, self._callback)
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["AuthenticationProvider", "CertificateAuth", "PskAuth"]
|
__all__ = [
|
||||||
|
"AuthenticationProvider",
|
||||||
|
"CertificateAuth",
|
||||||
|
"PskAuth",
|
||||||
|
"SamsungServerProfile",
|
||||||
|
"SamsungServerRole",
|
||||||
|
"ServerCertificateAuth",
|
||||||
|
]
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -10,6 +10,9 @@ from smartthings_local.protocol.auth import (
|
|||||||
AuthenticationProvider,
|
AuthenticationProvider,
|
||||||
CertificateAuth,
|
CertificateAuth,
|
||||||
PskAuth,
|
PskAuth,
|
||||||
|
SamsungServerProfile,
|
||||||
|
SamsungServerRole,
|
||||||
|
ServerCertificateAuth,
|
||||||
)
|
)
|
||||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||||
|
|
||||||
@@ -54,6 +57,47 @@ def test_certificate_auth_is_a_public_authentication_provider():
|
|||||||
provider = CertificateAuth.from_files("/synthetic/cert.pem", "/synthetic/key")
|
provider = CertificateAuth.from_files("/synthetic/cert.pem", "/synthetic/key")
|
||||||
assert isinstance(provider, AuthenticationProvider)
|
assert isinstance(provider, AuthenticationProvider)
|
||||||
|
|
||||||
|
for factory in (CertificateAuth.from_files, CertificateAuth.from_memory):
|
||||||
|
profile_parameter = inspect.signature(factory).parameters["server_profile"]
|
||||||
|
assert profile_parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||||
|
assert profile_parameter.default is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_samsung_server_profile_is_public_and_explicitly_bound():
|
||||||
|
parameters = inspect.signature(SamsungServerProfile.bound_device).parameters
|
||||||
|
assert list(parameters) == [
|
||||||
|
"expected_certificate_identity",
|
||||||
|
"role",
|
||||||
|
"additional_ca_pem",
|
||||||
|
]
|
||||||
|
assert (
|
||||||
|
parameters["expected_certificate_identity"].default
|
||||||
|
is inspect.Parameter.empty
|
||||||
|
)
|
||||||
|
assert parameters["role"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||||
|
assert parameters["role"].default is SamsungServerRole.HOME_APPLIANCE
|
||||||
|
assert parameters["additional_ca_pem"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||||
|
assert parameters["additional_ca_pem"].default is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_certificate_auth_is_a_public_authentication_provider():
|
||||||
|
profile = SamsungServerProfile.bound_device(
|
||||||
|
"abababab-abab-abab-abab-abababababab",
|
||||||
|
role=SamsungServerRole.VD_DEVICE,
|
||||||
|
)
|
||||||
|
provider = ServerCertificateAuth(server_profile=profile)
|
||||||
|
assert isinstance(provider, AuthenticationProvider)
|
||||||
|
session = DtlsCoapSession("device.example", 5684, auth=provider)
|
||||||
|
assert session.auth is provider
|
||||||
|
assert session.cert_path is None
|
||||||
|
assert session.key_path is None
|
||||||
|
assert session.cert_pem is None
|
||||||
|
assert session.key_pem is None
|
||||||
|
parameters = inspect.signature(ServerCertificateAuth).parameters
|
||||||
|
assert list(parameters) == ["server_profile"]
|
||||||
|
assert parameters["server_profile"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||||
|
assert parameters["server_profile"].default is inspect.Parameter.empty
|
||||||
|
|
||||||
|
|
||||||
def test_psk_auth_is_a_public_authentication_provider():
|
def test_psk_auth_is_a_public_authentication_provider():
|
||||||
provider = PskAuth(identity=b"i" * 16, key=b"k" * 16)
|
provider = PskAuth(identity=b"i" * 16, key=b"k" * 16)
|
||||||
|
|||||||
Reference in new issue
Block a user