Skip to content
Closed
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ uv.lock
# Packaging / build artifacts
/smartthings_local/_version.py
/dist/
smartthings_local/protocol/_mbedtls_native.so
*.egg-info/

# Bridge runtime
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
57 changes: 57 additions & 0 deletions docs/mbedtls.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions smartthings_local/protocol/_build_mbedtls.py
Original file line number Diff line number Diff line change
@@ -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()
110 changes: 110 additions & 0 deletions smartthings_local/protocol/_mbedtls.py
Original file line number Diff line number Diff line change
@@ -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
142 changes: 142 additions & 0 deletions smartthings_local/protocol/_mbedtls_native.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/* Optional Mbed TLS 3.6 memory transport. No Python or socket ownership. */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <mbedtls/ctr_drbg.h>
#include <mbedtls/entropy.h>
#include <mbedtls/platform_util.h>
#include <mbedtls/ssl.h>
#include <mbedtls/version.h>

#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;
}
Loading
Loading