feat(protocol): bound DTLS endpoint probing
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user