Author SHA1 Message Date
Jack Nagy 98e0020e2f fix(ci): treat sdist VCS-ignore files as optional in distribution check
hatchling 1.31.0 ships only .gitignore in the sdist; older releases also
bundled .hgignore. check_sdist required an exact member set including
.hgignore, so the Validate workflow's package job failed on main once CI
resolved the newer hatchling.

Require the tracked source set plus the fixed metadata files, and accept
.gitignore/.hgignore as optional members either way.
2026-08-03 19:45:09 +01:00
Quite Yellow 597a88ff25 Merge pull request #21 from Moballo-LLC/codex/py-01-ci-foundation
ci: add validation and package smoke tests
2026-08-03 19:33:28 +01:00
Quite Yellow 93e39de079 Merge pull request #22 from Moballo-LLC/codex/libressl-sha1-retry
fix(setup-cert): keep SHA-1 retry compatible with LibreSSL
2026-08-03 19:33:25 +01:00
Jason Morcos fc6240b72e test(safety): allow public GitHub attachments 2026-08-02 16:31:15 -07:00
Jason Morcos 6dc9dca339 fix(setup-cert): keep SHA-1 retry compatible with LibreSSL 2026-08-02 10:43:40 -07:00
Jason Morcos 2c93cb3097 test: make worker cleanup checks deterministic 2026-08-02 10:13:56 -07:00
Jason Morcos 23338995bf build: require pyOpenSSL DTLS timeout support 2026-08-02 10:07:49 -07:00
Jason Morcos 119c114daa ci: add pull request validation 2026-08-02 10:02:52 -07:00
Jack Nagy e5bd9456d4 docs(readme): document SHA-1 crypto-policy handling + list dtls_probe
- Note the Fedora/RHEL SHA-1 crypto-policy block in Part 2 and how
  setup_cert.py auto-retries / the manual update-crypto-policies remedy.
- Add dtls_probe.py to the repo-layout tree (it was referenced in three
  places but missing from the file listing).
- Drop the .venv/ prefix from the Part 1 probe command so it runs against
  the pip-installed package before the Part 4 venv exists.
- Expand the tests parenthetical to name the probe, port-resolution, and
  cert-signing suites.
2026-08-01 12:05:57 +01:00
Quite Yellow a494e73e89 fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy (#19)
* fix(setup_cert): surface openssl errors and work around SHA-1 crypto policy

The signing step forces -sha1 (the AC14K_M chain requires SHA-1-signed
leaves), which Fedora/RHEL's default crypto policy rejects on OpenSSL
3.x. run() also swallowed stderr, so the failure surfaced as an opaque
non-zero-exit traceback with no diagnostic.

- run() now raises CommandError carrying the command and openssl stderr
- mint_cert retries signing with a scoped OPENSSL_CONF enabling
  rh-allow-sha1-signatures when the first attempt fails
- main() prints the update-crypto-policies fallback on failure

Fixes #15

* test(setup_cert): cover SHA-1 signing, error surfacing, and crypto-policy retry

Regression tests for the #15 fix:
- full mint_cert flow (SHA-1 leaf, UUID SAN, custom OIDs, chain assembly)
- CommandError surfaces openssl stderr on a genuine signing failure
- signing retries via the SHA-1 override when the plain attempt is blocked
- run() raises CommandError with detail
2026-08-01 11:47:09 +01:00
Quite Yellow e0622eb087 ci(publish): bump actions off deprecated Node 20 runtimes (#18)
GitHub is deprecating the Node 20 action runtime; checkout@v4,
setup-python@v5, and upload/download-artifact@v4 all run on it and
were being auto-forced to Node 24 with a warning. Bump each to its
current major (checkout@v7, setup-python@v7, upload-artifact@v7,
download-artifact@v8), all of which run natively on Node 24.
2026-08-01 11:28:14 +01:00
Quite Yellow b7f2f20f29 Merge pull request #17 from QuiteYellow/feat/dtls-clienthello-probe
Add a DTLS ClientHello probe as the liveness + diagnostic primitive
2026-08-01 11:17:24 +01:00
Jack Nagy 46041bfb2c docs(readme): anti-AI writing pass
Convert em-dash prose splices to varied punctuation (periods, colons,
semicolons, commas, parens), turn **Label.**-period bullets into
**Label:** colons, drop sentence-spanning bold in "Traps to avoid", and
cut a couple of hollow intensifiers.

No content, facts, tables, code, or links changed (50/50 line diff). Left
as-is: the `## Part N —` headings (heading-anchor stability), everything
inside code/log fences, table N/A cells, and numbered-list
`**Bold** — desc` carve-outs.
2026-08-01 11:10:58 +01:00
Jack Nagy 1a35cd59a1 feat(protocol): add DTLS ClientHello liveness probe + wire it into the bridge
A stateless-by-default DTLS ClientHello probe that classifies a host:port
as DEAD/LIVE/COMPLETED/REJECTED in ~1 RTT off the server's first flight,
sitting in front of the full handshake.

Probe (smartthings_local/protocol/dtls_probe.py):
- Stateless liveness mode (default): stops at HelloVerifyRequest and never
  sends the cookie'd second ClientHello, so by RFC 6347 §4.2.1 it leaves
  no association on the device — safe to run before a real connect.
- Diagnostic mode (stateless=False): drives the handshake further to
  capture cipher/cert-chain/CertificateRequest or a fatal Alert, for
  OCF-PKI-wall characterization (#16). Kept out of hot reconnect paths.
- Retransmit + retries: services OpenSSL's DTLS retransmit timer so a
  single dropped ClientHello no longer reads as a false DEAD.

MQTT bridge (mqtt_demo):
- Stateless pre-flight gate in session_once() rejects a silent/rebooting
  device or wrong port in ~3s (retries=1) instead of eating the 12s
  HANDSHAKE_TIMEOUT_S per reconnect.
- OCF-band port autodiscovery when OCF_PORT is unset: races the band in
  parallel and returns on the first port to answer LIVE (~1 RTT, abandoning
  the dead-port probes), cached across reconnects; the stateless gate
  leaves no orphan, preserving the fixed-source-port §4.2.8 invariant.

Validated on real hardware (dryer 49155 / oven 49154): parallel discovery
resolves both ports in <1s, connect with no orphan cooldown, and a wrong
pinned port rejected in ~3s.

Tests: probe behaviour (retransmit recovery, stateless single-flight
guard, silent-port flight budget, diagnostic continuation) and bridge
port-resolution (pinned gate, parallel discovery early-exit, cache).
2026-08-01 10:57:54 +01:00
17 changed files with 1860 additions and 74 deletions
+130
View File
@@ -0,0 +1,130 @@
name: Validate
on:
pull_request:
branches:
- main
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
concurrency:
group: validate-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
name: Python ${{ matrix.python-version }}
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
python-version:
- "3.11"
- "3.12"
- "3.13"
- "3.14"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade pip
- run: python -m pip install -e ".[dev]"
- run: python -m pytest -q
dependency-bounds:
name: Dependencies (${{ matrix.mode }})
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- mode: floor
python-version: "3.11"
- mode: latest
python-version: "3.14"
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install --upgrade pip
- if: matrix.mode == 'floor'
run: >-
python -m pip install
"cbor2==5.6.0"
"pyOpenSSL==23.1.0"
"pytest==8.0.0"
- if: matrix.mode == 'floor'
run: python -m pip install --no-deps -e .
- if: matrix.mode == 'latest'
run: python -m pip install -e ".[dev]"
- run: python -m pytest -q
package:
name: Package artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: python -m pip install --upgrade pip
- run: python -m pip install build hatchling hatch-vcs
- run: python -m build
- run: python tools/check_distribution.py dist
- name: Install and import wheel
run: |
python -m venv "$RUNNER_TEMP/wheel-smoke"
"$RUNNER_TEMP/wheel-smoke/bin/python" -m pip install \
dist/*.whl
cd "$RUNNER_TEMP"
"$RUNNER_TEMP/wheel-smoke/bin/python" -I -c \
"from smartthings_local.protocol.dtls_session import DtlsCoapSession"
- name: Install and import sdist
run: |
python -m venv "$RUNNER_TEMP/sdist-smoke"
"$RUNNER_TEMP/sdist-smoke/bin/python" -m pip install \
dist/*.tar.gz
cd "$RUNNER_TEMP"
"$RUNNER_TEMP/sdist-smoke/bin/python" -I -c \
"from smartthings_local.ocf.state_cache import StateCache"
share-safety:
name: Share safety
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- name: Select comparison base
id: comparison
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
if [ -n "$PR_BASE_SHA" ]; then
echo "sha=$PR_BASE_SHA" >> "$GITHUB_OUTPUT"
elif [ -n "$PUSH_BEFORE_SHA" ] && \
[ "$PUSH_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then
echo "sha=$PUSH_BEFORE_SHA" >> "$GITHUB_OUTPUT"
else
echo "sha=$(git rev-parse HEAD^)" >> "$GITHUB_OUTPUT"
fi
- run: >-
python tools/check_share_safety.py
--changed-since "${{ steps.comparison.outputs.sha }}"
+4 -4
View File
@@ -15,10 +15,10 @@ jobs:
name: Build sdist + wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
with:
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
- uses: actions/setup-python@v5
- uses: actions/setup-python@v7
with:
python-version: "3.12"
- run: python -m pip install --upgrade build
@@ -30,7 +30,7 @@ jobs:
if ! ls dist/ | grep -q "smartthings_local-${version}"; then
echo "Built artifacts do not match tag version ${version}"; exit 1
fi
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: dist
path: dist/
@@ -43,7 +43,7 @@ jobs:
permissions:
id-token: write # required for Trusted Publishing (OIDC)
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
name: dist
path: dist/
+70 -56
View File
@@ -1,13 +1,13 @@
# 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 — 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 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.
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 supervising multiple appliances, each on its own DTLS session.
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.
<img width="778" height="367" alt="image" src="https://github.com/user-attachments/assets/cc1dca15-f272-4625-a13c-2dc82283ff95" />
> **Just want to control your Samsung appliance from Home Assistant?**
> Use [localthings](https://github.com/mbillow/localthings) — a Home
> Use [localthings](https://github.com/mbillow/localthings), a Home
> Assistant custom component built on the `smartthings-local` package.
> This repo is the protocol research project, the library itself, and a
> self-contained MQTT bridge demo; new appliance support (capability
@@ -48,23 +48,23 @@ If the cert/key are minted at runtime and never written to disk (e.g. inside an
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
```
For a full worked integration, the higher-level `smartthings_local.ocf` layer — `StateCache`, `PollScheduler`, `KeepaliveTask`, `ObserveRefreshTask` — coordinates tiered polling and OBSERVE on top of a session. The MQTT bridge demo below wires all of it together.
For a full worked integration, the higher-level `smartthings_local.ocf` layer (`StateCache`, `PollScheduler`, `KeepaliveTask`, `ObserveRefreshTask`) coordinates tiered polling and OBSERVE on top of a session. The MQTT bridge demo below wires all of it together.
### What the demo bridge gives you
- **Multi-appliance, one container.** Single Docker service holds N DTLS sessions in parallel, one per appliance, sharing one MQTT client. Adding an appliance class is ~150 lines and one descriptor file.
- **Bounded state latency.** Hot-tier resources (job state, door, operational state) refresh on a sub-second cadence regardless of whether the appliance has internet. Worst-case lag is the tier interval (≤1s idle, ≤500ms during an active cycle on the dryer).
- **Writes that work**: dryer Start/Pause/Stop, course selection, wrinkle prevent; oven lamp (light entity), sound, fast preheat, setpoint slider, mode select, stop.
- **Optimistic publish + verify**: HA sees the new value the instant the device 2.04-confirms the write; the PollScheduler verifies on its next tier tick (after a 4s defer past Samsung's fetchback-revert window).
- **Multi-appliance, one container:** single Docker service holds N DTLS sessions in parallel, one per appliance, sharing one MQTT client. Adding an appliance class is ~150 lines and one descriptor file.
- **Bounded state latency:** hot-tier resources (job state, door, operational state) refresh on a sub-second cadence regardless of whether the appliance has internet. Worst-case lag is the tier interval (≤1s idle, ≤500ms during an active cycle on the dryer).
- **Writes that work:** dryer Start/Pause/Stop, course selection, wrinkle prevent; oven lamp (light entity), sound, fast preheat, setpoint slider, mode select, stop.
- **Optimistic publish + verify:** HA sees the new value the instant the device 2.04-confirms the write; the PollScheduler verifies on its next tier tick (after a 4s defer past Samsung's fetchback-revert window).
- **HA Energy Dashboard ready** (dryer): live watts + cumulative kWh as `total_increasing`.
- **Bridge logs tagged per-appliance** with `<class>.<serial>` once each device's serial is read on connect — `dryer.<serial>` vs `oven.<serial>` interleaved in the same log stream, easy to grep.
- **Zero HA YAML.** Every entity is auto-discovered via MQTT discovery.
- **Your state stays on your LAN.** Bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung — appliance design, not ours.)*
- **A few controls the cloud HA integration doesn't offer.** Talking to the appliance directly happens to surface some writes the official SmartThings integration doesn't currently expose for these models — for example dryer course selection ([HA core #162501](https://github.com/home-assistant/core/issues/162501)) and the oven temperature setpoint (where the cloud integration provides a read-only sensor). It's not a strict superset — the cloud integration still covers surfaces this doesn't — but the reverse-engineered write set has genuine reach.
- **Bridge logs tagged per-appliance** with `<class>.<serial>` once each device's serial is read on connect: `dryer.<serial>` and `oven.<serial>` interleave in the same log stream, easy to grep.
- **Zero HA YAML:** every entity is auto-discovered via MQTT discovery.
- **Your state stays on your LAN:** bridge → broker → HA. Samsung's cloud sees nothing from HA. *(The appliance still maintains its own TLS session to Samsung. That's the appliance's design, not ours.)*
- **A few controls the cloud HA integration doesn't offer.** Talking to the appliance directly surfaces some writes the official SmartThings integration doesn't currently expose for these models: dryer course selection ([HA core #162501](https://github.com/home-assistant/core/issues/162501)) and the oven temperature setpoint (where the cloud integration provides a read-only sensor). It's not a strict superset (the cloud integration still covers surfaces this doesn't), but the reverse-engineered write set is broad.
### Under the hood
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 + 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.
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.
@@ -84,24 +84,33 @@ 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.
- **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
```
`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.
### Tested combinations
| Appliance class | Model family | Confirmed |
|---|---|---|
| Washer | WW11DG (`DA_WM_TP2_20_COMMON`) | All entities. Contributed by [@indykoning](https://github.com/indykoning) (PR #13); tested via [`mbillow/localthings`](https://github.com/mbillow/localthings) |
| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`); DV90T reported same family | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) |
| Washer | WW11DG (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`) | All entities. Contributed by [@indykoning](https://github.com/indykoning) (PR #13); tested via [`mbillow/localthings`](https://github.com/mbillow/localthings) |
| Dryer | DV5000T (`DA_WM_TP2_20_COMMON`, `mnid=0AJT`); DV90T (same `mnid=0AJT`) | All entities, ≤1s hot-tier poll (OBSERVE accelerates when online) |
| Oven | NV7000BS-class (`TP1X_DA-KS-OVEN-0107X`, `mnid=0AJT`) | All entities; hot-tier poll covers door + operational state regardless of cloud reachability |
| Fridge | ARTIK051_REF_17K (`DA-REF-ART-COMMON-1_20201124`) | Contributed by [@aminorjourney](https://github.com/aminorjourney) (PR #1). Older firmware family; port 49155, minimal `/oic/res` with full tree under `/device/0` |
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.
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.
### Firmware families — a limitation
### 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.
**What this means in practice:** if you set `APPLIANCE_<n>_CLASS=fridge` on a fridge that speaks a different firmware family than the one this descriptor was built for, the bridge will start and connect fine, but many sensors will publish as unknown and some controls won't work. Nothing catastrophic — you just get a half-broken HA device card.
**What this means in practice:** if you set `APPLIANCE_<n>_CLASS=fridge` on a fridge that speaks a different firmware family than the one this descriptor was built for, the bridge will start and connect fine, but many sensors will publish as unknown and some controls won't work. Nothing catastrophic. You just get a half-broken HA device card.
If your appliance model doesn't match a row in the tested table above, it may still work if it's on the same firmware family; otherwise you'd write a new descriptor (see "Adding a new appliance class" below). The ARTIK051 fridge and the newer RF9000B-class fridge, for example, expose genuinely different resource models (collection-resource vs per-instance-resource) and can't share a descriptor even though they're both "fridges".
If your appliance model doesn't match a row in the tested table above, it may still work if it's on the same firmware family; otherwise you'd write a new descriptor (see "Adding a new appliance class" below). The ARTIK051 fridge and the newer RF9000B-class fridge, for example, expose different resource models (collection-resource vs per-instance-resource) and can't share a descriptor even though they're both "fridges".
---
@@ -109,12 +118,12 @@ If your appliance model doesn't match a row in the tested table above, it may st
There are two parallel paths between the appliance and the app over the local CoAP-DTLS socket:
- **Push (OBSERVE).** When the appliance can reach Samsung's cloud, it emits a CoAP OBSERVE notification on the LAN socket within ~100ms of any state change — cycle start, door open, mode flip. The notification travels over the LAN; nothing about the push itself routes via Samsung. **But** the appliance's decision to emit it at all is gated inside its cloud-publish thread. Block the appliance from the internet and the LAN OBSERVE pushes stop, even though the LAN path itself is unaffected and the appliance still answers reads + accepts writes normally.
- **Push (OBSERVE).** When the appliance can reach Samsung's cloud, it emits a CoAP OBSERVE notification on the LAN socket within ~100ms of any state change: cycle start, door open, mode flip. The notification travels over the LAN; nothing about the push itself routes via Samsung. **But** the appliance's decision to emit it at all is gated inside its cloud-publish thread. Block the appliance from the internet and the LAN OBSERVE pushes stop, even though the LAN path itself is unaffected and the appliance still answers reads + accepts writes normally.
- **Polling.** The app always polls a small tier of hot resources (operational state, door, etc.) on a sub-second cadence, a warmer tier (mode, kidslock, alarms, …) every 15–30 s, and a full `/device/0` sweep every 5 minutes. This carries the UX regardless of whether OBSERVE is firing.
In normal operation both happen at once: an OBSERVE notification arrives first, the cache absorbs it, and the next-poll timer for that resource is reset. In an air-gapped LAN the app keeps working — only the worst-case freshness changes (from ~100 ms with push to ≤1 s on hot-tier resources via polling). Reads, writes, and HA entities behave identically.
In normal operation both happen at once: an OBSERVE notification arrives first, the cache absorbs it, and the next-poll timer for that resource is reset. In an air-gapped LAN the app keeps working. Only the worst-case freshness changes (from ~100 ms with push to ≤1 s on hot-tier resources via polling). Reads, writes, and HA entities behave identically.
Which path is doing the work is visible in Home Assistant. The bridge publishes per-appliance diagnostic entities including **Push Active** (on while OBSERVE is firing), **Last Update Source** (`observe` / `poll` / `sweep` / `optimistic`), **Last OBSERVE Age**, **Poll Max RTT**, **Slow Polls (window)**, **Poll Errors (window)**, and **Stalest Resource Age** — all under each device's Diagnostic section.
Which path is doing the work is visible in Home Assistant. The bridge publishes per-appliance diagnostic entities including **Push Active** (on while OBSERVE is firing), **Last Update Source** (`observe` / `poll` / `sweep` / `optimistic`), **Last OBSERVE Age**, **Poll Max RTT**, **Slow Polls (window)**, **Poll Errors (window)**, and **Stalest Resource Age**, all under each device's Diagnostic section.
---
@@ -133,13 +142,13 @@ openssl s_client -connect <samsung-host>:443 -servername <samsung-host> \
The UUID lives in `OU=uuid:<UUID>`. The server cert is currently valid through **2035-04-09**.
This README doesn't pin the literal UUID — the setup script extracts it live each run, so it self-updates if upstream rotates.
This README doesn't pin the literal UUID: the setup script extracts it live each run, so it self-updates if upstream rotates.
### 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=*`.
- TizenRT iotivity derives peerId from `memmem(subject_dn, "uuid:")` — 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.
- 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.
### One-command setup
@@ -156,17 +165,19 @@ What it does:
4. Generates a fresh RSA-2048 key pair you own.
5. Builds a CSR with the UUID in OU + CN + SAN and signs it with `AC14K_M` (SHA-1, matching the on-device trust hierarchy).
6. Concatenates `leaf + AC14K_M + 3 upstream CAs` into the fullchain PEM.
7. With `--test`: opens a DTLS handshake against `$TARGET_IP:$TARGET_PORT` (default `49154`) and GETs `/oic/sec/acl` — a `2.05` reply proves the cert authenticated (anonymous peers get `4.01`).
7. With `--test`: opens a DTLS handshake against `$TARGET_IP:$TARGET_PORT` (default `49154`) and GETs `/oic/sec/acl`; a `2.05` reply proves the cert authenticated (anonymous peers get `4.01`).
Output in `./certs/`: `client_fullchain.pem` + `client.key`.
Neither the UUID nor the AC14K_M bundle is hardcoded in this repo — both are fetched live each run, so the script self-updates if upstream rotates. If either fetch fails, the script prints an inline workaround: supply the UUID via `UUID=<uuid>` env, or supply the AC14K_M bundle via `AC14K_M_CERT_BUNDLE=/path/to/cert.pem`. `BRAYSTORM_URL=<mirror>` points at a different bundle source.
Neither the UUID nor the AC14K_M bundle is hardcoded in this repo; both are fetched live each run, so the script self-updates if upstream rotates. If either fetch fails, the script prints an inline workaround: supply the UUID via `UUID=<uuid>` env, or supply the AC14K_M bundle via `AC14K_M_CERT_BUNDLE=/path/to/cert.pem`. `BRAYSTORM_URL=<mirror>` points at a different bundle source.
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?
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 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.
> **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.
> **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.
---
@@ -189,19 +200,19 @@ APPLIANCE_COUNT=2
# Appliance 1 — dryer
APPLIANCE_1_CLASS=dryer
APPLIANCE_1_IP=192.168.1.100
APPLIANCE_1_OCF_PORT= # blank → descriptor default (49155 for dryer)
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_OCF_PORT= # blank → descriptor default (49154 for oven)
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 descriptor key in `mqtt_demo/samples/__init__.py::DESCRIPTORS`: currently `dryer`, `oven`, and `fridge`.
---
@@ -214,7 +225,7 @@ docker compose up -d --build
docker compose logs -f
```
Container name `smartthings-local`. Outbound-only — no ports exposed. Needs egress to each appliance's IP/port (UDP) and to your MQTT broker. The certs in `./certs/` (or whatever `APPDATA_DIR` points to via the volume mount) are read-only mounted at `/config`.
Container name `smartthings-local`. Outbound-only; no ports exposed. Needs egress to each appliance's IP/port (UDP) and to your MQTT broker. The certs in `./certs/` (or whatever `APPDATA_DIR` points to via the volume mount) are read-only mounted at `/config`.
### Deploying to a remote Linux host (Unraid, etc.)
@@ -242,9 +253,11 @@ python3 -m venv .venv
```
14:08:42 INFO mqtt_demo SmartThings-Local Bridge starting (2 appliances)
14:08:42 INFO mqtt_demo broker = <broker-ip>:1883 (user=<mqtt-user>)
14:08:42 INFO mqtt_demo [1] dryer @ <dryer-ip>:49155 (DTLS) → topic samsung_dryer/*
14:08:42 INFO mqtt_demo [2] oven @ <oven-ip>:49154 (DTLS) → topic samsung_oven/*
14:08:42 INFO mqtt_demo [1] dryer @ <dryer-ip>:49155? (DTLS, auto-discover) → topic samsung_dryer/*
14:08:42 INFO mqtt_demo [2] oven @ <oven-ip>:49154? (DTLS, auto-discover) → topic samsung_oven/*
14:08:42 INFO mqtt_demo MQTT connected → <broker-ip>:1883
14:08:43 INFO dryer discovered DTLS port 49155
14:08:43 INFO oven discovered DTLS port 49154
14:08:43 INFO dryer DTLS connected — subscribing 11 paths
14:08:44 INFO dryer.<dryer-serial> identified — serial=…
14:08:44 INFO dryer.<dryer-serial> seeded → 25 links; sensors live
@@ -267,8 +280,8 @@ In HA: **Settings → Devices & Services → MQTT** should show both devices pop
| Wrinkle Prevent toggle | ✅ | Persists |
| Start / Pause / Stop | ✅ | Via `/operational/state/vs/0`; needs Remote Control on |
| Change course | ✅ | Via `/st/dryercourse/vs/0`; needs Remote Control on. **Not exposed by the SmartThings cloud HA integration.** |
| Power on/off | ❌ | Accepted (2.04) but reverts within seconds — hardware-mirrored |
| Child Lock / Remote Control toggle | ❌ | Same — hardware-mirrored physical buttons |
| Power on/off | ❌ | Accepted (2.04) but reverts within seconds; hardware-mirrored |
| Child Lock / Remote Control toggle | ❌ | Same; hardware-mirrored physical buttons |
The dryer's `/operational/state/vs/0` is on the bridge's hot poll tier (1s idle / 0.5s while a cycle is active) and also accepts OBSERVE registration. When the appliance has internet it pushes notifications within ~100ms of any state change and the cache absorbs them as fast freshness; when air-gapped the hot-tier poll carries the same UX with worst-case lag of one tier interval.
@@ -277,12 +290,12 @@ The dryer's `/operational/state/vs/0` is on the bridge's hot poll tier (1s idle
| Capability | Works? | Notes |
|---|---|---|
| Read state | ✅ | Cavity state, current/target temp, door, mode, alarms, firmware-update-available |
| Lamp (light entity) | ✅ | Binary On/Off only — High/Low/Dim values are accepted (2.04) but silently coerced back. Works regardless of Remote Control. |
| Lamp (light entity) | ✅ | Binary On/Off only; High/Low/Dim values are accepted (2.04) but silently coerced back. Works regardless of Remote Control. |
| Sound, Fast preheat | ⚠️ | Wired but untested RC-gated. |
| Setpoint slider | ⚠️ | Wired but untested RC-gated. |
| Mode select | ⚠️ | Wired but untested RC-gated. |
| Stop button | ✅ | |
| **Kitchen timer (`⏲` icon)** | ❌ | **The oven's panel kitchen timer is not exposed via CoAP at all.** Confirmed by full `/device/0` dump — `UpperTimer*` fields in `/mode/vs/0` only populate when set via the API, not from the panel. |
| **Kitchen timer (`⏲` icon)** | ❌ | **The oven's panel kitchen timer is not exposed via CoAP at all.** Confirmed by full `/device/0` dump: `UpperTimer*` fields in `/mode/vs/0` only populate when set via the API, not from the panel. |
**The oven doesn't push OBSERVE on `/mode/vs/0` writes** (the dryer does). The bridge handles this transparently because state freshness comes from polling rather than from OBSERVE:
1. **Optimistic publish** — the moment a POST returns 2.04, the bridge merges the write body into the cache and publishes to MQTT. HA reflects the new value instantly.
@@ -306,8 +319,8 @@ Contributed by [@aminorjourney](https://github.com/aminorjourney) in PR #1, veri
Notes specific to this firmware family:
- **Port 49155**, not the 49154 the oven defaults to.
- `/oic/res` only advertises 15 paths — the full resource tree lives at `/device/0` (32 links). The bridge's periodic `/device/0` sweep handles this transparently; no descriptor change needed.
- `/hass/state/vs/0` and `/hass/command/vs/0` return `4.04` — they're vestigial paths from an earlier firmware and are ignored.
- `/oic/res` only advertises 15 paths; the full resource tree lives at `/device/0` (32 links). The bridge's periodic `/device/0` sweep handles this transparently; no descriptor change needed.
- `/hass/state/vs/0` and `/hass/command/vs/0` return `4.04`. They're vestigial paths from an earlier firmware and are ignored.
- Doors are exposed as a Samsung-plural collection resource (`/doors/vs/0` with an `items[]` array keyed by `x.com.samsung.da.description`), not as per-room OCF resources like the newer RF9000B-class fridges use. This is one of the concrete divergences behind the "Firmware families" caveat in Part 1.
---
@@ -321,8 +334,8 @@ 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 override (blank → descriptor default: 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>_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>_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 |
| `HA_DISCOVERY_PREFIX` | HA discovery topic root (default `homeassistant`) |
@@ -340,20 +353,20 @@ Per appliance, where `<prefix>` is its `APPLIANCE_<n>_TOPIC`.
| `<prefix>/availability` | ✓ | `online` after seed; `offline` on disconnect (LWT for appliance #1) |
| `<prefix>/remote_available` | ✓ | `online` iff bridge is up AND Remote Control on the appliance is on. Gates the control entities. |
| `<prefix>/state` | ✓ | JSON sensor dict; published only when sensors actually diff |
| `<prefix>/bridge/health` | ✓ | Every `HEALTH_INTERVAL_S` — connect_count, error_count, notif_count, poll_count, poll_error_count, ping_count, ping_fail_count, reachable, last_change_age_s, last_seed_age_s, session_age_s, stalest_href, stalest_age_s, serial |
| `<prefix>/bridge/health` | ✓ | Every `HEALTH_INTERVAL_S`: connect_count, error_count, notif_count, poll_count, poll_error_count, ping_count, ping_fail_count, reachable, last_change_age_s, last_seed_age_s, session_age_s, stalest_href, stalest_age_s, serial |
| `<ha_prefix>/{sensor,binary_sensor,switch,light,number,select,button}/<prefix>/.../config` | ✓ | HA MQTT discovery, republished on every MQTT (re)connect |
### MQTT topics — incoming (bridge subscribes)
`<prefix>/cmd/#`. **The MQTT user must have READ permission on this subtree** — without it the broker silently drops the TCP connection shortly after SUBSCRIBE. Check broker logs if writes never land.
`<prefix>/cmd/#`. **The MQTT user must have READ permission on this subtree.** Without it the broker silently drops the TCP connection shortly after SUBSCRIBE. Check broker logs if writes never land.
Dryer:
| Suffix | Payloads | Effect |
|---|---|---|
| `cmd/wrinkle_prevent` | `On`, `Off` | POST `/washer/vs/0` |
| `cmd/operational_state` | `Run`, `Pause`, `Ready` | POST `/operational/state/vs/0` — requires RC |
| `cmd/dryer_mode` | Course name (e.g. `Cotton`) | Translated to `Course_HH` then POST `/st/dryercourse/vs/0` — requires RC |
| `cmd/operational_state` | `Run`, `Pause`, `Ready` | POST `/operational/state/vs/0`; requires RC |
| `cmd/dryer_mode` | Course name (e.g. `Cotton`) | Translated to `Course_HH` then POST `/st/dryercourse/vs/0`; requires RC |
Oven:
@@ -362,8 +375,8 @@ Oven:
| `cmd/lamp` | `On`, `Off` | RMW of `/mode/vs/0 .options[UpperLamp_*]` |
| `cmd/sound` | `On`, `Off` | RMW of `/mode/vs/0 .options[Sound_*]` |
| `cmd/fastpreheat` | `On`, `Off` | RMW of `/mode/vs/0 .options[fastpreheat_*]` |
| `cmd/setpoint` | Integer °C (30–270, step 5) | RMW of `/temperatures/vs/0 .items[0].desired` — requires RC |
| `cmd/mode` | Mode name (e.g. `Convection`, `LargeGrill`) | POST `/mode/vs/0 {modes: [<name>]}` — requires RC |
| `cmd/setpoint` | Integer °C (30–270, step 5) | RMW of `/temperatures/vs/0 .items[0].desired`; requires RC |
| `cmd/mode` | Mode name (e.g. `Convection`, `LargeGrill`) | POST `/mode/vs/0 {modes: [<name>]}`; requires RC |
| `cmd/stop` | (button press) | POST `/operational/state/vs/0 {state: Ready}` |
### Entity counts (approximate, per appliance)
@@ -389,6 +402,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)
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
ocf/ OCF resource + state layer (reusable)
__init__.py
@@ -415,7 +429,7 @@ mqtt_demo/ MQTT bridge demo (consumes smartthings_loca
.env.example Template — copy to .env, fill in
setup_cert.py One-shot cert minting script (live-fetches AC14K_M + UUID)
pyproject.toml Packaging — PyPI dist `smartthings-local`, hatch-vcs versioning
tests/ pytest suite (CoAP wire, state cache, import isolation, cert loading)
tests/ pytest suite (CoAP wire, state cache, import isolation, cert loading, DTLS probe, bridge port resolution, cert signing)
.github/workflows/publish.yml Build + PyPI Trusted Publishing on `v*` tags
```
@@ -426,7 +440,7 @@ tests/ pytest suite (CoAP wire, state cache, impor
## Adding appliance support
The three descriptors in `mqtt_demo/samples/` (dryer, oven, fridge) are
frozen reference implementations — enough to exercise both the newer
frozen reference implementations: enough to exercise both the newer
Tizen RT 3.x family and the older ARTIK051 family, proving the
`smartthings_local` library layers generalize across firmware generations.
They are not updated for new appliance models.
@@ -443,15 +457,15 @@ These each looked like obvious improvements at some point. Each one broke someth
- **Don't add OBSERVE subscriptions on OCF-standard `/<x>/0` paths.** They register successfully but never push. Use the Samsung `/<x>/vs/0` siblings (which do).
- **Don't assume OBSERVE silence means the appliance is broken.** When the appliance can't reach Samsung's cloud, its OBSERVE notify dispatch goes quiet even though the local DTLS session, GETs, POSTs, and the cache continue to work normally (measured at `~14 req/s` dryer / `~8 req/s` oven with 200/200 GETs successful while firewalled). The polling tiers are the structural answer to this; treat OBSERVE strictly as an optional accelerator.
- **Don't touch `/oic/sec/*` (doxm, pstat, cred, acl).** The bridge doesn't, and you shouldn't from helper scripts either — those resources have wedge/brick risk on Samsung's RT-OCF security stack. The bridge surfaces are strictly `/<x>/vs/0` and `/device/0`.
- **Don't touch `/oic/sec/*` (doxm, pstat, cred, acl).** The bridge doesn't, and you shouldn't from helper scripts either. Those resources have wedge/brick risk on Samsung's RT-OCF security stack. The bridge surfaces are strictly `/<x>/vs/0` and `/device/0`.
- **Don't run two clients against the same appliance simultaneously.** Samsung's RT-OCF DTLS allows one active session per peer; a second handshake will get the device to drop the new socket. If HA seems to flap, check whether you've got `python -m mqtt_demo` running locally AND the Docker container up.
- **Expect gaps in write coverage, but few are hard limits.** The local DTLS surface appears to expose every write Samsung's own app uses — the ceiling is per-surface reverse-engineering (finding the resource, field, and encoding), not an API boundary. A control that isn't wired yet usually just hasn't been mapped. **Oven cavity remote-start is the marquee open example:** it works today through Samsung's cloud, and locally the write is accepted (`2.04`) but the cavity never engages — a reverse-engineering problem we haven't cracked yet, not a dead end. The genuine hard limits are the few surfaces Samsung gates in hardware/firmware — **power, child lock, remote-control enable** — which accept the write then snap back to the physical switch. **That mirrors Samsung's own behaviour, not a shortfall of the local path: the SmartThings app can't flip those remotely either** (Remote Control is a button you press on the appliance). The optimistic-publish-then-verify pattern absorbs the reverts transparently: HA briefly shows the new value, then the PollScheduler's next tier poll — deferred ~4s past Samsung's revert window — re-reads and republishes the actual state. (The bridge deliberately does **not** fetch-back right after a write; that GET is itself what triggers the revert.)
- **Expect gaps in write coverage, but few are hard limits.** The local DTLS surface appears to expose every write Samsung's own app uses; the ceiling is per-surface reverse-engineering (finding the resource, field, and encoding), not an API boundary. A control that isn't wired yet usually just hasn't been mapped. Oven cavity remote-start is the marquee open example: it works today through Samsung's cloud, and locally the write is accepted (`2.04`) but the cavity never engages. That's a reverse-engineering problem we haven't cracked yet, not a dead end. The hard limits are the few surfaces Samsung gates in hardware/firmware (power, child lock, remote-control enable), which accept the write then snap back to the physical switch. That mirrors Samsung's own behaviour, not a shortfall of the local path: the SmartThings app can't flip those remotely either (Remote Control is a button you press on the appliance). The optimistic-publish-then-verify pattern absorbs the reverts transparently: HA briefly shows the new value, then the PollScheduler's next tier poll (deferred ~4s past Samsung's revert window) re-reads and republishes the actual state. (The bridge deliberately does **not** fetch-back right after a write; that GET is itself what triggers the revert.)
---
## Known DTLS flakiness
Samsung's RT-OCF DTLS stack occasionally closes sessions actively — usually right after a Block2 GET or in the seconds after a POST. The bridge handles this with exponential reconnect (1s → 30s) and a re-seed on each new session. From HA's perspective the entity briefly goes offline then comes back; from the bridge's perspective you'll see lines like:
Samsung's RT-OCF DTLS stack occasionally closes sessions actively, usually right after a Block2 GET or in the seconds after a POST. The bridge handles this with exponential reconnect (1s → 30s) and a re-seed on each new session. From HA's perspective the entity briefly goes offline then comes back; from the bridge's perspective you'll see lines like:
```
oven.… DTLS recv: Unexpected EOF
@@ -460,10 +474,10 @@ oven.… DTLS connected — subscribing 11 paths
oven.… seeded → 16 links; sensors live
```
If reconnects become persistent (e.g. >10 in a minute) something's actually wrong — check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN.
If reconnects become persistent (e.g. >10 in a minute) something's wrong: check the appliance's Wi-Fi link first, then look for a competing DTLS client on the LAN.
---
## Contributing
If you submit a PR, please don't include real device UUIDs, MACs, serials, IPs, or bearer tokens — use the placeholders from `.env.example`.
If you submit a PR, please don't include real device UUIDs, MACs, serials, IPs, or bearer tokens. Use the placeholders from `.env.example`.
+3 -2
View File
@@ -11,8 +11,9 @@ APPLIANCE_COUNT=1
# Appliance 1 — Samsung dryer
APPLIANCE_1_CLASS=dryer
APPLIANCE_1_IP=192.168.1.100
# Leave OCF_PORT blank to inherit the descriptor's default
# (dryer=49155, oven=49154).
# OCF_PORT is optional. Leave it blank to auto-discover the live DTLS
# port each connect (a stateless ClientHello races the OCF band
# 49153-49156); set it to pin a specific port and skip discovery.
APPLIANCE_1_OCF_PORT=
APPLIANCE_1_TOPIC=samsung_dryer
APPLIANCE_1_NAME=Samsung Dryer
+6 -3
View File
@@ -63,9 +63,12 @@ def main():
shared.MQTT_BROKER, shared.MQTT_PORT,
shared.MQTT_USER or '<anon>')
for app, desc in pairs:
port = app.ocf_port or desc.default_observe_port
logger.info(" [%d] %s @ %s:%d (DTLS) → topic %s/*",
app.index, app.klass, app.ip, port, app.topic_prefix)
if app.ocf_port is not None:
port_note = f"{app.ocf_port} (DTLS)"
else:
port_note = f"{desc.default_observe_port}? (DTLS, auto-discover)"
logger.info(" [%d] %s @ %s:%s → topic %s/*",
app.index, app.klass, app.ip, port_note, app.topic_prefix)
# --- MQTT client (shared) ---
cli = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
+93 -1
View File
@@ -24,6 +24,7 @@ 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
@@ -69,6 +70,21 @@ 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)
# 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),
# 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
# fixed-source-port reconnect invariant is untouched (see session_once).
_GATE_RETRIES = 1
_GATE_TIMEOUT_S = 4.0
class PushBridge:
@@ -85,7 +101,11 @@ class PushBridge:
self.log = bridge_logger(app.klass)
self._serial: str | None = None
# Best-effort port for the startup log; the real port is resolved
# per-connect by _resolve_port (a pinned OCF_PORT is used as-is, an
# unset one is auto-discovered and cached in _discovered_port).
self.port = app.ocf_port or descriptor.default_observe_port
self._discovered_port: int | None = None
self.session: DtlsCoapSession | None = None
self.scheduler: PollScheduler | None = None
@@ -242,15 +262,87 @@ 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})
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 _resolve_port(self) -> int:
"""Return a port that just answered a stateless DTLS ClientHello,
or raise ConnectionError so run_forever backs off — instead of
committing a 12 s handshake against a silent/rebooting device or a
wrong port. The probe is stateless (RFC 6347 §4.2.1: the device
allocates nothing for a first ClientHello), so it leaves no
orphaned association to collide with the fixed-source-port
reconnect.
A pinned OCF_PORT is gated but never overridden. An unset port is
auto-discovered across the band and cached; the cache is tried
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)
if not r.is_dtls_server:
raise ConnectionError(
f"port {pinned} not a live DTLS server ({r.outcome})")
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
# 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)
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
def session_once(self):
port = self._resolve_port()
sess = DtlsCoapSession(
self.app.ip, self.port,
self.app.ip, port,
cert_path=self.shared.CERT_PATH,
key_path=self.shared.KEY_PATH,
on_notification=self._on_notification,
local_port=DTLS_LOCAL_PORT_BASE + self.app.index,
)
sess.connect()
self.port = port
self.session = sess
self.session_started_ts = time.time()
self.connect_count += 1
+1 -1
View File
@@ -21,7 +21,7 @@ classifiers = [
]
dependencies = [
"cbor2>=5.6",
"pyOpenSSL>=23.0",
"pyOpenSSL>=23.1",
]
[project.urls]
+81 -7
View File
@@ -184,8 +184,57 @@ def verify_cert_key_pair(cert_path, key_path):
f"AC14K_M cert and key do not pair (cert modulus != key modulus)")
# OpenSSL config that force-enables SHA-1 signatures. Fedora/RHEL (and some
# other hardened OpenSSL 3.x builds) reject SHA-1 signing under the default
# crypto policy, but the AC14K_M trust chain requires a SHA-1-signed leaf, so
# we re-enable it just for the signing step via a scoped OPENSSL_CONF.
SHA1_OVERRIDE_CONF = """\
openssl_conf = openssl_init
[openssl_init]
alg_section = evp_properties
[evp_properties]
rh-allow-sha1-signatures = yes
"""
class CommandError(RuntimeError):
"""A subprocess exited non-zero; carries the command and its output."""
def run(cmd, **kw):
return subprocess.run(cmd, check=True, capture_output=True, text=True, **kw)
proc = subprocess.run(cmd, capture_output=True, text=True, **kw)
if proc.returncode != 0:
detail = (proc.stderr or proc.stdout or '').strip()
raise CommandError(
f"command failed (exit {proc.returncode}): {' '.join(cmd)}"
+ (f"\n{detail}" if detail else ""))
return proc
def run_allow_sha1(cmd):
"""Run an openssl command with SHA-1 signatures force-enabled, for
distros whose crypto policy otherwise blocks SHA-1 signing."""
version = run(['openssl', 'version']).stdout.strip()
if not version.startswith('OpenSSL 3.'):
# The provider configuration below is specific to OpenSSL 3.
# LibreSSL can exit successfully without running the requested
# command when it is given that configuration, leaving no output
# certificate behind. Older OpenSSL releases do not need the
# provider override either, so retry them with a clean environment.
env = dict(os.environ)
env.pop('OPENSSL_CONF', None)
return run(cmd, env=env)
conf = tempfile.NamedTemporaryFile(
'w', suffix='.cnf', prefix='sha1_ok_', delete=False)
conf.write(SHA1_OVERRIDE_CONF)
conf.close()
try:
return run(cmd, env=dict(os.environ, OPENSSL_CONF=conf.name))
finally:
os.unlink(conf.name)
def mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir):
@@ -229,11 +278,23 @@ DNS.1 = {uuid}
run(['openssl', 'req', '-new', '-key', str(paths['key']),
'-out', str(paths['csr']), '-subj', subject])
run(['openssl', 'x509', '-req', '-in', str(paths['csr']),
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
'-CAcreateserial', '-CAserial', str(paths['srl']),
'-out', str(paths['leaf']), '-days', '3650',
'-extfile', str(paths['ext']), '-sha1'])
sign_cmd = ['openssl', 'x509', '-req', '-in', str(paths['csr']),
'-CA', str(ac14k_cert), '-CAkey', str(ac14k_key),
'-CAcreateserial', '-CAserial', str(paths['srl']),
'-out', str(paths['leaf']), '-days', '3650',
'-extfile', str(paths['ext']), '-sha1']
try:
run(sign_cmd)
except CommandError as first:
# Most likely the local crypto policy blocks SHA-1 signing
# (common on Fedora/RHEL). Retry once with SHA-1 force-enabled;
# if that still fails, surface the original error.
print(" SHA-1 signing was rejected by the local OpenSSL policy; "
"retrying with a SHA-1 override...")
try:
run_allow_sha1(sign_cmd)
except CommandError:
raise first
parts = [paths['leaf'].read_text()]
for p in chain_files:
@@ -459,7 +520,20 @@ def main():
print("=" * 60)
print(f"Phase 3: mint client cert with UUID {uuid}")
print("=" * 60)
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
try:
paths = mint_cert(uuid, ac14k_cert, ac14k_key, chain_files, out_dir)
except CommandError as e:
print(f"\n[!] Failed to mint the client cert:\n{e}", file=sys.stderr)
print(
"\n If the failure mentions SHA-1 / disabled digests, your "
"OpenSSL build blocks SHA-1 signing (common on Fedora/RHEL).\n"
" The AC14K_M chain requires SHA-1, so allow it and re-run:\n"
" sudo update-crypto-policies --set DEFAULT:SHA1\n"
" (or LEGACY). Undo afterwards with: "
"sudo update-crypto-policies --set DEFAULT",
file=sys.stderr)
return 4
print(f" key: {paths['key']}")
print(f" leaf: {paths['leaf']}")
print(f" fullchain: {paths['fullchain']}")
+326
View File
@@ -0,0 +1,326 @@
"""DTLS ClientHello probe — a cheap, deterministic liveness + diagnostic
primitive that sits in front of a full handshake.
Two problems this solves:
1. Liveness. A 1-byte UDP probe cannot tell a silent port from a real
DTLS server: anything that doesn't return ICMP-unreachable looks
"live", so a discovery loop pays the full HANDSHAKE_TIMEOUT_S
(12 s) on every false-positive port. A real DTLS server, by
contrast, answers a ClientHello with a HelloVerifyRequest (RFC 6347
§4.2.1 stateless cookie exchange) in ~1 RTT, *before* any
certificate work. So one ClientHello round-trip distinguishes the
real port from dead ones deterministically and cheaply — then the
expensive cert handshake is committed to exactly one port.
2. Diagnosis. The full handshake collapses "no DTLS server here",
"server up but rejected my cert", and "server up but no shared
cipher/version" into one opaque timeout/error. Everything the
server volunteers about itself — chosen cipher, its cert chain, its
CertificateRequest, or a fatal Alert — arrives in its first flight,
*before* we send our own certificate. Driving the handshake only
that far (no client cert required) characterizes a device. This is
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).
"""
import socket
import time
from OpenSSL import SSL
from .coap import split_dtls
from .dtls_session import _OCF_ROOT_CA, _load_pem_chain
# DTLS record content types (RFC 6347 §4.1)
_CT_CHANGE_CIPHER_SPEC = 20
_CT_ALERT = 21
_CT_HANDSHAKE = 22
_CT_APP_DATA = 23
# Handshake message types (RFC 5246 §7.4 / RFC 6347)
_HS_NAMES = {
0: 'HelloRequest',
1: 'ClientHello',
2: 'ServerHello',
3: 'HelloVerifyRequest',
11: 'Certificate',
12: 'ServerKeyExchange',
13: 'CertificateRequest',
14: 'ServerHelloDone',
15: 'CertificateVerify',
16: 'ClientKeyExchange',
20: 'Finished',
}
# TLS alert descriptions (RFC 5246 §7.2) — the ones a picky OCF stack
# actually sends are called out; the rest are here so a probe never
# reports a bare number.
_ALERT_NAMES = {
0: 'close_notify',
10: 'unexpected_message',
20: 'bad_record_mac',
40: 'handshake_failure',
42: 'bad_certificate',
43: 'unsupported_certificate',
44: 'certificate_revoked',
45: 'certificate_expired',
46: 'certificate_unknown',
47: 'illegal_parameter',
48: 'unknown_ca',
49: 'access_denied',
50: 'decode_error',
51: 'decrypt_error',
70: 'protocol_version',
71: 'insufficient_security',
80: 'internal_error',
86: 'inappropriate_fallback',
90: 'user_canceled',
112: 'unrecognized_name',
116: 'certificate_required',
}
# Outcome classes, coarsest first.
DEAD = 'dead' # no DTLS response at all — silent/non-DTLS port
LIVE = 'live' # DTLS server confirmed (HelloVerifyRequest/ServerHello)
COMPLETED = 'completed' # full handshake succeeded (cert accepted)
REJECTED = 'rejected' # server sent a fatal Alert
class ProbeResult:
"""What a single ClientHello probe learned about one host:port."""
def __init__(self, host, port):
self.host = host
self.port = port
self.outcome = DEAD
self.rtt_s = None
# Ordered, de-duplicated handshake message names the server sent.
self.handshake_msgs = []
# (level, description_name) if a fatal/warning Alert was seen.
self.alert = None
# Raw inbound datagrams, for callers that want to dig deeper.
self.datagrams = []
self.error = None
@property
def is_dtls_server(self):
"""True when a DTLS server was proven present, regardless of
whether it liked our credentials."""
return self.outcome in (LIVE, COMPLETED, REJECTED)
def __repr__(self):
bits = [f'{self.host}:{self.port}', self.outcome]
if self.rtt_s is not None:
bits.append(f'{self.rtt_s * 1000:.0f}ms')
if self.handshake_msgs:
bits.append('+'.join(self.handshake_msgs))
if self.alert:
bits.append(f'alert={self.alert[1]}')
if self.error:
bits.append(f'err={self.error}')
return f'<ProbeResult {" ".join(bits)}>'
def classify_datagram(dgram):
"""Parse one inbound UDP datagram into a list of
(content_type, detail) tuples — detail is the handshake message name
for handshake records, an (level, description_name) tuple for alerts,
or None otherwise. Pure; safe to unit-test on captured bytes."""
out = []
for rec in split_dtls(dgram):
ct = rec[0]
frag = rec[13:]
if ct == _CT_HANDSHAKE and frag:
out.append((ct, _HS_NAMES.get(frag[0], f'hs{frag[0]}')))
elif ct == _CT_ALERT and len(frag) >= 2:
out.append((ct, (frag[0], _ALERT_NAMES.get(frag[1], str(frag[1])))))
else:
out.append((ct, None))
return out
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.
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.
Never raises on a network/handshake failure — those are folded into
the ProbeResult so a discovery loop can race many ports safely.
"""
result = ProbeResult(host, port)
ctx = SSL.Context(SSL.DTLS_METHOD)
ctx.load_verify_locations(_OCF_ROOT_CA)
# 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')
if cert_pem is not None:
_load_pem_chain(ctx, cert_pem, key_pem)
elif cert_path is not None:
ctx.use_certificate_chain_file(cert_path)
ctx.use_privatekey_file(key_path)
ctx.check_privatekey()
conn = SSL.Connection(ctx, 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)
t0 = time.time()
seen = set()
retransmits = 0
try:
while time.time() - t0 < timeout:
try:
conn.do_handshake()
result.outcome = COMPLETED
if result.rtt_s is None:
result.rtt_s = time.time() - t0
break
except SSL.WantReadError:
pass
except SSL.Error as e:
# A fatal Alert lands here; the alert record was already
# captured below, so classification still works.
result.error = str(e)
break
try:
o = conn.bio_read(65535)
if o:
for r in split_dtls(o):
sock.sendto(r, dest)
except SSL.WantReadError:
pass
try:
d, _ = sock.recvfrom(65535)
except socket.timeout:
# 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
# write BIO for the next iteration to flush. A live server
# answers within a flight or two; a silent/non-DTLS port
# never does, so we give up only after `retries`
# retransmits — one dropped ClientHello no longer reads as
# a false DEAD.
to = conn.DTLSv1_get_timeout()
if to is not None and to <= 0:
if retransmits >= retries:
break
conn.DTLSv1_handle_timeout()
retransmits += 1
continue
if not d:
continue
if result.rtt_s is None:
result.rtt_s = time.time() - t0
result.datagrams.append(d)
server_flight = False
for ct, detail in classify_datagram(d):
if ct == _CT_HANDSHAKE:
if detail not in seen:
seen.add(detail)
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)
finally:
sock.close()
return result
def _main(argv):
import concurrent.futures as cf
if len(argv) < 2:
print('usage: python -m smartthings_local.protocol.dtls_probe '
'HOST PORT [PORT...] [--cert FILE --key FILE] [--stateless]')
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
ports = []
it = iter(argv[1:])
for a in it:
if a == '--cert':
cert_path = next(it)
elif a == '--key':
key_path = next(it)
elif a == '--stateless':
stateless = True
else:
ports.append(int(a))
# 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.
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
for p in ports}
results = [f.result() for f in cf.as_completed(futs)]
for r in sorted(results, key=lambda r: r.port):
print(r)
return 0
if __name__ == '__main__':
import sys
raise SystemExit(_main(sys.argv[1:]))
+106
View File
@@ -0,0 +1,106 @@
"""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.
"""
import logging
import time
import types
import pytest
import mqtt_demo.bridge as bridge
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.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(ip, port, **kw):
alive = port in live_ports
return types.SimpleNamespace(
port=port, is_dtls_server=alive,
outcome='live' if alive else 'dead')
return fake
def test_pinned_live_port_is_gated_and_returned(monkeypatch):
monkeypatch.setattr(bridge, 'probe', _fake_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()))
b = _mk_bridge(ocf_port=49155)
with pytest.raises(ConnectionError):
b._resolve_port()
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}))
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}))
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
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}))
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()))
b = _mk_bridge(ocf_port=None, discovered=49154)
with pytest.raises(ConnectionError):
b._resolve_port()
assert b._discovered_port is None
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 49200 in cands
assert cands == sorted(cands)
+159
View File
@@ -0,0 +1,159 @@
import socket
import time
from smartthings_local.protocol import dtls_probe as p
def _rec(content_type, frag):
"""Build one DTLS record: 13-byte header + fragment."""
return (bytes([content_type])
+ b'\xfe\xfd' # DTLS 1.2
+ b'\x00\x00' # 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)
def _alert(level, desc):
return _rec(p._CT_ALERT, bytes([level, desc]))
def test_classify_hello_verify_request():
assert p.classify_datagram(_hs(3, b'\x00' * 20)) == [
(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)
assert p.classify_datagram(dgram) == [
(p._CT_HANDSHAKE, 'ServerHello'),
(p._CT_HANDSHAKE, 'Certificate')]
def test_classify_fatal_alert_names_description():
# The OCF-PKI-wall signature: fatal unsupported_certificate (43).
assert p.classify_datagram(_alert(2, 43)) == [
(p._CT_ALERT, (2, 'unsupported_certificate'))]
def test_classify_unknown_handshake_type_is_not_lost():
assert p.classify_datagram(_hs(99)) == [(p._CT_HANDSHAKE, 'hs99')]
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)
assert r.outcome == p.DEAD
assert not r.is_dtls_server
assert r.datagrams == []
def test_is_dtls_server_reflects_outcome():
r = p.ProbeResult('h', 1)
r.outcome = p.LIVE
assert r.is_dtls_server
r.outcome = p.REJECTED
assert r.is_dtls_server
r.outcome = p.DEAD
assert not r.is_dtls_server
# --- probe() behavioural tests over a scripted fake UDP socket ----------
#
# OpenSSL runs for real against a memory BIO, so the ClientHello on the
# wire is genuine; only the datagram transport is faked. `responder(fake)`
# is called on every recvfrom and returns the bytes to deliver, or None to
# simulate a lost/silent flight (which sleeps the socket timeout so
# OpenSSL's DTLS retransmit clock advances in real time).
class _FakeSock:
def __init__(self, responder):
self._responder = responder
self._timeout = 0.5
self.sends = []
self.recv_calls = 0
self.closed = False
def settimeout(self, t):
self._timeout = t
def setsockopt(self, *a):
pass
def bind(self, *a):
pass
def sendto(self, data, dest):
self.sends.append(data)
return len(data)
def recvfrom(self, n):
self.recv_calls += 1
resp = self._responder(self)
if resp is None:
time.sleep(self._timeout)
raise socket.timeout()
return resp, ('127.0.0.1', 5684)
def close(self):
self.closed = True
def _patch_sock(monkeypatch, fake):
monkeypatch.setattr(p.socket, 'socket', lambda *a, **k: fake)
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))
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=2.0)
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_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
else None)
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=5.0)
assert r.outcome == p.LIVE
assert len(fake.sends) == 2 # initial + one retransmit
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
# A truly silent port: DEAD, but only after the initial flight plus
# `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)
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))
_patch_sock(monkeypatch, fake)
r = p.probe('127.0.0.1', 5684, stateless=False, timeout=3.0)
assert r.outcome == p.LIVE # HVR still proved liveness
assert r.error is not None # OpenSSL processed the fed-back flight
+111
View File
@@ -0,0 +1,111 @@
"""Compatibility baseline for the published API and LocalThings consumer."""
from __future__ import annotations
import inspect
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
def _assert_compatible_signature(callable_object, expected: list[str]) -> None:
"""Require the existing call surface while allowing safe extensions."""
parameters = list(inspect.signature(callable_object).parameters.values())
assert [parameter.name for parameter in parameters[: len(expected)]] == expected
for parameter in parameters[len(expected) :]:
assert (
parameter.kind
in (
inspect.Parameter.VAR_POSITIONAL,
inspect.Parameter.VAR_KEYWORD,
)
or parameter.default is not inspect.Parameter.empty
)
def test_dtls_session_constructor_keeps_file_memory_and_local_port_inputs():
_assert_compatible_signature(
DtlsCoapSession,
[
"host",
"port",
"cert_path",
"key_path",
"cert_pem",
"key_pem",
"on_notification",
"mtu",
"rate_limit_rps",
"local_port",
],
)
def test_dtls_session_keeps_current_consumer_methods():
expected = {
"close",
"connect",
"get",
"join",
"pace",
"ping",
"post",
"refresh_observes",
"start_reader",
"subscribe",
}
assert expected <= set(dir(DtlsCoapSession))
_assert_compatible_signature(
DtlsCoapSession.get,
[
"self",
"path_segs",
"query",
"timeout",
],
)
_assert_compatible_signature(
DtlsCoapSession.post,
[
"self",
"path_segs",
"body_cbor",
"timeout",
],
)
_assert_compatible_signature(
DtlsCoapSession.subscribe,
["self", "path_segs"],
)
def test_state_cache_keeps_current_consumer_surface():
_assert_compatible_signature(StateCache, ["descriptor"])
expected = {
"apply_optimistic",
"apply_rep",
"freshness_s",
"get",
"index_device_tree",
"set_on_change",
"snapshot",
"stalest",
}
assert expected <= set(dir(StateCache))
def test_observe_refresh_task_keeps_current_consumer_surface():
_assert_compatible_signature(
ObserveRefreshTask,
[
"session",
"paths",
"interval_s",
"logger",
],
)
_assert_compatible_signature(
ObserveRefreshTask.run_forever,
["self", "stop"],
)
+101
View File
@@ -0,0 +1,101 @@
import shutil
import subprocess
import pytest
import setup_cert
# All of these drive the real `openssl` CLI the way setup_cert does.
pytestmark = pytest.mark.skipif(
shutil.which("openssl") is None, reason="openssl CLI not available")
UUID = "04700f20-1111-2222-3333-444455556666"
def _make_ca(dir_path):
"""A throwaway self-signed CA standing in for the AC14K_M signer."""
cert = dir_path / "ca.pem"
key = dir_path / "ca.key"
subprocess.run(
["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes",
"-keyout", str(key), "-out", str(cert), "-days", "1",
"-subj", "/CN=AC14K_M"],
check=True, capture_output=True)
return cert, key
def test_mint_cert_produces_sha1_leaf_with_uuid(tmp_path):
ca_cert, ca_key = _make_ca(tmp_path)
paths = setup_cert.mint_cert(
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
for name in ("key", "leaf", "fullchain"):
assert paths[name].exists() and paths[name].stat().st_size > 0
text = subprocess.run(
["openssl", "x509", "-in", str(paths["leaf"]), "-noout", "-text"],
check=True, capture_output=True, text=True).stdout
assert "sha1WithRSAEncryption" in text # SHA-1 signed leaf
assert f"URI:urn:uuid:{UUID}" in text # UUID in the SAN
assert "1.3.6.1.4.1.51414" in text # custom OIDs parsed
# fullchain is leaf + supplied chain
assert paths["fullchain"].read_text().count("BEGIN CERTIFICATE") == 2
def test_mint_cert_surfaces_openssl_error(tmp_path):
"""A genuine signing failure raises CommandError carrying openssl's
output, instead of a bare non-zero-exit traceback."""
ca_cert, _ = _make_ca(tmp_path)
with pytest.raises(setup_cert.CommandError) as exc:
setup_cert.mint_cert(
UUID, ca_cert, tmp_path / "missing.key", [ca_cert],
tmp_path / "out")
assert "command failed" in str(exc.value)
assert len(str(exc.value)) > 40 # includes detail, not just an exit code
def test_mint_cert_retries_when_sha1_signing_blocked(tmp_path, monkeypatch):
"""Simulate a Fedora/RHEL crypto policy rejecting SHA-1: the first
(plain) signing attempt fails, and the SHA-1-override retry recovers."""
ca_cert, ca_key = _make_ca(tmp_path)
real_run = setup_cert.run
attempts = {"plain": 0}
def fake_run(cmd, **kw):
# Only the plain attempt has no OPENSSL_CONF override in its env.
if cmd[:3] == ["openssl", "x509", "-req"] and "env" not in kw:
attempts["plain"] += 1
raise setup_cert.CommandError(
"error: sha1 signature disabled by crypto policy")
return real_run(cmd, **kw)
monkeypatch.setattr(setup_cert, "run", fake_run)
paths = setup_cert.mint_cert(
UUID, ca_cert, ca_key, [ca_cert], tmp_path / "out")
assert attempts["plain"] == 1 # the plain path was exercised
assert paths["leaf"].exists() # the override retry recovered
def test_sha1_retry_does_not_give_openssl_3_config_to_libressl(monkeypatch):
calls = []
def fake_run(cmd, **kw):
calls.append((cmd, kw))
if cmd == ["openssl", "version"]:
return subprocess.CompletedProcess(cmd, 0, "LibreSSL 3.3.6\n", "")
return subprocess.CompletedProcess(cmd, 0, "", "")
monkeypatch.setattr(setup_cert, "run", fake_run)
monkeypatch.setenv("OPENSSL_CONF", "/synthetic/inherited.cnf")
setup_cert.run_allow_sha1(["openssl", "x509", "-req"])
assert calls[1][0] == ["openssl", "x509", "-req"]
assert "OPENSSL_CONF" not in calls[1][1]["env"]
def test_command_error_includes_stderr():
with pytest.raises(setup_cert.CommandError) as exc:
setup_cert.run(["openssl", "x509", "-in", "/no/such/file"])
assert "command failed" in str(exc.value)
+158
View File
@@ -0,0 +1,158 @@
from __future__ import annotations
import subprocess
from tools import check_share_safety
def test_documentation_addresses_and_synthetic_uuid_are_safe():
text = (
"192.0.2.10 198.51.100.20 203.0.113.30 "
"2001:db8::10 11111111-2222-3333-4444-555555555555"
)
assert check_share_safety.scan_text("fixture.txt", text) == []
def test_dotted_object_identifiers_are_not_ipv4_addresses():
text = "extendedKeyUsage = 1.3.6.1.4.1.51414.0.1.2"
assert check_share_safety.scan_text("fixture.txt", text) == []
def test_public_github_attachment_uuid_is_safe_but_bare_uuid_is_not():
value = "cc1dca15-f272-4625-" + "a13c-2dc82283ff95"
public_url = f"https://github.com/user-attachments/assets/{value}"
assert check_share_safety.scan_text("README.md", public_url) == []
assert check_share_safety.scan_text("fixture.txt", value) == [
check_share_safety.Finding("fixture.txt", 1, "UUID")
]
def test_findings_never_echo_matched_content():
cases = {
"PEM_PRIVATE_KEY": "-----BEGIN " + "PRIVATE KEY-----",
"EMAIL_ADDRESS": "person" + "@example.net",
"MAC_ADDRESS": "aa:bb:cc:" + "dd:ee:ff",
"NON_DOCUMENTATION_IPV4": "10." + "24.8.9",
"NON_DOCUMENTATION_IPV6": "fd00" + 2 * chr(58) + "1234",
"PRIVATE_DNS": "appliance" + chr(46) + "house" + chr(46) + "local",
"HOME_PATH": "/" + "Users/person/private.txt",
"CREDENTIAL_URL": "https://user:" + "pass" + chr(64) + "example.net/data",
"SECRET_ASSIGNMENT": (
"access_token " + chr(61) + " " + chr(34) + "never-print-this" + chr(34)
),
"SERIAL_ASSIGNMENT": (
"serialNumber " + chr(61) + " " + chr(34) + "device-123456" + chr(34)
),
"REAL_TIMESTAMP": "2026-08-02" + "T12:34:56Z",
"QR_PAYLOAD": "qr_" + "payload = value",
"UUID": "12345678-1234-4234-9234-" + "123456789abc",
}
for rule_id, value in cases.items():
findings = check_share_safety.scan_text("candidate.txt", value)
rendered = "\n".join(finding.render() for finding in findings)
assert f"candidate.txt:1:{rule_id}" in rendered
assert value not in rendered
def test_binary_and_archive_inputs_are_rejected(tmp_path):
binary = tmp_path / "fixture.bin"
binary.write_bytes(b"before\x00after")
capture = tmp_path / "fixture.pcap"
capture.write_text("text-looking content")
assert check_share_safety.scan_file(binary, "fixture.bin") == [
check_share_safety.Finding("fixture.bin", 0, "BINARY_CONTENT")
]
assert check_share_safety.scan_file(capture, "fixture.pcap") == [
check_share_safety.Finding("fixture.pcap", 0, "FORBIDDEN_FILE_TYPE")
]
def test_changed_paths_include_staged_unstaged_and_untracked_files(
tmp_path, monkeypatch
):
def git(*args):
return subprocess.run(
[
"git",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=Test",
"-c",
"user.email=" + "test" + chr(64) + "example.invalid",
*args,
],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
git("init", "--quiet")
baseline = tmp_path / "baseline.txt"
baseline.write_text("before\n")
git("add", "baseline.txt")
git("commit", "--quiet", "-m", "baseline")
base = git("rev-parse", "HEAD").stdout.strip()
staged = tmp_path / "staged.txt"
staged.write_text("staged\n")
git("add", "staged.txt")
baseline.write_text("after\n")
(tmp_path / "untracked.txt").write_text("untracked\n")
monkeypatch.chdir(tmp_path)
assert check_share_safety._changed_paths(base) == [
"baseline.txt",
"staged.txt",
"untracked.txt",
]
def test_committed_scan_ignores_unchanged_findings_but_checks_added_lines(
tmp_path, monkeypatch
):
def git(*args):
return subprocess.run(
[
"git",
"-c",
"commit.gpgsign=false",
"-c",
"user.name=Test",
"-c",
"user.email=" + "test" + chr(64) + "example.invalid",
*args,
],
cwd=tmp_path,
capture_output=True,
check=True,
text=True,
)
candidate = tmp_path / "candidate.txt"
private_one = "10." + "24.8.9"
private_two = "10." + "24.8.10"
git("init", "--quiet")
candidate.write_text(f"existing {private_one}\n")
git("add", "candidate.txt")
git("commit", "--quiet", "-m", "baseline")
base = git("rev-parse", "HEAD").stdout.strip()
candidate.write_text(f"existing {private_one}\nsafe addition\n")
git("add", "candidate.txt")
git("commit", "--quiet", "-m", "safe change")
monkeypatch.chdir(tmp_path)
assert check_share_safety.check_changed(base) == []
candidate.write_text(
f"existing {private_one}\nsafe addition\nintroduced {private_two}\n"
)
git("add", "candidate.txt")
git("commit", "--quiet", "-m", "unsafe change")
assert check_share_safety.check_changed(base) == [
check_share_safety.Finding("candidate.txt", 3, "NON_DOCUMENTATION_IPV4")
]
+75
View File
@@ -0,0 +1,75 @@
"""Deterministic baseline checks for the current OCF worker stop contract."""
from __future__ import annotations
import threading
from smartthings_local.ocf.keepalive import KeepaliveTask
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
from smartthings_local.ocf.poll_scheduler import PollScheduler, PollTier
from smartthings_local.ocf.state_cache import StateCache
_THREAD_DEADLINE_S = 2.0
class _Session:
def ping(self):
return None
def refresh_observes(self, paths):
return None
class _Descriptor:
def on_observation(self, state, href, rep):
return None
class _ObservedEvent(threading.Event):
def __init__(self):
super().__init__()
self.waiting = threading.Event()
def wait(self, timeout=None):
self.waiting.set()
return super().wait(timeout)
def _assert_worker_stops(target, name: str):
stop = _ObservedEvent()
errors: list[str] = []
def run():
try:
target(stop)
except Exception as error: # noqa: BLE001 # pragma: no cover
errors.append(type(error).__name__)
worker = threading.Thread(target=run, name=name, daemon=True)
worker.start()
assert stop.waiting.wait(_THREAD_DEADLINE_S), (
f"{name} did not enter an interruptible wait"
)
stop.set()
worker.join(_THREAD_DEADLINE_S)
assert not worker.is_alive(), f"{name} did not stop"
assert errors == [], f"{name} raised {errors[0]}"
def test_keepalive_worker_stops_without_waiting_for_interval():
task = KeepaliveTask(_Session(), interval_s=3600.0)
_assert_worker_stops(task.run_forever, "test-keepalive")
def test_observe_refresh_worker_stops_without_waiting_for_interval():
task = ObserveRefreshTask(_Session(), [], interval_s=3600.0)
_assert_worker_stops(task.run_forever, "test-observe-refresh")
def test_poll_scheduler_worker_stops_without_leaking_thread():
scheduler = PollScheduler(
_Session(),
StateCache(_Descriptor()),
[PollTier("idle", interval_s=3600.0, paths=())],
)
_assert_worker_stops(scheduler.run_forever, "test-poll-scheduler")
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Verify that SmartThings-Local wheel and sdist contents are intentional."""
from __future__ import annotations
import argparse
import subprocess
import tarfile
import zipfile
from pathlib import Path, PurePosixPath
class DistributionError(RuntimeError):
"""An artifact contains a missing, unexpected, or unsafe member."""
def _tracked_files() -> set[str]:
proc = subprocess.run(
["git", "ls-files", "-z", "--", "smartthings_local", "tests"],
capture_output=True,
check=True,
)
return {value.decode("utf-8") for value in proc.stdout.split(b"\0") if value}
def _safe_member(name: str) -> bool:
path = PurePosixPath(name)
return bool(name) and not path.is_absolute() and ".." not in path.parts
def _expected_package_files() -> set[str]:
tracked = {
path for path in _tracked_files() if path.startswith("smartthings_local/")
}
tracked.add("smartthings_local/_version.py")
return tracked
def check_wheel(path: Path) -> None:
with zipfile.ZipFile(path) as archive:
names = set(archive.namelist())
if not names or any(not _safe_member(name) for name in names):
raise DistributionError("wheel has an unsafe member")
package_files = {name for name in names if name.startswith("smartthings_local/")}
if package_files != _expected_package_files():
raise DistributionError(
"wheel package contents differ from the tracked package"
)
metadata = names - package_files
roots = {name.split("/", 1)[0] for name in metadata}
if len(roots) != 1:
raise DistributionError("wheel must contain one dist-info directory")
dist_info = roots.pop()
if not dist_info.endswith(".dist-info"):
raise DistributionError("wheel metadata directory is invalid")
expected_metadata = {
f"{dist_info}/METADATA",
f"{dist_info}/WHEEL",
f"{dist_info}/licenses/LICENSE",
f"{dist_info}/RECORD",
}
if metadata != expected_metadata:
raise DistributionError("wheel metadata contents are unexpected")
def check_sdist(path: Path) -> None:
with tarfile.open(path, mode="r:gz") as archive:
members = archive.getmembers()
if not members or any(
not member.isfile() or member.issym() or member.islnk() for member in members
):
raise DistributionError("sdist must contain regular files only")
names = {member.name for member in members}
if any(not _safe_member(name) for name in names):
raise DistributionError("sdist has an unsafe member")
roots = {name.split("/", 1)[0] for name in names}
if len(roots) != 1:
raise DistributionError("sdist must contain one top-level directory")
root = roots.pop()
relative = {name[len(root) + 1 :] for name in names if name.startswith(f"{root}/")}
required = _tracked_files() | {
"LICENSE",
"PKG-INFO",
"README.md",
"pyproject.toml",
"smartthings_local/_version.py",
}
# hatchling bundles the VCS ignore files it finds, but which ones ship
# depends on the hatchling version (newer releases drop .hgignore), so
# treat them as optional rather than exact members.
optional = {".gitignore", ".hgignore"}
if not required <= relative <= required | optional:
raise DistributionError("sdist contents differ from the intended source set")
def check_directory(directory: Path) -> None:
wheels = sorted(directory.glob("*.whl"))
sdists = sorted(directory.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
raise DistributionError("expected exactly one wheel and one sdist")
check_wheel(wheels[0])
check_sdist(sdists[0])
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("directory", type=Path)
args = parser.parse_args()
try:
check_directory(args.directory)
except (
DistributionError,
OSError,
subprocess.SubprocessError,
tarfile.TarError,
zipfile.BadZipFile,
):
print("distribution check failed")
return 1
print("distribution contents verified")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+308
View File
@@ -0,0 +1,308 @@
#!/usr/bin/env python3
"""Check introduced public content for common private-data and secret shapes.
Findings contain only path, line, and rule ID. Matched content is never
printed because it may itself be sensitive.
"""
from __future__ import annotations
import argparse
import ipaddress
import re
import subprocess
from collections.abc import Iterable
from dataclasses import dataclass
from pathlib import Path
MAX_TEXT_BYTES = 2 * 1024 * 1024
DOCUMENTATION_IPV4 = tuple(
ipaddress.ip_network(value)
for value in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24")
)
DOCUMENTATION_IPV6 = ipaddress.ip_network("2001:db8::/32")
SAFE_UUIDS = {
"00000000-0000-0000-0000-000000000000",
"11111111-2222-3333-4444-555555555555",
}
FORBIDDEN_SUFFIXES = {
".7z",
".apk",
".cap",
".db",
".der",
".gz",
".jks",
".key",
".p12",
".pcap",
".pcapng",
".pfx",
".sqlite",
".sqlite3",
".tar",
".tgz",
".zip",
}
PATTERNS = (
(
"PEM_PRIVATE_KEY",
re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
),
(
"EMAIL_ADDRESS",
re.compile(r"[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}"),
),
(
"MAC_ADDRESS",
re.compile(r"(?i)(?<![0-9a-f])(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}(?![0-9a-f])"),
),
(
"PRIVATE_DNS",
re.compile(r"(?i)\b(?:[a-z0-9-]+\.)+(?:corp|home|internal|lan|local)\b"),
),
("HOME_PATH", re.compile(r"(?<![A-Za-z0-9._-])/(?:Users|home)/[^\s'\"`]+")),
(
"CREDENTIAL_URL",
re.compile(
r"(?i)\bhttps?://(?:[^\s/@:]+:[^\s/@]+@|[^\s?#]+[?&](?:access_token|api_key|password|refresh_token|token)=)"
),
),
(
"SECRET_ASSIGNMENT",
re.compile(
r"(?i)\b(?:access[_-]?token|api[_-]?key|bearer|owner[_-]?psk|password|passwd|private[_-]?key|psk|refresh[_-]?token|secret)\b\s*(?::|=)\s*(?:b|br|f|r|rb)?['\"][^'\"]+['\"]"
),
),
(
"SERIAL_ASSIGNMENT",
re.compile(
r"(?i)\b(?:device[_-]?)?serial(?:number|num)?\b\s*(?::|=)\s*['\"][^'\"]+['\"]"
),
),
(
"REAL_TIMESTAMP",
re.compile(
r"\b20[0-9]{2}-[01][0-9]-[0-3][0-9][T ][0-2][0-9]:[0-5][0-9](?::[0-6][0-9](?:\.[0-9]+)?)?(?:Z|[+-][0-2][0-9]:?[0-5][0-9])?\b"
),
),
(
"QR_PAYLOAD",
re.compile(r"(?i)\b(?:qr[_-]?payload|setup[_-]?payload)\b\s*(?::|=)"),
),
)
UUID_PATTERN = r"(?<![0-9a-f])[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(?![0-9a-f])"
UUID_RE = re.compile(UUID_PATTERN, re.IGNORECASE)
PUBLIC_GITHUB_ATTACHMENT_RE = re.compile(
rf"https://github\.com/user-attachments/assets/(?P<uuid>{UUID_PATTERN})",
re.IGNORECASE,
)
IPV4_RE = re.compile(
r"(?<![0-9.])(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})(?:\.(?:25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})){3}(?![0-9.])"
)
IPV6_RE = re.compile(
r"(?i)(?<![0-9a-f:])(?:\[)?(?:[0-9a-f]{0,4}:){2,7}[0-9a-f]{0,4}(?:%[A-Za-z0-9_.-]+)?(?:\])?(?![0-9a-f:])"
)
@dataclass(frozen=True, order=True)
class Finding:
path: str
line: int
rule_id: str
def render(self) -> str:
return f"{self.path}:{self.line}:{self.rule_id}"
def _safe_ipv4(value: str) -> bool:
address = ipaddress.ip_address(value)
return (
address.is_loopback
or address.is_unspecified
or any(address in network for network in DOCUMENTATION_IPV4)
)
def _safe_ipv6(value: str) -> bool:
address = ipaddress.ip_address(value.strip("[]").split("%", 1)[0])
return (
address.is_loopback or address.is_unspecified or address in DOCUMENTATION_IPV6
)
def scan_text(path: str, text: str) -> list[Finding]:
findings: set[Finding] = set()
for line_number, line in enumerate(text.splitlines(), start=1):
public_attachment_uuids = {
match.group("uuid").lower()
for match in PUBLIC_GITHUB_ATTACHMENT_RE.finditer(line)
}
for rule_id, pattern in PATTERNS:
if pattern.search(line):
findings.add(Finding(path, line_number, rule_id))
for match in UUID_RE.finditer(line):
value = match.group(0).lower()
if value not in SAFE_UUIDS and value not in public_attachment_uuids:
findings.add(Finding(path, line_number, "UUID"))
for match in IPV4_RE.finditer(line):
if not _safe_ipv4(match.group(0)):
findings.add(Finding(path, line_number, "NON_DOCUMENTATION_IPV4"))
for match in IPV6_RE.finditer(line):
try:
safe = _safe_ipv6(match.group(0))
except ValueError:
continue
if not safe:
findings.add(Finding(path, line_number, "NON_DOCUMENTATION_IPV6"))
return sorted(findings)
def scan_file(path: Path, display_path: str) -> list[Finding]:
if path.is_symlink():
return [Finding(display_path, 0, "SYMLINK")]
if path.suffix.casefold() in FORBIDDEN_SUFFIXES:
return [Finding(display_path, 0, "FORBIDDEN_FILE_TYPE")]
data = path.read_bytes()
if len(data) > MAX_TEXT_BYTES:
return [Finding(display_path, 0, "FILE_TOO_LARGE")]
if b"\x00" in data:
return [Finding(display_path, 0, "BINARY_CONTENT")]
try:
text = data.decode("utf-8", errors="strict")
except UnicodeDecodeError:
return [Finding(display_path, 0, "NON_UTF8_CONTENT")]
return scan_text(display_path, text)
def _changed_paths(base: str) -> list[str]:
commands = (
[
"git",
"diff",
"--name-only",
"--diff-filter=ACMR",
"-z",
f"{base}..HEAD",
"--",
],
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z", "--"],
["git", "diff", "--name-only", "--diff-filter=ACMR", "-z", "--"],
)
changed = [
subprocess.run(command, capture_output=True, check=True) for command in commands
]
untracked = subprocess.run(
["git", "ls-files", "--others", "--exclude-standard", "-z"],
capture_output=True,
check=True,
)
return sorted(
{
value.decode("utf-8")
for output in (*(result.stdout for result in changed), untracked.stdout)
for value in output.split(b"\0")
if value
}
)
def _local_changed_paths() -> set[str]:
commands = (
["git", "diff", "--cached", "--name-only", "--diff-filter=ACMR", "-z", "--"],
["git", "diff", "--name-only", "--diff-filter=ACMR", "-z", "--"],
["git", "ls-files", "--others", "--exclude-standard", "-z"],
)
outputs = (
subprocess.run(command, capture_output=True, check=True).stdout
for command in commands
)
return {
value.decode("utf-8")
for output in outputs
for value in output.split(b"\0")
if value
}
HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(?P<start>\d+)(?:,(?P<count>\d+))? @@")
def _introduced_lines(base: str, path: str) -> set[int]:
result = subprocess.run(
[
"git",
"diff",
"--no-color",
"--no-ext-diff",
"--unified=0",
"--diff-filter=ACMR",
f"{base}..HEAD",
"--",
path,
],
capture_output=True,
check=True,
text=True,
)
lines: set[int] = set()
for value in result.stdout.splitlines():
match = HUNK_RE.match(value)
if match is None:
continue
start = int(match.group("start"))
count = int(match.group("count") or 1)
lines.update(range(start, start + count))
return lines
def check_changed(base: str) -> list[Finding]:
"""Scan introduced committed lines and all local-only file content."""
local_paths = _local_changed_paths()
findings: list[Finding] = []
for path in _changed_paths(base):
path_findings = scan_file(Path(path), path)
if path in local_paths:
findings.extend(path_findings)
continue
introduced = _introduced_lines(base, path)
findings.extend(
finding
for finding in path_findings
if finding.line == 0 or finding.line in introduced
)
return sorted(set(findings))
def check_paths(paths: Iterable[str]) -> list[Finding]:
findings: list[Finding] = []
for value in paths:
findings.extend(scan_file(Path(value), value))
return sorted(set(findings))
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("paths", nargs="*")
parser.add_argument("--changed-since")
args = parser.parse_args()
try:
paths = _changed_paths(args.changed_since) if args.changed_since else args.paths
if not paths:
raise ValueError("no paths selected")
findings = (
check_changed(args.changed_since)
if args.changed_since
else check_paths(paths)
)
except (OSError, UnicodeDecodeError, subprocess.SubprocessError, ValueError):
print("share-safety scan failed closed:SCAN_ERROR")
return 2
for finding in findings:
print(finding.render())
return 1 if findings else 0
if __name__ == "__main__":
raise SystemExit(main())