diff --git a/.gitignore b/.gitignore index eac875d..1c73d28 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ uv.lock # Packaging / build artifacts /smartthings_local/_version.py /dist/ +smartthings_local/protocol/_mbedtls_native.so *.egg-info/ # Bridge runtime diff --git a/README.md b/README.md index c87da42..e5905aa 100644 --- a/README.md +++ b/README.md @@ -392,7 +392,7 @@ sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth) The identity must be the raw 16-byte OCF UUID and the key exactly 16 or 32 bytes. `PskAuth` selects only `ECDHE-PSK-AES128-CBC-SHA256` and does not acquire, derive, provision, rotate, or persist credentials. Ownership transfer and credential discovery are outside this package. -An identity containing a zero byte is rejected, and that limit is OpenSSL's rather than the appliance's. An OCF device takes the identity as bytes with an explicit length, so a zero byte means nothing to it, but OpenSSL's DTLS 1.2 PSK client callback returns the identity as a C string. Measured against OpenSSL 4.0.0, a 16-byte identity with a NUL at byte 8 reaches the wire as 8 bytes and the handshake raises nothing locally, so the appliance answers a truncated identity it has never seen. DTLS 1.2 offers no length-carrying PSK callback, so such a credential is unusable here: roughly 6% of uniformly random 16-byte identities, and about 5% of UUIDv4s, which have two fixed bytes. +An identity containing a zero byte requires the optional [Mbed TLS backend](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/mbedtls.md). OpenSSL's DTLS 1.2 PSK callback truncates identities at NUL; Mbed TLS sends the full binary identity with an explicit length. Validation rejects these identities when the backend is unavailable. Certificate authentication and other PSK identities retain OpenSSL. Code holding a credential can check it, and report why, before building a provider or storing anything: diff --git a/docs/api.md b/docs/api.md index 3e03ed5..a11375a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -157,7 +157,7 @@ PskAuth(*, identity: bytes, key: bytes) *class*: DTLS authentication using an existing OCF PSK credential. - `configure_context(context: OpenSSL.SSL.Context) -> None`: Configure one context for the narrow Samsung OCF PSK profile. -- `validate_identity(identity: bytes) -> None`: Raise unless `identity` is one OpenSSL can put on the wire. +- `validate_identity(identity: bytes) -> None`: Raise unless an installed backend can send the complete identity. #### `SamsungServerProfile` diff --git a/docs/mbedtls.md b/docs/mbedtls.md new file mode 100644 index 0000000..1ef6bfb --- /dev/null +++ b/docs/mbedtls.md @@ -0,0 +1,57 @@ +# Binary PSK identities + +OpenSSL's DTLS 1.2 PSK callback treats an identity as a NUL-terminated string. +It cannot send a raw OCF UUID containing a zero byte intact. +The optional Mbed TLS backend sends the identity with its explicit length. + +`PskAuth` selects Mbed TLS only when the identity contains a zero byte. +Certificate authentication and other PSK identities retain OpenSSL. +Both backends use the existing CoAP, cancellation, retry, and observation code. +The Mbed TLS backend permits only DTLS 1.2 with `TLS-ECDHE-PSK-WITH-AES-128-CBC-SHA256`. + +## Build + +Install a C compiler and Mbed TLS **3.6** development headers and libraries. +Build against the same library configuration used at runtime. +On Linux and macOS, run: + +```sh +python -m smartthings_local.protocol._build_mbedtls +``` + +For Homebrew's versioned installation, run: + +```sh +MBEDTLS_PREFIX="$(brew --prefix mbedtls@3)" python -m smartthings_local.protocol._build_mbedtls +``` + +The command creates `_mbedtls_native.so` beside the Python backend. +The ordinary wheel contains source, not a platform-specific binary. +No compiler runs during authentication or integration setup. +A missing or incompatible binary causes validation to fail before any network request. +An identity containing zero bytes never falls back to OpenSSL. + +Linux builders can use `--static` with position-independent Mbed TLS archives. +This isolates the backend from another Mbed TLS version loaded by the host process. +Build separately for each architecture and C library. +Do not copy a macOS binary into a Linux installation. +Package or integration updates can remove local patches; retain deployment backups. + +## Evidence and limits + +I authenticated my Samsung LCD oven, profile `DA-KS-OVEN-0105X`, through Mbed TLS 3.6.7 on 2026-10-03. +My owner UUID contained a zero byte. +A read-only GET of `/oic/d` returned the expected device identity. +Both a standalone native probe and the Python session performed that read. +This observation does not establish compatibility with other appliance models. + +The tests exchange encrypted records with a local OpenSSL peer using synthetic binary identities. +They check identity length, zero-byte positions, missing-backend rejection, retransmission timing, and native cleanup. +Run these tests after building the native backend: + +```sh +python -m pytest tests/test_mbedtls.py tests/test_psk_auth.py +``` + +The existing credential-acquisition and device-identity requirements still apply. +This backend neither acquires a PSK nor writes OCF security resources. diff --git a/smartthings_local/protocol/_build_mbedtls.py b/smartthings_local/protocol/_build_mbedtls.py new file mode 100644 index 0000000..1f1770c --- /dev/null +++ b/smartthings_local/protocol/_build_mbedtls.py @@ -0,0 +1,46 @@ +"""Build the optional Mbed TLS 3.6 shim against installed development headers. + +Run explicitly at installation time, never from the integration's runtime. +Set MBEDTLS_PREFIX for a non-system installation such as Homebrew mbedtls@3. +""" +import os +import argparse +from pathlib import Path +import shlex +import subprocess +import sys +import tempfile + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--static", action="store_true", help="link Mbed TLS archives on Linux") + arguments = parser.parse_args() + if sys.platform not in ("linux", "darwin"): + raise SystemExit("The optional Mbed TLS build currently supports Linux and macOS") + directory = Path(__file__).resolve().parent + prefix = os.environ.get("MBEDTLS_PREFIX") + flags = [] + if prefix: + flags = [f"-I{prefix}/include", f"-L{prefix}/lib", f"-Wl,-rpath,{prefix}/lib"] + libraries = ["-lmbedtls", "-lmbedx509", "-lmbedcrypto"] + if arguments.static: + if sys.platform != "linux": + raise SystemExit("Static Mbed TLS linking currently supports Linux only") + libraries = ["-Wl,-Bstatic", *libraries, "-Wl,-Bdynamic", "-Wl,--exclude-libs,ALL"] + with tempfile.TemporaryDirectory(prefix="localthings-mbedtls-", dir=directory) as temporary: + output = Path(temporary) / "_mbedtls_native.so" + subprocess.run([ + *shlex.split(os.environ.get("CC", "cc")), "-std=c11", "-D_POSIX_C_SOURCE=200809L", + "-O2", "-Wall", "-Wextra", "-Werror", "-fPIC", "-shared", *flags, + str(directory / "_mbedtls_native.c"), *libraries, + "-o", str(output), + ], check=True) + # Validate ABI/loading in another process before replacing an existing build. + subprocess.run([sys.executable, "-c", "import ctypes,sys; lib=ctypes.CDLL(sys.argv[1]); assert lib.lt_api_version()==1", str(output)], check=True) + output.replace(directory / output.name) + print(directory / "_mbedtls_native.so") + + +if __name__ == "__main__": + main() diff --git a/smartthings_local/protocol/_mbedtls.py b/smartthings_local/protocol/_mbedtls.py new file mode 100644 index 0000000..a117bb0 --- /dev/null +++ b/smartthings_local/protocol/_mbedtls.py @@ -0,0 +1,110 @@ +"""Optional binary-identity DTLS backend, sharing the existing session driver. + +The C shim owns Mbed TLS contexts compiled against real headers. No guessed +structure sizes, runtime compilation, or Python callbacks cross the native ABI. +""" +from __future__ import annotations + +import ctypes +from functools import lru_cache +from pathlib import Path +import weakref + +from OpenSSL import SSL + +_UNAVAILABLE = ( + "a PSK identity containing a NUL byte requires the optional Mbed TLS " + "backend; build it with python -m smartthings_local.protocol._build_mbedtls" +) + + +@lru_cache(maxsize=1) +def _load_library(): + try: + library = ctypes.CDLL(str(Path(__file__).with_name("_mbedtls_native.so"))) + pointer, size, integer = ctypes.c_void_p, ctypes.c_size_t, ctypes.c_int + signatures = { + "lt_api_version": ([], integer), + "lt_new": ([pointer, size, pointer, size, ctypes.c_ushort, ctypes.POINTER(integer)], pointer), + "lt_free": ([pointer], None), + "lt_handshake": ([pointer], integer), + "lt_shutdown": ([pointer], integer), + "lt_timeout": ([pointer], ctypes.c_double), + } + for name in ("lt_write", "lt_read", "lt_feed", "lt_drain"): + signatures[name] = ([pointer, pointer, size], integer) + for name, (arguments, result) in signatures.items(): + function = getattr(library, name) + function.argtypes = arguments + function.restype = result + if library.lt_api_version() != 1: + raise ValueError(_UNAVAILABLE) + return library + except (OSError, AttributeError): + raise ValueError(_UNAVAILABLE) from None + + +def _check(result): + if result in (-0x6900, -0x6880): # WANT_READ / WANT_WRITE: memory BIO never blocks. + raise SSL.WantReadError() + if result == -0x7880: + raise SSL.ZeroReturnError() + if result < 0: + raise SSL.Error([("Mbed TLS", "DTLS", f"backend error {-result:#x}")]) + return result + + +class _MbedConnection: + """The memory-BIO operations used by DtlsCoapSession, not a public SSL API.""" + + def __init__(self, identity: bytes, key: bytes, mtu: int): + if type(mtu) is not int or not 256 <= mtu <= 65535: + raise ValueError("DTLS MTU must be between 256 and 65535") + self._library = _load_library() + error = ctypes.c_int() + self._handle = self._library.lt_new(identity, len(identity), key, len(key), mtu, ctypes.byref(error)) + if not self._handle: + _check(error.value) + raise SSL.Error("Mbed TLS allocation failed") + # The reader retains a connection reference until it exits. Finalize + # only after that reference is gone, including failure and abort paths. + self._finalizer = weakref.finalize(self, self._library.lt_free, self._handle) + + def do_handshake(self): + _check(self._library.lt_handshake(self._handle)) + + def bio_write(self, data): + return _check(self._library.lt_feed(self._handle, data, len(data))) + + def _read(self, function, capacity): + if not 1 <= capacity <= 65535: + raise ValueError("DTLS buffer size must be between 1 and 65535") + buffer = ctypes.create_string_buffer(capacity) + length = _check(function(self._handle, buffer, capacity)) + return buffer.raw[:length] + + def bio_read(self, capacity): + return self._read(self._library.lt_drain, capacity) + + def recv(self, capacity): + result = self._read(self._library.lt_read, capacity) + if not result: + raise SSL.ZeroReturnError() + return result + + def send(self, data): + written = _check(self._library.lt_write(self._handle, data, len(data))) + if written != len(data): + raise SSL.Error("Mbed TLS incomplete datagram write") + return written + + def shutdown(self): + _check(self._library.lt_shutdown(self._handle)) + + def DTLSv1_get_timeout(self): + remaining = self._library.lt_timeout(self._handle) + return None if remaining < 0 else remaining + + def DTLSv1_handle_timeout(self): + # Mbed TLS services its expired timer on the next handshake call. + return None diff --git a/smartthings_local/protocol/_mbedtls_native.c b/smartthings_local/protocol/_mbedtls_native.c new file mode 100644 index 0000000..d5a8d53 --- /dev/null +++ b/smartthings_local/protocol/_mbedtls_native.c @@ -0,0 +1,142 @@ +/* Optional Mbed TLS 3.6 memory transport. No Python or socket ownership. */ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if MBEDTLS_VERSION_MAJOR != 3 || MBEDTLS_VERSION_MINOR != 6 +#error "This backend requires Mbed TLS 3.6 headers and libraries" +#endif + +#define CAPACITY 65535 +typedef struct { + mbedtls_ssl_context ssl; + mbedtls_ssl_config config; + mbedtls_entropy_context entropy; + mbedtls_ctr_drbg_context random; + unsigned char incoming[CAPACITY], outgoing[CAPACITY]; + size_t incoming_size, outgoing_size; + double timer_start; + uint32_t intermediate_ms, final_ms; +} lt_connection; + +static double monotonic_seconds(void) { + struct timespec stamp; + clock_gettime(CLOCK_MONOTONIC, &stamp); + return stamp.tv_sec + stamp.tv_nsec / 1e9; +} + +static void set_timer(void *context, uint32_t intermediate, uint32_t final) { + lt_connection *connection = context; + connection->timer_start = monotonic_seconds(); + connection->intermediate_ms = intermediate; + connection->final_ms = final; +} + +static int get_timer(void *context) { + lt_connection *connection = context; + if (!connection->final_ms) return -1; + double elapsed = 1000 * (monotonic_seconds() - connection->timer_start); + if (elapsed >= connection->final_ms) return 2; + return elapsed >= connection->intermediate_ms ? 1 : 0; +} + +static int send_record(void *context, const unsigned char *data, size_t size) { + lt_connection *connection = context; + if (size > CAPACITY - connection->outgoing_size) + return MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL; + memcpy(connection->outgoing + connection->outgoing_size, data, size); + connection->outgoing_size += size; + return (int)size; +} + +static int receive_record(void *context, unsigned char *data, size_t capacity) { + lt_connection *connection = context; + if (!connection->incoming_size) return MBEDTLS_ERR_SSL_WANT_READ; + size_t size = connection->incoming_size; + connection->incoming_size = 0; + if (size > capacity) return MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL; + memcpy(data, connection->incoming, size); + return (int)size; +} + +int lt_api_version(void) { + return (mbedtls_version_get_number() >> 16) == (MBEDTLS_VERSION_NUMBER >> 16) ? 1 : 0; +} + +void lt_free(lt_connection *connection) { + if (!connection) return; + mbedtls_ssl_free(&connection->ssl); + mbedtls_ssl_config_free(&connection->config); + mbedtls_ctr_drbg_free(&connection->random); + mbedtls_entropy_free(&connection->entropy); + mbedtls_platform_zeroize(connection, sizeof(*connection)); + free(connection); +} + +lt_connection *lt_new(const unsigned char *identity, size_t identity_size, + const unsigned char *key, size_t key_size, + unsigned short mtu, int *error) { + *error = MBEDTLS_ERR_SSL_BAD_INPUT_DATA; + if (!identity || !key || identity_size != 16 || (key_size != 16 && key_size != 32)) + return NULL; + lt_connection *connection = calloc(1, sizeof(*connection)); + if (!connection) { *error = MBEDTLS_ERR_SSL_ALLOC_FAILED; return NULL; } + mbedtls_ssl_init(&connection->ssl); + mbedtls_ssl_config_init(&connection->config); + mbedtls_entropy_init(&connection->entropy); + mbedtls_ctr_drbg_init(&connection->random); + static const int ciphers[] = {MBEDTLS_TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256, 0}; +#define CHECK(call) do { *error = (call); if (*error != 0) goto failed; } while (0) + CHECK(mbedtls_ctr_drbg_seed(&connection->random, mbedtls_entropy_func, + &connection->entropy, NULL, 0)); + CHECK(mbedtls_ssl_config_defaults(&connection->config, MBEDTLS_SSL_IS_CLIENT, + MBEDTLS_SSL_TRANSPORT_DATAGRAM, MBEDTLS_SSL_PRESET_DEFAULT)); + mbedtls_ssl_conf_rng(&connection->config, mbedtls_ctr_drbg_random, &connection->random); + mbedtls_ssl_conf_min_tls_version(&connection->config, MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_max_tls_version(&connection->config, MBEDTLS_SSL_VERSION_TLS1_2); + mbedtls_ssl_conf_ciphersuites(&connection->config, ciphers); + CHECK(mbedtls_ssl_conf_psk(&connection->config, key, key_size, identity, identity_size)); + CHECK(mbedtls_ssl_setup(&connection->ssl, &connection->config)); + mbedtls_ssl_set_mtu(&connection->ssl, mtu); + mbedtls_ssl_set_bio(&connection->ssl, connection, send_record, receive_record, NULL); + mbedtls_ssl_set_timer_cb(&connection->ssl, connection, set_timer, get_timer); + return connection; +failed: + lt_free(connection); + return NULL; +#undef CHECK +} + +int lt_handshake(lt_connection *connection) { return mbedtls_ssl_handshake(&connection->ssl); } +int lt_shutdown(lt_connection *connection) { return mbedtls_ssl_close_notify(&connection->ssl); } +int lt_write(lt_connection *connection, const unsigned char *data, size_t size) { + return mbedtls_ssl_write(&connection->ssl, data, size); +} +int lt_read(lt_connection *connection, unsigned char *data, size_t size) { + return mbedtls_ssl_read(&connection->ssl, data, size); +} +int lt_feed(lt_connection *connection, const unsigned char *data, size_t size) { + if (connection->incoming_size || size > CAPACITY) return MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL; + memcpy(connection->incoming, data, size); + connection->incoming_size = size; + return (int)size; +} +int lt_drain(lt_connection *connection, unsigned char *data, size_t capacity) { + size_t size = connection->outgoing_size; + if (!size) return MBEDTLS_ERR_SSL_WANT_READ; + if (size > capacity) return MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL; + memcpy(data, connection->outgoing, size); + connection->outgoing_size = 0; + return (int)size; +} +double lt_timeout(lt_connection *connection) { + if (!connection->final_ms) return -1; + double remaining = connection->final_ms / 1000.0 - (monotonic_seconds() - connection->timer_start); + return remaining > 0 ? remaining : 0; +} diff --git a/smartthings_local/protocol/auth.py b/smartthings_local/protocol/auth.py index e2fb505..2564b48 100644 --- a/smartthings_local/protocol/auth.py +++ b/smartthings_local/protocol/auth.py @@ -556,7 +556,7 @@ def _authenticated_server_identity(self, connection) -> UUID | None: class PskAuth: """DTLS authentication using an existing OCF PSK credential. - The identity must be a raw 16-byte OCF UUID that OpenSSL can present, as + The identity must be a raw 16-byte OCF UUID that a backend can present, as described on :meth:`validate_identity`. The key must contain 16 or 32 bytes. Credential material is intentionally not exposed as public attributes and is never included in this provider's representation. A @@ -564,37 +564,23 @@ class PskAuth: enforces that lifetime by retaining its provider. """ - __slots__ = ("_callback",) + __slots__ = ("_callback", "_mbedtls_factory") @staticmethod def validate_identity(identity: bytes) -> None: - """Raise unless ``identity`` is one OpenSSL can put on the wire. - - A caller holding a credential can check it here, and report the - reason, before building a provider or storing anything. - - An OCF appliance takes the identity as bytes with an explicit length, - so a zero byte is unremarkable to the device. OpenSSL's DTLS 1.2 PSK - client callback returns the identity as a C string, which leaves no - way to express one: a NUL truncates the identity on the wire, and the - handshake then fails against the truncated value with no local error - to point at the cause. Roughly 6% of uniformly random 16-byte - identities carry a zero byte, and about 5% of UUIDv4s, whose version - and variant bytes can never be zero. There is no length-carrying PSK - callback for DTLS 1.2 to fall back on, so such a credential cannot be - used through this library. + """Raise unless an installed backend can send the complete identity. + + OpenSSL's DTLS 1.2 callback truncates binary identities at NUL. + Those identities require the optional compiled Mbed TLS 3.6 backend. + Validation never opens a socket or falls back to a truncated identity. """ if type(identity) is not bytes: raise TypeError("identity must be bytes") if len(identity) != 16: raise ValueError("identity must be a raw 16-byte OCF UUID") if b"\x00" in identity: - raise ValueError( - "identity cannot contain a NUL byte: OpenSSL presents a " - "DTLS 1.2 PSK identity as a C string, so a NUL truncates it " - "and the appliance would be sent a shorter identity than the " - "one supplied" - ) + from ._mbedtls import _load_library + _load_library() def __init__(self, *, identity: bytes, key: bytes) -> None: if type(identity) is not bytes or type(key) is not bytes: @@ -603,6 +589,13 @@ def __init__(self, *, identity: bytes, key: bytes) -> None: if len(key) not in (16, 32): raise ValueError("key must be 16 or 32 bytes") + object.__setattr__(self, "_mbedtls_factory", None) + if b"\x00" in identity: + from ._mbedtls import _MbedConnection + object.__setattr__(self, "_callback", None) + object.__setattr__(self, "_mbedtls_factory", lambda mtu: _MbedConnection(identity, key, mtu)) + return + ffi = _util.ffi @ffi.callback(_PSK_CLIENT_CALLBACK_CDEF) @@ -646,6 +639,8 @@ def __repr__(self) -> str: def configure_context(self, context: SSL.Context) -> None: """Configure one context for the narrow Samsung OCF PSK profile.""" + if self._mbedtls_factory is not None: + raise ValueError("this binary PSK identity requires a Mbed TLS session") setter = getattr(_util.lib, "SSL_CTX_set_psk_client_callback", None) if setter is None: raise RuntimeError( diff --git a/smartthings_local/protocol/dtls_handshake.py b/smartthings_local/protocol/dtls_handshake.py index ec5be7b..4582860 100644 --- a/smartthings_local/protocol/dtls_handshake.py +++ b/smartthings_local/protocol/dtls_handshake.py @@ -199,9 +199,10 @@ def _drive_dtls_handshake( """ retransmits = 0 while time.monotonic() < deadline: + completed = False try: connection.do_handshake() - return True + completed = True except SSL.WantReadError: pass @@ -216,6 +217,9 @@ def _drive_dtls_handshake( if on_record_sent is not None: on_record_sent(record) + if completed: + return True + remaining = deadline - time.monotonic() if remaining <= 0: break diff --git a/smartthings_local/protocol/dtls_probe.py b/smartthings_local/protocol/dtls_probe.py index 82da7d5..5802cbe 100644 --- a/smartthings_local/protocol/dtls_probe.py +++ b/smartthings_local/protocol/dtls_probe.py @@ -780,17 +780,19 @@ def diagnose_dtls_handshake( _validate_diagnostic_auth(auth, cert_pem, key_pem, cert_path, key_path) result = ProbeResult(host, port) - ctx = _diagnostic_context( - auth=auth, - cert_pem=cert_pem, - key_pem=key_pem, - cert_path=cert_path, - key_path=key_path, - ) - - conn = SSL.Connection(ctx, None) - conn.set_connect_state() - conn.set_ciphertext_mtu(mtu) + if isinstance(auth, PskAuth) and auth._mbedtls_factory is not None: + conn = auth._mbedtls_factory(mtu) + else: + ctx = _diagnostic_context( + auth=auth, + cert_pem=cert_pem, + key_pem=key_pem, + cert_path=cert_path, + key_path=key_path, + ) + conn = SSL.Connection(ctx, None) + conn.set_connect_state() + conn.set_ciphertext_mtu(mtu) try: sock, _endpoint = open_host_filtered_udp_socket( @@ -843,6 +845,7 @@ def record_datagram(datagram): if level == 2: # fatal result.outcome = REJECTED + completed = False try: completed = _drive_dtls_handshake( conn, @@ -862,6 +865,9 @@ def record_datagram(datagram): except OSError: result.error = ProbeError() finally: + if completed and isinstance(auth, PskAuth) and auth._mbedtls_factory is not None: + from .dtls_session import DtlsCoapSession + DtlsCoapSession._send_close_notify(conn, sock) sock.close() return result diff --git a/smartthings_local/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py index bf37c83..a4a19c7 100644 --- a/smartthings_local/protocol/dtls_session.py +++ b/smartthings_local/protocol/dtls_session.py @@ -56,6 +56,7 @@ from .auth import ( AuthenticationProvider, CertificateAuth, + PskAuth, SamsungServerProfile, ServerCertificateAuth, ) @@ -96,6 +97,7 @@ _drive_dtls_handshake, _HandshakeCancelled, ) +from ._mbedtls import _MbedConnection from .endpoint import open_host_filtered_udp_socket # Private compatibility exports used by dtls_probe and existing callers. @@ -773,15 +775,22 @@ def connect( (cancel is not None and cancel.is_set()): raise SessionClosedError() deadline = time.monotonic() + handshake_timeout - ctx = SSL.Context(SSL.DTLS_METHOD) - self.auth.configure_context(ctx) + mbedtls_factory = getattr(self.auth, '_mbedtls_factory', None) + if isinstance(self.auth, PskAuth) and mbedtls_factory is not None: + conn = mbedtls_factory(self.mtu) + else: + ctx = SSL.Context(SSL.DTLS_METHOD) + self.auth.configure_context(ctx) + if self._lifecycle_cancel.is_set() or \ + (cancel is not None and cancel.is_set()): + raise SessionClosedError() + conn = SSL.Connection(ctx, None) + conn.set_connect_state() + conn.set_ciphertext_mtu(self.mtu) if self._lifecycle_cancel.is_set() or \ (cancel is not None and cancel.is_set()): raise SessionClosedError() - conn = SSL.Connection(ctx, None) - conn.set_connect_state() - conn.set_ciphertext_mtu(self.mtu) if self._lifecycle_cancel.is_set() or \ (cancel is not None and cancel.is_set()): raise SessionClosedError() @@ -1388,6 +1397,15 @@ def _reader_loop(self): if not pl: break packets.append(pl) + if isinstance(conn, _MbedConnection): + # Reading may enqueue a retransmitted final flight or alert. + try: + outbound = conn.bio_read(65535) + except SSL.WantReadError: + outbound = b'' + for record in _split_dtls(outbound): + if sock.send(record) != len(record): + raise OSError('incomplete UDP send') for pl in packets: try: self._dispatch_coap(pl) diff --git a/tests/test_dtls_probe.py b/tests/test_dtls_probe.py index a7a6454..3ee385b 100644 --- a/tests/test_dtls_probe.py +++ b/tests/test_dtls_probe.py @@ -837,9 +837,13 @@ def test_cli_refuses_a_certificate_and_a_psk_together(capsys): assert 'not both' in capsys.readouterr().out -def test_cli_reports_an_unusable_psk_credential_without_a_traceback(capsys): +def test_cli_reports_an_unusable_psk_credential_without_a_traceback(capsys, monkeypatch): # A NUL in the identity is rejected by PskAuth, and the CLI has to render # that as a usage error rather than an exception. + from smartthings_local.protocol import _mbedtls + def unavailable(): + raise ValueError("Mbed TLS unavailable") + monkeypatch.setattr(_mbedtls, '_load_library', unavailable) result = p._main([ '127.0.0.1', '5684', '--diagnostic', '--psk-identity', '0102030405060708000a0b0c0d0e0f10', diff --git a/tests/test_mbedtls.py b/tests/test_mbedtls.py new file mode 100644 index 0000000..8f21465 --- /dev/null +++ b/tests/test_mbedtls.py @@ -0,0 +1,169 @@ +"""Binary PSK identity regression tests; no appliance or network needed.""" +from pathlib import Path + +import pytest +from OpenSSL import SSL + +from smartthings_local.protocol.auth import PskAuth + + +def _relay(source, destination): + try: + data = source.bio_read(65535) + except SSL.WantReadError: + return b"" + if data: + destination.bio_write(data) + return data + + +def _openssl_peer(): + from smartthings_local.protocol.auth import _util + context = SSL.Context(SSL.DTLS_METHOD) + context.set_cipher_list(b"ECDHE-PSK-AES128-CBC-SHA256:@SECLEVEL=0") + + @_util.ffi.callback("unsigned int(*)(SSL *, const char *, unsigned char *, unsigned int)") + def callback(_ssl, _identity, key, capacity): + if capacity < 16: + return 0 + _util.ffi.memmove(key, b"k" * 16, 16) + return 16 + + _util.lib.SSL_CTX_set_psk_server_callback(context._context, callback) + peer = SSL.Connection(context, None) + peer.set_accept_state() + return peer, callback + + +@pytest.mark.parametrize("zero_position", [0, 8, 15]) +def test_native_handshake_preserves_binary_identity_and_exchanges_data(zero_position): + from smartthings_local.protocol._mbedtls import _MbedConnection + if not Path("smartthings_local/protocol/_mbedtls_native.so").exists(): + pytest.skip("optional Mbed TLS shim not built") + identity = bytearray(range(1, 17)) + identity[zero_position] = 0 + client = _MbedConnection(bytes(identity), b"k" * 16, 1200) + server, callback = _openssl_peer() + complete = [False, False] + outbound = bytearray() + for _ in range(50): + for i, source, destination in [(0, client, server), (1, server, client)]: + try: + source.do_handshake() + complete[i] = True + except SSL.WantReadError: + pass + data = _relay(source, destination) + if i == 0: + outbound.extend(data) + if all(complete): + break + assert all(complete) + # ClientKeyExchange precedes encryption and carries the explicit identity length. + from smartthings_local.protocol.coap import split_dtls + exchanges = [r[25:] for r in split_dtls(bytes(outbound)) if r[0] == 22 and r[13] == 16] + assert any(body[:2] == b"\x00\x10" and body[2:18] == identity for body in exchanges) + client.send(b"client payload") + _relay(client, server) + assert server.recv(65535) == b"client payload" + server.send(b"server payload") + _relay(server, client) + assert client.recv(65535) == b"server payload" + client.shutdown() + _relay(client, server) + with pytest.raises(SSL.ZeroReturnError): + server.recv(65535) + + +def test_binary_identity_uses_native_backend_when_installed(): + if not Path("smartthings_local/protocol/_mbedtls_native.so").exists(): + pytest.skip("optional Mbed TLS shim not built") + identity = bytes(range(16)) + provider = PskAuth(identity=identity, key=b"k" * 16) + connection = provider._mbedtls_factory(1200) + with pytest.raises(SSL.WantReadError): + connection.do_handshake() + assert connection.bio_read(65535)[0] == 22 + with pytest.raises(ValueError, match="Mbed TLS"): + provider.configure_context(SSL.Context(SSL.DTLS_METHOD)) + + +def test_binary_identity_never_falls_back_when_backend_missing(monkeypatch): + from smartthings_local.protocol import _mbedtls + + def unavailable(): + raise ValueError("Mbed TLS backend is unavailable") + + monkeypatch.setattr(_mbedtls, "_load_library", unavailable) + with pytest.raises(ValueError, match="Mbed TLS"): + PskAuth(identity=bytes(range(16)), key=b"k" * 16) + + +def test_non_nul_identity_keeps_openssl_without_native_dependency(monkeypatch): + from smartthings_local.protocol import _mbedtls + + monkeypatch.setattr(_mbedtls, "_load_library", lambda: pytest.fail("native load")) + provider = PskAuth(identity=b"i" * 16, key=b"k" * 16) + assert provider._mbedtls_factory is None + provider.configure_context(SSL.Context(SSL.DTLS_METHOD)) + + +def test_native_timer_retransmits_and_finalizer_frees_context(): + import gc + import time + from smartthings_local.protocol._mbedtls import _MbedConnection + + if not Path("smartthings_local/protocol/_mbedtls_native.so").exists(): + pytest.skip("optional Mbed TLS shim not built") + client = _MbedConnection(bytes(range(16)), b"k" * 16, 1200) + finalizer = client._finalizer + with pytest.raises(SSL.WantReadError): + client.do_handshake() + initial = client.bio_read(65535) + remaining = client.DTLSv1_get_timeout() + assert 0 < remaining <= 1.1 + time.sleep(remaining + 0.01) + assert client.DTLSv1_get_timeout() == 0 + client.DTLSv1_handle_timeout() + with pytest.raises(SSL.WantReadError): + client.do_handshake() + assert client.bio_read(65535)[0] == initial[0] == 22 + assert client.DTLSv1_get_timeout() > 0 + del client + gc.collect() + assert not finalizer.alive + + +def test_native_wrong_key_cannot_complete_authentication(): + from smartthings_local.protocol._mbedtls import _MbedConnection + if not Path("smartthings_local/protocol/_mbedtls_native.so").exists(): + pytest.skip("optional Mbed TLS shim not built") + client = _MbedConnection(bytes(range(16)), b"wrong key bytes!", 1200) + server, callback = _openssl_peer() + completed = [False, False] + failed = False + for _ in range(50): + for i, source, destination in [(0, client, server), (1, server, client)]: + try: + source.do_handshake() + completed[i] = True + except SSL.WantReadError: + pass + except SSL.Error: + failed = True + _relay(source, destination) + if failed: + break + assert not all(completed) + + +def test_native_rejects_oversized_and_unconsumed_datagrams(): + from smartthings_local.protocol._mbedtls import _MbedConnection + if not Path("smartthings_local/protocol/_mbedtls_native.so").exists(): + pytest.skip("optional Mbed TLS shim not built") + connection = _MbedConnection(bytes(range(16)), b"k" * 16, 1200) + with pytest.raises(SSL.Error): + connection.bio_write(b"x" * 65536) + assert connection.bio_write(b"one datagram") == 12 + with pytest.raises(SSL.Error): + connection.bio_write(b"another datagram") diff --git a/tests/test_psk_auth.py b/tests/test_psk_auth.py index fbc7bdb..3ce3443 100644 --- a/tests/test_psk_auth.py +++ b/tests/test_psk_auth.py @@ -84,40 +84,43 @@ def test_psk_auth_rejects_invalid_identity_lengths(identity_length): PskAuth(identity=b"i" * identity_length, key=_KEY) -def test_psk_auth_rejects_identity_with_nul_byte(): - with pytest.raises(ValueError, match="cannot contain a NUL"): +def test_psk_auth_rejects_identity_with_nul_without_backend(monkeypatch): + from smartthings_local.protocol import _mbedtls + monkeypatch.setattr(_mbedtls.ctypes, "CDLL", lambda *_: (_ for _ in ()).throw(OSError())) + _mbedtls._load_library.cache_clear() + with pytest.raises(ValueError, match="Mbed TLS"): PskAuth(identity=b"i" * 15 + b"\x00", key=_KEY) -def test_nul_rejection_explains_the_truncation_it_prevents(): - # Measured against OpenSSL 4.0.0: a 16-byte identity with a NUL at byte 8 - # goes on the wire as 8 bytes and the handshake raises nothing locally, so - # the guard is the only thing standing between a caller and a silently - # wrong identity. The message has to carry that, because an appliance - # answers the truncated value with unknown_psk_identity and nothing else - # points back here. +def test_nul_rejection_explains_the_missing_backend(monkeypatch): + from smartthings_local.protocol import _mbedtls + monkeypatch.setattr(_mbedtls.ctypes, "CDLL", lambda *_: (_ for _ in ()).throw(OSError())) + _mbedtls._load_library.cache_clear() with pytest.raises(ValueError) as raised: PskAuth(identity=b"i" * 15 + b"\x00", key=_KEY) message = str(raised.value) - assert "C string" in message - assert "truncates" in message - assert "shorter identity" in message + assert "NUL byte" in message + assert "Mbed TLS" in message + assert "_build_mbedtls" in message -def test_validate_identity_checks_a_credential_before_one_is_assembled(): +def test_validate_identity_checks_a_credential_before_one_is_assembled(monkeypatch): # An import flow holds an identity before it has a provider to build, and # needs the reason to show a user, so the check is reachable on its own # and raises what the constructor raises. assert PskAuth.validate_identity(_IDENTITY) is None - with pytest.raises(ValueError, match="cannot contain a NUL"): + from smartthings_local.protocol import _mbedtls + monkeypatch.setattr(_mbedtls.ctypes, "CDLL", lambda *_: (_ for _ in ()).throw(OSError())) + _mbedtls._load_library.cache_clear() + with pytest.raises(ValueError, match="Mbed TLS"): PskAuth.validate_identity(b"i" * 15 + b"\x00") @pytest.mark.parametrize( "identity", - [b"i" * 15 + b"\x00", b"i" * 15, b"i" * 17, b""], + [b"i" * 15, b"i" * 17, b""], ) def test_validate_identity_rejects_what_the_constructor_rejects(identity): # One code path, so the reason a caller can show a user is the same @@ -527,7 +530,7 @@ def test_openssl_sends_a_clean_identity_whole(): assert wire == identity -def test_a_nul_identity_would_reach_the_wire_truncated(): +def test_a_nul_identity_would_reach_the_openssl_wire_truncated(): # The reason PskAuth refuses this rather than passing it through. # OpenSSL's DTLS 1.2 PSK client callback returns the identity as a # C string and takes its strlen, so everything from the NUL onward is @@ -543,6 +546,3 @@ def test_a_nul_identity_would_reach_the_wire_truncated(): assert declared == 8 assert wire == identity[:8] - - with pytest.raises(ValueError, match="cannot contain a NUL"): - PskAuth(identity=identity, key=_KEY) diff --git a/tests/test_public_api_contract.py b/tests/test_public_api_contract.py index b6e2aeb..7edafd3 100644 --- a/tests/test_public_api_contract.py +++ b/tests/test_public_api_contract.py @@ -254,7 +254,7 @@ def test_psk_auth_is_a_public_authentication_provider(): ) -def test_psk_identity_validation_is_reachable_without_a_key(): +def test_psk_identity_validation_is_reachable_without_a_key(monkeypatch): # An import flow validates a stored identity before it has a key to pair # with it, so this is part of the supported surface rather than an # internal guard. @@ -262,6 +262,10 @@ def test_psk_identity_validation_is_reachable_without_a_key(): assert list(parameters) == ["identity"] assert PskAuth.validate_identity(b"i" * 16) is None + from smartthings_local.protocol import _mbedtls + def unavailable(): + raise ValueError("Mbed TLS unavailable") + monkeypatch.setattr(_mbedtls, '_load_library', unavailable) with pytest.raises(ValueError): PskAuth.validate_identity(b"i" * 15 + b"\x00")