feat(protocol): bound DTLS endpoint probing

This commit is contained in:
Jason Morcos
2026-08-03 12:45:14 -07:00
parent d677c72f89
commit dd453ebdfb
7 changed files with 1166 additions and 201 deletions
+38 -20
View File
@@ -1,6 +1,6 @@
# SmartThings-Local
**`smartthings-local` is a Python library for local, cloud-free control of newer-generation Samsung connected appliances over cert-authenticated CoAP-DTLS.** It gives you the DTLS-CoAP transport, a tiered polling + OBSERVE state layer, and one-command identity-cert minting. That covers everything needed to read state from and write commands to a Samsung dryer, oven, fridge, etc. on your LAN, with no SmartThings cloud round-trip.
**`smartthings-local` is a Python library for local, cloud-free control of Samsung connected appliances over authenticated CoAP-DTLS.** It gives you the DTLS-CoAP transport, a tiered polling + OBSERVE state layer, and identity-cert tooling for AC14K_M-compatible firmware. Newer OCF-PKI appliances require a different authentication profile; see [the laundry compatibility findings](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/ocf-pki-laundry.md). Supported profiles can read state and write commands on the LAN with no SmartThings cloud round-trip.
The repo also ships a self-contained **reference bridge demo** (`mqtt_demo/`) that turns the library into auto-discovered Home Assistant entities over MQTT. One process supervises multiple appliances, each on its own DTLS session.
@@ -21,14 +21,16 @@ The repo also ships a self-contained **reference bridge demo** (`mqtt_demo/`) th
pip install smartthings-local
```
Mint a client cert once (see [Part 2](#part-2--auth-get-the-identity-cert)), then drive a session directly:
For compatible firmware, mint a client cert once (see
[Part 2](#part-2--auth-for-ac14k_m-compatible-firmware)), then drive a
session directly:
```python
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession
sess = DtlsCoapSession(
"192.168.1.100", 49154,
"192.0.2.100", 49154,
cert_path="certs/client_fullchain.pem",
key_path="certs/client.key",
)
@@ -45,7 +47,7 @@ sess.close()
If the cert/key are minted at runtime and never written to disk (e.g. inside an HA config flow), pass them in memory instead of by path:
```python
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
sess = DtlsCoapSession("192.0.2.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
```
### Classified errors
@@ -127,7 +129,7 @@ For a full worked integration, the higher-level `smartthings_local.ocf` layer (`
Each appliance runs an independent bridge built around three coordinated pieces over one persistent DTLS session: a `StateCache` (single source of truth for all reps), a `PollScheduler` (tiered adaptive polling: hot/warm/cold plus a periodic `/device/0` sweep), and a `KeepaliveTask` (CoAP empty-CON ping for DTLS-layer liveness, with consecutive-failure detection for MQTT availability). Tier cadences are descriptor-declared and were calibrated against the empirically-measured per-firmware ceilings: dryer ~14 req/s, oven ~8 req/s. OBSERVE registrations (RFC 7641) are kept as an opportunistic freshness accelerator: when the appliance has internet and emits notifications, the cache absorbs them and the next-poll timer is reset for that resource; when it's air-gapped, polling alone carries the UX with no other code change. Token-stable Block2 (RFC 7959) handles multi-block reads. Writes are optimistically merged into the cache the moment the device 2.04-confirms, with the scheduler deferring that resource's next poll past the fetchback-revert window. Reconnect with exponential backoff on session errors, gated by a stateless DTLS ClientHello pre-flight (`smartthings_local/protocol/dtls_probe.py`) so a silent/rebooting device or wrong port drops into backoff in ~1 RTT instead of eating the full handshake timeout; when `OCF_PORT` is unset the same probe auto-discovers the live port across the OCF band.
Authentication uses a client cert keyed to the UUID published in Samsung's own wildcard cloud TLS cert. Every Samsung Tizen/RT-OCF appliance's factory ACL grants that UUID `perm=31` (full CRUDN) on `href=*`, so a single cert chain works across the whole fleet. Setup is one Python script.
On the currently supported firmware families, authentication uses a client cert keyed to the UUID published in Samsung's own wildcard cloud TLS cert. Their factory ACL grants that UUID `perm=31` (full CRUDN) on `href=*`. That certificate path is not universal: the WD53 profile in issue #16 and the washer in issue #20 reject it and need separate authentication work.
---
@@ -136,23 +138,24 @@ Authentication uses a client cert keyed to the UUID published in Samsung's own w
Check before anything else; if it's older firmware, this project doesn't target it.
```sh
# UDP scan for DTLS-CoAP ports
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
# UDP scan for public/secure standard OCF plus the dynamic appliance band
nmap -Pn -sU -p 5683,5684,49152-49160 "$APPLIANCE_IP"
```
Read the result:
- **`49154/udp` (or similar 4915x) open|filtered with a DTLS handshake responding** → newer firmware (Tizen RT 3.x with DAWIT 3.0). This is what the bridge talks to.
- **`5684/udp` or a 4915x port with a DTLS first-flight response** → an OCF DTLS listener. Standard-port OCF-PKI firmware may still require an unsupported authentication profile.
- **`5683/udp` responds to public OCF security/resource GETs** → use `/oic/res` to learn the device's advertised secure endpoint; do not assume that endpoint is fixed.
- **Only `8888/tcp` open (token-based HTTPS)** → older firmware (~2018–2022). **Not supported here.**
nmap's `open|filtered` can't tell a real DTLS server from a silent UDP port. Confirm which of the candidate ports actually speaks DTLS with the ClientHello probe, which sends one ClientHello and reports back per port:
```sh
# Stateless liveness check: one ClientHello round trip, leaves no state on the device
python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 49153 49154 49155 49156 --stateless
python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 5684 49153 49154 49155 49156 --stateless
```
`live` means a DTLS server answered its `HelloVerifyRequest` (that's your control port); `dead` means silent / not DTLS. Once you have the client cert (Part 2), drop `--stateless` to run the default *diagnostic* drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert. An `unsupported_certificate` / `unknown_ca` alert is the signature of a newer OCF-PKI device that won't accept the AC14K_M cert. The same probe gates the bridge's own reconnect loop and auto-discovers the port when `OCF_PORT` is unset.
`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.
### Tested combinations
@@ -165,6 +168,13 @@ python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 49153 49154 4915
Other appliances on the same firmware family (dishwashers, AC units) almost certainly speak the same protocol: the auth path and read primitives are common, and a washer on the shared `DA_WM_TP2_20_COMMON` controller is already confirmed above. You'd write one new descriptor for the `localthings` registry.
The Bespoke AI Laundry Combo `WD53DBA900HZ[A1]` on Tizen 7 software
`20260416.215549` is a known OCF-PKI profile, but is not yet supported by the
public authentication path. Its endpoint and manufacturer-OTM/OwnerPSK findings
are documented [here](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/ocf-pki-laundry.md), including the exact relationship
to issues [#16](https://github.com/QuiteYellow/SmartThings-Local/issues/16) and
[#20](https://github.com/QuiteYellow/SmartThings-Local/issues/20).
### Firmware families: a limitation
Descriptors are firmware-family-specific. Each descriptor hardcodes the resource layout of one firmware family: which hrefs it polls, which fields it reads, which write surfaces it exposes. There's no runtime feature detection. The three sample descriptors here (`mqtt_demo/samples/`) are frozen references.
@@ -188,9 +198,12 @@ Which path is doing the work is visible in Home Assistant. The bridge publishes
---
## Part 2 — Auth: get the identity cert
## Part 2 — Auth for AC14K_M-compatible firmware
The bridge authenticates with a **client cert** signed by `AC14K_M`, an intermediate CA that has been public for years and remains in current firmware trust stores. The cert's Subject DN carries a UUID that the on-device ACL grants full access to.
For a compatible firmware family, the bridge authenticates with a **client
cert** signed by `AC14K_M`, an intermediate CA that has been public for years.
The cert's Subject DN carries a UUID that those appliances' on-device ACLs
grant full access to.
You can read the UUID yourself out of the relevant server cert:
@@ -207,7 +220,7 @@ This README doesn't pin the literal UUID: the setup script extracts it live each
### Why this works
- Every Samsung Tizen/RT-OCF appliance has a **factory-baked ACE** in `/oic/sec/acl` granting this UUID `perm=31` on `href=*`.
- Each currently supported Tizen/RT-OCF firmware family has a **factory-baked ACE** in `/oic/sec/acl` granting this UUID `perm=31` on `href=*`.
- TizenRT iotivity derives peerId from `memmem(subject_dn, "uuid:")`, which is RDN-agnostic. A cert with the UUID in CN authenticates the same as one with it in OU.
- We don't need the matching private key from the original keyholder. We mint our own key and have `AC14K_M` sign our leaf. Different key, same identity, same access.
@@ -234,9 +247,13 @@ Neither the UUID nor the AC14K_M bundle is hardcoded in this repo; both are fetc
On Fedora/RHEL (and other hardened OpenSSL 3.x builds) the default crypto policy blocks SHA-1 signing, which step 5 needs. The script detects this, retries the signing step once with SHA-1 force-enabled for just that command, and only fails if the retry also fails. If it does, it prints the remedy: `sudo update-crypto-policies --set DEFAULT:SHA1` (undo afterward with `sudo update-crypto-policies --set DEFAULT`).
### How durable is this?
### How durable is this on the compatible firmware families?
Rotating the published UUID would require Samsung to re-issue TLS certs across their IoT cloud, push new ACLs to every device in the field, and update the on-device daemon identity: a multi-quarter change with a long backwards-compat tail. `AC14K_M` has been public for years and is still in 2026 firmware trust stores. Local access via this path is roughly as durable as cloud control of these appliances.
Rotating the published UUID would require coordinated cloud certificate, ACL,
and device identity changes across the compatible firmware families.
`AC14K_M` has been public for years and remains accepted by the tested rows
above, but it is already rejected by other 2026 appliance profiles. Do not
extrapolate this certificate path to an untested model.
> **Legacy path:** earlier versions used a per-hub-UUID cert via an anonymous `/oic/sec/doxm` read escalation. That still works on the dryer-family firmware but isn't necessary: the cert minted here authenticates against every appliance and survives device resets. The old `bootstrap.py` for the legacy flow was removed when the package was renamed; see git history if you need it.
@@ -260,20 +277,21 @@ APPLIANCE_COUNT=2
# Appliance 1 — dryer
APPLIANCE_1_CLASS=dryer
APPLIANCE_1_IP=192.168.1.100
APPLIANCE_1_IP=192.0.2.100
APPLIANCE_1_OCF_PORT= # blank → auto-discover across the OCF band (dryer=49155)
APPLIANCE_1_TOPIC=samsung_dryer
APPLIANCE_1_NAME=Samsung Dryer
# Appliance 2 — oven
APPLIANCE_2_CLASS=oven
APPLIANCE_2_IP=192.168.1.101
APPLIANCE_2_IP=192.0.2.101
APPLIANCE_2_OCF_PORT= # blank → auto-discover across the OCF band (oven=49154)
APPLIANCE_2_TOPIC=samsung_oven
APPLIANCE_2_NAME=Samsung Oven
```
Each `APPLIANCE_<n>_CLASS` must match a descriptor key in `mqtt_demo/samples/__init__.py::DESCRIPTORS`: currently `dryer`, `oven`, and `fridge`.
Each `APPLIANCE_<n>_CLASS` must match a key in
`mqtt_demo.samples.DESCRIPTORS`: currently `dryer`, `oven`, and `fridge`.
---
@@ -395,7 +413,7 @@ Notes specific to this firmware family:
| `APPLIANCE_COUNT` | Number of `APPLIANCE_<n>_*` blocks to read (1-indexed) |
| `APPLIANCE_<n>_CLASS` | Descriptor name: `dryer`, `oven`, `fridge` |
| `APPLIANCE_<n>_IP` | LAN IP of the appliance |
| `APPLIANCE_<n>_OCF_PORT` | Optional. Blank → auto-discover the DTLS port across the OCF band 49153–49156 (via a stateless ClientHello probe); set it to pin a specific port and skip discovery (dryer=49155, oven=49154, fridge=49155) |
| `APPLIANCE_<n>_OCF_PORT` | Optional. Blank → probe standard port 5684 and the dynamic range 49152–49160 with a stateless ClientHello; set it to pin and gate one specific port (dryer=49155, oven=49154, fridge=49155) |
| `APPLIANCE_<n>_TOPIC` | MQTT topic prefix (also the HA device identifier; changing it re-keys the device) |
| `APPLIANCE_<n>_NAME` | Friendly name on the HA device card |
| `MQTT_BROKER` / `MQTT_PORT` / `MQTT_USER` / `MQTT_PASS` | Broker config |
@@ -463,7 +481,7 @@ smartthings_local/ The installable library — `pip install sm
__init__.py
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 DTLS ClientHello liveness probe (stateless gate + diagnostic mode)
dtls_probe.py Stateless DTLS liveness + opt-in stateful diagnostic
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
ocf/ OCF resource + state layer (reusable)
__init__.py
+240
View File
@@ -0,0 +1,240 @@
# Newer OCF-PKI laundry: connection findings and current limits
This is a compatibility and implementation note, not an ownership-reset or
onboarding guide. It records the sanitized protocol facts that made local
control possible on two Samsung Bespoke AI Laundry Combo appliances and maps
those facts to [issue #16](https://github.com/QuiteYellow/SmartThings-Local/issues/16)
and [issue #20](https://github.com/QuiteYellow/SmartThings-Local/issues/20).
The important distinction is that endpoint reachability, DTLS authentication,
resource authorization, and OCF ownership are four separate states. A response
at one layer is not proof that the next layer is usable.
## Hardware and software validated
The locally validated appliances are two `WD53DBA900HZA1` all-in-one
washer/dryers. Both report:
- model family `AWM-US-M64-24-WD80`;
- Tizen 7 / One UI 7 Laundry Combo; and
- primary software version `20260416.215549`.
Issue #16 reports `WD53DBA900HZ` and the same primary software version. The
reported protocol behavior also matches, so it is the same appliance/software
profile for the purposes of this library.
Issue #20 is different hardware: a `WW11BB534DAWS6` washer and
`DV90BB5245AWS6` dryer. Only the washer has detailed protocol evidence in that
issue, so nothing here claims that the dryer has the same profile.
## How the WD53 connection was established
### 1. Discover OCF instead of assuming a 4915x port
The WD53 exposes its public OCF surface on UDP 5683. `GET /oic/res` returns a
multi-block resource directory and advertises secure endpoint data. The two
validated units exposed the same 72 hrefs. They also have IPv4, IPv6 ULA, and
IPv6 link-local endpoints, and the secure endpoint can move.
The practical rules are:
- include the standard CoAP-DTLS port 5684 as well as the 4915x appliance
range;
- preserve an IPv6 scope ID instead of flattening a link-local address into a
host string;
- rediscover the secure endpoint before authentication when the appliance has
slept or restarted; and
- prove a listener with a DTLS ClientHello instead of treating an Nmap
`open|filtered` result as protocol evidence.
The production liveness probe must stop after the first
HelloVerifyRequest/ServerHello/Alert. It never returns the cookie to the
appliance, and packet-loss retries resend the exact same first flight. This
avoids creating half-open DTLS associations while searching several candidate
ports.
### 2. Treat the AC14K_M rejection as an authentication-profile result
An AC14K_M client chain reaches the WD53 DTLS server but is rejected with a
fatal `unknown_ca` alert. RSA versus ECDSA client keys do not change that
result. Re-signing only a leaf with SHA-256 cannot repair a trust chain the
appliance does not accept.
That result does **not** mean local OCF was removed. It means the fleet
certificate used by older SmartThings appliances is not the runtime principal
for this profile. Repeated AC14K_M attempts, a broader cipher list, or disabling
server verification do not produce authorization.
The accepted cipher for the authenticated paths below is exactly
`ECDHE-ECDSA-AES128-GCM-SHA256`. The production sessions did not disable TLS
verification. A first-flight diagnostic can classify an offered certificate
without authenticating it, but that observation never grants authorization.
### 3. Read and classify the public security state
The public security resources expose enough redacted state to choose a safe
next step:
- `/oic/sec/doxm` advertises standard manufacturer-certificate OTM `2` and
Samsung manufacturer-certificate OTM `0xFF02` (`65282`);
- the two validated units were observed with each of those methods selected;
- `/oic/sec/pstat` distinguishes an operational owned device from a real
manufacturer ownership-transfer window;
- the provisioning nonce rotates on every read; and
- this model declares that additional authorization is required.
Device, owner, and resource-owner UUIDs are sensitive identifiers and are not
needed in a public fixture. They must be compared locally and replaced with
synthetic values in tests or diagnostics.
### 4. Use the model's authorized, non-reset transition
During one-time research while the appliance was idle, the signed-in
SmartThings Android path was used to invoke the model's signed same-account,
non-factory-reset confirmation. This was a setup research carrier, not a
runtime dependency. It intentionally moved the OCF security state from owned
operation into a bounded, unowned manufacturer-OTM window; the later steps
installed a new OCF owner. SmartThings pairing survived on the two tested
units, but that does not make an ownership-changing operation generically safe.
The fresh confirmation had to occur immediately before the manufacturer DTLS
connection; a delayed confirmation missed the firmware's window.
The installed `5.0.47` appliance stack exposed provisioning feature `0x4000`
and validated two proof requests in this exact order:
Here `serial_hash_ascii` is the 128-character lowercase hexadecimal SHA-512
digest of the ASCII registration serial.
1. `TriggerSerialHashRequest` checks
`SHA256(serial_hash_ascii || nonce_raw)`. The nonce is the current raw four
bytes, not its eight-character hexadecimal text. This proof contains no
account value.
2. The appliance rotates its nonce. `TriggerAutoResetHashRequest` then checks
`SHA256(serial_hash_ascii || SHA256(user_id_ascii) || fresh_nonce_raw)`,
where the inner SHA-256 is its raw 32-byte digest and the same-account user
ID is ten ASCII characters. There are no delimiters between fields.
The order is the inverse of the method names in a newer application helper.
Reversing the requests caused the second stage to fail; matching the appliance
order opened the clean manufacturer-certificate RFOTM state. Only a fresh
public DOXM/PSTAT read—not an application callback—was accepted as proof of
that transition. No serial, account ID, nonce, or computed proof is included
here.
This authorization transition is the part that is **not yet a supported public
workflow**. The public formulas explain the installed firmware's checks; they
do not supply Samsung's signed request authority or disclose an account value.
A stock SmartThings-paired appliance must not be reset, claimed, or have its
owner replaced merely because its public OCF endpoint is reachable. A public
implementation still needs a model-supported same-account grant that does not
depend on private application state, captured credentials, or
reverse-engineering tools.
### 5. Open manufacturer DTLS without a client identity leaf
Inside the confirmed manufacturer window, the successful carrier is
server-authenticated DTLS using Samsung's manufacturer trust path. The client
does not present an AC14K_M, TEST, or OneApp identity leaf. This trust-only
connection can read the authenticated OCF security state needed for the
selected manufacturer OTM.
No new Samsung CA private key is needed for this step. The earlier
`unknown_ca` result and the successful manufacturer carrier are different
authentication modes, not contradictory observations.
### 6. Derive, stage, prove, and finalize OwnerPSK
The standards-based OwnerPSK derivation uses the selected method's exact label:
- method `2`: `oic.sec.doxm.mfgcert`;
- method `0xFF02`: `x.org.iotivity.conmfgcert`.
For the negotiated `ECDHE-ECDSA-AES128-GCM-SHA256` session, IoTivity computes:
1. `key_block = P_SHA256(master_secret, "key expansion" || server_random || client_random, 120)`;
2. `OwnerPSK = P_SHA256(key_block, selected_otm_label || owner_uuid || appliance_uuid, 16)`.
The master secret is 48 bytes, each random is 32 bytes, and each UUID is its
raw 16-byte value. The derivation is pure; obtaining the authenticated session
and deciding that an ownership transaction is authorized are separate
responsibilities.
The validated transaction stages the derived credential before the first
security mutation, writes only the reviewed credential/ACL/DOXM/PSTAT shapes,
then proves the new key on a fresh ECDHE-PSK session before publishing it as a
usable runtime credential. Final DOXM/PSTAT and public postflight reads must
all agree before the transaction is considered complete.
The resulting OwnerPSK is per appliance. It is never logged, returned by a
diagnostic, embedded in a fixture, or committed to source control.
### 7. Run normal control over OwnerPSK
After finalization, normal reads and writes use ECDHE-PSK CoAP-DTLS over the
currently advertised LAN endpoint. On each validated WD53, that path returned
39 complete protected representations with no link stubs. Low-risk settings
and power changes were accepted, verified by exact protected readback, and
restored. The same changes remained visible through SmartThings, demonstrating
coexistence for the tested transaction rather than a cloud replacement.
When the panel enters deep sleep, the secure endpoint can disappear. Runtime
code therefore retains last-good state honestly, backs off, and rediscovers
the endpoint when the panel returns; it does not use the cloud or an Android
application as a wake or polling dependency.
## How this maps to issues #16 and #20
### Issue #16: exact WD53 profile
Issue #16 reproduces both halves of the initial diagnosis:
- standard OCF ports rather than a fixed 4915x-only assumption; and
- AC14K_M client authentication rejected with `unknown_ca`.
The validated WD53 work demonstrates a path beyond that boundary:
manufacturer OTM followed by per-appliance OwnerPSK runtime authentication.
The remaining upstream gap is not proof that the protocol works; it is a safe,
portable, owner-preserving authorization and credential setup flow.
### Issue #20: related `0xFF02` evidence, different models
The washer in issue #20 exposes public OCF on 5683, a DTLS listener on 49154,
and reports `owned:false`, `isop:false`, with only OTM `0xFF02` advertised. That
is consistent with a Samsung manufacturer-OTM window, and it makes the WD53
`0xFF02` transport and OwnerPSK work directly relevant.
It is not yet proof of support. The issue reports `handshake_failure` rather
than the WD53's `unknown_ca`, and the model-specific additional-authorization,
nonce, confirmation timing, security payload, and protected-read behavior have
not been validated. The dryer in the issue has not supplied equivalent
evidence. Both devices need independent, non-destructive validation.
## What this pull request does and does not solve
This pull request implements the endpoint half of these reports:
- bounded, connected IPv4/IPv6 stateless probes;
- byte-identical first-flight retransmission;
- concurrent standard-port and 4915x probing;
- deterministic listener selection; and
- an explicit ambiguous result instead of first-responder guessing.
It does not make AC14K_M authenticate to either issue's appliance and does not
perform OTM or write `/oic/sec/*`. Follow-up package work is still required for
explicit authentication providers, PSK sessions, Samsung certificate profiles,
OwnerPSK derivation, reviewed OCF security codecs, and the separately reviewed
authorization/setup policy.
## Safe evidence for another device report
Useful public evidence is limited to:
- retail model without a serial number;
- software version;
- sanitized candidate ports and first-flight response classes;
- redacted `/oic/res`, `/oic/sec/doxm`, and `/oic/sec/pstat` shapes; and
- the fixed TLS alert number/name.
Do not post appliance or owner UUIDs, account identifiers, network addresses,
registration values, nonces, certificate fingerprints, credentials, packet
captures, or raw exception traces.
+50 -52
View File
@@ -23,19 +23,21 @@ import time
import cbor2
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
from smartthings_local.protocol.dtls_probe import probe
from smartthings_local.ocf.keepalive import KeepaliveTask
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.poll_scheduler import PollScheduler
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_probe import (
AMBIGUOUS,
probe_dtls_port,
probe_dtls_ports,
)
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
from .config import ApplianceConfig, SharedConfig
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
from .logger import bridge_logger
DEBUG_BRIDGE = os.environ.get('DEBUG_BRIDGE') == '1'
@@ -70,14 +72,15 @@ OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
# orphan otherwise lingers 5-15 min.
DTLS_LOCAL_PORT_BASE = 49700
# SmartThings appliances bind their OCF CoAP-DTLS control port in this
# dynamic band (dryer/fridge 49155, oven 49154). When OCF_PORT is unset we
# race a stateless ClientHello across the band to find the live one instead
# of trusting a single hardcoded default.
OCF_PORT_BAND = range(49153, 49157)
# Samsung's RT-OCF appliances commonly bind CoAP-DTLS in this dynamic band,
# while full-Tizen OCF-PKI appliances also use the standard secure CoAP port.
# When OCF_PORT is unset, probe both profiles instead of assuming one fleet-
# wide port layout.
OCF_PORT_BAND = range(49152, 49161)
OCF_STANDARD_SECURE_PORT = 5684
# The pre-flight liveness gate tolerates one dropped ClientHello (retries=1
# → ~1 RTT when the device answers, ~2.6 s to call a silent port DEAD),
# → ~1 RTT when the device answers, ~4 s to call a silent port DEAD),
# which is far cheaper than eating the 12 s HANDSHAKE_TIMEOUT_S on a
# rebooting device or a wrong port. It is stateless (stops at
# HelloVerifyRequest), so it leaves no association on the device and the
@@ -263,35 +266,20 @@ class PushBridge:
# ---- session lifecycle ------------------------------------------
def _candidate_ports(self) -> list[int]:
"""The OCF band plus the descriptor's documented default, deduped
and ordered — the search space when OCF_PORT is unset."""
return sorted(set(OCF_PORT_BAND) | {self.descriptor.default_observe_port})
"""Known OCF secure ports plus the descriptor default, in order."""
return sorted(
set(OCF_PORT_BAND)
| {OCF_STANDARD_SECURE_PORT, self.descriptor.default_observe_port}
)
def _race_probe(self, candidates: list[int]) -> int | None:
"""Race a stateless ClientHello across all candidates in parallel
and return the first port that answers LIVE — without waiting for
the dead ones to burn their full retry budget. Returns None if none
answer.
The winner comes back in ~1 RTT; the losing probes are abandoned
(shutdown(wait=False)) and each just runs out its own ~timeout loop
and closes its own socket in finally. This is a latency win, not a
correctness need — unlike #212's full-handshake race the losers are
bounded at a few seconds, not 12 s. Real appliances expose exactly
one DTLS port, so first-to-answer is unambiguous."""
import concurrent.futures as cf
ex = cf.ThreadPoolExecutor(max_workers=len(candidates))
try:
futs = [ex.submit(probe, self.app.ip, p,
retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S)
for p in candidates]
for fut in cf.as_completed(futs):
r = fut.result()
if r.is_dtls_server:
return r.port
return None
finally:
ex.shutdown(wait=False)
def _probe_candidates(self, candidates: list[int]):
"""Probe all candidates inside one budget and preserve ambiguity."""
return probe_dtls_ports(
self.app.ip,
tuple(candidates),
retries=_GATE_RETRIES,
timeout=_GATE_TIMEOUT_S,
)
def _resolve_port(self) -> int:
"""Return a port that just answered a stateless DTLS ClientHello,
@@ -307,30 +295,40 @@ class PushBridge:
first on the next reconnect and rediscovered only if it goes DEAD."""
pinned = self.app.ocf_port
if pinned is not None:
r = probe(self.app.ip, pinned,
retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S)
r = probe_dtls_port(
self.app.ip,
pinned,
retries=_GATE_RETRIES,
timeout=_GATE_TIMEOUT_S,
)
if not r.is_dtls_server:
raise ConnectionError(
f"port {pinned} not a live DTLS server ({r.outcome})")
raise ConnectionError('configured port is not a DTLS server')
return pinned
# A previously discovered port is almost certainly still the one —
# try it alone first and only fall back to a full band re-race if
# try it alone first and only fall back to the full candidate set if
# it has gone silent (firmware moved it, or it was never right).
if self._discovered_port is not None:
r = probe(self.app.ip, self._discovered_port,
retries=_GATE_RETRIES, timeout=_GATE_TIMEOUT_S)
r = probe_dtls_port(
self.app.ip,
self._discovered_port,
retries=_GATE_RETRIES,
timeout=_GATE_TIMEOUT_S,
)
if r.is_dtls_server:
return self._discovered_port
self._discovered_port = None
candidates = self._candidate_ports()
live = self._race_probe(candidates)
if live is None:
raise ConnectionError(f"no live DTLS server across {candidates}")
self.log.info("discovered DTLS port %d", live)
self._discovered_port = live
return live
selection = self._probe_candidates(candidates)
if selection.outcome == AMBIGUOUS:
raise ConnectionError(
'multiple DTLS listeners answered; configure OCF_PORT')
if selection.selected_port is None:
raise ConnectionError('no live DTLS server found')
self.log.info("discovered DTLS port %d", selection.selected_port)
self._discovered_port = selection.selected_port
return selection.selected_port
def session_once(self):
port = self._resolve_port()
+470 -64
View File
@@ -23,19 +23,25 @@ Two problems this solves:
how you tell an OCF-PKI-wall device (rejects at cert-verify) from a
cipher/version mismatch without a cert it would ever accept.
Reuses split_dtls() (the record framer) and the same memory-BIO pump as
DtlsCoapSession.connect(), so the ClientHello on the wire is byte-for-byte
what our real client emits (same cipher list, same @SECLEVEL=0).
The production probe generates its frozen ClientHello through the same OpenSSL
memory-BIO profile as DtlsCoapSession.connect(), including the exact cipher
list, security level, and MTU. The opt-in diagnostic drive retains the full
memory-BIO pump for characterizing later server flights.
"""
import concurrent.futures as cf
import math
import socket
import time
import warnings
from dataclasses import dataclass
from OpenSSL import SSL
from ..errors import ProbeError
from .coap import split_dtls
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
from .dtls_session import _DTLS_CIPHERS, _OCF_ROOT_CA, _load_pem_chain
from .endpoint import open_connected_udp_socket
# DTLS record content types (RFC 6347 §4.1)
_CT_CHANGE_CIPHER_SPEC = 20
@@ -91,6 +97,349 @@ LIVE = 'live' # DTLS server confirmed (HelloVerifyRequest/ServerHello
COMPLETED = 'completed' # full handshake succeeded (cert accepted)
REJECTED = 'rejected' # server sent a fatal Alert
# Aggregate stateless-probe outcomes.
SELECTED = 'selected'
UNREACHABLE = 'unreachable'
AMBIGUOUS = 'ambiguous'
# First-flight response classes retained by the production liveness API.
HELLO_VERIFY_REQUEST = 'hello_verify_request'
SERVER_HELLO = 'server_hello'
ALERT = 'alert'
_DTLS_VERSIONS = frozenset((b'\xfe\xff', b'\xfe\xfd'))
@dataclass(frozen=True, slots=True)
class DtlsLivenessResult:
"""Bounded, non-sensitive result for one stateless port probe."""
port: int
response_kind: str | None
attempts: int
rtt_s: float | None = None
alert: tuple[int, str] | None = None
error_code: str | None = None
@property
def is_dtls_server(self):
"""Return whether a structurally valid first-flight reply arrived."""
return self.response_kind is not None
@dataclass(frozen=True, slots=True)
class DtlsPortProbeResult:
"""Selection result for one bounded concurrent probe set."""
outcome: str
selected_port: int | None
results: tuple[DtlsLivenessResult, ...]
@property
def live_ports(self):
"""Return proven listeners in caller-supplied order."""
return tuple(
result.port for result in self.results if result.is_dtls_server)
def _validate_liveness_options(port, retries, timeout, mtu):
if isinstance(port, bool) or not isinstance(port, int):
raise TypeError('port must be an integer')
if not 1 <= port <= 65535:
raise ValueError('port must be between 1 and 65535')
if isinstance(retries, bool) or not isinstance(retries, int):
raise TypeError('retries must be an integer')
if not 0 <= retries <= 4:
raise ValueError('retries must be between zero and four')
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
raise TypeError('timeout must be a number')
if not math.isfinite(timeout) or not 0 < timeout <= 30:
raise ValueError('timeout must be greater than zero and at most 30')
if isinstance(mtu, bool) or not isinstance(mtu, int):
raise TypeError('mtu must be an integer')
if not 576 <= mtu <= 16384:
raise ValueError('mtu is outside the safe UDP range')
def _validate_probe_family(family):
if isinstance(family, bool) or not isinstance(family, int):
raise TypeError('family must be an address-family integer')
if family not in (socket.AF_UNSPEC, socket.AF_INET, socket.AF_INET6):
raise ValueError('family must be AF_UNSPEC, AF_INET, or AF_INET6')
def _client_hello_flight(*, mtu):
"""Build and freeze the same narrow first flight as a real session."""
context = SSL.Context(SSL.DTLS_METHOD)
context.load_verify_locations(_OCF_ROOT_CA)
context.set_verify(SSL.VERIFY_PEER, lambda *args: True)
context.set_cipher_list(_DTLS_CIPHERS)
connection = SSL.Connection(context, None)
connection.set_connect_state()
connection.set_ciphertext_mtu(mtu)
try:
connection.do_handshake()
except SSL.WantReadError:
pass
records = []
while True:
try:
outbound = connection.bio_read(65535)
except SSL.WantReadError:
break
if not outbound:
break
records.extend(split_dtls(outbound))
if not records:
raise ProbeError()
return tuple(records)
def _is_complete_hello_verify(body):
"""Validate the DTLS version and length-prefixed cookie."""
return (
len(body) >= 3
and body[:2] in _DTLS_VERSIONS
and len(body) == 3 + body[2]
)
def _is_complete_server_hello(body):
"""Validate the fixed fields, session ID, and optional extensions."""
if len(body) < 38 or body[:2] not in _DTLS_VERSIONS:
return False
session_id_length = body[34]
if session_id_length > 32:
return False
fixed_end = 38 + session_id_length
if len(body) == fixed_end:
return True
if len(body) < fixed_end + 2:
return False
extensions_length = int.from_bytes(body[fixed_end:fixed_end + 2], 'big')
return len(body) == fixed_end + 2 + extensions_length
def _parse_liveness_response(datagram):
"""Return the kind and validated alert for an epoch-zero first flight."""
records = split_dtls(datagram)
if not records or sum(map(len, records)) != len(datagram):
return None, None
fallback_kind = None
fallback_alert = None
for record in records:
if len(record) < 13 or record[1:3] not in _DTLS_VERSIONS:
continue
if record[3:5] != b'\x00\x00':
continue
fragment = record[13:]
if record[0] == _CT_HANDSHAKE:
offset = 0
while offset + 12 <= len(fragment):
header = fragment[offset:offset + 12]
message_length = int.from_bytes(header[1:4], 'big')
fragment_offset = int.from_bytes(header[6:9], 'big')
fragment_length = int.from_bytes(header[9:12], 'big')
end = offset + 12 + fragment_length
if end > len(fragment):
break
if fragment_offset == 0 and fragment_length == message_length:
body = fragment[offset + 12:end]
if header[0] == 3 and _is_complete_hello_verify(body):
return HELLO_VERIFY_REQUEST, None
if header[0] == 2 and _is_complete_server_hello(body):
if fallback_kind is None:
fallback_kind = SERVER_HELLO
offset = end
elif record[0] == _CT_ALERT and len(fragment) == 2:
level, description = fragment
fallback_kind = ALERT
fallback_alert = (
level,
_ALERT_NAMES.get(description, str(description)),
)
if level == 2:
return fallback_kind, fallback_alert
return fallback_kind, fallback_alert
def _classify_liveness_response(datagram):
"""Classify a structurally complete epoch-zero DTLS first flight."""
return _parse_liveness_response(datagram)[0]
def _probe_dtls_port_with_flight(
host, port, *, flight, timeout, retries, family):
"""Send one frozen ClientHello flight on a connected UDP socket."""
attempt_budget = float(timeout) / (retries + 1)
attempts = 0
sock = None
try:
sock, _endpoint = open_connected_udp_socket(
host,
port,
family=family,
timeout=attempt_budget,
)
started = time.monotonic()
for attempts in range(1, retries + 2):
for record in flight:
if sock.send(record) != len(record):
raise OSError('short UDP send')
attempt_deadline = started + attempts * attempt_budget
while True:
remaining = attempt_deadline - time.monotonic()
if remaining <= 0:
break
sock.settimeout(remaining)
try:
datagram = sock.recv(65535)
except TimeoutError:
break
response_kind, alert = _parse_liveness_response(datagram)
if response_kind is None:
# A connected UDP socket already rejects other peers. An
# unrelated or malformed datagram from the appliance must
# not consume a retransmission or count as DTLS proof.
continue
return DtlsLivenessResult(
port=port,
response_kind=response_kind,
attempts=attempts,
rtt_s=time.monotonic() - started,
alert=alert,
)
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=attempts,
error_code='no_dtls_response',
)
except OSError:
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=attempts,
error_code='endpoint_unavailable',
)
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
def probe_dtls_port(
host, port, *, timeout=3.0, retries=2, mtu=1200,
family=socket.AF_UNSPEC):
"""Prove one DTLS listener without sending a cookie-bearing flight.
The ClientHello is generated once. Packet-loss retries resend those exact
bytes and no response is ever fed back into OpenSSL, so this function
cannot emit a second ClientHello or allocate a server association.
``timeout`` bounds socket I/O after synchronous platform name resolution;
resolver timing remains controlled by the operating system.
"""
_validate_liveness_options(port, retries, timeout, mtu)
_validate_probe_family(family)
try:
flight = _client_hello_flight(mtu=mtu)
except Exception: # noqa: BLE001 - return only a fixed failure code
return DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='client_hello_unavailable',
)
return _probe_dtls_port_with_flight(
host,
port,
flight=flight,
timeout=timeout,
retries=retries,
family=family,
)
def probe_dtls_ports(
host, ports, *, preferred_port=None, timeout=3.0, retries=2,
mtu=1200, family=socket.AF_UNSPEC):
"""Probe a bounded port set concurrently and select without guessing.
One proven listener is selected. If multiple listeners answer, a proven
``preferred_port`` wins; otherwise the explicit outcome is ``ambiguous``.
Results preserve the caller's de-duplicated port order. Each worker's
``timeout`` starts after synchronous platform name resolution.
"""
_validate_probe_family(family)
ordered_ports = tuple(dict.fromkeys(ports))
if not ordered_ports:
return DtlsPortProbeResult(UNREACHABLE, None, ())
if len(ordered_ports) > 32:
raise ValueError('at most 32 DTLS ports may be probed')
for port in ordered_ports:
_validate_liveness_options(port, retries, timeout, mtu)
if preferred_port is not None:
_validate_liveness_options(preferred_port, retries, timeout, mtu)
try:
flight = _client_hello_flight(mtu=mtu)
except Exception: # noqa: BLE001 - duplicate one fixed result per port
results = tuple(
DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='client_hello_unavailable',
)
for port in ordered_ports
)
return DtlsPortProbeResult(UNREACHABLE, None, results)
by_port = {}
with cf.ThreadPoolExecutor(
max_workers=len(ordered_ports),
thread_name_prefix='smartthings-dtls-probe') as executor:
futures = {
executor.submit(
_probe_dtls_port_with_flight,
host,
port,
flight=flight,
timeout=timeout,
retries=retries,
family=family,
): port
for port in ordered_ports
}
for future in cf.as_completed(futures):
port = futures[future]
try:
by_port[port] = future.result()
except Exception: # noqa: BLE001 - isolate one bounded worker
by_port[port] = DtlsLivenessResult(
port=port,
response_kind=None,
attempts=0,
error_code='probe_worker_failed',
)
results = tuple(by_port[port] for port in ordered_ports)
live_ports = tuple(
result.port for result in results if result.is_dtls_server)
if preferred_port is not None and preferred_port in live_ports:
return DtlsPortProbeResult(SELECTED, preferred_port, results)
if len(live_ports) == 1:
return DtlsPortProbeResult(SELECTED, live_ports[0], results)
if live_ports:
return DtlsPortProbeResult(AMBIGUOUS, None, results)
return DtlsPortProbeResult(UNREACHABLE, None, results)
class ProbeResult:
"""What a single ClientHello probe learned about one host:port."""
@@ -147,38 +496,80 @@ def classify_datagram(dgram):
def probe(host, port, *, cert_pem=None, key_pem=None,
cert_path=None, key_path=None,
stateless=True, retries=2, timeout=3.0, mtu=1280):
"""Send a DTLS ClientHello to host:port and classify the server's
first flight.
stateless=True, retries=2, timeout=3.0, mtu=1200,
family=socket.AF_UNSPEC):
"""Run the backward-compatible stateless liveness probe.
Two modes:
stateless=True (default) — a *liveness* gate. Stop the instant the
server proves itself with a HelloVerifyRequest (or ServerHello),
and never send the cookie'd second ClientHello. By RFC 6347
§4.2.1 the server answers the first ClientHello WITHOUT allocating
association state, so a stateless probe leaves the device
completely untouched — no orphaned association, no ~8 s §4.2.8
cooldown for a later real connect from a different source port.
Outcome is DEAD or LIVE. This is the mode a discovery/reconnect
loop should use in front of a real handshake.
stateless=False — a *diagnostic* drive. Continue the handshake as
far as the server's own flight goes (up to ServerHelloDone, or to
COMPLETED with a client cert), capturing its cipher, cert chain,
CertificateRequest, or a fatal Alert. This deliberately commits
association state on the device, so keep it out of hot reconnect
paths; it is the tool for characterizing an OCF-PKI-wall device
(#16) — trust rejection vs cipher/version mismatch.
A single dropped ClientHello would otherwise read as a false DEAD, so
the silent path services OpenSSL's DTLS retransmit timer and re-sends
up to `retries` times before giving up. A live server still answers
on the first RTT — retransmit only lengthens the silent path.
Production callers should prefer :func:`probe_dtls_port`, whose immutable
result cannot retain remote datagrams or host names. This adapter preserves
the original ``ProbeResult`` shape. ``stateless=False`` remains only as a
deprecated compatibility path to the explicitly named stateful diagnostic.
Never raises on a network/handshake failure — those are folded into
the ProbeResult so a discovery loop can race many ports safely.
"""
if not stateless:
warnings.warn(
'probe(stateless=False) is deprecated; use '
'diagnose_dtls_handshake() explicitly',
DeprecationWarning,
stacklevel=2,
)
return diagnose_dtls_handshake(
host,
port,
cert_pem=cert_pem,
key_pem=key_pem,
cert_path=cert_path,
key_path=key_path,
retries=retries,
timeout=timeout,
mtu=mtu,
family=family,
)
result = ProbeResult(host, port)
liveness = probe_dtls_port(
host,
port,
timeout=timeout,
retries=retries,
mtu=mtu,
family=family,
)
if liveness.response_kind == HELLO_VERIFY_REQUEST:
result.outcome = LIVE
result.handshake_msgs.append('HelloVerifyRequest')
elif liveness.response_kind == SERVER_HELLO:
result.outcome = LIVE
result.handshake_msgs.append('ServerHello')
elif liveness.response_kind == ALERT:
result.alert = liveness.alert
result.outcome = (
REJECTED
if liveness.alert is not None and liveness.alert[0] == 2
else LIVE
)
result.rtt_s = liveness.rtt_s
if liveness.error_code not in (None, 'no_dtls_response'):
result.error = ProbeError()
return result
def diagnose_dtls_handshake(
host, port, *, cert_pem=None, key_pem=None,
cert_path=None, key_path=None,
retries=2, timeout=3.0, mtu=1200,
family=socket.AF_UNSPEC):
"""Opt in to a stateful DTLS handshake for protocol diagnosis.
Unlike :func:`probe_dtls_port`, this function feeds the server flight back
into OpenSSL. It can therefore emit a cookie-bearing second ClientHello and
allocate appliance-side association state. Keep it out of discovery,
reconnect, and other production liveness paths.
"""
_validate_liveness_options(port, retries, timeout, mtu)
_validate_probe_family(family)
result = ProbeResult(host, port)
ctx = SSL.Context(SSL.DTLS_METHOD)
@@ -186,7 +577,7 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
# Accept the chain unconditionally: a probe classifies what the server
# sends, it does not gate on our trust decision.
ctx.set_verify(SSL.VERIFY_PEER, lambda *a: True)
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
ctx.set_cipher_list(_DTLS_CIPHERS)
if cert_pem is not None:
_load_pem_chain(ctx, cert_pem, key_pem)
elif cert_path is not None:
@@ -198,20 +589,28 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
conn.set_connect_state()
conn.set_ciphertext_mtu(mtu)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(0.5)
dest = (host, port)
try:
sock, _endpoint = open_connected_udp_socket(
host,
port,
family=family,
timeout=min(0.5, timeout),
)
except OSError:
result.error = ProbeError()
return result
t0 = time.time()
started = time.monotonic()
deadline = started + timeout
seen = set()
retransmits = 0
try:
while time.time() - t0 < timeout:
while time.monotonic() < deadline:
try:
conn.do_handshake()
result.outcome = COMPLETED
if result.rtt_s is None:
result.rtt_s = time.time() - t0
result.rtt_s = time.monotonic() - started
break
except SSL.WantReadError:
pass
@@ -225,13 +624,18 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
o = conn.bio_read(65535)
if o:
for r in split_dtls(o):
sock.sendto(r, dest)
if sock.send(r) != len(r):
raise OSError('short UDP send')
except SSL.WantReadError:
pass
remaining = deadline - time.monotonic()
if remaining <= 0:
break
sock.settimeout(min(0.5, remaining))
try:
d, _ = sock.recvfrom(65535)
except socket.timeout:
d = sock.recv(65535)
except TimeoutError:
# No answer to the last flight. Service OpenSSL's DTLS
# retransmit timer: once it has counted down to 0,
# handle_timeout() re-queues the previous flight into the
@@ -251,9 +655,8 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
continue
if result.rtt_s is None:
result.rtt_s = time.time() - t0
result.rtt_s = time.monotonic() - started
result.datagrams.append(d)
server_flight = False
for ct, detail in classify_datagram(d):
if ct == _CT_HANDSHAKE:
if detail not in seen:
@@ -261,23 +664,14 @@ def probe(host, port, *, cert_pem=None, key_pem=None,
result.handshake_msgs.append(detail)
if result.outcome == DEAD:
result.outcome = LIVE
if detail in ('HelloVerifyRequest', 'ServerHello'):
server_flight = True
elif ct == _CT_ALERT and detail is not None:
level, name = detail
result.alert = (level, name)
if level == 2: # fatal
result.outcome = REJECTED
# Stateless liveness: the server proved itself with a
# HelloVerifyRequest/ServerHello, which it answered without
# allocating state. Stop before feeding this flight back to
# OpenSSL — doing so would make it emit the cookie'd second
# ClientHello, the message that actually commits association
# state on the device. Not writing it keeps the probe
# zero-footprint.
if stateless and server_flight:
break
conn.bio_write(d)
except OSError:
result.error = ProbeError()
finally:
sock.close()
@@ -289,14 +683,11 @@ def _main(argv):
if len(argv) < 2:
print('usage: python -m smartthings_local.protocol.dtls_probe '
'HOST PORT [PORT...] [--cert FILE --key FILE] [--stateless]')
'HOST PORT [PORT...] [--diagnostic --cert FILE --key FILE]')
return 2
host = argv[0]
cert_path = key_path = None
# CLI defaults to the diagnostic drive so `HOST PORT` characterizes a
# device (cipher/cert/Alert). Pass --stateless for the zero-footprint
# liveness gate a reconnect loop would use.
stateless = False
diagnostic = False
ports = []
it = iter(argv[1:])
for a in it:
@@ -304,16 +695,31 @@ def _main(argv):
cert_path = next(it)
elif a == '--key':
key_path = next(it)
elif a == '--diagnostic':
diagnostic = True
elif a == '--stateless':
stateless = True
# Compatibility no-op: stateless is now the fail-safe default.
pass
else:
ports.append(int(a))
if not ports:
print('at least one PORT is required')
return 2
ports = list(dict.fromkeys(ports))
if len(ports) > 32:
print('at most 32 PORT values may be probed')
return 2
if (cert_path is None) != (key_path is None):
print('--cert and --key must be supplied together')
return 2
if not diagnostic and (cert_path is not None or key_path is not None):
print('--cert/--key require the explicit --diagnostic mode')
return 2
# Race the ports: a ClientHello probe is cheap, so fan out and let the
# live one answer in ~1 RTT instead of serializing 12 s timeouts.
target = diagnose_dtls_handshake if diagnostic else probe
with cf.ThreadPoolExecutor(max_workers=max(1, len(ports))) as ex:
futs = {ex.submit(probe, host, p, cert_path=cert_path,
key_path=key_path, stateless=stateless): p
futs = {ex.submit(target, host, p, cert_path=cert_path,
key_path=key_path): p
for p in ports}
results = [f.result() for f in cf.as_completed(futs)]
+2 -1
View File
@@ -48,6 +48,7 @@ import logging
logger = logging.getLogger(__name__)
_OCF_ROOT_CA = str(Path(__file__).parent / 'ocf_root_ca.pem')
_DTLS_CIPHERS = b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0'
# Diagnostic logging — when DEBUG_BRIDGE=1 in env, the bridge dumps
@@ -202,7 +203,7 @@ class DtlsCoapSession:
# 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.
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
ctx.set_cipher_list(_DTLS_CIPHERS)
if self.cert_pem is not None:
_load_pem_chain(ctx, self.cert_pem, self.key_pem)
else:
+49 -39
View File
@@ -1,9 +1,8 @@
"""Port-resolution logic for the MQTT bridge: the stateless pre-flight
gate and OCF-band autodiscovery in PushBridge. The DTLS probe is faked so
these run without hardware — only the routing/gating/caching is exercised.
gate and standard/dynamic OCF port discovery in PushBridge. The DTLS probe is
faked so these run without hardware; only routing and selection are exercised.
"""
import logging
import time
import types
import pytest
@@ -15,31 +14,52 @@ def _mk_bridge(ocf_port, default=49155, discovered=None):
"""A PushBridge shell with only the attributes _resolve_port touches,
bypassing the heavyweight __init__ (MQTT client, cert paths, …)."""
b = bridge.PushBridge.__new__(bridge.PushBridge)
b.app = types.SimpleNamespace(ip='10.0.0.9', ocf_port=ocf_port, index=0)
b.app = types.SimpleNamespace(ip='192.0.2.9', ocf_port=ocf_port, index=0)
b.descriptor = types.SimpleNamespace(default_observe_port=default)
b._discovered_port = discovered
b.log = logging.getLogger('test-bridge')
return b
def _fake_probe(live_ports):
"""Return a probe() stand-in reporting is_dtls_server for live_ports."""
def _fake_port_probe(live_ports):
"""Return a one-port probe stand-in for the selected live ports."""
def fake(ip, port, **kw):
alive = port in live_ports
return types.SimpleNamespace(
port=port, is_dtls_server=alive,
outcome='live' if alive else 'dead')
port=port,
is_dtls_server=alive,
)
return fake
def _fake_port_set(live_ports):
"""Return an aggregate probe stand-in with explicit ambiguity."""
def fake(ip, ports, **kw):
live = tuple(port for port in ports if port in live_ports)
if len(live) == 1:
outcome = 'selected'
selected_port = live[0]
elif live:
outcome = 'ambiguous'
selected_port = None
else:
outcome = 'unreachable'
selected_port = None
return types.SimpleNamespace(
outcome=outcome,
selected_port=selected_port,
)
return fake
def test_pinned_live_port_is_gated_and_returned(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe({49155}))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe({49155}))
b = _mk_bridge(ocf_port=49155)
assert b._resolve_port() == 49155
def test_pinned_dead_port_raises_for_backoff(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe(set()))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe(set()))
b = _mk_bridge(ocf_port=49155)
with pytest.raises(ConnectionError):
b._resolve_port()
@@ -48,50 +68,39 @@ def test_pinned_dead_port_raises_for_backoff(monkeypatch):
def test_autodiscovery_finds_and_caches_live_port(monkeypatch):
# Only 49154 answers; it isn't the descriptor default, so discovery is
# what finds it — and it must be cached for the next reconnect.
monkeypatch.setattr(bridge, 'probe', _fake_probe({49154}))
monkeypatch.setattr(bridge, 'probe_dtls_ports', _fake_port_set({49154}))
b = _mk_bridge(ocf_port=None, default=49155)
assert b._resolve_port() == 49154
assert b._discovered_port == 49154
def test_autodiscovery_returns_a_live_port(monkeypatch):
# Early-exit: the first candidate to answer LIVE wins. Real devices
# expose exactly one DTLS port; if several answer, any live one is a
# correct result.
monkeypatch.setattr(bridge, 'probe', _fake_probe({49153, 49155}))
def test_autodiscovery_refuses_ambiguous_live_ports(monkeypatch):
monkeypatch.setattr(
bridge,
'probe_dtls_ports',
_fake_port_set({5684, 49154}),
)
b = _mk_bridge(ocf_port=None, default=49155)
assert b._resolve_port() in {49153, 49155}
def test_autodiscovery_early_exits_before_dead_ports_finish(monkeypatch):
# The live port answers immediately; the dead ports "hang" on their
# retry budget. Discovery must return at the live port's speed, not
# block on the slow dead probes.
def slow_probe(ip, port, **kw):
if port == 49154:
return types.SimpleNamespace(
port=port, is_dtls_server=True, outcome='live')
time.sleep(0.5) # a dead port burning its retry budget
return types.SimpleNamespace(
port=port, is_dtls_server=False, outcome='dead')
monkeypatch.setattr(bridge, 'probe', slow_probe)
b = _mk_bridge(ocf_port=None, default=49155)
t0 = time.time()
assert b._resolve_port() == 49154
assert time.time() - t0 < 0.25 # did not wait out the 0.5s dead probes
with pytest.raises(ConnectionError, match='multiple DTLS listeners'):
b._resolve_port()
def test_cached_live_port_is_reused_without_rediscovery(monkeypatch):
# Cached 49156 and the default 49155 are both live; the cache-first
# path must return the cached port, not re-race the band (which would
# tie-break to the default).
monkeypatch.setattr(bridge, 'probe', _fake_probe({49155, 49156}))
# path must return the previously proven port without an ambiguous
# full-set probe.
monkeypatch.setattr(
bridge,
'probe_dtls_port',
_fake_port_probe({49155, 49156}),
)
b = _mk_bridge(ocf_port=None, default=49155, discovered=49156)
assert b._resolve_port() == 49156
def test_autodiscovery_all_dead_raises_and_clears_cache(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_probe(set()))
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe(set()))
monkeypatch.setattr(bridge, 'probe_dtls_ports', _fake_port_set(set()))
b = _mk_bridge(ocf_port=None, discovered=49154)
with pytest.raises(ConnectionError):
b._resolve_port()
@@ -102,5 +111,6 @@ def test_candidate_ports_cover_band_plus_default(monkeypatch):
b = _mk_bridge(ocf_port=None, default=49200)
cands = b._candidate_ports()
assert set(bridge.OCF_PORT_BAND) <= set(cands)
assert bridge.OCF_STANDARD_SECURE_PORT in cands
assert 49200 in cands
assert cands == sorted(cands)
+317 -25
View File
@@ -1,36 +1,60 @@
import socket
import threading
import time
from smartthings_local.errors import ProbeError
import pytest
from smartthings_local.protocol import dtls_probe as p
def _rec(content_type, frag):
def _rec(content_type, frag, *, epoch=0):
"""Build one DTLS record: 13-byte header + fragment."""
return (bytes([content_type])
+ b'\xfe\xfd' # DTLS 1.2
+ b'\x00\x00' # epoch
+ epoch.to_bytes(2, 'big') # epoch
+ b'\x00\x00\x00\x00\x00\x00' # sequence number
+ len(frag).to_bytes(2, 'big')
+ frag)
def _hs(msg_type, body=b''):
return _rec(p._CT_HANDSHAKE, bytes([msg_type]) + body)
header = (
bytes([msg_type])
+ len(body).to_bytes(3, 'big')
+ b'\x00\x00' # message sequence
+ b'\x00\x00\x00' # fragment offset
+ len(body).to_bytes(3, 'big')
)
return _rec(p._CT_HANDSHAKE, header + body)
def _alert(level, desc):
return _rec(p._CT_ALERT, bytes([level, desc]))
def _hvr(cookie=b'cookie'):
return _hs(3, b'\xfe\xfd' + bytes([len(cookie)]) + cookie)
def _server_hello():
body = (
b'\xfe\xfd'
+ b'\x00' * 32
+ b'\x00' # session ID length
+ b'\xc0\x2b' # ECDHE-ECDSA-AES128-GCM-SHA256
+ b'\x00' # null compression
)
return _hs(2, body)
def _alert(level, desc, *, epoch=0):
return _rec(p._CT_ALERT, bytes([level, desc]), epoch=epoch)
def test_classify_hello_verify_request():
assert p.classify_datagram(_hs(3, b'\x00' * 20)) == [
assert p.classify_datagram(_hvr()) == [
(p._CT_HANDSHAKE, 'HelloVerifyRequest')]
def test_classify_coalesced_server_flight():
# OpenSSL commonly hands back ServerHello+Certificate back-to-back.
dgram = _hs(2, b'\x00' * 30) + _hs(11, b'\x00' * 40)
dgram = _server_hello() + _hs(11, b'\x00' * 40)
assert p.classify_datagram(dgram) == [
(p._CT_HANDSHAKE, 'ServerHello'),
(p._CT_HANDSHAKE, 'Certificate')]
@@ -49,7 +73,7 @@ def test_classify_unknown_handshake_type_is_not_lost():
def test_dead_port_probe_is_dead_and_never_raises():
# Nothing listens here; the probe must fold the silence into a DEAD
# result within the timeout rather than raise.
r = p.probe('127.0.0.1', 5684, timeout=1.0)
r = p.probe('127.0.0.1', 5684, timeout=0.1)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert r.datagrams == []
@@ -80,6 +104,7 @@ class _FakeSock:
self.sends = []
self.recv_calls = 0
self.closed = False
self.destination = None
def settimeout(self, t):
self._timeout = t
@@ -90,16 +115,31 @@ class _FakeSock:
def bind(self, *a):
pass
def connect(self, destination):
self.destination = destination
def send(self, data):
self.sends.append(data)
return len(data)
def sendto(self, data, dest):
self.sends.append(data)
return len(data)
def recv(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise TimeoutError()
return resp
def recvfrom(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise socket.timeout()
raise TimeoutError()
return resp, ('127.0.0.1', 5684)
def close(self):
@@ -114,25 +154,49 @@ def test_stateless_probe_sends_exactly_one_clienthello(monkeypatch):
# The §4.2.8 regression guard: a HelloVerifyRequest proves liveness,
# and the stateless gate must stop there — never emitting the cookie'd
# second ClientHello that would commit association state on the device.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
fake = _FakeSock(lambda _fake: _hvr())
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=2.0)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
assert r.outcome == p.LIVE
assert len(fake.sends) == 1 # only the initial ClientHello
assert fake.recv_calls == 1 # stopped on the first flight
assert fake.closed
def test_stateless_probe_preserves_first_flight_alert(monkeypatch):
fake = _FakeSock(lambda _fake: _alert(2, 48))
_patch_sock(monkeypatch, fake)
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
assert result.outcome == p.REJECTED
assert result.alert == (2, 'unknown_ca')
assert len(fake.sends) == 1
def test_stateless_warning_alert_proves_liveness_without_fatal_rejection(
monkeypatch):
fake = _FakeSock(lambda _fake: _alert(1, 90))
_patch_sock(monkeypatch, fake)
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
assert result.outcome == p.LIVE
assert result.is_dtls_server
assert result.alert == (1, 'user_canceled')
def test_retransmit_recovers_from_dropped_first_flight(monkeypatch):
# The first ClientHello is "lost" (recvfrom times out) until OpenSSL's
# retransmit timer fires a second flight; only then does the server
# answer. A single dropped datagram must NOT read as DEAD.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20) if len(f.sends) >= 2
fake = _FakeSock(lambda f: _hvr() if len(f.sends) >= 2
else None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=5.0)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=0.3)
assert r.outcome == p.LIVE
assert len(fake.sends) == 2 # initial + one retransmit
assert fake.sends[0] == fake.sends[1]
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
@@ -140,21 +204,249 @@ def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
# `retries` retransmits — not on the first unanswered datagram.
fake = _FakeSock(lambda f: None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=1, timeout=6.0)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=1, timeout=0.2)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert len(fake.sends) == 2 # initial + retries(1) retransmit
def test_diagnostic_mode_feeds_server_flight_back(monkeypatch):
# The inverse of the stateless guard: stateless=False must NOT stop at
# the HelloVerifyRequest — it feeds the flight back into OpenSSL to
# drive the handshake onward (the #16 characterization path). The
# fed-back record here is a stub, so OpenSSL surfaces an error the
# moment it processes it, which is precisely what proves the probe did
# not short-circuit before the write.
fake = _FakeSock(lambda f: _hs(3, b'\x00' * 20))
def test_explicit_diagnostic_feeds_server_flight_back(monkeypatch):
# The explicitly named diagnostic must NOT stop at the
# HelloVerifyRequest: it feeds the flight back into OpenSSL to drive the
# handshake onward (the #16 characterization path). The
# fed-back record makes OpenSSL emit a cookie-bearing second ClientHello,
# which is precisely what proves the diagnostic did not short-circuit.
fake = _FakeSock(
lambda f: _hvr() if f.recv_calls == 1 else None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
r = p.diagnose_dtls_handshake('127.0.0.1', 5684, timeout=0.3)
assert r.outcome == p.LIVE # HVR still proved liveness
assert isinstance(r.error, ProbeError) # OpenSSL processed the flight
assert len(fake.sends) >= 2 # OpenSSL processed the flight
def test_stateless_probe_ignores_unrelated_datagram_without_retransmit(
monkeypatch):
responses = iter((
_rec(p._CT_APP_DATA, b'unrelated'),
_hvr(),
))
fake = _FakeSock(lambda _fake: next(responses))
_patch_sock(monkeypatch, fake)
result = p.probe_dtls_port(
'127.0.0.1', 5684, retries=1, timeout=0.2)
assert result.response_kind == p.HELLO_VERIFY_REQUEST
assert result.attempts == 1
assert len(fake.sends) == 1
assert fake.recv_calls == 2
def test_stateless_probe_forwards_explicit_address_family(monkeypatch):
fake = _FakeSock(lambda _fake: _hvr())
calls = []
def open_socket(host, port, *, family, timeout):
calls.append((host, port, family, timeout))
fake.settimeout(timeout)
return fake, object()
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
result = p.probe_dtls_port(
'appliance.invalid', 5684, family=socket.AF_INET6, timeout=0.2)
assert result.is_dtls_server
assert calls == [('appliance.invalid', 5684, socket.AF_INET6, 0.2 / 3)]
def test_client_hello_flight_is_complete_epoch_zero_dtls():
flight = p._client_hello_flight(mtu=1200)
assert flight
assert all(len(record) <= 1200 for record in flight)
assert all(record[1:3] in p._DTLS_VERSIONS for record in flight)
assert all(record[3:5] == b'\x00\x00' for record in flight)
assert any(
record[0] == p._CT_HANDSHAKE and record[13] == 1
for record in flight
)
def test_liveness_classifier_accepts_first_flight_response_classes():
assert p._classify_liveness_response(_hvr()) == \
p.HELLO_VERIFY_REQUEST
assert p._classify_liveness_response(_server_hello()) == \
p.SERVER_HELLO
assert p._classify_liveness_response(_alert(2, 48)) == p.ALERT
def test_liveness_classifier_rejects_truncated_or_nonzero_epoch():
assert p._classify_liveness_response(_hvr()[:-1]) is None
assert p._classify_liveness_response(_hs(3)) is None
assert p._classify_liveness_response(_hs(2, b'\x00' * 20)) is None
nonzero_epoch = bytearray(_hvr())
nonzero_epoch[4] = 1
assert p._classify_liveness_response(bytes(nonzero_epoch)) is None
def test_liveness_alert_detail_comes_from_valid_epoch_zero_record(monkeypatch):
datagram = _alert(2, 40, epoch=1) + _alert(2, 48)
fake = _FakeSock(lambda _fake: datagram)
_patch_sock(monkeypatch, fake)
result = p.probe_dtls_port('127.0.0.1', 5684, timeout=0.2)
assert result.response_kind == p.ALERT
assert result.alert == (2, 'unknown_ca')
def _liveness(port, *, live=True, error_code=None):
return p.DtlsLivenessResult(
port=port,
response_kind=p.HELLO_VERIFY_REQUEST if live else None,
attempts=1,
error_code=error_code,
)
def test_multi_port_probe_runs_concurrently_and_preserves_order(monkeypatch):
ports = (5684, 49154, 49155)
barrier = threading.Barrier(len(ports))
def fake_probe(_host, port, **_kwargs):
barrier.wait(timeout=2.0)
return _liveness(port, live=port == 5684)
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(p, '_probe_dtls_port_with_flight', fake_probe)
result = p.probe_dtls_ports('appliance.invalid', ports)
assert result.outcome == p.SELECTED
assert result.selected_port == 5684
assert tuple(item.port for item in result.results) == ports
assert not any(
thread.name.startswith('smartthings-dtls-probe')
for thread in threading.enumerate()
)
def test_multi_port_probe_reports_ambiguity_without_guessing(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda _host, port, **_kwargs: _liveness(port),
)
result = p.probe_dtls_ports('appliance.invalid', (5684, 49154))
assert result.outcome == p.AMBIGUOUS
assert result.selected_port is None
assert result.live_ports == (5684, 49154)
def test_multi_port_probe_prefers_previously_proven_listener(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda _host, port, **_kwargs: _liveness(port),
)
result = p.probe_dtls_ports(
'appliance.invalid',
(5684, 49154),
preferred_port=49154,
)
assert result.outcome == p.SELECTED
assert result.selected_port == 49154
def test_multi_port_probe_folds_worker_failure_into_redacted_result(monkeypatch):
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
monkeypatch.setattr(
p,
'_probe_dtls_port_with_flight',
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError('private')),
)
result = p.probe_dtls_ports('private-host.invalid', (5684,))
assert result.outcome == p.UNREACHABLE
assert result.results[0].error_code == 'probe_worker_failed'
assert 'private-host' not in repr(result)
assert 'private' not in repr(result)
def test_multi_port_probe_bounds_candidate_count():
with pytest.raises(ValueError, match='at most 32'):
p.probe_dtls_ports('appliance.invalid', tuple(range(1, 34)))
def test_multi_port_probe_rejects_invalid_family_before_starting_workers():
with pytest.raises(ValueError, match='family'):
p.probe_dtls_ports(
'appliance.invalid',
(5684, 49154),
family=9999,
)
def test_diagnostic_honors_timeout_below_half_second(monkeypatch):
now = [10.0]
class BudgetSocket:
def __init__(self):
self.timeout = None
self.timeouts = []
def settimeout(self, timeout):
self.timeout = timeout
self.timeouts.append(timeout)
def send(self, data):
return len(data)
def recv(self, _size):
now[0] += self.timeout
raise TimeoutError()
def close(self):
pass
sock = BudgetSocket()
open_timeouts = []
def open_socket(_host, _port, *, family, timeout):
assert family == socket.AF_UNSPEC
open_timeouts.append(timeout)
sock.settimeout(timeout)
return sock, object()
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
monkeypatch.setattr(p.time, 'monotonic', lambda: now[0])
result = p.diagnose_dtls_handshake(
'appliance.invalid',
5684,
timeout=0.1,
retries=0,
)
assert result.outcome == p.DEAD
assert open_timeouts == [0.1]
assert sock.timeouts and max(sock.timeouts) <= 0.1
assert now[0] <= 10.1
def test_cli_bounds_port_fanout(capsys):
result = p._main([
'appliance.invalid',
*(str(port) for port in range(1, 34)),
])
assert result == 2
assert 'at most 32 PORT values' in capsys.readouterr().out