From dd453ebdfb1d5050195c90d3d9c236872ba73755 Mon Sep 17 00:00:00 2001 From: Jason Morcos Date: Sun, 2 Aug 2026 15:38:19 -0700 Subject: [PATCH] feat(protocol): bound DTLS endpoint probing --- README.md | 58 ++- docs/ocf-pki-laundry.md | 240 +++++++++ mqtt_demo/bridge.py | 102 ++-- smartthings_local/protocol/dtls_probe.py | 534 ++++++++++++++++++--- smartthings_local/protocol/dtls_session.py | 3 +- tests/test_bridge_port_resolution.py | 88 ++-- tests/test_dtls_probe.py | 342 ++++++++++++- 7 files changed, 1166 insertions(+), 201 deletions(-) create mode 100644 docs/ocf-pki-laundry.md diff --git a/README.md b/README.md index a84ae52..d575aee 100644 --- a/README.md +++ b/README.md @@ -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__CLASS` must match a descriptor key in `mqtt_demo/samples/__init__.py::DESCRIPTORS`: currently `dryer`, `oven`, and `fridge`. +Each `APPLIANCE__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__*` blocks to read (1-indexed) | | `APPLIANCE__CLASS` | Descriptor name: `dryer`, `oven`, `fridge` | | `APPLIANCE__IP` | LAN IP of the appliance | -| `APPLIANCE__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__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__TOPIC` | MQTT topic prefix (also the HA device identifier; changing it re-keys the device) | | `APPLIANCE__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 diff --git a/docs/ocf-pki-laundry.md b/docs/ocf-pki-laundry.md new file mode 100644 index 0000000..c4e1a54 --- /dev/null +++ b/docs/ocf-pki-laundry.md @@ -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. diff --git a/mqtt_demo/bridge.py b/mqtt_demo/bridge.py index cb67db0..d1f66d2 100644 --- a/mqtt_demo/bridge.py +++ b/mqtt_demo/bridge.py @@ -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() diff --git a/smartthings_local/protocol/dtls_probe.py b/smartthings_local/protocol/dtls_probe.py index afd897a..9232912 100644 --- a/smartthings_local/protocol/dtls_probe.py +++ b/smartthings_local/protocol/dtls_probe.py @@ -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)] diff --git a/smartthings_local/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py index e5c9731..cfee7a2 100644 --- a/smartthings_local/protocol/dtls_session.py +++ b/smartthings_local/protocol/dtls_session.py @@ -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: diff --git a/tests/test_bridge_port_resolution.py b/tests/test_bridge_port_resolution.py index 6d65dbe..03bc0c6 100644 --- a/tests/test_bridge_port_resolution.py +++ b/tests/test_bridge_port_resolution.py @@ -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) diff --git a/tests/test_dtls_probe.py b/tests/test_dtls_probe.py index d29b878..d8b8e76 100644 --- a/tests/test_dtls_probe.py +++ b/tests/test_dtls_probe.py @@ -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