Skip to content
Merged
Show file tree
Hide file tree
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
54 changes: 52 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,31 @@ Setting the signal stops subscribed connection attempts and closes their
temporary UDP sockets. It does not alter an already established session.
Interrupted attempts raise `SessionClosedError`.

Some Samsung OCF-PKI firmware can retain a half-open DTLS peer when a
handshake stops immediately after the cookie exchange. A caller using a
`SamsungServerProfile` and a fixed non-zero local UDP port can opt in to the
narrow cleanup path:

```python
from smartthings_local.errors import HandshakePeerCleanupError

try:
sess.connect(timeout=8.0, cleanup_hvr_peer=True)
except HandshakePeerCleanupError:
# Apply the device-specific settle delay, then retry under caller policy.
schedule_connection_retry()
```

Cleanup is sent only after the bounded transcript contains at least two
complete epoch-zero ClientHello messages and every received record is a
complete epoch-zero HelloVerifyRequest. A malformed, fragmented, mixed, or
oversized transcript remains an ordinary `SessionTimeoutError`. On the exact
HVR-only shape, the session sends one epoch-zero fatal `handshake_failure`
alert, closes the temporary socket, and raises `HandshakePeerCleanupError`.
The package never sleeps or retries automatically, so the caller retains the
overall recovery budget and can use the settle interval validated for its
device. Cancellation and backend failures never trigger the alert.

Hosts that stop network work before their blocking executor drains can use the
session's two-phase shutdown. `quiesce_for_close()` is terminal: it interrupts
an in-progress handshake, wakes pending requests and notification refetches,
Expand Down Expand Up @@ -338,8 +363,32 @@ 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.
subject role, and pinned certificate UUID. It cannot be combined with client
credentials.

An explicit first-use workflow may need to authenticate the Samsung hardware
certificate before its subject UUID is known. Use the discovery profile only
for that bounded step:

```python
server_profile = SamsungServerProfile.discover_device(
additional_ca_pem=additional_samsung_ca_pem,
)
auth = ServerCertificateAuth(server_profile=server_profile)
sess = DtlsCoapSession("192.0.2.100", 5684, auth=auth)
sess.connect()
certificate_uuid = sess.server_certificate_identity
```

The discovery profile still verifies the CA chain and the complete selected
Samsung subject role before `connect()` exposes the non-zero certificate UUID.
It does not trust an arbitrary first certificate, and neither the immutable
profile nor its provider retains the learned identity. The caller must bind
that UUID to independently authenticated device evidence, such as `/oic/d`
read over the same authenticated session, before persisting it. Subsequent
connections should use `bound_device()` with that verified binding. The
certificate UUID and the OCF device UUID are separate identities and must not
be assumed equal.

This API deliberately does not discover, mint, authorize, provision, rotate,
or persist credentials, and it performs no ownership transfer or OCF security
Expand Down Expand Up @@ -417,6 +466,7 @@ callers can keep catching the built-in types used by earlier releases:
| `AuthenticationError` | `authentication` | `ConnectionError` |
| `AuthorizationError` | `authorization` | `PermissionError` |
| `SessionTimeoutError` | `timeout` | `TimeoutError` |
| `HandshakePeerCleanupError` | `handshake_peer_cleanup` | `TimeoutError` |
| `SessionClosedError` | `session_closed` | `ConnectionError` |
| `MalformedMessageError` | `malformed_message` | `ValueError` |
| `BlockwiseError` | `blockwise` | `ConnectionError` |
Expand Down
11 changes: 11 additions & 0 deletions smartthings_local/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
'AuthorizationError',
'BlockwiseError',
'EndpointError',
'HandshakePeerCleanupError',
'MalformedMessageError',
'ObserveError',
'ProbeError',
Expand Down Expand Up @@ -77,6 +78,16 @@ class SessionTimeoutError(SmartThingsLocalError, TimeoutError):
message = 'session operation timed out'


class HandshakePeerCleanupError(SessionTimeoutError):
"""A cleanup alert was sent for an HVR-only half-open DTLS peer.

The caller may apply its device-specific settle delay and retry policy.
"""

code = 'handshake_peer_cleanup'
message = 'handshake peer cleanup was sent'


class SessionClosedError(SessionError):
"""An operation was attempted on a closed session."""

Expand Down
82 changes: 74 additions & 8 deletions smartthings_local/protocol/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,19 @@ def __init__(
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
additional_ca_pem: str | None = None,
) -> None:
parsed_identity = self._parse_expected_identity(
expected_certificate_identity
)
self._initialize(
expected_certificate_identity=parsed_identity,
role=role,
additional_ca_pem=additional_ca_pem,
)

@staticmethod
def _parse_expected_identity(
expected_certificate_identity: UUID | str,
) -> UUID:
if type(expected_certificate_identity) is UUID:
parsed_identity = expected_certificate_identity
elif type(expected_certificate_identity) is str:
Expand All @@ -117,6 +130,15 @@ def __init__(
"expected_certificate_identity must be a canonical "
"non-zero UUID"
)
return parsed_identity

def _initialize(
self,
*,
expected_certificate_identity: UUID | None,
role: SamsungServerRole,
additional_ca_pem: str | None,
) -> None:
if type(role) is not SamsungServerRole:
raise TypeError("role must be a SamsungServerRole")

Expand Down Expand Up @@ -176,7 +198,7 @@ def __init__(
object.__setattr__(
self,
"_expected_certificate_identity",
parsed_identity,
expected_certificate_identity,
)
object.__setattr__(self, "_role", role)
object.__setattr__(self, "_additional_ca_certificates", certificates)
Expand All @@ -202,6 +224,27 @@ def bound_device(
additional_ca_pem=additional_ca_pem,
)

@classmethod
def discover_device(
cls,
*,
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
additional_ca_pem: str | None = None,
) -> SamsungServerProfile:
"""Verify a Samsung hardware leaf before learning its certificate UUID.

This profile is intended only for an explicit first-use workflow. The
caller must bind the returned session identity to independently
authenticated device evidence before persisting it.
"""
profile = object.__new__(cls)
profile._initialize(
expected_certificate_identity=None,
role=role,
additional_ca_pem=additional_ca_pem,
)
return profile

def __repr__(self) -> str:
"""Return a representation without device or trust-chain details."""
return "SamsungServerProfile()"
Expand Down Expand Up @@ -273,6 +316,14 @@ def _verify_peer(
return False
if depth > 0:
return True
identity = self._certificate_identity(certificate)
return identity is not None and (
self._expected_certificate_identity is None
or identity == self._expected_certificate_identity
)

def _certificate_identity(self, certificate) -> UUID | None:
"""Extract a UUID only from the selected Samsung subject role."""
try:
with warnings.catch_warnings():
# pyOpenSSL deprecates this API in favor of cryptography, but
Expand All @@ -291,7 +342,7 @@ def _verify_peer(
crypto.Error,
):
logger.warning("Unable to parse Samsung server certificate subject")
return False
return None

common_names = [value for name, value in components if name == "CN"]
organizational_units = [
Expand All @@ -308,13 +359,12 @@ def _verify_peer(
or organizations != ["Samsung Electronics"]
or countries != ["KR"]
):
return False
return None
match = _SAMSUNG_SERVER_CN_RE.fullmatch(common_names[0])
return (
match is not None
and UUID(match.group("device_identity"))
== self._expected_certificate_identity
)
if match is None:
return None
identity = UUID(match.group("device_identity"))
return identity if identity.int != 0 else None


def _configure_certificate_server(
Expand Down Expand Up @@ -362,6 +412,13 @@ def configure_context(self, context: SSL.Context) -> None:
"""Verify the selected server profile without loading client material."""
_configure_certificate_server(context, self._server_profile)

def _authenticated_server_identity(self, connection) -> UUID | None:
certificate = connection.get_peer_certificate()
identity = self._server_profile._certificate_identity(certificate)
if identity is None:
raise ValueError("verified server identity was unavailable")
return identity


class CertificateAuth:
"""Certificate authentication loaded from files or in-memory PEM data.
Expand Down Expand Up @@ -486,6 +543,15 @@ def configure_context(self, context: SSL.Context) -> None:
context.use_privatekey_file(self._private_key_path)
context.check_privatekey()

def _authenticated_server_identity(self, connection) -> UUID | None:
if self._server_profile is None:
return None
certificate = connection.get_peer_certificate()
identity = self._server_profile._certificate_identity(certificate)
if identity is None:
raise ValueError("verified server identity was unavailable")
return identity


class PskAuth:
"""DTLS authentication using an existing OCF PSK credential.
Expand Down
Loading