Skip to content
Open
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
65 changes: 65 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,70 @@ python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 5684 49153 49154

`live` means a DTLS server answered its first flight; `dead` means silent or not DTLS. Once you have the client cert (Part 2), add the explicit `--diagnostic` flag to run the stateful diagnostic drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert. Diagnostic mode can allocate appliance-side DTLS state and is never used by discovery or reconnect. An `unsupported_certificate` / `unknown_ca` alert means the endpoint is reachable but this certificate profile was rejected. It is not a reason to disable verification or keep retrying. The same bounded stateless API gates the bridge's reconnect loop and, when `OCF_PORT` is unset, probes both standard 5684 and ports 49152–49160.

Consumers can discover ports outside that fallback range through the public,
read-only OCF resource directory before probing them:

```python
from smartthings_local.protocol.dtls_probe import probe_dtls_ports
from smartthings_local.protocol.ocf_discovery import discover_ocf_secure_ports

fallback_ports = (5684, *range(49152, 49161))
advertisement = discover_ocf_secure_ports(appliance_host)
candidates = advertisement.ports or fallback_ports
probe = probe_dtls_ports(appliance_host, candidates)
```

`discover_ocf_secure_ports()` sends only
`GET /oic/res?rt=oic.r.doxm`. It accepts Samsung's dynamic plaintext response
source port while still requiring the resolved target address and CoAP token,
and assembles Block2 responses within fixed time, block-count, and payload
limits. Its default three-second timeout bounds all socket I/O in total (DNS
resolution is synchronous and outside that budget); retries do not multiply
that deadline. The example probes the fixed fallback range only when discovery
does not return an advertised port, so this remains an explicit consumer
policy rather than an automatic or widened scan. An advertised port remains
only a candidate: require a successful stateless DTLS probe before attempting
authentication.

Some Samsung hosts expose multiple logical OCF devices from one IPv4 address,
so the root `/oic/sec/doxm` identity is not always the appliance identity. When
the SmartThings OCF `di` UUID is available, use the identity-aware multicast
variant on one explicit LAN interface:

```python
from smartthings_local.protocol.ocf_discovery import (
discover_ocf_secure_ports_multicast,
)

advertisement = discover_ocf_secure_ports_multicast(
"11111111-2222-3333-4444-555555555555",
interface_address="192.0.2.10",
)
```

This API performs exactly two IPv4 multicast NON discovery rounds, with a
six-second collection window per round by default (about 12 seconds maximum)
and accepts at most 64 datagrams per round. It succeeds only when the same sole
source advertises the same ports in both rounds, reads links only from the exact
normalized `di` container, binds legacy `p.sec` / `port` values to that
response source, and accepts an `eps` URI only when its host equals the response
source. The result exposes the matched address and ports to the caller but
redacts the address, UUID, and port values from its `repr`. The existing unicast
API remains appropriate when the target host is already identity-safe.

The unicast and multicast entry points are explicit alternatives. Neither
function invokes the other or starts an automatic fallback, so adding these
APIs does not add discovery time to existing callers. A consumer chooses the
known-host unicast path or the identity-aware multicast path for its own flow,
then applies its separate DTLS and authentication gates.

On a host that exposes multiple logical OCF devices, the two results may
legitimately contain different ports. Do not merge them automatically:
known-host unicast expresses trust in the caller-supplied host, while
identity-aware multicast selects the one directory container with the requested
`di`. That UUID match selects a candidate endpoint; it does not replace DTLS
liveness and authenticated device-identity checks.

### Tested combinations

| Appliance class | Model family | Confirmed |
Expand Down Expand Up @@ -509,6 +573,7 @@ smartthings_local/ The installable library — `pip install sm
coap.py CoAP wire protocol: message encode/decode, token handling
dtls_session.py DTLS session: handshake, client-cert auth (file or in-memory PEM), Block2, liveness
dtls_probe.py Stateless DTLS liveness + opt-in stateful diagnostic
ocf_discovery.py Bounded public OCF secure-port discovery
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
ocf/ OCF resource + state layer (reusable)
__init__.py
Expand Down
27 changes: 26 additions & 1 deletion smartthings_local/protocol/coap.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,22 @@ def encode_options(opts):

def parse_coap(data):
"""Decode a CoAP datagram. Returns (mtype, code, mid, token,
options, payload). options is a list of (num, value_bytes)."""
options, payload). options is a list of (num, value_bytes).

The decoder is intentionally strict because some callers use it on
unauthenticated UDP datagrams. Truncated headers, tokens, extended option
fields, option values, and empty payload markers are classified rather
than leaking ``IndexError`` or being accepted as partial messages.
"""
if not isinstance(data, (bytes, bytearray, memoryview)):
raise MalformedMessageError()
data = bytes(data)
if len(data) < 4 or data[0] >> 6 != 1:
raise MalformedMessageError()
mt = (data[0] >> 4) & 0x03
tkl = data[0] & 0x0F
if tkl > 8 or len(data) < 4 + tkl:
raise MalformedMessageError()
code = data[1]
mid = int.from_bytes(data[2:4], 'big')
tok = data[4:4 + tkl]
Expand All @@ -74,27 +87,39 @@ def parse_coap(data):
while i < len(data):
b = data[i]
if b == 0xFF:
if i + 1 >= len(data):
raise MalformedMessageError()
payload = data[i + 1:]
break
d_nib, l_nib = b >> 4, b & 0x0F
i += 1
if d_nib == 13:
if i >= len(data):
raise MalformedMessageError()
delta = 13 + data[i]; i += 1
elif d_nib == 14:
if i + 2 > len(data):
raise MalformedMessageError()
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
elif d_nib == 15:
raise MalformedMessageError()
else:
delta = d_nib
if l_nib == 13:
if i >= len(data):
raise MalformedMessageError()
length = 13 + data[i]; i += 1
elif l_nib == 14:
if i + 2 > len(data):
raise MalformedMessageError()
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
elif l_nib == 15:
raise MalformedMessageError()
else:
length = l_nib
num = prev + delta
if i + length > len(data):
raise MalformedMessageError()
opts.append((num, data[i:i + length]))
i += length
prev = num
Expand Down
Loading