Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dec84c9ad8 | ||
|
|
e9aee1c235 | ||
|
|
9ef5598813 | ||
|
|
917b0e47c5 | ||
|
|
83a5973434 | ||
|
|
3f0e437880 | ||
|
|
a44930f9df | ||
|
|
b0d51abcc8 | ||
|
|
7a74a955f3 | ||
|
|
d4aebebca4 | ||
|
|
7f0f1e5531 | ||
|
|
2a6fc627f2 | ||
|
|
8fb37ca2ed | ||
|
|
74d78543e1 | ||
|
|
da8f917541 | ||
|
|
a63bc0bb01 | ||
|
|
11c87b275b | ||
|
|
7ec999b3ff | ||
|
|
31a52d6ff6 | ||
|
|
2a82a764bb | ||
|
|
dd453ebdfb | ||
|
|
d677c72f89 | ||
|
|
c7e15a7dd3 | ||
|
|
280939d646 | ||
|
|
98e0020e2f | ||
|
|
597a88ff25 | ||
|
|
93e39de079 | ||
|
|
fc6240b72e | ||
|
|
6dc9dca339 | ||
|
|
2c93cb3097 | ||
|
|
23338995bf | ||
|
|
119c114daa | ||
|
|
e5bd9456d4 | ||
|
|
a494e73e89 | ||
|
|
e0622eb087 | ||
|
|
b7f2f20f29 | ||
|
|
46041bfb2c | ||
|
|
1a35cd59a1 |
@@ -0,0 +1 @@
|
||||
buy_me_a_coffee: quiteyellow
|
||||
@@ -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 }}"
|
||||
@@ -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/
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
SmartThings-Local
|
||||
Copyright (c) 2026 Jack Nagy
|
||||
|
||||
This software is licensed under the MIT License. See the LICENSE file for
|
||||
the full terms.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Trademarks & disclaimer
|
||||
------------------------------------------------------------------------
|
||||
|
||||
This is an independent, unofficial project. It is NOT affiliated with,
|
||||
authorised, endorsed, or sponsored by Samsung Electronics Co., Ltd. or any
|
||||
of its subsidiaries.
|
||||
|
||||
"Samsung", "SmartThings", and any related names, marks, and logos are
|
||||
trademarks of Samsung Electronics Co., Ltd. They are used in this project
|
||||
only nominatively -- to identify the hardware and protocols this software
|
||||
interoperates with -- and no claim is made to any right in them. Use of
|
||||
these marks does not imply any affiliation with or endorsement by their
|
||||
owner.
|
||||
|
||||
The software is provided for interoperability with hardware you own,
|
||||
without warranty of any kind.
|
||||
@@ -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 Samsung connected appliances over authenticated CoAP-DTLS.** It gives you the DTLS-CoAP transport, a tiered polling + OBSERVE state layer, and identity-cert tooling for AC14K_M-compatible firmware. Newer OCF-PKI appliances require a different authentication profile; see [the laundry compatibility findings](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/ocf-pki-laundry.md). Supported profiles can read state and write commands on the LAN with no SmartThings cloud round-trip.
|
||||
|
||||
The repo also ships a self-contained **reference bridge demo** (`mqtt_demo/`) that turns the library into auto-discovered Home Assistant entities over MQTT — one process 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
|
||||
@@ -21,16 +21,22 @@ The repo also ships a self-contained **reference bridge demo** (`mqtt_demo/`) th
|
||||
pip install smartthings-local
|
||||
```
|
||||
|
||||
Mint a client cert once (see [Part 2](#part-2--auth-get-the-identity-cert)), then drive a session directly:
|
||||
For compatible firmware, mint a client cert once (see
|
||||
[Part 2](#part-2--auth-for-ac14k_m-compatible-firmware)), then drive a
|
||||
session directly:
|
||||
|
||||
```python
|
||||
import cbor2
|
||||
from smartthings_local.protocol.auth import CertificateAuth
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
auth = CertificateAuth.from_files(
|
||||
"certs/client_fullchain.pem",
|
||||
"certs/client.key",
|
||||
)
|
||||
sess = DtlsCoapSession(
|
||||
"192.168.1.100", 49154,
|
||||
cert_path="certs/client_fullchain.pem",
|
||||
key_path="certs/client.key",
|
||||
"192.0.2.100", 49154,
|
||||
auth=auth,
|
||||
)
|
||||
sess.connect()
|
||||
sess.start_reader()
|
||||
@@ -42,31 +48,209 @@ sess.subscribe(["operational", "state", "vs", "0"], # OBSERVE
|
||||
sess.close()
|
||||
```
|
||||
|
||||
If the cert/key are minted at runtime and never written to disk (e.g. inside an HA config flow), pass them in memory instead of by path:
|
||||
`connect()` uses a 12-second monotonic DTLS handshake deadline by default. A
|
||||
caller that needs a shorter bounded attempt can pass a positive finite value
|
||||
without changing later reader timeouts. OpenSSL's DTLS timer schedules flight
|
||||
retransmissions within that same deadline:
|
||||
|
||||
```python
|
||||
sess = DtlsCoapSession("192.168.1.100", 49154, cert_pem=cert_pem, key_pem=key_pem)
|
||||
sess.connect(timeout=4.0)
|
||||
```
|
||||
|
||||
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.
|
||||
Connection attempts can also use a one-way cancellation signal. The signal is
|
||||
backed by a socketpair, so setting it wakes the network wait immediately while
|
||||
OpenSSL retains control of DTLS retransmission timing:
|
||||
|
||||
```python
|
||||
from smartthings_local.protocol.dtls_session import ConnectCancellation
|
||||
|
||||
cancel_connect = ConnectCancellation()
|
||||
# Another thread may call cancel_connect.set().
|
||||
sess.connect(timeout=8.0, cancel=cancel_connect)
|
||||
```
|
||||
|
||||
Setting the signal stops subscribed connection attempts and closes their
|
||||
temporary UDP sockets. It does not alter an already established session or add
|
||||
new session lifecycle methods. Interrupted attempts raise `SessionClosedError`.
|
||||
|
||||
If the cert/key are minted at runtime and never written to disk (e.g. inside
|
||||
an HA config flow), create the provider from memory instead:
|
||||
|
||||
```python
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
||||
```
|
||||
|
||||
Some newer OCF-PKI devices require an exact Samsung DTLS offer and present a
|
||||
hardware certificate whose subject contains a certificate UUID. That UUID can
|
||||
be distinct from the runtime OCF device UUID reported by `/oic/d`, so callers
|
||||
must obtain and verify the certificate identity independently. When the caller
|
||||
already has an authorized client certificate and a previously verified
|
||||
hardware-certificate UUID, opt in to both requirements explicitly:
|
||||
|
||||
```python
|
||||
from smartthings_local.protocol.auth import (
|
||||
CertificateAuth,
|
||||
SamsungServerProfile,
|
||||
)
|
||||
|
||||
server_profile = SamsungServerProfile.bound_device(
|
||||
expected_certificate_uuid,
|
||||
additional_ca_pem=additional_samsung_ca_pem,
|
||||
)
|
||||
auth = CertificateAuth.from_memory(
|
||||
cert_pem,
|
||||
key_pem,
|
||||
server_profile=server_profile,
|
||||
)
|
||||
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
||||
```
|
||||
|
||||
The default profile is restricted to Samsung home-appliance leaves with
|
||||
`OU=OCF HA Device`. The profile limits the ClientHello to P-256,
|
||||
`ECDHE-ECDSA-AES128-GCM-SHA256`, and the observed SHA-256/SHA-1 RSA/ECDSA
|
||||
signature set, disables session tickets, preserves certificate-chain
|
||||
verification, and requires the exact subject role
|
||||
`C=KR, O=Samsung Electronics, OU=OCF HA Device` with a common name ending in
|
||||
the expected certificate UUID. `additional_ca_pem` is optional and accepts
|
||||
only a bounded PEM CA-certificate chain; it is applied only to this profiled
|
||||
context. Without a profile, `CertificateAuth` retains its existing verification
|
||||
behavior.
|
||||
|
||||
Samsung VD-family devices can present the same wire profile with the distinct
|
||||
`OU=OCF VD Device` role. Select that role explicitly; profiles never fall back
|
||||
between device classes:
|
||||
|
||||
```python
|
||||
from smartthings_local.protocol.auth import (
|
||||
SamsungServerProfile,
|
||||
SamsungServerRole,
|
||||
ServerCertificateAuth,
|
||||
)
|
||||
|
||||
server_profile = SamsungServerProfile.bound_device(
|
||||
expected_certificate_uuid,
|
||||
role=SamsungServerRole.VD_DEVICE,
|
||||
)
|
||||
auth = ServerCertificateAuth(server_profile=server_profile)
|
||||
sess = DtlsCoapSession("192.0.2.100", 5684, auth=auth)
|
||||
```
|
||||
|
||||
`ServerCertificateAuth` is for a server-authenticated channel that does not
|
||||
send a client certificate, such as the initial DTLS carrier used by
|
||||
manufacturer-certificate OTM. It still verifies the CA chain, exact selected
|
||||
subject role, and pinned certificate UUID. It does not learn an identity from
|
||||
the first endpoint it reaches and cannot be combined with client credentials.
|
||||
|
||||
This API deliberately does not discover, mint, authorize, provision, rotate,
|
||||
or persist credentials, and it performs no ownership transfer or OCF security
|
||||
resource writes. In particular, the server-only provider can authenticate the
|
||||
initial manufacturer-certificate channel, but it does not implement the OTM
|
||||
that follows. The already-owned new-PKI case in
|
||||
[issue #16](https://github.com/QuiteYellow/SmartThings-Local/issues/16) still
|
||||
requires an authorized client identity before ordinary protected resources
|
||||
can be used.
|
||||
|
||||
For compatibility, the existing `cert_path` / `key_path` and `cert_pem` /
|
||||
`key_pem` session arguments remain supported without a deprecation warning.
|
||||
They are routed through `CertificateAuth` internally. Do not combine `auth`
|
||||
with those legacy arguments.
|
||||
|
||||
An existing OCF PSK credential can be supplied through `PskAuth`:
|
||||
|
||||
```python
|
||||
from smartthings_local.protocol.auth import PskAuth
|
||||
|
||||
auth = PskAuth(identity=psk_identity, key=psk_key)
|
||||
sess = DtlsCoapSession("192.0.2.100", 49154, auth=auth)
|
||||
```
|
||||
|
||||
The identity must be the raw 16-byte OCF UUID and cannot contain a NUL byte;
|
||||
the key must be exactly 16 or 32 bytes. `PskAuth` selects only
|
||||
`ECDHE-PSK-AES128-CBC-SHA256` and does not acquire, derive, provision, rotate,
|
||||
or persist credentials. Ownership transfer and credential discovery are
|
||||
outside this package.
|
||||
|
||||
### Classified errors
|
||||
|
||||
Runtime transport failures use the public types in
|
||||
|
||||
```python
|
||||
from smartthings_local.errors import SessionClosedError, SmartThingsLocalError
|
||||
```
|
||||
|
||||
All classified errors inherit from `SmartThingsLocalError` and expose a stable
|
||||
`code`. Their messages are fixed and deliberately omit remote endpoints, local
|
||||
paths, credential metadata, raw packets, and backend exception text. Existing
|
||||
callers can keep catching the built-in types used by earlier releases:
|
||||
|
||||
| Error | Stable code | Compatible built-in |
|
||||
| --- | --- | --- |
|
||||
| `EndpointError` | `endpoint` | `OSError` |
|
||||
| `ProbeError` | `probe` | `ConnectionError` |
|
||||
| `SessionError` | `session` | `ConnectionError` |
|
||||
| `AuthenticationError` | `authentication` | `ConnectionError` |
|
||||
| `AuthorizationError` | `authorization` | `PermissionError` |
|
||||
| `SessionTimeoutError` | `timeout` | `TimeoutError` |
|
||||
| `SessionClosedError` | `session_closed` | `ConnectionError` |
|
||||
| `MalformedMessageError` | `malformed_message` | `ValueError` |
|
||||
| `BlockwiseError` | `blockwise` | `ConnectionError` |
|
||||
| `ObserveError` | `observe` | `ConnectionError` |
|
||||
|
||||
Constructor argument validation remains a normal `ValueError`. When a backend
|
||||
failure is chained for debugging, the cause is replaced with a fixed redacted
|
||||
marker; raw backend text is not copied into the public error or its formatted
|
||||
traceback.
|
||||
|
||||
### Resolved UDP endpoints
|
||||
|
||||
Sessions resolve a host to a first-class `ResolvedUdpEndpoint` and use a
|
||||
connected UDP socket for the DTLS transport. Connecting the datagram socket
|
||||
pins it to the exact resolved peer, so unrelated datagrams from another host
|
||||
using the same port are discarded by the operating system. IPv4, IPv6, and
|
||||
scoped IPv6 tuples are preserved without putting the address or scope in the
|
||||
endpoint's `repr`.
|
||||
|
||||
Address family and fixed source-port behavior are explicit and optional:
|
||||
|
||||
```python
|
||||
import socket
|
||||
|
||||
sess = DtlsCoapSession(
|
||||
"device.example",
|
||||
49154,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
family=socket.AF_INET6,
|
||||
local_port=56830,
|
||||
)
|
||||
sess.connect()
|
||||
assert sess.endpoint.family == socket.AF_INET6
|
||||
```
|
||||
|
||||
The resolver retains candidate order and the socket setup tries the next
|
||||
candidate after a family, bind, or connect failure. Resolution and socket
|
||||
setup failures raise the redacted `EndpointError` documented above.
|
||||
|
||||
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.
|
||||
On the currently supported firmware families, authentication uses a client cert keyed to the UUID published in Samsung's own wildcard cloud TLS cert. Their factory ACL grants that UUID `perm=31` (full CRUDN) on `href=*`. That certificate path is not universal: the WD53 profile in issue #16 and the washer in issue #20 reject it. For those newer OCF-PKI devices, the `SamsungServerProfile` and `ServerCertificateAuth` providers (see Quick start) pin and verify the device's hardware certificate, but getting an authorized client credential to reach protected resources is still an open problem.
|
||||
|
||||
---
|
||||
|
||||
@@ -75,33 +259,50 @@ Authentication uses a client cert keyed to the UUID published in Samsung's own w
|
||||
Check before anything else; if it's older firmware, this project doesn't target it.
|
||||
|
||||
```sh
|
||||
# UDP scan for DTLS-CoAP ports
|
||||
nmap -Pn -sU -p 49152-49160 "$APPLIANCE_IP"
|
||||
# UDP scan for public/secure standard OCF plus the dynamic appliance band
|
||||
nmap -Pn -sU -p 5683,5684,49152-49160 "$APPLIANCE_IP"
|
||||
```
|
||||
|
||||
Read the result:
|
||||
|
||||
- **`49154/udp` (or similar 4915x) open|filtered with a DTLS handshake responding** → newer firmware (Tizen RT 3.x with DAWIT 3.0). This is what the bridge talks to.
|
||||
- **`5684/udp` or a 4915x port with a DTLS first-flight response** → an OCF DTLS listener. Standard-port OCF-PKI firmware needs the Samsung server-certificate profile (`SamsungServerProfile` / `ServerCertificateAuth`, see Quick start), and no working client credential for it exists yet.
|
||||
- **`5683/udp` responds to public OCF security/resource GETs** → use `/oic/res` to learn the device's advertised secure endpoint; do not assume that endpoint is fixed.
|
||||
- **Only `8888/tcp` open (token-based HTTPS)** → older firmware (~2018–2022). **Not supported here.**
|
||||
|
||||
nmap's `open|filtered` can't tell a real DTLS server from a silent UDP port. Confirm which of the candidate ports actually speaks DTLS with the ClientHello probe, which sends one ClientHello and reports back per port:
|
||||
|
||||
```sh
|
||||
# Stateless liveness check: one ClientHello round trip, leaves no state on the device
|
||||
python -m smartthings_local.protocol.dtls_probe "$APPLIANCE_IP" 5684 49153 49154 49155 49156 --stateless
|
||||
```
|
||||
|
||||
`live` means a DTLS server answered its first flight; `dead` means silent or not DTLS. Once you have the client cert (Part 2), add the explicit `--diagnostic` flag to run the stateful diagnostic drive, which reports `completed` (cert accepted) or `rejected` with the server's fatal alert. Diagnostic mode can allocate appliance-side DTLS state and is never used by discovery or reconnect. An `unsupported_certificate` / `unknown_ca` alert means the endpoint is reachable but this certificate profile was rejected. It is not a reason to disable verification or keep retrying. The same bounded stateless API gates the bridge's reconnect loop and, when `OCF_PORT` is unset, probes both standard 5684 and ports 49152–49160.
|
||||
|
||||
### Tested combinations
|
||||
|
||||
| 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
|
||||
The Bespoke AI Laundry Combo `WD53DBA900HZ[A1]` on Tizen 7 software
|
||||
`20260416.215549` is a known OCF-PKI profile, but is not yet supported by the
|
||||
public authentication path. Its endpoint and manufacturer-OTM/OwnerPSK findings
|
||||
are documented [here](https://github.com/QuiteYellow/SmartThings-Local/blob/main/docs/ocf-pki-laundry.md), including the exact relationship
|
||||
to issues [#16](https://github.com/QuiteYellow/SmartThings-Local/issues/16) and
|
||||
[#20](https://github.com/QuiteYellow/SmartThings-Local/issues/20).
|
||||
|
||||
### Firmware families: a limitation
|
||||
|
||||
Descriptors are firmware-family-specific. Each descriptor hardcodes the resource layout of one firmware family: which hrefs it polls, which fields it reads, which write surfaces it exposes. There's no runtime feature detection. The three sample descriptors here (`mqtt_demo/samples/`) are frozen references.
|
||||
|
||||
**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,18 +310,21 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Auth: get the identity cert
|
||||
## Part 2 — Auth for AC14K_M-compatible firmware
|
||||
|
||||
The bridge authenticates with a **client cert** signed by `AC14K_M`, an intermediate CA that has been public for years and remains in current firmware trust stores. The cert's Subject DN carries a UUID that the on-device ACL grants full access to.
|
||||
For a compatible firmware family, the bridge authenticates with a **client
|
||||
cert** signed by `AC14K_M`, an intermediate CA that has been public for years.
|
||||
The cert's Subject DN carries a UUID that those appliances' on-device ACLs
|
||||
grant full access to.
|
||||
|
||||
You can read the UUID yourself out of the relevant server cert:
|
||||
|
||||
@@ -133,13 +337,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.
|
||||
- Each currently supported Tizen/RT-OCF firmware family has a **factory-baked ACE** in `/oic/sec/acl` granting this UUID `perm=31` on `href=*`.
|
||||
- TizenRT iotivity derives peerId from `memmem(subject_dn, "uuid:")`, which is RDN-agnostic. A cert with the UUID in CN authenticates the same as one with it in OU.
|
||||
- We don't need the matching private key from the original keyholder. We mint our own key and have `AC14K_M` sign our leaf. Different key, same identity, same access.
|
||||
|
||||
### One-command setup
|
||||
|
||||
@@ -156,17 +360,23 @@ 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.
|
||||
|
||||
### How durable is this?
|
||||
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`).
|
||||
|
||||
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.
|
||||
### How durable is this on the compatible firmware families?
|
||||
|
||||
> **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.
|
||||
Rotating the published UUID would require coordinated cloud certificate, ACL,
|
||||
and device identity changes across the compatible firmware families.
|
||||
`AC14K_M` has been public for years and remains accepted by the tested rows
|
||||
above, but it is already rejected by other 2026 appliance profiles. Do not
|
||||
extrapolate this certificate path to an untested model.
|
||||
|
||||
> **Legacy path:** earlier versions used a per-hub-UUID cert via an anonymous `/oic/sec/doxm` read escalation. That still works on the dryer-family firmware but isn't necessary: the cert minted here authenticates against every appliance and survives device resets. The old `bootstrap.py` for the legacy flow was removed when the package was renamed; see git history if you need it.
|
||||
|
||||
---
|
||||
|
||||
@@ -188,20 +398,21 @@ 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_IP=192.0.2.100
|
||||
APPLIANCE_1_OCF_PORT= # blank → auto-discover across the OCF band (dryer=49155)
|
||||
APPLIANCE_1_TOPIC=samsung_dryer
|
||||
APPLIANCE_1_NAME=Samsung Dryer
|
||||
|
||||
# Appliance 2 — oven
|
||||
APPLIANCE_2_CLASS=oven
|
||||
APPLIANCE_2_IP=192.168.1.101
|
||||
APPLIANCE_2_OCF_PORT= # blank → descriptor default (49154 for oven)
|
||||
APPLIANCE_2_IP=192.0.2.101
|
||||
APPLIANCE_2_OCF_PORT= # blank → auto-discover across the OCF band (oven=49154)
|
||||
APPLIANCE_2_TOPIC=samsung_oven
|
||||
APPLIANCE_2_NAME=Samsung Oven
|
||||
```
|
||||
|
||||
Each `APPLIANCE_<n>_CLASS` must match a descriptor key in `mqtt_demo/samples/__init__.py::DESCRIPTORS` — currently `dryer`, `oven`, and `fridge`.
|
||||
Each `APPLIANCE_<n>_CLASS` must match a key in
|
||||
`mqtt_demo.samples.DESCRIPTORS`: currently `dryer`, `oven`, and `fridge`.
|
||||
|
||||
---
|
||||
|
||||
@@ -214,7 +425,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 +453,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 +480,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 +490,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 +519,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 +534,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 → probe standard port 5684 and the dynamic range 49152–49160 with a stateless ClientHello; set it to pin and gate one specific port (dryer=49155, oven=49154, fridge=49155) |
|
||||
| `APPLIANCE_<n>_TOPIC` | MQTT topic prefix (also the HA device identifier; changing it re-keys the device) |
|
||||
| `APPLIANCE_<n>_NAME` | Friendly name on the HA device card |
|
||||
| `MQTT_BROKER` / `MQTT_PORT` / `MQTT_USER` / `MQTT_PASS` | Broker config |
|
||||
| `HA_DISCOVERY_PREFIX` | HA discovery topic root (default `homeassistant`) |
|
||||
@@ -340,20 +553,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 +575,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)
|
||||
@@ -387,8 +600,11 @@ smartthings_local/ The installable library — `pip install sm
|
||||
__init__.py
|
||||
protocol/ DTLS-CoAP transport (reusable by any consumer, not just MQTT)
|
||||
__init__.py
|
||||
auth.py Immutable DTLS authentication providers
|
||||
coap.py CoAP wire protocol: message encode/decode, token handling
|
||||
dtls_session.py DTLS session: handshake, client-cert auth (file or in-memory PEM), Block2, liveness
|
||||
dtls_probe.py Stateless DTLS liveness + opt-in stateful diagnostic
|
||||
dtls_handshake.py Shared memory-BIO handshake driver, bounded by a monotonic deadline (used by session + probe)
|
||||
ocf_root_ca.pem Samsung OCF root CA, bundled for handshake verification
|
||||
ocf/ OCF resource + state layer (reusable)
|
||||
__init__.py
|
||||
@@ -415,7 +631,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, certificate profiles, connect deadline, session interruption)
|
||||
.github/workflows/publish.yml Build + PyPI Trusted Publishing on `v*` tags
|
||||
```
|
||||
|
||||
@@ -426,7 +642,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 +659,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 +676,20 @@ 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`.
|
||||
|
||||
---
|
||||
|
||||
## Trademarks & disclaimer
|
||||
|
||||
This is an independent, unofficial project. It is **not affiliated with, authorised, endorsed, or sponsored by Samsung Electronics Co., Ltd.** or any of its subsidiaries.
|
||||
|
||||
"Samsung", "SmartThings", and any related names, marks, and logos are trademarks of Samsung Electronics Co., Ltd. They are used in this project **only nominatively** — to identify the hardware and protocols this software interoperates with — and no claim is made to any right in them. Use of these marks does not imply any affiliation with or endorsement by their owner.
|
||||
|
||||
The software is provided under the [MIT License](LICENSE) for interoperability with hardware you own, without warranty of any kind.
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Newer OCF-PKI laundry: connection findings and current limits
|
||||
|
||||
This is a compatibility and implementation note, not an ownership-reset or
|
||||
onboarding guide. It records the sanitized protocol facts that made local
|
||||
control possible on two Samsung Bespoke AI Laundry Combo appliances and maps
|
||||
those facts to [issue #16](https://github.com/QuiteYellow/SmartThings-Local/issues/16)
|
||||
and [issue #20](https://github.com/QuiteYellow/SmartThings-Local/issues/20).
|
||||
|
||||
The important distinction is that endpoint reachability, DTLS authentication,
|
||||
resource authorization, and OCF ownership are four separate states. A response
|
||||
at one layer is not proof that the next layer is usable.
|
||||
|
||||
## Hardware and software validated
|
||||
|
||||
The locally validated appliances are two `WD53DBA900HZA1` all-in-one
|
||||
washer/dryers. Both report:
|
||||
|
||||
- model family `AWM-US-M64-24-WD80`;
|
||||
- Tizen 7 / One UI 7 Laundry Combo; and
|
||||
- primary software version `20260416.215549`.
|
||||
|
||||
Issue #16 reports `WD53DBA900HZ` and the same primary software version. The
|
||||
reported protocol behavior also matches, so it is the same appliance/software
|
||||
profile for the purposes of this library.
|
||||
|
||||
Issue #20 is different hardware: a `WW11BB534DAWS6` washer and
|
||||
`DV90BB5245AWS6` dryer. Only the washer has detailed protocol evidence in that
|
||||
issue, so nothing here claims that the dryer has the same profile.
|
||||
|
||||
## How the WD53 connection was established
|
||||
|
||||
### 1. Discover OCF instead of assuming a 4915x port
|
||||
|
||||
The WD53 exposes its public OCF surface on UDP 5683. `GET /oic/res` returns a
|
||||
multi-block resource directory and advertises secure endpoint data. The two
|
||||
validated units exposed the same 72 hrefs. They also have IPv4, IPv6 ULA, and
|
||||
IPv6 link-local endpoints, and the secure endpoint can move.
|
||||
|
||||
The practical rules are:
|
||||
|
||||
- include the standard CoAP-DTLS port 5684 as well as the 4915x appliance
|
||||
range;
|
||||
- preserve an IPv6 scope ID instead of flattening a link-local address into a
|
||||
host string;
|
||||
- rediscover the secure endpoint before authentication when the appliance has
|
||||
slept or restarted; and
|
||||
- prove a listener with a DTLS ClientHello instead of treating an Nmap
|
||||
`open|filtered` result as protocol evidence.
|
||||
|
||||
The production liveness probe must stop after the first
|
||||
HelloVerifyRequest/ServerHello/Alert. It never returns the cookie to the
|
||||
appliance, and packet-loss retries resend the exact same first flight. This
|
||||
avoids creating half-open DTLS associations while searching several candidate
|
||||
ports.
|
||||
|
||||
### 2. Treat the AC14K_M rejection as an authentication-profile result
|
||||
|
||||
An AC14K_M client chain reaches the WD53 DTLS server but is rejected with a
|
||||
fatal `unknown_ca` alert. RSA versus ECDSA client keys do not change that
|
||||
result. Re-signing only a leaf with SHA-256 cannot repair a trust chain the
|
||||
appliance does not accept.
|
||||
|
||||
That result does **not** mean local OCF was removed. It means the fleet
|
||||
certificate used by older SmartThings appliances is not the runtime principal
|
||||
for this profile. Repeated AC14K_M attempts, a broader cipher list, or disabling
|
||||
server verification do not produce authorization.
|
||||
|
||||
The accepted cipher for the authenticated paths below is exactly
|
||||
`ECDHE-ECDSA-AES128-GCM-SHA256`. The production sessions did not disable TLS
|
||||
verification. A first-flight diagnostic can classify an offered certificate
|
||||
without authenticating it, but that observation never grants authorization.
|
||||
|
||||
### 3. Read and classify the public security state
|
||||
|
||||
The public security resources expose enough redacted state to choose a safe
|
||||
next step:
|
||||
|
||||
- `/oic/sec/doxm` advertises standard manufacturer-certificate OTM `2` and
|
||||
Samsung manufacturer-certificate OTM `0xFF02` (`65282`);
|
||||
- the two validated units were observed with each of those methods selected;
|
||||
- `/oic/sec/pstat` distinguishes an operational owned device from a real
|
||||
manufacturer ownership-transfer window;
|
||||
- the provisioning nonce rotates on every read; and
|
||||
- this model declares that additional authorization is required.
|
||||
|
||||
Device, owner, and resource-owner UUIDs are sensitive identifiers and are not
|
||||
needed in a public fixture. They must be compared locally and replaced with
|
||||
synthetic values in tests or diagnostics.
|
||||
|
||||
### 4. Use the model's authorized, non-reset transition
|
||||
|
||||
During one-time research while the appliance was idle, the signed-in
|
||||
SmartThings Android path was used to invoke the model's signed same-account,
|
||||
non-factory-reset confirmation. This was a setup research carrier, not a
|
||||
runtime dependency. It intentionally moved the OCF security state from owned
|
||||
operation into a bounded, unowned manufacturer-OTM window; the later steps
|
||||
installed a new OCF owner. SmartThings pairing survived on the two tested
|
||||
units, but that does not make an ownership-changing operation generically safe.
|
||||
The fresh confirmation had to occur immediately before the manufacturer DTLS
|
||||
connection; a delayed confirmation missed the firmware's window.
|
||||
|
||||
The installed `5.0.47` appliance stack exposed provisioning feature `0x4000`
|
||||
and validated two proof requests in this exact order:
|
||||
|
||||
Here `serial_hash_ascii` is the 128-character lowercase hexadecimal SHA-512
|
||||
digest of the ASCII registration serial.
|
||||
|
||||
1. `TriggerSerialHashRequest` checks
|
||||
`SHA256(serial_hash_ascii || nonce_raw)`. The nonce is the current raw four
|
||||
bytes, not its eight-character hexadecimal text. This proof contains no
|
||||
account value.
|
||||
2. The appliance rotates its nonce. `TriggerAutoResetHashRequest` then checks
|
||||
`SHA256(serial_hash_ascii || SHA256(user_id_ascii) || fresh_nonce_raw)`,
|
||||
where the inner SHA-256 is its raw 32-byte digest and the same-account user
|
||||
ID is ten ASCII characters. There are no delimiters between fields.
|
||||
|
||||
The order is the inverse of the method names in a newer application helper.
|
||||
Reversing the requests caused the second stage to fail; matching the appliance
|
||||
order opened the clean manufacturer-certificate RFOTM state. Only a fresh
|
||||
public DOXM/PSTAT read—not an application callback—was accepted as proof of
|
||||
that transition. No serial, account ID, nonce, or computed proof is included
|
||||
here.
|
||||
|
||||
This authorization transition is the part that is **not yet a supported public
|
||||
workflow**. The public formulas explain the installed firmware's checks; they
|
||||
do not supply Samsung's signed request authority or disclose an account value.
|
||||
A stock SmartThings-paired appliance must not be reset, claimed, or have its
|
||||
owner replaced merely because its public OCF endpoint is reachable. A public
|
||||
implementation still needs a model-supported same-account grant that does not
|
||||
depend on private application state, captured credentials, or
|
||||
reverse-engineering tools.
|
||||
|
||||
### 5. Open manufacturer DTLS without a client identity leaf
|
||||
|
||||
Inside the confirmed manufacturer window, the successful carrier is
|
||||
server-authenticated DTLS using Samsung's manufacturer trust path. The client
|
||||
does not present an AC14K_M, TEST, or OneApp identity leaf. This trust-only
|
||||
connection can read the authenticated OCF security state needed for the
|
||||
selected manufacturer OTM.
|
||||
|
||||
No new Samsung CA private key is needed for this step. The earlier
|
||||
`unknown_ca` result and the successful manufacturer carrier are different
|
||||
authentication modes, not contradictory observations.
|
||||
|
||||
### 6. Derive, stage, prove, and finalize OwnerPSK
|
||||
|
||||
The standards-based OwnerPSK derivation uses the selected method's exact label:
|
||||
|
||||
- method `2`: `oic.sec.doxm.mfgcert`;
|
||||
- method `0xFF02`: `x.org.iotivity.conmfgcert`.
|
||||
|
||||
For the negotiated `ECDHE-ECDSA-AES128-GCM-SHA256` session, IoTivity computes:
|
||||
|
||||
1. `key_block = P_SHA256(master_secret, "key expansion" || server_random || client_random, 120)`;
|
||||
2. `OwnerPSK = P_SHA256(key_block, selected_otm_label || owner_uuid || appliance_uuid, 16)`.
|
||||
|
||||
The master secret is 48 bytes, each random is 32 bytes, and each UUID is its
|
||||
raw 16-byte value. The derivation is pure; obtaining the authenticated session
|
||||
and deciding that an ownership transaction is authorized are separate
|
||||
responsibilities.
|
||||
|
||||
The validated transaction stages the derived credential before the first
|
||||
security mutation, writes only the reviewed credential/ACL/DOXM/PSTAT shapes,
|
||||
then proves the new key on a fresh ECDHE-PSK session before publishing it as a
|
||||
usable runtime credential. Final DOXM/PSTAT and public postflight reads must
|
||||
all agree before the transaction is considered complete.
|
||||
|
||||
The resulting OwnerPSK is per appliance. It is never logged, returned by a
|
||||
diagnostic, embedded in a fixture, or committed to source control.
|
||||
|
||||
### 7. Run normal control over OwnerPSK
|
||||
|
||||
After finalization, normal reads and writes use ECDHE-PSK CoAP-DTLS over the
|
||||
currently advertised LAN endpoint. On each validated WD53, that path returned
|
||||
39 complete protected representations with no link stubs. Low-risk settings
|
||||
and power changes were accepted, verified by exact protected readback, and
|
||||
restored. The same changes remained visible through SmartThings, demonstrating
|
||||
coexistence for the tested transaction rather than a cloud replacement.
|
||||
|
||||
When the panel enters deep sleep, the secure endpoint can disappear. Runtime
|
||||
code therefore retains last-good state honestly, backs off, and rediscovers
|
||||
the endpoint when the panel returns; it does not use the cloud or an Android
|
||||
application as a wake or polling dependency.
|
||||
|
||||
## How this maps to issues #16 and #20
|
||||
|
||||
### Issue #16: exact WD53 profile
|
||||
|
||||
Issue #16 reproduces both halves of the initial diagnosis:
|
||||
|
||||
- standard OCF ports rather than a fixed 4915x-only assumption; and
|
||||
- AC14K_M client authentication rejected with `unknown_ca`.
|
||||
|
||||
The validated WD53 work demonstrates a path beyond that boundary:
|
||||
manufacturer OTM followed by per-appliance OwnerPSK runtime authentication.
|
||||
The remaining upstream gap is not proof that the protocol works; it is a safe,
|
||||
portable, owner-preserving authorization and credential setup flow.
|
||||
|
||||
### Issue #20: related `0xFF02` evidence, different models
|
||||
|
||||
The washer in issue #20 exposes public OCF on 5683, a DTLS listener on 49154,
|
||||
and reports `owned:false`, `isop:false`, with only OTM `0xFF02` advertised. That
|
||||
is consistent with a Samsung manufacturer-OTM window, and it makes the WD53
|
||||
`0xFF02` transport and OwnerPSK work directly relevant.
|
||||
|
||||
It is not yet proof of support. The issue reports `handshake_failure` rather
|
||||
than the WD53's `unknown_ca`, and the model-specific additional-authorization,
|
||||
nonce, confirmation timing, security payload, and protected-read behavior have
|
||||
not been validated. The dryer in the issue has not supplied equivalent
|
||||
evidence. Both devices need independent, non-destructive validation.
|
||||
|
||||
## What this pull request does and does not solve
|
||||
|
||||
This pull request implements the endpoint half of these reports:
|
||||
|
||||
- bounded, connected IPv4/IPv6 stateless probes;
|
||||
- byte-identical first-flight retransmission;
|
||||
- concurrent standard-port and 4915x probing;
|
||||
- deterministic listener selection; and
|
||||
- an explicit ambiguous result instead of first-responder guessing.
|
||||
|
||||
It does not make AC14K_M authenticate to either issue's appliance and does not
|
||||
perform OTM or write `/oic/sec/*`. Follow-up package work is still required for
|
||||
explicit authentication providers, PSK sessions, Samsung certificate profiles,
|
||||
OwnerPSK derivation, reviewed OCF security codecs, and the separately reviewed
|
||||
authorization/setup policy.
|
||||
|
||||
## Safe evidence for another device report
|
||||
|
||||
Useful public evidence is limited to:
|
||||
|
||||
- retail model without a serial number;
|
||||
- software version;
|
||||
- sanitized candidate ports and first-flight response classes;
|
||||
- redacted `/oic/res`, `/oic/sec/doxm`, and `/oic/sec/pstat` shapes; and
|
||||
- the fixed TLS alert number/name.
|
||||
|
||||
Do not post appliance or owner UUIDs, account identifiers, network addresses,
|
||||
registration values, nonces, certificate fingerprints, credentials, packet
|
||||
captures, or raw exception traces.
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+95
-5
@@ -23,18 +23,21 @@ import time
|
||||
|
||||
import cbor2
|
||||
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
|
||||
|
||||
from smartthings_local.ocf.keepalive import KeepaliveTask
|
||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||
from smartthings_local.ocf.poll_scheduler import PollScheduler
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
from smartthings_local.protocol.dtls_probe import (
|
||||
AMBIGUOUS,
|
||||
probe_dtls_port,
|
||||
probe_dtls_ports,
|
||||
)
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
|
||||
|
||||
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
|
||||
from .config import ApplianceConfig, SharedConfig
|
||||
from .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
|
||||
from .logger import bridge_logger
|
||||
|
||||
|
||||
DEBUG_BRIDGE = os.environ.get('DEBUG_BRIDGE') == '1'
|
||||
|
||||
|
||||
@@ -69,6 +72,22 @@ OBSERVE_REFRESH_INTERVAL_S = 6 * 3600.0
|
||||
# orphan otherwise lingers 5-15 min.
|
||||
DTLS_LOCAL_PORT_BASE = 49700
|
||||
|
||||
# Samsung's RT-OCF appliances commonly bind CoAP-DTLS in this dynamic band,
|
||||
# while full-Tizen OCF-PKI appliances also use the standard secure CoAP port.
|
||||
# When OCF_PORT is unset, probe both profiles instead of assuming one fleet-
|
||||
# wide port layout.
|
||||
OCF_PORT_BAND = range(49152, 49161)
|
||||
OCF_STANDARD_SECURE_PORT = 5684
|
||||
|
||||
# The pre-flight liveness gate tolerates one dropped ClientHello (retries=1
|
||||
# → ~1 RTT when the device answers, ~4 s to call a silent port DEAD),
|
||||
# which is far cheaper than eating the 12 s HANDSHAKE_TIMEOUT_S on a
|
||||
# rebooting device or a wrong port. It is stateless (stops at
|
||||
# HelloVerifyRequest), so it leaves no association on the device and the
|
||||
# fixed-source-port reconnect invariant is untouched (see session_once).
|
||||
_GATE_RETRIES = 1
|
||||
_GATE_TIMEOUT_S = 4.0
|
||||
|
||||
|
||||
class PushBridge:
|
||||
|
||||
@@ -85,7 +104,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 +265,82 @@ class PushBridge:
|
||||
|
||||
# ---- session lifecycle ------------------------------------------
|
||||
|
||||
def _candidate_ports(self) -> list[int]:
|
||||
"""Known OCF secure ports plus the descriptor default, in order."""
|
||||
return sorted(
|
||||
set(OCF_PORT_BAND)
|
||||
| {OCF_STANDARD_SECURE_PORT, self.descriptor.default_observe_port}
|
||||
)
|
||||
|
||||
def _probe_candidates(self, candidates: list[int]):
|
||||
"""Probe all candidates inside one budget and preserve ambiguity."""
|
||||
return probe_dtls_ports(
|
||||
self.app.ip,
|
||||
tuple(candidates),
|
||||
retries=_GATE_RETRIES,
|
||||
timeout=_GATE_TIMEOUT_S,
|
||||
)
|
||||
|
||||
def _resolve_port(self) -> int:
|
||||
"""Return a port that just answered a stateless DTLS ClientHello,
|
||||
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_dtls_port(
|
||||
self.app.ip,
|
||||
pinned,
|
||||
retries=_GATE_RETRIES,
|
||||
timeout=_GATE_TIMEOUT_S,
|
||||
)
|
||||
if not r.is_dtls_server:
|
||||
raise ConnectionError('configured port is not a DTLS server')
|
||||
return pinned
|
||||
|
||||
# A previously discovered port is almost certainly still the one —
|
||||
# try it alone first and only fall back to the full candidate set if
|
||||
# it has gone silent (firmware moved it, or it was never right).
|
||||
if self._discovered_port is not None:
|
||||
r = probe_dtls_port(
|
||||
self.app.ip,
|
||||
self._discovered_port,
|
||||
retries=_GATE_RETRIES,
|
||||
timeout=_GATE_TIMEOUT_S,
|
||||
)
|
||||
if r.is_dtls_server:
|
||||
return self._discovered_port
|
||||
self._discovered_port = None
|
||||
|
||||
candidates = self._candidate_ports()
|
||||
selection = self._probe_candidates(candidates)
|
||||
if selection.outcome == AMBIGUOUS:
|
||||
raise ConnectionError(
|
||||
'multiple DTLS listeners answered; configure OCF_PORT')
|
||||
if selection.selected_port is None:
|
||||
raise ConnectionError('no live DTLS server found')
|
||||
self.log.info("discovered DTLS port %d", selection.selected_port)
|
||||
self._discovered_port = selection.selected_port
|
||||
return selection.selected_port
|
||||
|
||||
def session_once(self):
|
||||
port = self._resolve_port()
|
||||
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
|
||||
|
||||
+3
-2
@@ -9,7 +9,7 @@ description = "Local CoAP-over-DTLS (OCF) transport + polling layer for Samsung
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = "MIT"
|
||||
license-files = ["LICENSE"]
|
||||
license-files = ["LICENSE", "NOTICE"]
|
||||
authors = [{ name = "Jack Nagy" }]
|
||||
keywords = ["smartthings", "samsung", "ocf", "coap", "dtls", "home-assistant", "iot"]
|
||||
classifiers = [
|
||||
@@ -21,7 +21,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"cbor2>=5.6",
|
||||
"pyOpenSSL>=23.0",
|
||||
"pyOpenSSL>=23.1",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -60,5 +60,6 @@ include = [
|
||||
"tests",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
"NOTICE",
|
||||
"pyproject.toml",
|
||||
]
|
||||
|
||||
+81
-7
@@ -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']}")
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Public, redacted exception types for smartthings-local."""
|
||||
|
||||
__all__ = [
|
||||
'AuthenticationError',
|
||||
'AuthorizationError',
|
||||
'BlockwiseError',
|
||||
'EndpointError',
|
||||
'MalformedMessageError',
|
||||
'ObserveError',
|
||||
'ProbeError',
|
||||
'SessionClosedError',
|
||||
'SessionError',
|
||||
'SessionTimeoutError',
|
||||
'SmartThingsLocalError',
|
||||
]
|
||||
|
||||
|
||||
class SmartThingsLocalError(Exception):
|
||||
"""Base class for classified library failures.
|
||||
|
||||
Subclasses expose a stable ``code`` and a fixed, non-sensitive message.
|
||||
They intentionally do not accept arbitrary detail because backend errors
|
||||
can contain remote endpoints, local paths, or credential metadata.
|
||||
"""
|
||||
|
||||
code = 'smartthings_local_error'
|
||||
message = 'SmartThings Local operation failed'
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(self.message)
|
||||
|
||||
def __repr__(self):
|
||||
return f'{type(self).__name__}(code={self.code!r})'
|
||||
|
||||
|
||||
class EndpointError(SmartThingsLocalError, OSError):
|
||||
"""An endpoint could not be resolved, bound, or connected."""
|
||||
|
||||
code = 'endpoint'
|
||||
message = 'endpoint operation failed'
|
||||
|
||||
|
||||
class ProbeError(SmartThingsLocalError, ConnectionError):
|
||||
"""A DTLS probe failed before producing a protocol result."""
|
||||
|
||||
code = 'probe'
|
||||
message = 'DTLS probe failed'
|
||||
|
||||
|
||||
class SessionError(SmartThingsLocalError, ConnectionError):
|
||||
"""A connected-session operation failed."""
|
||||
|
||||
code = 'session'
|
||||
message = 'session operation failed'
|
||||
|
||||
|
||||
class AuthenticationError(SessionError):
|
||||
"""The peer or local credentials could not be authenticated."""
|
||||
|
||||
code = 'authentication'
|
||||
message = 'authentication failed'
|
||||
|
||||
|
||||
class AuthorizationError(SmartThingsLocalError, PermissionError):
|
||||
"""The authenticated peer is not authorized for an operation."""
|
||||
|
||||
code = 'authorization'
|
||||
message = 'operation is not authorized'
|
||||
|
||||
|
||||
class SessionTimeoutError(SmartThingsLocalError, TimeoutError):
|
||||
"""A bounded session operation exceeded its deadline."""
|
||||
|
||||
code = 'timeout'
|
||||
message = 'session operation timed out'
|
||||
|
||||
|
||||
class SessionClosedError(SessionError):
|
||||
"""An operation was attempted on a closed session."""
|
||||
|
||||
code = 'session_closed'
|
||||
message = 'session is closed'
|
||||
|
||||
|
||||
class MalformedMessageError(SmartThingsLocalError, ValueError):
|
||||
"""A protocol message could not be decoded safely."""
|
||||
|
||||
code = 'malformed_message'
|
||||
message = 'malformed protocol message'
|
||||
|
||||
|
||||
class BlockwiseError(SessionError):
|
||||
"""A Block1 or Block2 transfer violated its bounded contract."""
|
||||
|
||||
code = 'blockwise'
|
||||
message = 'blockwise transfer failed'
|
||||
|
||||
|
||||
class ObserveError(SessionError):
|
||||
"""A CoAP Observe relation could not be established or maintained."""
|
||||
|
||||
code = 'observe'
|
||||
message = 'Observe relation failed'
|
||||
@@ -0,0 +1,571 @@
|
||||
"""Immutable authentication providers for DTLS sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Protocol, runtime_checkable
|
||||
from uuid import UUID
|
||||
|
||||
from cryptography.x509.oid import ExtensionOID
|
||||
from OpenSSL import SSL, _util, crypto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OCF_ROOT_CA = str(Path(__file__).with_name("ocf_root_ca.pem"))
|
||||
_DTLS_CIPHERS = b"ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0"
|
||||
_DTLS_PSK_CIPHERS = b"ECDHE-PSK-AES128-CBC-SHA256:@SECLEVEL=0"
|
||||
_SAMSUNG_SERVER_CURVES = b"prime256v1"
|
||||
_SAMSUNG_SERVER_SIGNATURE_ALGORITHMS = (
|
||||
b"RSA+SHA256:ECDSA+SHA256:RSA+SHA1:ECDSA+SHA1"
|
||||
)
|
||||
_SAMSUNG_SERVER_CN_RE = re.compile(
|
||||
r"\AOCF Device: [^()\r\n]{1,96} "
|
||||
r"\((?P<device_identity>[0-9a-f]{8}-[0-9a-f]{4}-"
|
||||
r"[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\)\Z",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_PSK_CLIENT_CALLBACK_CDEF = (
|
||||
"unsigned int (*)(SSL *, char *, char *, unsigned int, "
|
||||
"unsigned char *, unsigned int)"
|
||||
)
|
||||
_PEM_CERT_RE = re.compile(
|
||||
rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _verify_peer(_connection, _certificate, _error, _depth, ok):
|
||||
"""Keep pyOpenSSL's existing verification result unchanged."""
|
||||
return ok
|
||||
|
||||
|
||||
def _load_pem_chain(ctx: SSL.Context, cert_pem: str, key_pem: str) -> None:
|
||||
"""Load a PEM certificate chain and private key into a context in memory."""
|
||||
certificates = _PEM_CERT_RE.findall(cert_pem.encode())
|
||||
if not certificates:
|
||||
raise ValueError("No certificates found in cert_pem")
|
||||
ctx.use_certificate(
|
||||
crypto.load_certificate(crypto.FILETYPE_PEM, certificates[0])
|
||||
)
|
||||
for extra in certificates[1:]:
|
||||
ctx.add_extra_chain_cert(
|
||||
crypto.load_certificate(crypto.FILETYPE_PEM, extra)
|
||||
)
|
||||
ctx.use_privatekey(
|
||||
crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem.encode())
|
||||
)
|
||||
ctx.check_privatekey()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuthenticationProvider(Protocol):
|
||||
"""Configure authentication for a newly created DTLS context."""
|
||||
|
||||
def configure_context(self, context: SSL.Context) -> None:
|
||||
"""Configure a context while this provider remains session-owned."""
|
||||
|
||||
|
||||
class SamsungServerRole(Enum):
|
||||
"""Known Samsung OCF hardware-certificate subject roles."""
|
||||
|
||||
HOME_APPLIANCE = "OCF HA Device"
|
||||
VD_DEVICE = "OCF VD Device"
|
||||
|
||||
|
||||
class SamsungServerProfile:
|
||||
"""Opt-in Samsung hardware-certificate verification profile."""
|
||||
|
||||
__slots__ = (
|
||||
"_additional_ca_certificates",
|
||||
"_expected_certificate_identity",
|
||||
"_role",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
expected_certificate_identity: UUID | str,
|
||||
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
|
||||
additional_ca_pem: str | None = None,
|
||||
) -> None:
|
||||
if type(expected_certificate_identity) is UUID:
|
||||
parsed_identity = expected_certificate_identity
|
||||
elif type(expected_certificate_identity) is str:
|
||||
try:
|
||||
parsed_identity = UUID(expected_certificate_identity)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"expected_certificate_identity must be a canonical "
|
||||
"non-zero UUID"
|
||||
) from None
|
||||
if expected_certificate_identity != str(parsed_identity):
|
||||
raise ValueError(
|
||||
"expected_certificate_identity must be a canonical "
|
||||
"non-zero UUID"
|
||||
)
|
||||
else:
|
||||
raise TypeError(
|
||||
"expected_certificate_identity must be a UUID or string"
|
||||
)
|
||||
if parsed_identity.int == 0:
|
||||
raise ValueError(
|
||||
"expected_certificate_identity must be a canonical "
|
||||
"non-zero UUID"
|
||||
)
|
||||
if type(role) is not SamsungServerRole:
|
||||
raise TypeError("role must be a SamsungServerRole")
|
||||
|
||||
certificates: tuple[bytes, ...] = ()
|
||||
if additional_ca_pem is not None:
|
||||
if type(additional_ca_pem) is not str:
|
||||
raise TypeError("additional_ca_pem must be a string")
|
||||
try:
|
||||
raw_ca_pem = additional_ca_pem.encode("ascii")
|
||||
except UnicodeEncodeError:
|
||||
raise ValueError(
|
||||
"additional_ca_pem must contain ASCII PEM certificates"
|
||||
) from None
|
||||
parsed_certificates = tuple(_PEM_CERT_RE.findall(raw_ca_pem))
|
||||
if (
|
||||
not 1 <= len(parsed_certificates) <= 4
|
||||
or len(raw_ca_pem) > 32 * 1024
|
||||
or _PEM_CERT_RE.sub(b"", raw_ca_pem).strip()
|
||||
):
|
||||
raise ValueError(
|
||||
"additional_ca_pem must contain one to four PEM certificates"
|
||||
)
|
||||
try:
|
||||
loaded_certificates = [
|
||||
crypto.load_certificate(crypto.FILETYPE_PEM, certificate)
|
||||
for certificate in parsed_certificates
|
||||
]
|
||||
basic_constraints = [
|
||||
[
|
||||
extension
|
||||
for extension in certificate.to_cryptography().extensions
|
||||
if extension.oid == ExtensionOID.BASIC_CONSTRAINTS
|
||||
]
|
||||
for certificate in loaded_certificates
|
||||
]
|
||||
except (crypto.Error, ValueError):
|
||||
raise ValueError(
|
||||
"additional_ca_pem contains an invalid certificate"
|
||||
) from None
|
||||
if any(
|
||||
len(constraints) != 1 or not constraints[0].value.ca
|
||||
for constraints in basic_constraints
|
||||
):
|
||||
raise ValueError(
|
||||
"additional_ca_pem must contain only CA certificates"
|
||||
)
|
||||
fingerprints = {
|
||||
crypto.dump_certificate(crypto.FILETYPE_ASN1, certificate)
|
||||
for certificate in loaded_certificates
|
||||
}
|
||||
if len(fingerprints) != len(loaded_certificates):
|
||||
raise ValueError(
|
||||
"additional_ca_pem must not contain duplicate certificates"
|
||||
)
|
||||
certificates = parsed_certificates
|
||||
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_expected_certificate_identity",
|
||||
parsed_identity,
|
||||
)
|
||||
object.__setattr__(self, "_role", role)
|
||||
object.__setattr__(self, "_additional_ca_certificates", certificates)
|
||||
|
||||
def __setattr__(self, _name: str, _value: object) -> None:
|
||||
raise AttributeError("SamsungServerProfile is immutable")
|
||||
|
||||
def __delattr__(self, _name: str) -> None:
|
||||
raise AttributeError("SamsungServerProfile is immutable")
|
||||
|
||||
@classmethod
|
||||
def bound_device(
|
||||
cls,
|
||||
expected_certificate_identity: UUID | str,
|
||||
*,
|
||||
role: SamsungServerRole = SamsungServerRole.HOME_APPLIANCE,
|
||||
additional_ca_pem: str | None = None,
|
||||
) -> SamsungServerProfile:
|
||||
"""Bind a verified Samsung hardware leaf to its certificate UUID."""
|
||||
return cls(
|
||||
expected_certificate_identity=expected_certificate_identity,
|
||||
role=role,
|
||||
additional_ca_pem=additional_ca_pem,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a representation without device or trust-chain details."""
|
||||
return "SamsungServerProfile()"
|
||||
|
||||
def _configure_context(self, context: SSL.Context) -> None:
|
||||
curve_setter = getattr(_util.lib, "SSL_CTX_set1_curves_list", None)
|
||||
if curve_setter is not None:
|
||||
if curve_setter(context._context, _SAMSUNG_SERVER_CURVES) != 1:
|
||||
raise RuntimeError(
|
||||
"OpenSSL rejected the Samsung server certificate profile"
|
||||
)
|
||||
else:
|
||||
# pyOpenSSL 23.1 does not expose SSL_CTX_set1_curves_list. Its
|
||||
# public set_tmp_ecdh fallback produces the same single P-256
|
||||
# supported-groups ClientHello extension; wire-level tests protect
|
||||
# that compatibility path. Newer pyOpenSSL uses the exact setter.
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
curve = crypto.get_elliptic_curve("prime256v1")
|
||||
context.set_tmp_ecdh(curve)
|
||||
except (AttributeError, TypeError, ValueError, SSL.Error):
|
||||
raise RuntimeError(
|
||||
"OpenSSL rejected the Samsung server certificate profile"
|
||||
) from None
|
||||
|
||||
signature_setter = getattr(
|
||||
_util.lib,
|
||||
"SSL_CTX_set1_sigalgs_list",
|
||||
None,
|
||||
)
|
||||
if (
|
||||
signature_setter is None
|
||||
or signature_setter(
|
||||
context._context,
|
||||
_SAMSUNG_SERVER_SIGNATURE_ALGORITHMS,
|
||||
)
|
||||
!= 1
|
||||
):
|
||||
raise RuntimeError(
|
||||
"OpenSSL rejected the Samsung server certificate profile"
|
||||
)
|
||||
context.set_options(SSL.OP_NO_TICKET)
|
||||
|
||||
if self._additional_ca_certificates:
|
||||
store = context.get_cert_store()
|
||||
try:
|
||||
for certificate in self._additional_ca_certificates:
|
||||
store.add_cert(
|
||||
crypto.load_certificate(
|
||||
crypto.FILETYPE_PEM,
|
||||
certificate,
|
||||
)
|
||||
)
|
||||
except crypto.Error:
|
||||
raise RuntimeError(
|
||||
"OpenSSL rejected the Samsung server trust profile"
|
||||
) from None
|
||||
|
||||
def _verify_peer(
|
||||
self,
|
||||
_connection,
|
||||
certificate,
|
||||
_error,
|
||||
depth,
|
||||
ok,
|
||||
) -> bool:
|
||||
if not ok or certificate is None or depth < 0:
|
||||
return False
|
||||
if depth > 0:
|
||||
return True
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
# pyOpenSSL deprecates this API in favor of cryptography, but
|
||||
# reparsing Samsung's non-DER factory leaves with cryptography
|
||||
# rejects certificates that OpenSSL has already verified.
|
||||
warnings.simplefilter("ignore", DeprecationWarning)
|
||||
components = [
|
||||
(name.decode("ascii"), value.decode("ascii"))
|
||||
for name, value in certificate.get_subject().get_components()
|
||||
]
|
||||
except (
|
||||
AttributeError,
|
||||
TypeError,
|
||||
UnicodeDecodeError,
|
||||
ValueError,
|
||||
crypto.Error,
|
||||
):
|
||||
logger.warning("Unable to parse Samsung server certificate subject")
|
||||
return False
|
||||
|
||||
common_names = [value for name, value in components if name == "CN"]
|
||||
organizational_units = [
|
||||
value for name, value in components if name == "OU"
|
||||
]
|
||||
organizations = [value for name, value in components if name == "O"]
|
||||
countries = [value for name, value in components if name == "C"]
|
||||
# This deliberately pins the complete Samsung subject role. The OCF
|
||||
# reference implementation reads only the UUID-bearing CN, but
|
||||
# relaxing C/O/OU here could accept a different certificate cohort.
|
||||
if (
|
||||
len(common_names) != 1
|
||||
or organizational_units != [self._role.value]
|
||||
or organizations != ["Samsung Electronics"]
|
||||
or countries != ["KR"]
|
||||
):
|
||||
return False
|
||||
match = _SAMSUNG_SERVER_CN_RE.fullmatch(common_names[0])
|
||||
return (
|
||||
match is not None
|
||||
and UUID(match.group("device_identity"))
|
||||
== self._expected_certificate_identity
|
||||
)
|
||||
|
||||
|
||||
def _configure_certificate_server(
|
||||
context: SSL.Context,
|
||||
server_profile: SamsungServerProfile | None,
|
||||
) -> None:
|
||||
"""Configure certificate-server verification for one DTLS context."""
|
||||
context.load_verify_locations(_OCF_ROOT_CA)
|
||||
if server_profile is None:
|
||||
context.set_verify(SSL.VERIFY_PEER, _verify_peer)
|
||||
else:
|
||||
server_profile._configure_context(context)
|
||||
context.set_verify(
|
||||
SSL.VERIFY_PEER,
|
||||
server_profile._verify_peer,
|
||||
)
|
||||
# @SECLEVEL=0 permits SHA-1 in Samsung's server cert chain (AC14K_M
|
||||
# intermediate is SHA-1 signed). This is the only channel that reaches
|
||||
# the OpenSSL instance cryptography bundles; ctypes and cffi bindings
|
||||
# do not expose SSL_CTX_set_security_level on this build.
|
||||
context.set_cipher_list(_DTLS_CIPHERS)
|
||||
|
||||
|
||||
class ServerCertificateAuth:
|
||||
"""Verify a pinned Samsung server without a client certificate."""
|
||||
|
||||
__slots__ = ("_server_profile",)
|
||||
|
||||
def __init__(self, *, server_profile: SamsungServerProfile) -> None:
|
||||
if type(server_profile) is not SamsungServerProfile:
|
||||
raise TypeError("server_profile must be a SamsungServerProfile")
|
||||
object.__setattr__(self, "_server_profile", server_profile)
|
||||
|
||||
def __setattr__(self, _name: str, _value: object) -> None:
|
||||
raise AttributeError("ServerCertificateAuth is immutable")
|
||||
|
||||
def __delattr__(self, _name: str) -> None:
|
||||
raise AttributeError("ServerCertificateAuth is immutable")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a representation without server identity or trust details."""
|
||||
return "ServerCertificateAuth()"
|
||||
|
||||
def configure_context(self, context: SSL.Context) -> None:
|
||||
"""Verify the selected server profile without loading client material."""
|
||||
_configure_certificate_server(context, self._server_profile)
|
||||
|
||||
|
||||
class CertificateAuth:
|
||||
"""Certificate authentication loaded from files or in-memory PEM data.
|
||||
|
||||
Use :meth:`from_files` or :meth:`from_memory` to create an instance.
|
||||
Credential sources are intentionally not exposed as public attributes.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_certificate_path",
|
||||
"_certificate_pem",
|
||||
"_private_key_path",
|
||||
"_private_key_pem",
|
||||
"_server_profile",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
certificate_path: str | PathLike[str] | None = None,
|
||||
private_key_path: str | PathLike[str] | None = None,
|
||||
certificate_pem: str | None = None,
|
||||
private_key_pem: str | None = None,
|
||||
server_profile: SamsungServerProfile | None = None,
|
||||
) -> None:
|
||||
file_supplied = (
|
||||
certificate_path is not None or private_key_path is not None
|
||||
)
|
||||
memory_supplied = (
|
||||
certificate_pem is not None or private_key_pem is not None
|
||||
)
|
||||
if file_supplied and memory_supplied:
|
||||
raise ValueError(
|
||||
"pass either certificate_path/private_key_path or "
|
||||
"certificate_pem/private_key_pem, not both"
|
||||
)
|
||||
if file_supplied:
|
||||
if certificate_path is None or private_key_path is None:
|
||||
raise ValueError(
|
||||
"certificate_path and private_key_path must be passed together"
|
||||
)
|
||||
elif memory_supplied:
|
||||
if certificate_pem is None or private_key_pem is None:
|
||||
raise ValueError(
|
||||
"certificate_pem and private_key_pem must be passed together"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"must pass either certificate_path/private_key_path or "
|
||||
"certificate_pem/private_key_pem"
|
||||
)
|
||||
if (
|
||||
server_profile is not None
|
||||
and type(server_profile) is not SamsungServerProfile
|
||||
):
|
||||
raise TypeError("server_profile must be a SamsungServerProfile")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_certificate_path",
|
||||
str(certificate_path) if certificate_path is not None else None,
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_private_key_path",
|
||||
str(private_key_path) if private_key_path is not None else None,
|
||||
)
|
||||
object.__setattr__(self, "_certificate_pem", certificate_pem)
|
||||
object.__setattr__(self, "_private_key_pem", private_key_pem)
|
||||
object.__setattr__(self, "_server_profile", server_profile)
|
||||
|
||||
def __setattr__(self, _name: str, _value: object) -> None:
|
||||
raise AttributeError("CertificateAuth is immutable")
|
||||
|
||||
def __delattr__(self, _name: str) -> None:
|
||||
raise AttributeError("CertificateAuth is immutable")
|
||||
|
||||
@classmethod
|
||||
def from_files(
|
||||
cls,
|
||||
certificate_path: str | PathLike[str],
|
||||
private_key_path: str | PathLike[str],
|
||||
*,
|
||||
server_profile: SamsungServerProfile | None = None,
|
||||
) -> CertificateAuth:
|
||||
"""Create a provider backed by certificate-chain and key files."""
|
||||
return cls(
|
||||
certificate_path=certificate_path,
|
||||
private_key_path=private_key_path,
|
||||
server_profile=server_profile,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_memory(
|
||||
cls,
|
||||
certificate_pem: str,
|
||||
private_key_pem: str,
|
||||
*,
|
||||
server_profile: SamsungServerProfile | None = None,
|
||||
) -> CertificateAuth:
|
||||
"""Create a provider backed by an in-memory PEM chain and key."""
|
||||
return cls(
|
||||
certificate_pem=certificate_pem,
|
||||
private_key_pem=private_key_pem,
|
||||
server_profile=server_profile,
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a representation that never includes credential material."""
|
||||
return "CertificateAuth()"
|
||||
|
||||
def configure_context(self, context: SSL.Context) -> None:
|
||||
"""Apply the existing certificate authentication profile to a context."""
|
||||
_configure_certificate_server(context, self._server_profile)
|
||||
if self._certificate_pem is not None:
|
||||
_load_pem_chain(
|
||||
context,
|
||||
self._certificate_pem,
|
||||
self._private_key_pem,
|
||||
)
|
||||
else:
|
||||
context.use_certificate_chain_file(self._certificate_path)
|
||||
context.use_privatekey_file(self._private_key_path)
|
||||
context.check_privatekey()
|
||||
|
||||
|
||||
class PskAuth:
|
||||
"""DTLS authentication using an existing OCF PSK credential.
|
||||
|
||||
The identity must be a raw 16-byte OCF UUID. The key must contain 16 or
|
||||
32 bytes. Credential material is intentionally not exposed as public
|
||||
attributes and is never included in this provider's representation. A
|
||||
configured context must not outlive this provider; ``DtlsCoapSession``
|
||||
enforces that lifetime by retaining its provider.
|
||||
"""
|
||||
|
||||
__slots__ = ("_callback",)
|
||||
|
||||
def __init__(self, *, identity: bytes, key: bytes) -> None:
|
||||
if type(identity) is not bytes or type(key) is not bytes:
|
||||
raise TypeError("identity and key must be bytes")
|
||||
if len(identity) != 16:
|
||||
raise ValueError("identity must be a raw 16-byte OCF UUID")
|
||||
if b"\x00" in identity:
|
||||
raise ValueError("identity cannot contain a NUL byte")
|
||||
if len(key) not in (16, 32):
|
||||
raise ValueError("key must be 16 or 32 bytes")
|
||||
|
||||
ffi = _util.ffi
|
||||
|
||||
@ffi.callback(_PSK_CLIENT_CALLBACK_CDEF)
|
||||
def client_callback(
|
||||
_ssl,
|
||||
_identity_hint,
|
||||
identity_buffer,
|
||||
max_identity_length,
|
||||
key_buffer,
|
||||
max_key_length,
|
||||
):
|
||||
# OpenSSL callbacks cannot propagate Python exceptions. Fail
|
||||
# before touching either destination when a buffer is unavailable
|
||||
# or too small for the complete credential.
|
||||
if (
|
||||
identity_buffer == ffi.NULL
|
||||
or key_buffer == ffi.NULL
|
||||
or len(identity) + 1 > max_identity_length
|
||||
or len(key) > max_key_length
|
||||
):
|
||||
return 0
|
||||
ffi.memmove(
|
||||
identity_buffer,
|
||||
identity + b"\x00",
|
||||
len(identity) + 1,
|
||||
)
|
||||
ffi.memmove(key_buffer, key, len(key))
|
||||
return len(key)
|
||||
|
||||
object.__setattr__(self, "_callback", client_callback)
|
||||
|
||||
def __setattr__(self, _name: str, _value: object) -> None:
|
||||
raise AttributeError("PskAuth is immutable")
|
||||
|
||||
def __delattr__(self, _name: str) -> None:
|
||||
raise AttributeError("PskAuth is immutable")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a representation that never includes credential material."""
|
||||
return "PskAuth()"
|
||||
|
||||
def configure_context(self, context: SSL.Context) -> None:
|
||||
"""Configure one context for the narrow Samsung OCF PSK profile."""
|
||||
setter = getattr(_util.lib, "SSL_CTX_set_psk_client_callback", None)
|
||||
if setter is None:
|
||||
raise RuntimeError(
|
||||
"the installed OpenSSL binding does not support DTLS PSK"
|
||||
)
|
||||
context.set_cipher_list(_DTLS_PSK_CIPHERS)
|
||||
setter(context._context, self._callback)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthenticationProvider",
|
||||
"CertificateAuth",
|
||||
"PskAuth",
|
||||
"SamsungServerProfile",
|
||||
"SamsungServerRole",
|
||||
"ServerCertificateAuth",
|
||||
]
|
||||
@@ -7,6 +7,8 @@ independently.
|
||||
"""
|
||||
import struct
|
||||
|
||||
from ..errors import MalformedMessageError
|
||||
|
||||
# CoAP option numbers (RFC 7252 + 7641 + 7959)
|
||||
URI_PATH = 11
|
||||
URI_QUERY = 15
|
||||
@@ -81,7 +83,7 @@ def parse_coap(data):
|
||||
elif d_nib == 14:
|
||||
delta = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||
elif d_nib == 15:
|
||||
raise ValueError("reserved option delta nibble 15")
|
||||
raise MalformedMessageError()
|
||||
else:
|
||||
delta = d_nib
|
||||
if l_nib == 13:
|
||||
@@ -89,7 +91,7 @@ def parse_coap(data):
|
||||
elif l_nib == 14:
|
||||
length = 269 + int.from_bytes(data[i:i + 2], 'big'); i += 2
|
||||
elif l_nib == 15:
|
||||
raise ValueError("reserved option length nibble 15")
|
||||
raise MalformedMessageError()
|
||||
else:
|
||||
length = l_nib
|
||||
num = prev + delta
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Shared memory-BIO driver for bounded DTLS handshakes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import select
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from .coap import split_dtls
|
||||
|
||||
_HANDSHAKE_POLL_S = 0.5
|
||||
_MAX_DATAGRAM_SIZE = 65535
|
||||
|
||||
|
||||
class _HandshakeCancelled(Exception):
|
||||
"""Internal signal that a handshake wake socket became readable."""
|
||||
|
||||
|
||||
def _drive_dtls_handshake(
|
||||
connection,
|
||||
sock,
|
||||
*,
|
||||
deadline: float,
|
||||
retries: int | None = None,
|
||||
on_datagram: Callable[[bytes], None] | None = None,
|
||||
wake_socket=None,
|
||||
) -> bool:
|
||||
"""Drive one memory-BIO DTLS handshake up to a monotonic deadline.
|
||||
|
||||
OpenSSL owns the retransmission schedule. ``retries`` optionally limits
|
||||
how many expired retransmission timers are serviced; a normal session is
|
||||
bounded only by its deadline, while the diagnostic probe retains its
|
||||
explicit retry budget.
|
||||
|
||||
Return ``True`` only when the handshake completes before the deadline.
|
||||
TLS and socket failures are left to the caller to classify.
|
||||
"""
|
||||
retransmits = 0
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
connection.do_handshake()
|
||||
return time.monotonic() < deadline
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
|
||||
try:
|
||||
output = connection.bio_read(_MAX_DATAGRAM_SIZE)
|
||||
except SSL.WantReadError:
|
||||
output = None
|
||||
if output:
|
||||
for record in split_dtls(output):
|
||||
if sock.send(record) != len(record):
|
||||
raise OSError("incomplete UDP send")
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
wait = min(_HANDSHAKE_POLL_S, remaining)
|
||||
timed_out = False
|
||||
if wake_socket is None:
|
||||
sock.settimeout(wait)
|
||||
try:
|
||||
datagram = sock.recv(_MAX_DATAGRAM_SIZE)
|
||||
except TimeoutError:
|
||||
timed_out = True
|
||||
else:
|
||||
readable, _, _ = select.select(
|
||||
(sock, wake_socket),
|
||||
(),
|
||||
(),
|
||||
wait,
|
||||
)
|
||||
if wake_socket in readable:
|
||||
raise _HandshakeCancelled()
|
||||
if sock not in readable:
|
||||
timed_out = True
|
||||
else:
|
||||
datagram = sock.recv(_MAX_DATAGRAM_SIZE)
|
||||
|
||||
if timed_out:
|
||||
timer = connection.DTLSv1_get_timeout()
|
||||
if timer is not None and timer <= 0:
|
||||
if retries is not None and retransmits >= retries:
|
||||
break
|
||||
connection.DTLSv1_handle_timeout()
|
||||
retransmits += 1
|
||||
continue
|
||||
|
||||
if not datagram:
|
||||
continue
|
||||
if on_datagram is not None:
|
||||
on_datagram(datagram)
|
||||
connection.bio_write(datagram)
|
||||
|
||||
return False
|
||||
@@ -0,0 +1,701 @@
|
||||
"""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.
|
||||
|
||||
The production probe generates its frozen ClientHello through the same OpenSSL
|
||||
memory-BIO profile as DtlsCoapSession.connect(), including the exact cipher
|
||||
list, security level, and MTU. The opt-in diagnostic drive retains the full
|
||||
memory-BIO pump for characterizing later server flights.
|
||||
"""
|
||||
|
||||
import concurrent.futures as cf
|
||||
import math
|
||||
import socket
|
||||
import time
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from ..errors import ProbeError
|
||||
from .auth import _DTLS_CIPHERS, _OCF_ROOT_CA, _load_pem_chain
|
||||
from .coap import split_dtls
|
||||
from .dtls_handshake import _drive_dtls_handshake
|
||||
from .endpoint import open_connected_udp_socket
|
||||
|
||||
# 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
|
||||
|
||||
# Aggregate stateless-probe outcomes.
|
||||
SELECTED = 'selected'
|
||||
UNREACHABLE = 'unreachable'
|
||||
AMBIGUOUS = 'ambiguous'
|
||||
|
||||
# First-flight response classes retained by the production liveness API.
|
||||
HELLO_VERIFY_REQUEST = 'hello_verify_request'
|
||||
SERVER_HELLO = 'server_hello'
|
||||
ALERT = 'alert'
|
||||
|
||||
_DTLS_VERSIONS = frozenset((b'\xfe\xff', b'\xfe\xfd'))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DtlsLivenessResult:
|
||||
"""Bounded, non-sensitive result for one stateless port probe."""
|
||||
|
||||
port: int
|
||||
response_kind: str | None
|
||||
attempts: int
|
||||
rtt_s: float | None = None
|
||||
alert: tuple[int, str] | None = None
|
||||
error_code: str | None = None
|
||||
|
||||
@property
|
||||
def is_dtls_server(self):
|
||||
"""Return whether a structurally valid first-flight reply arrived."""
|
||||
return self.response_kind is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DtlsPortProbeResult:
|
||||
"""Selection result for one bounded concurrent probe set."""
|
||||
|
||||
outcome: str
|
||||
selected_port: int | None
|
||||
results: tuple[DtlsLivenessResult, ...]
|
||||
|
||||
@property
|
||||
def live_ports(self):
|
||||
"""Return proven listeners in caller-supplied order."""
|
||||
return tuple(
|
||||
result.port for result in self.results if result.is_dtls_server)
|
||||
|
||||
|
||||
def _validate_liveness_options(port, retries, timeout, mtu):
|
||||
if isinstance(port, bool) or not isinstance(port, int):
|
||||
raise TypeError('port must be an integer')
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError('port must be between 1 and 65535')
|
||||
if isinstance(retries, bool) or not isinstance(retries, int):
|
||||
raise TypeError('retries must be an integer')
|
||||
if not 0 <= retries <= 4:
|
||||
raise ValueError('retries must be between zero and four')
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
||||
raise TypeError('timeout must be a number')
|
||||
if not math.isfinite(timeout) or not 0 < timeout <= 30:
|
||||
raise ValueError('timeout must be greater than zero and at most 30')
|
||||
if isinstance(mtu, bool) or not isinstance(mtu, int):
|
||||
raise TypeError('mtu must be an integer')
|
||||
if not 576 <= mtu <= 16384:
|
||||
raise ValueError('mtu is outside the safe UDP range')
|
||||
|
||||
|
||||
def _validate_probe_family(family):
|
||||
if isinstance(family, bool) or not isinstance(family, int):
|
||||
raise TypeError('family must be an address-family integer')
|
||||
if family not in (socket.AF_UNSPEC, socket.AF_INET, socket.AF_INET6):
|
||||
raise ValueError('family must be AF_UNSPEC, AF_INET, or AF_INET6')
|
||||
|
||||
|
||||
def _client_hello_flight(*, mtu):
|
||||
"""Build and freeze the same narrow first flight as a real session."""
|
||||
context = SSL.Context(SSL.DTLS_METHOD)
|
||||
context.load_verify_locations(_OCF_ROOT_CA)
|
||||
context.set_verify(SSL.VERIFY_PEER, lambda *args: True)
|
||||
context.set_cipher_list(_DTLS_CIPHERS)
|
||||
|
||||
connection = SSL.Connection(context, None)
|
||||
connection.set_connect_state()
|
||||
connection.set_ciphertext_mtu(mtu)
|
||||
try:
|
||||
connection.do_handshake()
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
|
||||
records = []
|
||||
while True:
|
||||
try:
|
||||
outbound = connection.bio_read(65535)
|
||||
except SSL.WantReadError:
|
||||
break
|
||||
if not outbound:
|
||||
break
|
||||
records.extend(split_dtls(outbound))
|
||||
if not records:
|
||||
raise ProbeError()
|
||||
return tuple(records)
|
||||
|
||||
|
||||
def _is_complete_hello_verify(body):
|
||||
"""Validate the DTLS version and length-prefixed cookie."""
|
||||
return (
|
||||
len(body) >= 3
|
||||
and body[:2] in _DTLS_VERSIONS
|
||||
and len(body) == 3 + body[2]
|
||||
)
|
||||
|
||||
|
||||
def _is_complete_server_hello(body):
|
||||
"""Validate the fixed fields, session ID, and optional extensions."""
|
||||
if len(body) < 38 or body[:2] not in _DTLS_VERSIONS:
|
||||
return False
|
||||
session_id_length = body[34]
|
||||
if session_id_length > 32:
|
||||
return False
|
||||
fixed_end = 38 + session_id_length
|
||||
if len(body) == fixed_end:
|
||||
return True
|
||||
if len(body) < fixed_end + 2:
|
||||
return False
|
||||
extensions_length = int.from_bytes(body[fixed_end:fixed_end + 2], 'big')
|
||||
return len(body) == fixed_end + 2 + extensions_length
|
||||
|
||||
|
||||
def _parse_liveness_response(datagram):
|
||||
"""Return the kind and validated alert for an epoch-zero first flight."""
|
||||
records = split_dtls(datagram)
|
||||
if not records or sum(map(len, records)) != len(datagram):
|
||||
return None, None
|
||||
|
||||
fallback_kind = None
|
||||
fallback_alert = None
|
||||
for record in records:
|
||||
if len(record) < 13 or record[1:3] not in _DTLS_VERSIONS:
|
||||
continue
|
||||
if record[3:5] != b'\x00\x00':
|
||||
continue
|
||||
fragment = record[13:]
|
||||
if record[0] == _CT_HANDSHAKE:
|
||||
offset = 0
|
||||
while offset + 12 <= len(fragment):
|
||||
header = fragment[offset:offset + 12]
|
||||
message_length = int.from_bytes(header[1:4], 'big')
|
||||
fragment_offset = int.from_bytes(header[6:9], 'big')
|
||||
fragment_length = int.from_bytes(header[9:12], 'big')
|
||||
end = offset + 12 + fragment_length
|
||||
if end > len(fragment):
|
||||
break
|
||||
if fragment_offset == 0 and fragment_length == message_length:
|
||||
body = fragment[offset + 12:end]
|
||||
if header[0] == 3 and _is_complete_hello_verify(body):
|
||||
return HELLO_VERIFY_REQUEST, None
|
||||
if header[0] == 2 and _is_complete_server_hello(body):
|
||||
if fallback_kind is None:
|
||||
fallback_kind = SERVER_HELLO
|
||||
offset = end
|
||||
elif record[0] == _CT_ALERT and len(fragment) == 2:
|
||||
level, description = fragment
|
||||
fallback_kind = ALERT
|
||||
fallback_alert = (
|
||||
level,
|
||||
_ALERT_NAMES.get(description, str(description)),
|
||||
)
|
||||
if level == 2:
|
||||
return fallback_kind, fallback_alert
|
||||
return fallback_kind, fallback_alert
|
||||
|
||||
|
||||
def _classify_liveness_response(datagram):
|
||||
"""Classify a structurally complete epoch-zero DTLS first flight."""
|
||||
return _parse_liveness_response(datagram)[0]
|
||||
|
||||
|
||||
def _probe_dtls_port_with_flight(
|
||||
host, port, *, flight, timeout, retries, family):
|
||||
"""Send one frozen ClientHello flight on a connected UDP socket."""
|
||||
attempt_budget = float(timeout) / (retries + 1)
|
||||
attempts = 0
|
||||
sock = None
|
||||
try:
|
||||
sock, _endpoint = open_connected_udp_socket(
|
||||
host,
|
||||
port,
|
||||
family=family,
|
||||
timeout=attempt_budget,
|
||||
)
|
||||
started = time.monotonic()
|
||||
for attempts in range(1, retries + 2):
|
||||
for record in flight:
|
||||
if sock.send(record) != len(record):
|
||||
raise OSError('short UDP send')
|
||||
attempt_deadline = started + attempts * attempt_budget
|
||||
while True:
|
||||
remaining = attempt_deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
sock.settimeout(remaining)
|
||||
try:
|
||||
datagram = sock.recv(65535)
|
||||
except TimeoutError:
|
||||
break
|
||||
response_kind, alert = _parse_liveness_response(datagram)
|
||||
if response_kind is None:
|
||||
# A connected UDP socket already rejects other peers. An
|
||||
# unrelated or malformed datagram from the appliance must
|
||||
# not consume a retransmission or count as DTLS proof.
|
||||
continue
|
||||
return DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=response_kind,
|
||||
attempts=attempts,
|
||||
rtt_s=time.monotonic() - started,
|
||||
alert=alert,
|
||||
)
|
||||
return DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=None,
|
||||
attempts=attempts,
|
||||
error_code='no_dtls_response',
|
||||
)
|
||||
except OSError:
|
||||
return DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=None,
|
||||
attempts=attempts,
|
||||
error_code='endpoint_unavailable',
|
||||
)
|
||||
finally:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def probe_dtls_port(
|
||||
host, port, *, timeout=3.0, retries=2, mtu=1200,
|
||||
family=socket.AF_UNSPEC):
|
||||
"""Prove one DTLS listener without sending a cookie-bearing flight.
|
||||
|
||||
The ClientHello is generated once. Packet-loss retries resend those exact
|
||||
bytes and no response is ever fed back into OpenSSL, so this function
|
||||
cannot emit a second ClientHello or allocate a server association.
|
||||
|
||||
``timeout`` bounds socket I/O after synchronous platform name resolution;
|
||||
resolver timing remains controlled by the operating system.
|
||||
"""
|
||||
_validate_liveness_options(port, retries, timeout, mtu)
|
||||
_validate_probe_family(family)
|
||||
try:
|
||||
flight = _client_hello_flight(mtu=mtu)
|
||||
except Exception: # noqa: BLE001 - return only a fixed failure code
|
||||
return DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=None,
|
||||
attempts=0,
|
||||
error_code='client_hello_unavailable',
|
||||
)
|
||||
return _probe_dtls_port_with_flight(
|
||||
host,
|
||||
port,
|
||||
flight=flight,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
family=family,
|
||||
)
|
||||
|
||||
|
||||
def probe_dtls_ports(
|
||||
host, ports, *, preferred_port=None, timeout=3.0, retries=2,
|
||||
mtu=1200, family=socket.AF_UNSPEC):
|
||||
"""Probe a bounded port set concurrently and select without guessing.
|
||||
|
||||
One proven listener is selected. If multiple listeners answer, a proven
|
||||
``preferred_port`` wins; otherwise the explicit outcome is ``ambiguous``.
|
||||
Results preserve the caller's de-duplicated port order. Each worker's
|
||||
``timeout`` starts after synchronous platform name resolution.
|
||||
"""
|
||||
_validate_probe_family(family)
|
||||
ordered_ports = tuple(dict.fromkeys(ports))
|
||||
if not ordered_ports:
|
||||
return DtlsPortProbeResult(UNREACHABLE, None, ())
|
||||
if len(ordered_ports) > 32:
|
||||
raise ValueError('at most 32 DTLS ports may be probed')
|
||||
for port in ordered_ports:
|
||||
_validate_liveness_options(port, retries, timeout, mtu)
|
||||
if preferred_port is not None:
|
||||
_validate_liveness_options(preferred_port, retries, timeout, mtu)
|
||||
|
||||
try:
|
||||
flight = _client_hello_flight(mtu=mtu)
|
||||
except Exception: # noqa: BLE001 - duplicate one fixed result per port
|
||||
results = tuple(
|
||||
DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=None,
|
||||
attempts=0,
|
||||
error_code='client_hello_unavailable',
|
||||
)
|
||||
for port in ordered_ports
|
||||
)
|
||||
return DtlsPortProbeResult(UNREACHABLE, None, results)
|
||||
|
||||
by_port = {}
|
||||
with cf.ThreadPoolExecutor(
|
||||
max_workers=len(ordered_ports),
|
||||
thread_name_prefix='smartthings-dtls-probe') as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
_probe_dtls_port_with_flight,
|
||||
host,
|
||||
port,
|
||||
flight=flight,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
family=family,
|
||||
): port
|
||||
for port in ordered_ports
|
||||
}
|
||||
for future in cf.as_completed(futures):
|
||||
port = futures[future]
|
||||
try:
|
||||
by_port[port] = future.result()
|
||||
except Exception: # noqa: BLE001 - isolate one bounded worker
|
||||
by_port[port] = DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=None,
|
||||
attempts=0,
|
||||
error_code='probe_worker_failed',
|
||||
)
|
||||
|
||||
results = tuple(by_port[port] for port in ordered_ports)
|
||||
live_ports = tuple(
|
||||
result.port for result in results if result.is_dtls_server)
|
||||
if preferred_port is not None and preferred_port in live_ports:
|
||||
return DtlsPortProbeResult(SELECTED, preferred_port, results)
|
||||
if len(live_ports) == 1:
|
||||
return DtlsPortProbeResult(SELECTED, live_ports[0], results)
|
||||
if live_ports:
|
||||
return DtlsPortProbeResult(AMBIGUOUS, None, results)
|
||||
return DtlsPortProbeResult(UNREACHABLE, None, results)
|
||||
|
||||
|
||||
class ProbeResult:
|
||||
"""What a single ClientHello probe learned about one host:port."""
|
||||
|
||||
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=1200,
|
||||
family=socket.AF_UNSPEC):
|
||||
"""Run the backward-compatible stateless liveness probe.
|
||||
|
||||
Production callers should prefer :func:`probe_dtls_port`, whose immutable
|
||||
result cannot retain remote datagrams or host names. This adapter preserves
|
||||
the original ``ProbeResult`` shape. ``stateless=False`` remains only as a
|
||||
deprecated compatibility path to the explicitly named stateful diagnostic.
|
||||
|
||||
Never raises on a network/handshake failure — those are folded into
|
||||
the ProbeResult so a discovery loop can race many ports safely.
|
||||
"""
|
||||
if not stateless:
|
||||
warnings.warn(
|
||||
'probe(stateless=False) is deprecated; use '
|
||||
'diagnose_dtls_handshake() explicitly',
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return diagnose_dtls_handshake(
|
||||
host,
|
||||
port,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
retries=retries,
|
||||
timeout=timeout,
|
||||
mtu=mtu,
|
||||
family=family,
|
||||
)
|
||||
|
||||
result = ProbeResult(host, port)
|
||||
liveness = probe_dtls_port(
|
||||
host,
|
||||
port,
|
||||
timeout=timeout,
|
||||
retries=retries,
|
||||
mtu=mtu,
|
||||
family=family,
|
||||
)
|
||||
if liveness.response_kind == HELLO_VERIFY_REQUEST:
|
||||
result.outcome = LIVE
|
||||
result.handshake_msgs.append('HelloVerifyRequest')
|
||||
elif liveness.response_kind == SERVER_HELLO:
|
||||
result.outcome = LIVE
|
||||
result.handshake_msgs.append('ServerHello')
|
||||
elif liveness.response_kind == ALERT:
|
||||
result.alert = liveness.alert
|
||||
result.outcome = (
|
||||
REJECTED
|
||||
if liveness.alert is not None and liveness.alert[0] == 2
|
||||
else LIVE
|
||||
)
|
||||
result.rtt_s = liveness.rtt_s
|
||||
if liveness.error_code not in (None, 'no_dtls_response'):
|
||||
result.error = ProbeError()
|
||||
return result
|
||||
|
||||
|
||||
def diagnose_dtls_handshake(
|
||||
host, port, *, cert_pem=None, key_pem=None,
|
||||
cert_path=None, key_path=None,
|
||||
retries=2, timeout=3.0, mtu=1200,
|
||||
family=socket.AF_UNSPEC):
|
||||
"""Opt in to a stateful DTLS handshake for protocol diagnosis.
|
||||
|
||||
Unlike :func:`probe_dtls_port`, this function feeds the server flight back
|
||||
into OpenSSL. It can therefore emit a cookie-bearing second ClientHello and
|
||||
allocate appliance-side association state. Keep it out of discovery,
|
||||
reconnect, and other production liveness paths.
|
||||
"""
|
||||
_validate_liveness_options(port, retries, timeout, mtu)
|
||||
_validate_probe_family(family)
|
||||
result = ProbeResult(host, port)
|
||||
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
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(_DTLS_CIPHERS)
|
||||
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)
|
||||
|
||||
try:
|
||||
sock, _endpoint = open_connected_udp_socket(
|
||||
host,
|
||||
port,
|
||||
family=family,
|
||||
timeout=min(0.5, timeout),
|
||||
)
|
||||
except OSError:
|
||||
result.error = ProbeError()
|
||||
return result
|
||||
|
||||
started = time.monotonic()
|
||||
deadline = started + timeout
|
||||
seen = set()
|
||||
|
||||
def record_datagram(datagram):
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.monotonic() - started
|
||||
result.datagrams.append(datagram)
|
||||
for content_type, detail in classify_datagram(datagram):
|
||||
if content_type == _CT_HANDSHAKE:
|
||||
if detail not in seen:
|
||||
seen.add(detail)
|
||||
result.handshake_msgs.append(detail)
|
||||
if result.outcome == DEAD:
|
||||
result.outcome = LIVE
|
||||
elif content_type == _CT_ALERT and detail is not None:
|
||||
level, name = detail
|
||||
result.alert = (level, name)
|
||||
if level == 2: # fatal
|
||||
result.outcome = REJECTED
|
||||
|
||||
try:
|
||||
completed = _drive_dtls_handshake(
|
||||
conn,
|
||||
sock,
|
||||
deadline=deadline,
|
||||
retries=retries,
|
||||
on_datagram=record_datagram,
|
||||
)
|
||||
if completed:
|
||||
result.outcome = COMPLETED
|
||||
if result.rtt_s is None:
|
||||
result.rtt_s = time.monotonic() - started
|
||||
except SSL.Error:
|
||||
# A fatal Alert lands here; record_datagram() has already classified
|
||||
# the alert record before it is fed back into OpenSSL.
|
||||
result.error = ProbeError()
|
||||
except OSError:
|
||||
result.error = ProbeError()
|
||||
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...] [--diagnostic --cert FILE --key FILE]')
|
||||
return 2
|
||||
host = argv[0]
|
||||
cert_path = key_path = None
|
||||
diagnostic = 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 == '--diagnostic':
|
||||
diagnostic = True
|
||||
elif a == '--stateless':
|
||||
# Compatibility no-op: stateless is now the fail-safe default.
|
||||
pass
|
||||
else:
|
||||
ports.append(int(a))
|
||||
if not ports:
|
||||
print('at least one PORT is required')
|
||||
return 2
|
||||
ports = list(dict.fromkeys(ports))
|
||||
if len(ports) > 32:
|
||||
print('at most 32 PORT values may be probed')
|
||||
return 2
|
||||
if (cert_path is None) != (key_path is None):
|
||||
print('--cert and --key must be supplied together')
|
||||
return 2
|
||||
if not diagnostic and (cert_path is not None or key_path is not None):
|
||||
print('--cert/--key require the explicit --diagnostic mode')
|
||||
return 2
|
||||
|
||||
target = diagnose_dtls_handshake if diagnostic else probe
|
||||
with cf.ThreadPoolExecutor(max_workers=max(1, len(ports))) as ex:
|
||||
futs = {ex.submit(target, host, p, cert_path=cert_path,
|
||||
key_path=key_path): p
|
||||
for p in ports}
|
||||
results = [f.result() for f in cf.as_completed(futs)]
|
||||
|
||||
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:]))
|
||||
@@ -18,15 +18,22 @@ Reader thread owns the UDP socket. Callers issue get()/post() and block
|
||||
on a per-token Event the reader signals. OBSERVE notifications are
|
||||
delivered via the on_notification callback.
|
||||
"""
|
||||
import errno
|
||||
import math
|
||||
import os
|
||||
import re as _re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from OpenSSL import SSL
|
||||
|
||||
from ..errors import (
|
||||
BlockwiseError,
|
||||
EndpointError,
|
||||
SessionClosedError,
|
||||
SessionError,
|
||||
SessionTimeoutError,
|
||||
)
|
||||
from .coap import (
|
||||
URI_PATH, URI_QUERY, OBSERVE, CONTENT_FORMAT, ACCEPT, BLOCK2, SIZE2,
|
||||
TYPE_CON, TYPE_NON, TYPE_ACK, TYPE_RST,
|
||||
@@ -35,13 +42,23 @@ from .coap import (
|
||||
encode_options, parse_coap, build_coap, block_value, fmt_code,
|
||||
split_dtls as _split_dtls,
|
||||
)
|
||||
from .auth import (
|
||||
AuthenticationProvider,
|
||||
CertificateAuth,
|
||||
_DTLS_CIPHERS,
|
||||
_OCF_ROOT_CA,
|
||||
_load_pem_chain,
|
||||
)
|
||||
from .dtls_handshake import (
|
||||
_HANDSHAKE_POLL_S,
|
||||
_HandshakeCancelled,
|
||||
_drive_dtls_handshake,
|
||||
)
|
||||
from .endpoint import open_connected_udp_socket
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OCF_ROOT_CA = str(Path(__file__).parent / 'ocf_root_ca.pem')
|
||||
|
||||
|
||||
# Diagnostic logging — when DEBUG_BRIDGE=1 in env, the bridge dumps
|
||||
# every received CoAP frame, every /operational/state/vs/0 + /oven/vs/0
|
||||
# + /power/vs/0 + /mode/vs/0-options rep change, the full link tree at
|
||||
@@ -62,30 +79,88 @@ _BLOCK_ACK_TIMEOUT = 4.0
|
||||
# once the ceiling is measured empirically.
|
||||
_DEFAULT_RATE_LIMIT_RPS = 5.0
|
||||
|
||||
_PEM_CERT_RE = _re.compile(
|
||||
rb'-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----',
|
||||
_re.DOTALL,
|
||||
# ICMP errors a connected UDP socket surfaces on the next recv. On these
|
||||
# appliances they show up while the device is rebooting, while it holds an
|
||||
# orphaned association, or across a router blip, and the next datagram
|
||||
# usually works. UDP delivery was never guaranteed, so treat them as
|
||||
# advisory and keep reading. Unconnected sockets never see any of this,
|
||||
# which is why the reader survived them before the connected-socket change
|
||||
# in d677c72 (v0.1.3).
|
||||
_ADVISORY_ERRNOS = frozenset(
|
||||
value for value in (
|
||||
getattr(errno, name, None)
|
||||
for name in ('ECONNREFUSED', 'EHOSTUNREACH', 'ENETUNREACH',
|
||||
'EHOSTDOWN', 'ENETDOWN')
|
||||
) if value is not None
|
||||
)
|
||||
|
||||
def _validate_handshake_timeout(timeout, default):
|
||||
"""Return one finite, positive DTLS handshake timeout."""
|
||||
value = default if timeout is None else timeout
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise TypeError('timeout must be a number or None')
|
||||
try:
|
||||
value = float(value)
|
||||
except OverflowError:
|
||||
raise ValueError(
|
||||
'timeout must be a positive finite number or None') from None
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError('timeout must be a positive finite number or None')
|
||||
return value
|
||||
|
||||
def _load_pem_chain(ctx: SSL.Context, cert_pem: str, key_pem: str) -> None:
|
||||
"""Load a PEM cert chain and private key into an SSL context in memory.
|
||||
|
||||
Parses all certificate blocks from cert_pem: the first is the leaf
|
||||
(use_certificate), the rest are intermediates (add_extra_chain_cert).
|
||||
No temp files are written.
|
||||
class ConnectCancellation:
|
||||
"""One-way, socket-backed cancellation signal for ``connect()``.
|
||||
|
||||
Each active connection attempt receives its own wake socket. ``set()``
|
||||
makes every subscribed socket readable immediately, without a polling
|
||||
thread or a session-level abort API.
|
||||
"""
|
||||
from OpenSSL import crypto
|
||||
certs = _PEM_CERT_RE.findall(cert_pem.encode())
|
||||
if not certs:
|
||||
raise ValueError("No certificates found in cert_pem")
|
||||
ctx.use_certificate(crypto.load_certificate(crypto.FILETYPE_PEM, certs[0]))
|
||||
for extra in certs[1:]:
|
||||
ctx.add_extra_chain_cert(
|
||||
crypto.load_certificate(crypto.FILETYPE_PEM, extra)
|
||||
)
|
||||
ctx.use_privatekey(crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem.encode()))
|
||||
ctx.check_privatekey()
|
||||
|
||||
__slots__ = ("_is_set", "_lock", "_writers")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._is_set = False
|
||||
self._lock = threading.Lock()
|
||||
self._writers: set[socket.socket] = set()
|
||||
|
||||
def set(self) -> None:
|
||||
"""Cancel current and future connection attempts using this signal."""
|
||||
with self._lock:
|
||||
if self._is_set:
|
||||
return
|
||||
self._is_set = True
|
||||
for writer in self._writers:
|
||||
try:
|
||||
writer.send(b"\0")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def is_set(self) -> bool:
|
||||
"""Return whether cancellation has been requested."""
|
||||
with self._lock:
|
||||
return self._is_set
|
||||
|
||||
def _subscribe(self) -> tuple[socket.socket, socket.socket]:
|
||||
reader, writer = socket.socketpair()
|
||||
reader.setblocking(False)
|
||||
with self._lock:
|
||||
self._writers.add(writer)
|
||||
if self._is_set:
|
||||
writer.send(b"\0")
|
||||
return reader, writer
|
||||
|
||||
def _unsubscribe(
|
||||
self,
|
||||
reader: socket.socket,
|
||||
writer: socket.socket,
|
||||
) -> bool:
|
||||
with self._lock:
|
||||
self._writers.discard(writer)
|
||||
interrupted = self._is_set
|
||||
reader.close()
|
||||
writer.close()
|
||||
return interrupted
|
||||
|
||||
|
||||
class DtlsCoapSession:
|
||||
@@ -100,10 +175,9 @@ class DtlsCoapSession:
|
||||
code, _ = sess.post(['mode','vs','0'], cbor)
|
||||
sess.close()
|
||||
|
||||
Cert material comes from either a file pair (cert_path, key_path) or
|
||||
an in-memory PEM pair (cert_pem, key_pem) — exactly one pair required.
|
||||
The in-memory path exists for callers (e.g. an HA config flow) that
|
||||
mint a client cert at runtime and never write it to disk.
|
||||
Authentication comes from an immutable provider. For compatibility,
|
||||
cert_path/key_path and cert_pem/key_pem create a CertificateAuth provider
|
||||
internally — exactly one legacy pair is required when auth is omitted.
|
||||
"""
|
||||
|
||||
HANDSHAKE_TIMEOUT_S = 12.0
|
||||
@@ -114,17 +188,24 @@ class DtlsCoapSession:
|
||||
cert_pem=None, key_pem=None,
|
||||
on_notification=None, mtu=1200,
|
||||
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS,
|
||||
local_port=None):
|
||||
if (cert_path is not None or key_path is not None) and \
|
||||
(cert_pem is not None or key_pem is not None):
|
||||
local_port=None, family=socket.AF_UNSPEC,
|
||||
auth: AuthenticationProvider | None = None):
|
||||
file_supplied = cert_path is not None or key_path is not None
|
||||
memory_supplied = cert_pem is not None or key_pem is not None
|
||||
if auth is not None and (file_supplied or memory_supplied):
|
||||
raise ValueError(
|
||||
"pass auth or legacy certificate arguments, not both")
|
||||
if auth is None and file_supplied and memory_supplied:
|
||||
raise ValueError(
|
||||
"pass either cert_path/key_path or cert_pem/key_pem, not both")
|
||||
if cert_pem is not None or key_pem is not None:
|
||||
if auth is None and memory_supplied:
|
||||
if cert_pem is None or key_pem is None:
|
||||
raise ValueError("cert_pem and key_pem must be passed together")
|
||||
elif cert_path is None or key_path is None:
|
||||
elif auth is None and (cert_path is None or key_path is None):
|
||||
raise ValueError(
|
||||
"must pass either cert_path/key_path or cert_pem/key_pem")
|
||||
if auth is not None and not isinstance(auth, AuthenticationProvider):
|
||||
raise TypeError("auth must implement AuthenticationProvider")
|
||||
|
||||
self.host = host
|
||||
self.port = port
|
||||
@@ -132,6 +213,12 @@ class DtlsCoapSession:
|
||||
self.key_path = str(key_path) if key_path is not None else None
|
||||
self.cert_pem = cert_pem
|
||||
self.key_pem = key_pem
|
||||
if auth is None:
|
||||
if cert_pem is not None:
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
else:
|
||||
auth = CertificateAuth.from_files(self.cert_path, self.key_path)
|
||||
self.auth = auth
|
||||
self.on_notification = on_notification # fn(href, payload_bytes)
|
||||
self.mtu = mtu
|
||||
self._min_req_interval = 1.0 / rate_limit_rps
|
||||
@@ -146,10 +233,12 @@ class DtlsCoapSession:
|
||||
# the new handshake and discard the old association. Verified
|
||||
# accepted by RT-OCF (oven, 2026-07-26).
|
||||
self.local_port = local_port
|
||||
self.family = family
|
||||
|
||||
self.sock = None
|
||||
self.conn = None
|
||||
self.dest = None
|
||||
self.endpoint = None
|
||||
|
||||
self._send_lock = threading.Lock()
|
||||
# Randomize MID and token counter starting points so reconnects
|
||||
@@ -170,6 +259,10 @@ class DtlsCoapSession:
|
||||
|
||||
self._stop = threading.Event()
|
||||
self._reader_thread = None
|
||||
# Set while the reader owns the socket. Cleared when it exits for
|
||||
# any reason, so callers fail fast through _check_live() instead of
|
||||
# waiting out a request timeout against a session nobody is reading.
|
||||
self._reader_running = threading.Event()
|
||||
self._last_send_ts = 0.0
|
||||
|
||||
def pace(self) -> None:
|
||||
@@ -181,82 +274,135 @@ class DtlsCoapSession:
|
||||
|
||||
# ---- lifecycle ---------------------------------------------------
|
||||
|
||||
def connect(self):
|
||||
"""DTLS handshake. Blocks up to HANDSHAKE_TIMEOUT_S. Raises
|
||||
ConnectionError / TimeoutError on failure."""
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
def connect(
|
||||
self,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
cancel: ConnectCancellation | None = None,
|
||||
):
|
||||
"""Perform a cancellable DTLS handshake within a monotonic deadline.
|
||||
|
||||
ctx.load_verify_locations(_OCF_ROOT_CA)
|
||||
ctx.set_verify(SSL.VERIFY_PEER, lambda conn, cert, err, depth, ok: ok)
|
||||
# @SECLEVEL=0 permits SHA-1 in Samsung's server cert chain (AC14K_M
|
||||
# intermediate is SHA-1 signed). This is the only channel that reaches
|
||||
# the OpenSSL instance cryptography bundles — ctypes and cffi bindings
|
||||
# do not expose SSL_CTX_set_security_level on this build.
|
||||
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
|
||||
if self.cert_pem is not None:
|
||||
_load_pem_chain(ctx, self.cert_pem, self.key_pem)
|
||||
else:
|
||||
ctx.use_certificate_chain_file(self.cert_path)
|
||||
ctx.use_privatekey_file(self.key_path)
|
||||
ctx.check_privatekey()
|
||||
``timeout`` overrides ``HANDSHAKE_TIMEOUT_S`` for this call. OpenSSL
|
||||
owns DTLS retransmission timing while every receive is capped by the
|
||||
remaining budget, so wall-clock adjustments cannot change the bound.
|
||||
A ``ConnectCancellation`` wakes the network wait immediately and does
|
||||
not alter an already established session.
|
||||
"""
|
||||
handshake_timeout = _validate_handshake_timeout(
|
||||
timeout, self.HANDSHAKE_TIMEOUT_S)
|
||||
if cancel is not None and not isinstance(cancel, ConnectCancellation):
|
||||
raise TypeError("cancel must be a ConnectCancellation or None")
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise SessionClosedError()
|
||||
deadline = time.monotonic() + handshake_timeout
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
self.auth.configure_context(ctx)
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise SessionClosedError()
|
||||
|
||||
conn = SSL.Connection(ctx, None)
|
||||
conn.set_connect_state()
|
||||
conn.set_ciphertext_mtu(self.mtu)
|
||||
if cancel is not None and cancel.is_set():
|
||||
raise SessionClosedError()
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
if self.local_port is not None:
|
||||
# Fixed source port → same 5-tuple on reconnect, so the device
|
||||
# evicts any orphaned association per RFC 6347 §4.2.8 instead
|
||||
# of serving a second one alongside it. See __init__.
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(('', self.local_port))
|
||||
sock.settimeout(2.0)
|
||||
dest = (self.host, self.port)
|
||||
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < self.HANDSHAKE_TIMEOUT_S:
|
||||
try:
|
||||
conn.do_handshake()
|
||||
break
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except SSL.Error as e:
|
||||
sock.close()
|
||||
raise ConnectionError(f"DTLS handshake error: {e}") from e
|
||||
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)
|
||||
if d:
|
||||
conn.bio_write(d)
|
||||
except socket.timeout:
|
||||
pass
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
raise SessionTimeoutError()
|
||||
sock, endpoint = open_connected_udp_socket(
|
||||
self.host,
|
||||
self.port,
|
||||
family=self.family,
|
||||
local_port=self.local_port,
|
||||
timeout=min(_HANDSHAKE_POLL_S, remaining),
|
||||
)
|
||||
dest = endpoint.sockaddr
|
||||
if cancel is not None and cancel.is_set():
|
||||
sock.close()
|
||||
raise TimeoutError(
|
||||
f"DTLS handshake timeout to {self.host}:{self.port}")
|
||||
raise SessionClosedError()
|
||||
|
||||
wake_subscription = None
|
||||
subscription_failed = False
|
||||
if cancel is not None:
|
||||
try:
|
||||
wake_subscription = cancel._subscribe()
|
||||
except OSError:
|
||||
subscription_failed = True
|
||||
if subscription_failed:
|
||||
sock.close()
|
||||
raise SessionError() from OSError(
|
||||
"connection cancellation setup failed"
|
||||
)
|
||||
|
||||
backend_failed = False
|
||||
io_failed = False
|
||||
cancelled = False
|
||||
interrupted = False
|
||||
try:
|
||||
try:
|
||||
completed = _drive_dtls_handshake(
|
||||
conn,
|
||||
sock,
|
||||
deadline=deadline,
|
||||
wake_socket=(
|
||||
wake_subscription[0]
|
||||
if wake_subscription is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
except _HandshakeCancelled:
|
||||
cancelled = True
|
||||
except SSL.Error:
|
||||
backend_failed = True
|
||||
except OSError:
|
||||
io_failed = True
|
||||
finally:
|
||||
if wake_subscription is not None:
|
||||
interrupted = cancel._unsubscribe(*wake_subscription)
|
||||
if cancelled or interrupted:
|
||||
sock.close()
|
||||
raise SessionClosedError()
|
||||
if backend_failed:
|
||||
sock.close()
|
||||
raise SessionError() from ConnectionError('DTLS backend failed')
|
||||
if io_failed:
|
||||
sock.close()
|
||||
raise EndpointError() from OSError('UDP handshake I/O failed')
|
||||
if not completed:
|
||||
sock.close()
|
||||
raise SessionTimeoutError()
|
||||
|
||||
self.sock = sock
|
||||
self.conn = conn
|
||||
self.dest = dest
|
||||
self.endpoint = endpoint
|
||||
self._stop.clear()
|
||||
|
||||
def start_reader(self):
|
||||
"""Spawn the reader thread. Must be called after connect()."""
|
||||
if self.sock is None:
|
||||
raise RuntimeError("connect() before start_reader()")
|
||||
self._reader_running.set()
|
||||
t = threading.Thread(target=self._reader_loop,
|
||||
daemon=True, name='dtls-reader')
|
||||
t.start()
|
||||
self._reader_thread = t
|
||||
|
||||
def _check_live(self):
|
||||
"""Raise if the session cannot carry a request. A dead reader is
|
||||
as fatal as a closed connection: the socket may still accept
|
||||
sends, but no response will ever be dispatched, so waiting out the
|
||||
request timeout only delays the inevitable SessionClosedError.
|
||||
|
||||
Callers that never start a reader (config-flow style) keep the old
|
||||
behaviour — only the conn check applies while _reader_thread is
|
||||
None."""
|
||||
if self.conn is None:
|
||||
raise SessionClosedError()
|
||||
if self._reader_thread is not None and \
|
||||
not self._reader_running.is_set():
|
||||
raise SessionClosedError()
|
||||
|
||||
def join(self):
|
||||
"""Block until the reader thread exits (i.e. socket dies)."""
|
||||
if self._reader_thread is not None:
|
||||
@@ -303,12 +449,14 @@ class DtlsCoapSession:
|
||||
except Exception:
|
||||
pass
|
||||
for tok, (ev, container) in list(self._pending.items()):
|
||||
container.setdefault('err', 'socket closed')
|
||||
container.setdefault('err', SessionClosedError())
|
||||
ev.set()
|
||||
self._pending.clear()
|
||||
self._observe_tokens.clear()
|
||||
self.sock = None
|
||||
self.conn = None
|
||||
self.dest = None
|
||||
self.endpoint = None
|
||||
|
||||
# ---- send / receive plumbing -------------------------------------
|
||||
|
||||
@@ -340,7 +488,8 @@ class DtlsCoapSession:
|
||||
BIO-drain so two writers can't interleave records."""
|
||||
with self._send_lock:
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
raise SessionClosedError()
|
||||
send_failed = False
|
||||
try:
|
||||
self.conn.send(datagram)
|
||||
self._last_send_ts = time.monotonic()
|
||||
@@ -349,9 +498,14 @@ class DtlsCoapSession:
|
||||
if not o:
|
||||
break
|
||||
for r in _split_dtls(o):
|
||||
self.sock.sendto(r, self.dest)
|
||||
if self.sock.send(r) != len(r):
|
||||
raise OSError('incomplete UDP send')
|
||||
except SSL.WantReadError:
|
||||
pass
|
||||
except OSError:
|
||||
send_failed = True
|
||||
if send_failed:
|
||||
raise EndpointError() from OSError('UDP send failed')
|
||||
|
||||
def _reader_loop(self):
|
||||
"""Pump UDP socket → DTLS BIO → CoAP parser. Demuxes to pending
|
||||
@@ -362,10 +516,26 @@ class DtlsCoapSession:
|
||||
try:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
d, _ = sock.recvfrom(65535)
|
||||
d = sock.recv(65535)
|
||||
except socket.timeout:
|
||||
continue
|
||||
except (OSError, ValueError):
|
||||
except OSError as e:
|
||||
if self._stop.is_set():
|
||||
return # close() got here first
|
||||
if e.errno in _ADVISORY_ERRNOS:
|
||||
logger.debug("reader: advisory %s from %s, continuing",
|
||||
errno.errorcode.get(e.errno, e.errno),
|
||||
self.host)
|
||||
continue
|
||||
logger.warning("reader exiting: socket error %s from %s",
|
||||
errno.errorcode.get(e.errno, e.errno),
|
||||
self.host)
|
||||
return
|
||||
except ValueError:
|
||||
# recv on a socket closed underneath the reader.
|
||||
if not self._stop.is_set():
|
||||
logger.warning("reader exiting: socket closed "
|
||||
"underneath it")
|
||||
return
|
||||
if not d:
|
||||
continue
|
||||
@@ -410,9 +580,11 @@ class DtlsCoapSession:
|
||||
if exit_reader:
|
||||
return
|
||||
finally:
|
||||
# Reader no longer owns the socket — callers must fail fast.
|
||||
self._reader_running.clear()
|
||||
# Make sure pending waiters don't hang if the reader dies.
|
||||
for tok, (ev, container) in list(self._pending.items()):
|
||||
container.setdefault('err', 'reader exited')
|
||||
container.setdefault('err', SessionClosedError())
|
||||
ev.set()
|
||||
|
||||
def _dispatch_coap(self, datagram):
|
||||
@@ -481,8 +653,7 @@ class DtlsCoapSession:
|
||||
response — Samsung's server keys per-transfer state on the
|
||||
token, and dropping a fresh token on block 1+ silently drops
|
||||
the request."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self._check_live()
|
||||
tok = self._next_tok()
|
||||
blob = b''
|
||||
num = 0
|
||||
@@ -518,8 +689,7 @@ class DtlsCoapSession:
|
||||
"GET %s /%s block %d: timed out after %d attempt(s)",
|
||||
self.host, '/'.join(path_segs), num, attempt + 1,
|
||||
)
|
||||
raise TimeoutError(
|
||||
f"GET /{'/'.join(path_segs)} block {num} timeout")
|
||||
raise SessionTimeoutError()
|
||||
logger.debug(
|
||||
"GET %s /%s block %d: attempt %d/%d timeout, retrying",
|
||||
self.host, '/'.join(path_segs), num,
|
||||
@@ -528,7 +698,7 @@ class DtlsCoapSession:
|
||||
finally:
|
||||
self._pending.pop(tok, None)
|
||||
if 'err' in container:
|
||||
raise ConnectionError(container['err'])
|
||||
raise container['err']
|
||||
|
||||
code = container['code']
|
||||
payload = container['payload']
|
||||
@@ -552,16 +722,13 @@ class DtlsCoapSession:
|
||||
break
|
||||
num += 1
|
||||
if num > self.MAX_BLOCKS:
|
||||
raise ConnectionError(
|
||||
f"GET /{'/'.join(path_segs)}: >{self.MAX_BLOCKS} "
|
||||
f"blocks, aborting")
|
||||
raise BlockwiseError()
|
||||
return last_code, blob
|
||||
|
||||
def post(self, path_segs, body_cbor, timeout=8.0):
|
||||
"""Single-frame POST with a CBOR-encoded body. Returns
|
||||
(code, payload_bytes). body_cbor must already be encoded."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self._check_live()
|
||||
tok = self._next_tok()
|
||||
mid = self._next_mid()
|
||||
opts = [(URI_PATH, s.encode()) for s in path_segs]
|
||||
@@ -575,10 +742,9 @@ class DtlsCoapSession:
|
||||
try:
|
||||
self._send_dgram(datagram)
|
||||
if not ev.wait(timeout):
|
||||
raise TimeoutError(
|
||||
f"POST /{'/'.join(path_segs)} timeout")
|
||||
raise SessionTimeoutError()
|
||||
if 'err' in container:
|
||||
raise ConnectionError(container['err'])
|
||||
raise container['err']
|
||||
return container['code'], container['payload']
|
||||
finally:
|
||||
self._pending.pop(tok, None)
|
||||
@@ -595,8 +761,7 @@ class DtlsCoapSession:
|
||||
Real half-open-session detection lives in PollScheduler's
|
||||
`last_success_ts`, surfaced through KeepaliveTask's
|
||||
`liveness_fn`."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self._check_live()
|
||||
mid = self._next_mid()
|
||||
self._send_dgram(build_coap(TYPE_CON, 0, mid, b'', []))
|
||||
return mid
|
||||
@@ -613,8 +778,7 @@ class DtlsCoapSession:
|
||||
tokens via subscribe. Brief race window where a notify on the
|
||||
old token gets dropped as 'stale' — acceptable for a 6h-scale
|
||||
safety net."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self._check_live()
|
||||
for tok, href in list(self._observe_tokens.items()):
|
||||
segs = [s for s in href.split('/') if s]
|
||||
try:
|
||||
@@ -637,8 +801,7 @@ class DtlsCoapSession:
|
||||
|
||||
Returns the token used (in case the caller wants to deregister
|
||||
later)."""
|
||||
if self.conn is None:
|
||||
raise ConnectionError("DTLS session closed")
|
||||
self._check_live()
|
||||
tok = self._next_observe_tok()
|
||||
href = '/' + '/'.join(path_segs)
|
||||
# Register the token BEFORE sending — otherwise the device
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Deterministic UDP endpoint resolution and connected socket setup."""
|
||||
|
||||
import math
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..errors import EndpointError
|
||||
|
||||
__all__ = [
|
||||
'ResolvedUdpEndpoint',
|
||||
'open_connected_udp_socket',
|
||||
'resolve_udp_endpoint',
|
||||
'resolve_udp_endpoints',
|
||||
]
|
||||
|
||||
_SUPPORTED_FAMILIES = (socket.AF_UNSPEC, socket.AF_INET, socket.AF_INET6)
|
||||
|
||||
|
||||
def _validate_port(port, *, allow_zero=False):
|
||||
lower_bound = 0 if allow_zero else 1
|
||||
if isinstance(port, bool) or not isinstance(port, int):
|
||||
raise TypeError('port must be an integer')
|
||||
if not lower_bound <= port <= 65535:
|
||||
raise ValueError(f'port must be between {lower_bound} and 65535')
|
||||
|
||||
|
||||
def _validate_family(family):
|
||||
if isinstance(family, bool) or not isinstance(family, int):
|
||||
raise TypeError('family must be an address-family integer')
|
||||
if family not in _SUPPORTED_FAMILIES:
|
||||
raise ValueError('family must be AF_UNSPEC, AF_INET, or AF_INET6')
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, repr=False)
|
||||
class ResolvedUdpEndpoint:
|
||||
"""One concrete IPv4 or IPv6 UDP destination.
|
||||
|
||||
``sockaddr`` is the exact tuple returned by ``getaddrinfo`` and therefore
|
||||
retains IPv6 flow and scope IDs. The custom representation omits the
|
||||
address, port, and scope so exception and diagnostic output does not leak
|
||||
a remote endpoint accidentally.
|
||||
"""
|
||||
|
||||
family: int
|
||||
sockaddr: tuple
|
||||
|
||||
def __post_init__(self):
|
||||
if self.family not in (socket.AF_INET, socket.AF_INET6):
|
||||
raise ValueError('resolved family must be AF_INET or AF_INET6')
|
||||
expected_length = 2 if self.family == socket.AF_INET else 4
|
||||
if not isinstance(self.sockaddr, tuple) or \
|
||||
len(self.sockaddr) != expected_length:
|
||||
raise ValueError('sockaddr does not match its address family')
|
||||
_validate_port(self.sockaddr[1])
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
return self.sockaddr[0]
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
return self.sockaddr[1]
|
||||
|
||||
@property
|
||||
def scope_id(self):
|
||||
return self.sockaddr[3] if self.family == socket.AF_INET6 else 0
|
||||
|
||||
@property
|
||||
def family_name(self):
|
||||
return socket.AddressFamily(self.family).name
|
||||
|
||||
def bind_address(self, local_port):
|
||||
"""Return the wildcard bind tuple matching this endpoint's family."""
|
||||
_validate_port(local_port, allow_zero=True)
|
||||
if self.family == socket.AF_INET6:
|
||||
return ('::', local_port, 0, 0)
|
||||
return ('', local_port)
|
||||
|
||||
def __repr__(self):
|
||||
return f'ResolvedUdpEndpoint(family={self.family_name})'
|
||||
|
||||
|
||||
def resolve_udp_endpoints(host, port, *, family=socket.AF_UNSPEC):
|
||||
"""Resolve all unique IPv4/IPv6 UDP candidates in resolver order."""
|
||||
if not isinstance(host, (str, bytes)):
|
||||
raise TypeError('host must be a string or bytes value')
|
||||
if not host:
|
||||
raise ValueError('host must be a non-empty string or bytes value')
|
||||
_validate_port(port)
|
||||
_validate_family(family)
|
||||
|
||||
infos = None
|
||||
try:
|
||||
infos = socket.getaddrinfo(
|
||||
host, port, family, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
except (OSError, UnicodeError):
|
||||
pass
|
||||
if infos is None:
|
||||
raise EndpointError() from OSError('UDP endpoint resolution failed')
|
||||
|
||||
endpoints = []
|
||||
seen = set()
|
||||
for resolved_family, socktype, protocol, _canonname, sockaddr in infos:
|
||||
if resolved_family not in (socket.AF_INET, socket.AF_INET6):
|
||||
continue
|
||||
if socktype not in (0, socket.SOCK_DGRAM):
|
||||
continue
|
||||
if protocol not in (0, socket.IPPROTO_UDP):
|
||||
continue
|
||||
expected_length = 2 if resolved_family == socket.AF_INET else 4
|
||||
if not isinstance(sockaddr, tuple) or len(sockaddr) != expected_length:
|
||||
continue
|
||||
key = (resolved_family, sockaddr)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
endpoints.append(ResolvedUdpEndpoint(resolved_family, sockaddr))
|
||||
|
||||
if not endpoints:
|
||||
raise EndpointError()
|
||||
return tuple(endpoints)
|
||||
|
||||
|
||||
def resolve_udp_endpoint(host, port, *, family=socket.AF_UNSPEC):
|
||||
"""Resolve the first usable UDP candidate."""
|
||||
return resolve_udp_endpoints(host, port, family=family)[0]
|
||||
|
||||
|
||||
def open_connected_udp_socket(
|
||||
host, port, *, family=socket.AF_UNSPEC, local_port=None, timeout=None):
|
||||
"""Create, optionally bind, and connect a UDP socket.
|
||||
|
||||
Candidates are tried in resolver order. A connected UDP socket accepts
|
||||
datagrams only from its exact remote peer and lets the caller use
|
||||
``send``/``recv`` instead of passing an address on every operation.
|
||||
"""
|
||||
if local_port is not None:
|
||||
_validate_port(local_port, allow_zero=True)
|
||||
if timeout is not None:
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)):
|
||||
raise TypeError('timeout must be a number or None')
|
||||
if not math.isfinite(timeout) or timeout < 0:
|
||||
raise ValueError('timeout must be a non-negative number or None')
|
||||
|
||||
endpoints = resolve_udp_endpoints(host, port, family=family)
|
||||
for endpoint in endpoints:
|
||||
sock = None
|
||||
try:
|
||||
sock = socket.socket(
|
||||
endpoint.family, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
||||
if local_port is not None:
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(endpoint.bind_address(local_port))
|
||||
sock.connect(endpoint.sockaddr)
|
||||
sock.settimeout(timeout)
|
||||
return sock, endpoint
|
||||
except OSError:
|
||||
if sock is not None:
|
||||
try:
|
||||
sock.close()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
raise EndpointError() from OSError('UDP socket setup failed')
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Port-resolution logic for the MQTT bridge: the stateless pre-flight
|
||||
gate and standard/dynamic OCF port discovery in PushBridge. The DTLS probe is
|
||||
faked so these run without hardware; only routing and selection are exercised.
|
||||
"""
|
||||
import logging
|
||||
import 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='192.0.2.9', ocf_port=ocf_port, index=0)
|
||||
b.descriptor = types.SimpleNamespace(default_observe_port=default)
|
||||
b._discovered_port = discovered
|
||||
b.log = logging.getLogger('test-bridge')
|
||||
return b
|
||||
|
||||
|
||||
def _fake_port_probe(live_ports):
|
||||
"""Return a one-port probe stand-in for the selected live ports."""
|
||||
def fake(ip, port, **kw):
|
||||
alive = port in live_ports
|
||||
return types.SimpleNamespace(
|
||||
port=port,
|
||||
is_dtls_server=alive,
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _fake_port_set(live_ports):
|
||||
"""Return an aggregate probe stand-in with explicit ambiguity."""
|
||||
def fake(ip, ports, **kw):
|
||||
live = tuple(port for port in ports if port in live_ports)
|
||||
if len(live) == 1:
|
||||
outcome = 'selected'
|
||||
selected_port = live[0]
|
||||
elif live:
|
||||
outcome = 'ambiguous'
|
||||
selected_port = None
|
||||
else:
|
||||
outcome = 'unreachable'
|
||||
selected_port = None
|
||||
return types.SimpleNamespace(
|
||||
outcome=outcome,
|
||||
selected_port=selected_port,
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def test_pinned_live_port_is_gated_and_returned(monkeypatch):
|
||||
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe({49155}))
|
||||
b = _mk_bridge(ocf_port=49155)
|
||||
assert b._resolve_port() == 49155
|
||||
|
||||
|
||||
def test_pinned_dead_port_raises_for_backoff(monkeypatch):
|
||||
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_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_dtls_ports', _fake_port_set({49154}))
|
||||
b = _mk_bridge(ocf_port=None, default=49155)
|
||||
assert b._resolve_port() == 49154
|
||||
assert b._discovered_port == 49154
|
||||
|
||||
|
||||
def test_autodiscovery_refuses_ambiguous_live_ports(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
'probe_dtls_ports',
|
||||
_fake_port_set({5684, 49154}),
|
||||
)
|
||||
b = _mk_bridge(ocf_port=None, default=49155)
|
||||
with pytest.raises(ConnectionError, match='multiple DTLS listeners'):
|
||||
b._resolve_port()
|
||||
|
||||
|
||||
def test_cached_live_port_is_reused_without_rediscovery(monkeypatch):
|
||||
# Cached 49156 and the default 49155 are both live; the cache-first
|
||||
# path must return the previously proven port without an ambiguous
|
||||
# full-set probe.
|
||||
monkeypatch.setattr(
|
||||
bridge,
|
||||
'probe_dtls_port',
|
||||
_fake_port_probe({49155, 49156}),
|
||||
)
|
||||
b = _mk_bridge(ocf_port=None, default=49155, discovered=49156)
|
||||
assert b._resolve_port() == 49156
|
||||
|
||||
|
||||
def test_autodiscovery_all_dead_raises_and_clears_cache(monkeypatch):
|
||||
monkeypatch.setattr(bridge, 'probe_dtls_port', _fake_port_probe(set()))
|
||||
monkeypatch.setattr(bridge, 'probe_dtls_ports', _fake_port_set(set()))
|
||||
b = _mk_bridge(ocf_port=None, discovered=49154)
|
||||
with pytest.raises(ConnectionError):
|
||||
b._resolve_port()
|
||||
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 bridge.OCF_STANDARD_SECURE_PORT in cands
|
||||
assert 49200 in cands
|
||||
assert cands == sorted(cands)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from smartthings_local.errors import MalformedMessageError
|
||||
from smartthings_local.protocol.coap import (
|
||||
build_coap, parse_coap, encode_options, block_value, fmt_code,
|
||||
TYPE_CON, METHOD_GET, URI_PATH, ACCEPT, CF_CBOR, BLOCK2,
|
||||
@@ -43,3 +46,13 @@ def test_block_value_promotes_to_two_bytes_when_num_is_large():
|
||||
def test_fmt_code_formats_class_dot_detail():
|
||||
assert fmt_code(0x45) == '2.05'
|
||||
assert fmt_code(0x84) == '4.04'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('option_header', (b'\xf0', b'\x0f'))
|
||||
def test_reserved_option_nibbles_raise_classified_value_error(option_header):
|
||||
datagram = b'\x40\x01\x00\x01' + option_header
|
||||
|
||||
with pytest.raises(MalformedMessageError) as exc:
|
||||
parse_coap(datagram)
|
||||
|
||||
assert isinstance(exc.value, ValueError)
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from smartthings_local.protocol import dtls_probe as p
|
||||
|
||||
|
||||
def _rec(content_type, frag, *, epoch=0):
|
||||
"""Build one DTLS record: 13-byte header + fragment."""
|
||||
return (bytes([content_type])
|
||||
+ b'\xfe\xfd' # DTLS 1.2
|
||||
+ epoch.to_bytes(2, 'big') # epoch
|
||||
+ b'\x00\x00\x00\x00\x00\x00' # sequence number
|
||||
+ len(frag).to_bytes(2, 'big')
|
||||
+ frag)
|
||||
|
||||
|
||||
def _hs(msg_type, body=b''):
|
||||
header = (
|
||||
bytes([msg_type])
|
||||
+ len(body).to_bytes(3, 'big')
|
||||
+ b'\x00\x00' # message sequence
|
||||
+ b'\x00\x00\x00' # fragment offset
|
||||
+ len(body).to_bytes(3, 'big')
|
||||
)
|
||||
return _rec(p._CT_HANDSHAKE, header + body)
|
||||
|
||||
|
||||
def _hvr(cookie=b'cookie'):
|
||||
return _hs(3, b'\xfe\xfd' + bytes([len(cookie)]) + cookie)
|
||||
|
||||
|
||||
def _server_hello():
|
||||
body = (
|
||||
b'\xfe\xfd'
|
||||
+ b'\x00' * 32
|
||||
+ b'\x00' # session ID length
|
||||
+ b'\xc0\x2b' # ECDHE-ECDSA-AES128-GCM-SHA256
|
||||
+ b'\x00' # null compression
|
||||
)
|
||||
return _hs(2, body)
|
||||
|
||||
|
||||
def _alert(level, desc, *, epoch=0):
|
||||
return _rec(p._CT_ALERT, bytes([level, desc]), epoch=epoch)
|
||||
|
||||
|
||||
def test_classify_hello_verify_request():
|
||||
assert p.classify_datagram(_hvr()) == [
|
||||
(p._CT_HANDSHAKE, 'HelloVerifyRequest')]
|
||||
|
||||
|
||||
def test_classify_coalesced_server_flight():
|
||||
# OpenSSL commonly hands back ServerHello+Certificate back-to-back.
|
||||
dgram = _server_hello() + _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=0.1)
|
||||
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
|
||||
self.destination = None
|
||||
|
||||
def settimeout(self, t):
|
||||
self._timeout = t
|
||||
|
||||
def setsockopt(self, *a):
|
||||
pass
|
||||
|
||||
def bind(self, *a):
|
||||
pass
|
||||
|
||||
def connect(self, destination):
|
||||
self.destination = destination
|
||||
|
||||
def send(self, data):
|
||||
self.sends.append(data)
|
||||
return len(data)
|
||||
|
||||
def sendto(self, data, dest):
|
||||
self.sends.append(data)
|
||||
return len(data)
|
||||
|
||||
def recv(self, n):
|
||||
self.recv_calls += 1
|
||||
resp = self._responder(self)
|
||||
if resp is None:
|
||||
time.sleep(self._timeout)
|
||||
raise TimeoutError()
|
||||
return resp
|
||||
|
||||
def recvfrom(self, n):
|
||||
self.recv_calls += 1
|
||||
resp = self._responder(self)
|
||||
if resp is None:
|
||||
time.sleep(self._timeout)
|
||||
raise TimeoutError()
|
||||
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 _fake: _hvr())
|
||||
_patch_sock(monkeypatch, fake)
|
||||
r = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
|
||||
assert r.outcome == p.LIVE
|
||||
assert len(fake.sends) == 1 # only the initial ClientHello
|
||||
assert fake.recv_calls == 1 # stopped on the first flight
|
||||
assert fake.closed
|
||||
|
||||
|
||||
def test_stateless_probe_preserves_first_flight_alert(monkeypatch):
|
||||
fake = _FakeSock(lambda _fake: _alert(2, 48))
|
||||
_patch_sock(monkeypatch, fake)
|
||||
|
||||
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
|
||||
|
||||
assert result.outcome == p.REJECTED
|
||||
assert result.alert == (2, 'unknown_ca')
|
||||
assert len(fake.sends) == 1
|
||||
|
||||
|
||||
def test_stateless_warning_alert_proves_liveness_without_fatal_rejection(
|
||||
monkeypatch):
|
||||
fake = _FakeSock(lambda _fake: _alert(1, 90))
|
||||
_patch_sock(monkeypatch, fake)
|
||||
|
||||
result = p.probe('127.0.0.1', 5684, stateless=True, timeout=0.2)
|
||||
|
||||
assert result.outcome == p.LIVE
|
||||
assert result.is_dtls_server
|
||||
assert result.alert == (1, 'user_canceled')
|
||||
|
||||
|
||||
def test_retransmit_recovers_from_dropped_first_flight(monkeypatch):
|
||||
# The first ClientHello is "lost" (recvfrom times out) until OpenSSL's
|
||||
# retransmit timer fires a second flight; only then does the server
|
||||
# answer. A single dropped datagram must NOT read as DEAD.
|
||||
fake = _FakeSock(lambda f: _hvr() if len(f.sends) >= 2
|
||||
else None)
|
||||
_patch_sock(monkeypatch, fake)
|
||||
r = p.probe('127.0.0.1', 5684, stateless=True, retries=2, timeout=0.3)
|
||||
assert r.outcome == p.LIVE
|
||||
assert len(fake.sends) == 2 # initial + one retransmit
|
||||
assert fake.sends[0] == fake.sends[1]
|
||||
|
||||
|
||||
def test_silent_port_is_dead_only_after_flight_budget(monkeypatch):
|
||||
# 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=0.2)
|
||||
assert r.outcome == p.DEAD
|
||||
assert not r.is_dtls_server
|
||||
assert len(fake.sends) == 2 # initial + retries(1) retransmit
|
||||
|
||||
|
||||
def test_explicit_diagnostic_feeds_server_flight_back(monkeypatch):
|
||||
# The explicitly named diagnostic must NOT stop at the
|
||||
# HelloVerifyRequest: it feeds the flight back into OpenSSL to drive the
|
||||
# handshake onward (the #16 characterization path). The
|
||||
# fed-back record makes OpenSSL emit a cookie-bearing second ClientHello,
|
||||
# which is precisely what proves the diagnostic did not short-circuit.
|
||||
fake = _FakeSock(
|
||||
lambda f: _hvr() if f.recv_calls == 1 else None)
|
||||
_patch_sock(monkeypatch, fake)
|
||||
r = p.diagnose_dtls_handshake('127.0.0.1', 5684, timeout=0.3)
|
||||
assert r.outcome == p.LIVE # HVR still proved liveness
|
||||
assert len(fake.sends) >= 2 # OpenSSL processed the flight
|
||||
|
||||
|
||||
def test_stateless_probe_ignores_unrelated_datagram_without_retransmit(
|
||||
monkeypatch):
|
||||
responses = iter((
|
||||
_rec(p._CT_APP_DATA, b'unrelated'),
|
||||
_hvr(),
|
||||
))
|
||||
fake = _FakeSock(lambda _fake: next(responses))
|
||||
_patch_sock(monkeypatch, fake)
|
||||
|
||||
result = p.probe_dtls_port(
|
||||
'127.0.0.1', 5684, retries=1, timeout=0.2)
|
||||
|
||||
assert result.response_kind == p.HELLO_VERIFY_REQUEST
|
||||
assert result.attempts == 1
|
||||
assert len(fake.sends) == 1
|
||||
assert fake.recv_calls == 2
|
||||
|
||||
|
||||
def test_stateless_probe_forwards_explicit_address_family(monkeypatch):
|
||||
fake = _FakeSock(lambda _fake: _hvr())
|
||||
calls = []
|
||||
|
||||
def open_socket(host, port, *, family, timeout):
|
||||
calls.append((host, port, family, timeout))
|
||||
fake.settimeout(timeout)
|
||||
return fake, object()
|
||||
|
||||
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
|
||||
|
||||
result = p.probe_dtls_port(
|
||||
'appliance.invalid', 5684, family=socket.AF_INET6, timeout=0.2)
|
||||
|
||||
assert result.is_dtls_server
|
||||
assert calls == [('appliance.invalid', 5684, socket.AF_INET6, 0.2 / 3)]
|
||||
|
||||
|
||||
def test_client_hello_flight_is_complete_epoch_zero_dtls():
|
||||
flight = p._client_hello_flight(mtu=1200)
|
||||
|
||||
assert flight
|
||||
assert all(len(record) <= 1200 for record in flight)
|
||||
assert all(record[1:3] in p._DTLS_VERSIONS for record in flight)
|
||||
assert all(record[3:5] == b'\x00\x00' for record in flight)
|
||||
assert any(
|
||||
record[0] == p._CT_HANDSHAKE and record[13] == 1
|
||||
for record in flight
|
||||
)
|
||||
|
||||
|
||||
def test_liveness_classifier_accepts_first_flight_response_classes():
|
||||
assert p._classify_liveness_response(_hvr()) == \
|
||||
p.HELLO_VERIFY_REQUEST
|
||||
assert p._classify_liveness_response(_server_hello()) == \
|
||||
p.SERVER_HELLO
|
||||
assert p._classify_liveness_response(_alert(2, 48)) == p.ALERT
|
||||
|
||||
|
||||
def test_liveness_classifier_rejects_truncated_or_nonzero_epoch():
|
||||
assert p._classify_liveness_response(_hvr()[:-1]) is None
|
||||
assert p._classify_liveness_response(_hs(3)) is None
|
||||
assert p._classify_liveness_response(_hs(2, b'\x00' * 20)) is None
|
||||
nonzero_epoch = bytearray(_hvr())
|
||||
nonzero_epoch[4] = 1
|
||||
assert p._classify_liveness_response(bytes(nonzero_epoch)) is None
|
||||
|
||||
|
||||
def test_liveness_alert_detail_comes_from_valid_epoch_zero_record(monkeypatch):
|
||||
datagram = _alert(2, 40, epoch=1) + _alert(2, 48)
|
||||
fake = _FakeSock(lambda _fake: datagram)
|
||||
_patch_sock(monkeypatch, fake)
|
||||
|
||||
result = p.probe_dtls_port('127.0.0.1', 5684, timeout=0.2)
|
||||
|
||||
assert result.response_kind == p.ALERT
|
||||
assert result.alert == (2, 'unknown_ca')
|
||||
|
||||
|
||||
def _liveness(port, *, live=True, error_code=None):
|
||||
return p.DtlsLivenessResult(
|
||||
port=port,
|
||||
response_kind=p.HELLO_VERIFY_REQUEST if live else None,
|
||||
attempts=1,
|
||||
error_code=error_code,
|
||||
)
|
||||
|
||||
|
||||
def test_multi_port_probe_runs_concurrently_and_preserves_order(monkeypatch):
|
||||
ports = (5684, 49154, 49155)
|
||||
barrier = threading.Barrier(len(ports))
|
||||
|
||||
def fake_probe(_host, port, **_kwargs):
|
||||
barrier.wait(timeout=2.0)
|
||||
return _liveness(port, live=port == 5684)
|
||||
|
||||
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
|
||||
monkeypatch.setattr(p, '_probe_dtls_port_with_flight', fake_probe)
|
||||
|
||||
result = p.probe_dtls_ports('appliance.invalid', ports)
|
||||
|
||||
assert result.outcome == p.SELECTED
|
||||
assert result.selected_port == 5684
|
||||
assert tuple(item.port for item in result.results) == ports
|
||||
assert not any(
|
||||
thread.name.startswith('smartthings-dtls-probe')
|
||||
for thread in threading.enumerate()
|
||||
)
|
||||
|
||||
|
||||
def test_multi_port_probe_reports_ambiguity_without_guessing(monkeypatch):
|
||||
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
|
||||
monkeypatch.setattr(
|
||||
p,
|
||||
'_probe_dtls_port_with_flight',
|
||||
lambda _host, port, **_kwargs: _liveness(port),
|
||||
)
|
||||
|
||||
result = p.probe_dtls_ports('appliance.invalid', (5684, 49154))
|
||||
|
||||
assert result.outcome == p.AMBIGUOUS
|
||||
assert result.selected_port is None
|
||||
assert result.live_ports == (5684, 49154)
|
||||
|
||||
|
||||
def test_multi_port_probe_prefers_previously_proven_listener(monkeypatch):
|
||||
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
|
||||
monkeypatch.setattr(
|
||||
p,
|
||||
'_probe_dtls_port_with_flight',
|
||||
lambda _host, port, **_kwargs: _liveness(port),
|
||||
)
|
||||
|
||||
result = p.probe_dtls_ports(
|
||||
'appliance.invalid',
|
||||
(5684, 49154),
|
||||
preferred_port=49154,
|
||||
)
|
||||
|
||||
assert result.outcome == p.SELECTED
|
||||
assert result.selected_port == 49154
|
||||
|
||||
|
||||
def test_multi_port_probe_folds_worker_failure_into_redacted_result(monkeypatch):
|
||||
monkeypatch.setattr(p, '_client_hello_flight', lambda **_kwargs: (b'hello',))
|
||||
monkeypatch.setattr(
|
||||
p,
|
||||
'_probe_dtls_port_with_flight',
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError('private')),
|
||||
)
|
||||
|
||||
result = p.probe_dtls_ports('private-host.invalid', (5684,))
|
||||
|
||||
assert result.outcome == p.UNREACHABLE
|
||||
assert result.results[0].error_code == 'probe_worker_failed'
|
||||
assert 'private-host' not in repr(result)
|
||||
assert 'private' not in repr(result)
|
||||
|
||||
|
||||
def test_multi_port_probe_bounds_candidate_count():
|
||||
with pytest.raises(ValueError, match='at most 32'):
|
||||
p.probe_dtls_ports('appliance.invalid', tuple(range(1, 34)))
|
||||
|
||||
|
||||
def test_multi_port_probe_rejects_invalid_family_before_starting_workers():
|
||||
with pytest.raises(ValueError, match='family'):
|
||||
p.probe_dtls_ports(
|
||||
'appliance.invalid',
|
||||
(5684, 49154),
|
||||
family=9999,
|
||||
)
|
||||
|
||||
|
||||
def test_diagnostic_honors_timeout_below_half_second(monkeypatch):
|
||||
now = [10.0]
|
||||
|
||||
class BudgetSocket:
|
||||
def __init__(self):
|
||||
self.timeout = None
|
||||
self.timeouts = []
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeout = timeout
|
||||
self.timeouts.append(timeout)
|
||||
|
||||
def send(self, data):
|
||||
return len(data)
|
||||
|
||||
def recv(self, _size):
|
||||
now[0] += self.timeout
|
||||
raise TimeoutError()
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
sock = BudgetSocket()
|
||||
open_timeouts = []
|
||||
|
||||
def open_socket(_host, _port, *, family, timeout):
|
||||
assert family == socket.AF_UNSPEC
|
||||
open_timeouts.append(timeout)
|
||||
sock.settimeout(timeout)
|
||||
return sock, object()
|
||||
|
||||
monkeypatch.setattr(p, 'open_connected_udp_socket', open_socket)
|
||||
monkeypatch.setattr(p.time, 'monotonic', lambda: now[0])
|
||||
|
||||
result = p.diagnose_dtls_handshake(
|
||||
'appliance.invalid',
|
||||
5684,
|
||||
timeout=0.1,
|
||||
retries=0,
|
||||
)
|
||||
|
||||
assert result.outcome == p.DEAD
|
||||
assert open_timeouts == [0.1]
|
||||
assert sock.timeouts and max(sock.timeouts) <= 0.1
|
||||
assert now[0] <= 10.1
|
||||
|
||||
|
||||
def test_cli_bounds_port_fanout(capsys):
|
||||
result = p._main([
|
||||
'appliance.invalid',
|
||||
*(str(port) for port in range(1, 34)),
|
||||
])
|
||||
|
||||
assert result == 2
|
||||
assert 'at most 32 PORT values' in capsys.readouterr().out
|
||||
@@ -1,31 +1,69 @@
|
||||
import gc
|
||||
import traceback
|
||||
import weakref
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from OpenSSL import SSL, crypto
|
||||
|
||||
from smartthings_local.protocol.auth import CertificateAuth
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession, _load_pem_chain
|
||||
|
||||
|
||||
def _make_self_signed_pem_pair():
|
||||
"""A throwaway self-signed cert + key, just to exercise PEM loading —
|
||||
not meant to resemble a real Samsung client cert."""
|
||||
key = crypto.PKey()
|
||||
key.generate_key(crypto.TYPE_RSA, 2048)
|
||||
def _make_generated_pem_chain():
|
||||
"""Create a throwaway leaf + root chain unrelated to Samsung devices."""
|
||||
now = datetime.now(timezone.utc)
|
||||
root_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
root_name = x509.Name(
|
||||
[x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic test root")]
|
||||
)
|
||||
root_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(root_name)
|
||||
.issuer_name(root_name)
|
||||
.public_key(root_key.public_key())
|
||||
.serial_number(1)
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(hours=1))
|
||||
.add_extension(x509.BasicConstraints(ca=True, path_length=None), True)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
cert = crypto.X509()
|
||||
cert.get_subject().CN = "test"
|
||||
cert.set_serial_number(1)
|
||||
cert.gmtime_adj_notBefore(0)
|
||||
cert.gmtime_adj_notAfter(3600)
|
||||
cert.set_issuer(cert.get_subject())
|
||||
cert.set_pubkey(key)
|
||||
cert.sign(key, "sha256")
|
||||
leaf_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
leaf_name = x509.Name(
|
||||
[x509.NameAttribute(NameOID.COMMON_NAME, "Synthetic test client")]
|
||||
)
|
||||
leaf_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(leaf_name)
|
||||
.issuer_name(root_name)
|
||||
.public_key(leaf_key.public_key())
|
||||
.serial_number(2)
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(hours=1))
|
||||
.add_extension(x509.BasicConstraints(ca=False, path_length=None), True)
|
||||
.sign(root_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
|
||||
key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key).decode()
|
||||
cert_pem = (
|
||||
leaf_cert.public_bytes(serialization.Encoding.PEM)
|
||||
+ root_cert.public_bytes(serialization.Encoding.PEM)
|
||||
).decode()
|
||||
key_pem = leaf_key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
).decode()
|
||||
return cert_pem, key_pem
|
||||
|
||||
|
||||
def test_load_pem_chain_loads_cert_and_key_in_memory():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
ctx = SSL.Context(SSL.DTLS_METHOD)
|
||||
_load_pem_chain(ctx, cert_pem, key_pem)
|
||||
ctx.check_privatekey() # raises if cert/key don't match
|
||||
@@ -37,7 +75,7 @@ def test_load_pem_chain_rejects_cert_pem_with_no_certificates():
|
||||
|
||||
|
||||
def test_session_requires_exactly_one_cert_source():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234) # neither pair given
|
||||
@@ -49,10 +87,226 @@ def test_session_requires_exactly_one_cert_source():
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234, cert_pem=cert_pem) # key_pem missing
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
DtlsCoapSession("host", 1234, cert_path="/a") # key_path missing
|
||||
|
||||
|
||||
def test_session_rejects_provider_with_legacy_certificate_arguments():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
with pytest.raises(ValueError, match="auth or legacy certificate"):
|
||||
DtlsCoapSession(
|
||||
"host",
|
||||
1234,
|
||||
cert_pem=cert_pem,
|
||||
key_pem=key_pem,
|
||||
auth=auth,
|
||||
)
|
||||
|
||||
|
||||
def test_session_rejects_object_that_is_not_an_authentication_provider():
|
||||
with pytest.raises(TypeError, match="AuthenticationProvider"):
|
||||
DtlsCoapSession("host", 1234, auth=object())
|
||||
|
||||
|
||||
def test_session_accepts_explicit_certificate_provider():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
session = DtlsCoapSession("host", 1234, auth=auth)
|
||||
|
||||
assert session.auth is auth
|
||||
assert session.cert_path is None
|
||||
assert session.key_path is None
|
||||
assert session.cert_pem is None
|
||||
assert session.key_pem is None
|
||||
|
||||
|
||||
def test_session_retains_authentication_provider_for_its_lifetime():
|
||||
class RetainedProvider:
|
||||
def configure_context(self, _context):
|
||||
return None
|
||||
|
||||
auth = RetainedProvider()
|
||||
reference = weakref.ref(auth)
|
||||
session = DtlsCoapSession("host", 1234, auth=auth)
|
||||
|
||||
del auth
|
||||
gc.collect()
|
||||
assert reference() is session.auth
|
||||
|
||||
|
||||
def test_session_accepts_pem_pair():
|
||||
cert_pem, key_pem = _make_self_signed_pem_pair()
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
sess = DtlsCoapSession("host", 1234, cert_pem=cert_pem, key_pem=key_pem)
|
||||
assert sess.cert_path is None
|
||||
assert sess.key_path is None
|
||||
assert sess.cert_pem == cert_pem
|
||||
assert isinstance(sess.auth, CertificateAuth)
|
||||
|
||||
|
||||
def test_session_routes_legacy_file_pair_through_certificate_auth(tmp_path):
|
||||
cert_path = tmp_path / "client.pem"
|
||||
key_path = tmp_path / "client-key.pem"
|
||||
session = DtlsCoapSession(
|
||||
"host",
|
||||
1234,
|
||||
cert_path=cert_path,
|
||||
key_path=key_path,
|
||||
)
|
||||
|
||||
assert isinstance(session.auth, CertificateAuth)
|
||||
assert session.cert_path == str(cert_path)
|
||||
assert session.key_path == str(key_path)
|
||||
|
||||
|
||||
def test_certificate_auth_loads_generated_chain_from_memory_and_files(tmp_path):
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
|
||||
memory_context = SSL.Context(SSL.DTLS_METHOD)
|
||||
CertificateAuth.from_memory(cert_pem, key_pem).configure_context(
|
||||
memory_context
|
||||
)
|
||||
memory_context.check_privatekey()
|
||||
|
||||
cert_path = tmp_path / "client.pem"
|
||||
key_path = tmp_path / "client-key.pem"
|
||||
cert_path.write_text(cert_pem)
|
||||
key_path.write_text(key_pem)
|
||||
file_context = SSL.Context(SSL.DTLS_METHOD)
|
||||
CertificateAuth.from_files(cert_path, key_path).configure_context(file_context)
|
||||
file_context.check_privatekey()
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_invalid_memory_material():
|
||||
auth = CertificateAuth.from_memory("not a certificate", "not a key")
|
||||
with pytest.raises(ValueError, match="No certificates found"):
|
||||
auth.configure_context(SSL.Context(SSL.DTLS_METHOD))
|
||||
|
||||
|
||||
def test_invalid_certificate_error_does_not_include_credential_material():
|
||||
marker = "credential" + "-marker"
|
||||
certificate_blob = (
|
||||
"-----BEGIN CERTIFICATE-----\n"
|
||||
f"{marker}\n"
|
||||
"-----END CERTIFICATE-----\n"
|
||||
)
|
||||
key_blob = "invalid-" + marker
|
||||
auth = CertificateAuth.from_memory(certificate_blob, key_blob)
|
||||
|
||||
with pytest.raises(crypto.Error) as captured:
|
||||
auth.configure_context(SSL.Context(SSL.DTLS_METHOD))
|
||||
rendered = (
|
||||
str(captured.value)
|
||||
+ repr(captured.value)
|
||||
+ "".join(traceback.format_exception(captured.value))
|
||||
)
|
||||
assert marker not in rendered
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_invalid_file_material(tmp_path):
|
||||
cert_path = tmp_path / "invalid.pem"
|
||||
key_path = tmp_path / "invalid-key.pem"
|
||||
cert_path.write_text("invalid")
|
||||
key_path.write_text("invalid")
|
||||
|
||||
with pytest.raises(SSL.Error):
|
||||
CertificateAuth.from_files(cert_path, key_path).configure_context(
|
||||
SSL.Context(SSL.DTLS_METHOD)
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_auth_rejects_incomplete_or_mixed_sources():
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth()
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(certificate_path="/synthetic/client.pem")
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(certificate_pem="certificate")
|
||||
|
||||
certificate_path = "/synthetic/client.pem"
|
||||
key_path = "/synthetic/client-key.pem"
|
||||
certificate_data = "certificate"
|
||||
key_data = "key"
|
||||
with pytest.raises(ValueError):
|
||||
CertificateAuth(
|
||||
certificate_path=certificate_path,
|
||||
private_key_path=key_path,
|
||||
certificate_pem=certificate_data,
|
||||
private_key_pem=key_data,
|
||||
)
|
||||
|
||||
|
||||
def test_certificate_auth_is_immutable_and_has_secret_safe_repr():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
rendered = repr(auth)
|
||||
assert rendered == "CertificateAuth()"
|
||||
assert cert_pem not in rendered
|
||||
assert key_pem not in rendered
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
auth.certificate_pem = None
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
del auth._certificate_pem
|
||||
|
||||
|
||||
def test_certificate_auth_has_no_public_or_dataclass_credential_surface():
|
||||
cert_pem, key_pem = _make_generated_pem_chain()
|
||||
auth = CertificateAuth.from_memory(cert_pem, key_pem)
|
||||
|
||||
assert not hasattr(auth, "certificate_pem")
|
||||
assert not hasattr(auth, "private_key_pem")
|
||||
with pytest.raises(TypeError):
|
||||
vars(auth)
|
||||
with pytest.raises(TypeError):
|
||||
asdict(auth)
|
||||
|
||||
|
||||
def test_certificate_auth_context_setup_matches_legacy_happy_path():
|
||||
class RecordingContext:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
self.verify_callback = None
|
||||
|
||||
def load_verify_locations(self, path):
|
||||
self.calls.append(("load_verify_locations", path))
|
||||
|
||||
def set_verify(self, mode, callback):
|
||||
self.calls.append(("set_verify", mode))
|
||||
self.verify_callback = callback
|
||||
|
||||
def set_cipher_list(self, ciphers):
|
||||
self.calls.append(("set_cipher_list", ciphers))
|
||||
|
||||
def use_certificate_chain_file(self, path):
|
||||
self.calls.append(("use_certificate_chain_file", path))
|
||||
|
||||
def use_privatekey_file(self, path):
|
||||
self.calls.append(("use_privatekey_file", path))
|
||||
|
||||
def check_privatekey(self):
|
||||
self.calls.append(("check_privatekey",))
|
||||
|
||||
context = RecordingContext()
|
||||
CertificateAuth.from_files(
|
||||
"/synthetic/client.pem",
|
||||
"/synthetic/client-key.pem",
|
||||
).configure_context(context)
|
||||
|
||||
assert [call[0] for call in context.calls] == [
|
||||
"load_verify_locations",
|
||||
"set_verify",
|
||||
"set_cipher_list",
|
||||
"use_certificate_chain_file",
|
||||
"use_privatekey_file",
|
||||
"check_privatekey",
|
||||
]
|
||||
assert context.calls[1] == ("set_verify", SSL.VERIFY_PEER)
|
||||
assert context.calls[2] == (
|
||||
"set_cipher_list",
|
||||
b"ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0",
|
||||
)
|
||||
callback = context.verify_callback
|
||||
assert callback(None, None, 0, 0, True) is True
|
||||
assert callback(None, None, 1, 0, False) is False
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Reader-thread death visibility (QuiteYellow/SmartThings-Local#37).
|
||||
|
||||
A connected UDP socket surfaces ICMP errors on recv; before this the
|
||||
reader exited silently on the first one and every later request waited
|
||||
out its full timeout against a session nobody was reading. These tests
|
||||
pin the three behaviours that fixed it: advisory ICMP errnos keep the
|
||||
reader alive, a real socket error exits with a WARNING and clears
|
||||
_reader_running, and callers then fail fast with SessionClosedError.
|
||||
"""
|
||||
import errno
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import SessionClosedError
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
_LOGGER_NAME = "smartthings_local.protocol.dtls_session"
|
||||
|
||||
|
||||
class _NullAuth:
|
||||
"""Structural AuthenticationProvider — never configured, we skip connect()."""
|
||||
|
||||
def configure_context(self, _context):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
"""Minimal stand-in for SSL.Connection: each datagram written to the
|
||||
BIO surfaces as one decrypted packet on the next recv(), then
|
||||
WantReadError like a drained DTLS record buffer."""
|
||||
|
||||
def __init__(self):
|
||||
self._decrypted = []
|
||||
|
||||
def bio_write(self, datagram):
|
||||
self._decrypted.append(datagram)
|
||||
|
||||
def recv(self, _n):
|
||||
if self._decrypted:
|
||||
return self._decrypted.pop(0)
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_read(self, _n):
|
||||
return b""
|
||||
|
||||
def send(self, _datagram):
|
||||
return None
|
||||
|
||||
def shutdown(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeSock:
|
||||
"""Scripted UDP socket. Each step is bytes to return, an exception to
|
||||
raise, or a callable to run (then a timeout, so the loop re-checks
|
||||
_stop). An exhausted script blocks like a real recv timeout; once
|
||||
close()d it raises EBADF the way a closed fd does."""
|
||||
|
||||
def __init__(self, steps=()):
|
||||
self._steps = list(steps)
|
||||
self.closed = False
|
||||
self.timeout = None
|
||||
|
||||
def settimeout(self, value):
|
||||
self.timeout = value
|
||||
|
||||
def recv(self, _n):
|
||||
if self.closed:
|
||||
raise OSError(errno.EBADF, "bad file descriptor")
|
||||
if self._steps:
|
||||
step = self._steps.pop(0)
|
||||
if callable(step):
|
||||
step()
|
||||
raise socket.timeout()
|
||||
if isinstance(step, BaseException):
|
||||
raise step
|
||||
return step
|
||||
time.sleep(0.01)
|
||||
raise socket.timeout()
|
||||
|
||||
def send(self, data):
|
||||
return len(data)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _make_session():
|
||||
sess = DtlsCoapSession("host", 1234, auth=_NullAuth())
|
||||
sess.conn = _FakeConn()
|
||||
return sess
|
||||
|
||||
|
||||
def _run_reader(sess, steps, timeout=2.0):
|
||||
sess.sock = _FakeSock(steps)
|
||||
sess.start_reader()
|
||||
sess._reader_thread.join(timeout)
|
||||
assert not sess._reader_thread.is_alive(), "reader thread did not exit"
|
||||
|
||||
|
||||
def test_advisory_icmp_error_does_not_kill_reader(caplog):
|
||||
sess = _make_session()
|
||||
dispatched = []
|
||||
sess._dispatch_coap = dispatched.append
|
||||
|
||||
with caplog.at_level(logging.DEBUG, logger=_LOGGER_NAME):
|
||||
_run_reader(sess, [
|
||||
OSError(errno.ECONNREFUSED, "connection refused"),
|
||||
b"\x60\x00\x00\x00", # survives, gets dispatched
|
||||
lambda: sess._stop.set(), # end the loop cleanly
|
||||
])
|
||||
|
||||
assert dispatched == [b"\x60\x00\x00\x00"]
|
||||
assert not sess._reader_running.is_set()
|
||||
assert any(r.levelno == logging.DEBUG and "advisory" in r.getMessage()
|
||||
for r in caplog.records)
|
||||
# An advisory errno is not a real exit — no WARNING.
|
||||
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_fatal_socket_error_exits_with_warning(caplog):
|
||||
sess = _make_session()
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
|
||||
_run_reader(sess, [OSError(errno.EBADF, "bad file descriptor")])
|
||||
|
||||
assert not sess._reader_running.is_set()
|
||||
warnings = [r for r in caplog.records if r.levelno == logging.WARNING]
|
||||
assert len(warnings) == 1
|
||||
assert "reader exiting" in warnings[0].getMessage()
|
||||
|
||||
|
||||
def test_request_fails_fast_after_reader_death():
|
||||
sess = _make_session()
|
||||
_run_reader(sess, [OSError(errno.EBADF, "bad file descriptor")])
|
||||
assert not sess._reader_running.is_set()
|
||||
|
||||
start = time.monotonic()
|
||||
with pytest.raises(SessionClosedError):
|
||||
sess.get(["oic", "d"], timeout=10.0)
|
||||
elapsed = time.monotonic() - start
|
||||
# The whole point: no waiting out the request timeout.
|
||||
assert elapsed < 1.0, f"get() waited {elapsed:.2f}s instead of failing fast"
|
||||
|
||||
|
||||
def test_close_does_not_log_warning_on_teardown(caplog):
|
||||
sess = _make_session()
|
||||
sess.sock = _FakeSock() # empty script: blocks on recv
|
||||
sess.start_reader()
|
||||
time.sleep(0.05) # let the reader reach recv
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=_LOGGER_NAME):
|
||||
sess.close()
|
||||
sess._reader_thread.join(2.0)
|
||||
|
||||
assert not sess._reader_thread.is_alive()
|
||||
assert not sess._reader_running.is_set()
|
||||
assert not any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_check_live_without_reader_matches_old_conn_guard():
|
||||
sess = _make_session() # conn set, reader never started
|
||||
assert sess._reader_thread is None
|
||||
sess._check_live() # must not raise — config-flow behaviour
|
||||
|
||||
sess.conn = None
|
||||
with pytest.raises(SessionClosedError):
|
||||
sess._check_live()
|
||||
@@ -0,0 +1,423 @@
|
||||
import socket
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import EndpointError
|
||||
from smartthings_local.protocol import dtls_session
|
||||
from smartthings_local.protocol.endpoint import (
|
||||
ResolvedUdpEndpoint,
|
||||
open_connected_udp_socket,
|
||||
resolve_udp_endpoint,
|
||||
resolve_udp_endpoints,
|
||||
)
|
||||
|
||||
|
||||
def _addrinfo(family, sockaddr, *, socktype=socket.SOCK_DGRAM,
|
||||
protocol=socket.IPPROTO_UDP):
|
||||
return family, socktype, protocol, '', sockaddr
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, family, *, fail_bind=False, fail_connect=False):
|
||||
self.family = family
|
||||
self.fail_bind = fail_bind
|
||||
self.fail_connect = fail_connect
|
||||
self.options = []
|
||||
self.bound = None
|
||||
self.peer = None
|
||||
self.timeout = None
|
||||
self.closed = False
|
||||
self.sent = []
|
||||
self.inbound = []
|
||||
|
||||
def setsockopt(self, *args):
|
||||
self.options.append(args)
|
||||
|
||||
def bind(self, address):
|
||||
if self.fail_bind:
|
||||
raise OSError('synthetic bind failure')
|
||||
self.bound = address
|
||||
|
||||
def connect(self, address):
|
||||
if self.fail_connect:
|
||||
raise OSError('synthetic connect failure')
|
||||
self.peer = address
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeout = timeout
|
||||
|
||||
def send(self, data):
|
||||
self.sent.append(data)
|
||||
return len(data)
|
||||
|
||||
def recv(self, _size):
|
||||
return self.inbound.pop(0)
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _socket_factory(monkeypatch, failures=()):
|
||||
created = []
|
||||
remaining = list(failures)
|
||||
|
||||
def factory(family, _socktype, _protocol):
|
||||
failure = remaining.pop(0) if remaining else None
|
||||
sock = FakeSocket(
|
||||
family,
|
||||
fail_bind=failure == 'bind',
|
||||
fail_connect=failure == 'connect',
|
||||
)
|
||||
created.append(sock)
|
||||
return sock
|
||||
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.socket', factory)
|
||||
return created
|
||||
|
||||
|
||||
def test_resolve_ipv4_endpoint_and_redacted_repr(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo',
|
||||
lambda *args: [_addrinfo(socket.AF_INET, ('192.0.2.10', 5684))],
|
||||
)
|
||||
|
||||
endpoint = resolve_udp_endpoint('device.example', 5684)
|
||||
|
||||
assert endpoint.family == socket.AF_INET
|
||||
assert endpoint.host == '192.0.2.10'
|
||||
assert endpoint.port == 5684
|
||||
assert endpoint.scope_id == 0
|
||||
assert repr(endpoint) == 'ResolvedUdpEndpoint(family=AF_INET)'
|
||||
assert '192.0.2.10' not in repr(endpoint)
|
||||
assert '5684' not in repr(endpoint)
|
||||
|
||||
|
||||
def test_resolve_scoped_ipv6_preserves_flow_and_scope(monkeypatch):
|
||||
sockaddr = ('2001:db8::1', 5684, 3, 7)
|
||||
calls = []
|
||||
|
||||
def resolve(*args):
|
||||
calls.append(args)
|
||||
return [_addrinfo(socket.AF_INET6, sockaddr)]
|
||||
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo',
|
||||
resolve,
|
||||
)
|
||||
|
||||
endpoint = resolve_udp_endpoint(
|
||||
'device.example', 5684, family=socket.AF_INET6)
|
||||
|
||||
assert endpoint.sockaddr == sockaddr
|
||||
assert endpoint.scope_id == 7
|
||||
assert endpoint.bind_address(55000) == ('::', 55000, 0, 0)
|
||||
assert '2001:db8::1' not in repr(endpoint)
|
||||
assert '7' not in repr(endpoint)
|
||||
assert calls == [(
|
||||
'device.example', 5684, socket.AF_INET6,
|
||||
socket.SOCK_DGRAM, socket.IPPROTO_UDP)]
|
||||
|
||||
|
||||
def test_resolver_order_is_stable_and_duplicates_are_removed(monkeypatch):
|
||||
first = _addrinfo(socket.AF_INET6, ('2001:db8::10', 5684, 0, 0))
|
||||
second = _addrinfo(socket.AF_INET, ('198.51.100.20', 5684))
|
||||
ignored = _addrinfo(
|
||||
socket.AF_INET, ('203.0.113.30', 5684), socktype=socket.SOCK_STREAM)
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo',
|
||||
lambda *args: [first, first, ignored, second],
|
||||
)
|
||||
|
||||
endpoints = resolve_udp_endpoints('device.example', 5684)
|
||||
|
||||
assert [endpoint.sockaddr for endpoint in endpoints] == [
|
||||
first[-1], second[-1]]
|
||||
|
||||
|
||||
def test_resolver_failure_raises_redacted_endpoint_error(monkeypatch):
|
||||
def fail(*args):
|
||||
raise socket.gaierror('credential-value at device.example')
|
||||
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo', fail)
|
||||
remote_host = 'device.example'
|
||||
|
||||
with pytest.raises(EndpointError) as exc:
|
||||
resolve_udp_endpoint(remote_host, 5684)
|
||||
|
||||
formatted = ''.join(traceback.format_exception(exc.value))
|
||||
assert isinstance(exc.value, OSError)
|
||||
assert exc.value.__context__ is None
|
||||
assert 'UDP endpoint resolution failed' in formatted
|
||||
assert 'credential-value' not in formatted
|
||||
assert 'device.example' not in formatted
|
||||
|
||||
|
||||
def test_socket_setup_tries_next_candidate_after_bind_failure(monkeypatch):
|
||||
candidates = [
|
||||
_addrinfo(socket.AF_INET6, ('2001:db8::10', 5684, 0, 0)),
|
||||
_addrinfo(socket.AF_INET, ('192.0.2.10', 5684)),
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo',
|
||||
lambda *args: candidates,
|
||||
)
|
||||
created = _socket_factory(monkeypatch, failures=('bind', None))
|
||||
|
||||
sock, endpoint = open_connected_udp_socket(
|
||||
'device.example', 5684, local_port=55000, timeout=1.5)
|
||||
|
||||
assert created[0].closed
|
||||
assert sock is created[1]
|
||||
assert endpoint.family == socket.AF_INET
|
||||
assert sock.bound == ('', 55000)
|
||||
assert sock.peer == ('192.0.2.10', 5684)
|
||||
assert sock.timeout == 1.5
|
||||
assert (socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) in sock.options
|
||||
|
||||
|
||||
def test_socket_setup_failure_is_redacted_and_closes_candidates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo',
|
||||
lambda *args: [
|
||||
_addrinfo(socket.AF_INET, ('192.0.2.10', 5684)),
|
||||
_addrinfo(socket.AF_INET, ('198.51.100.20', 5684)),
|
||||
],
|
||||
)
|
||||
created = _socket_factory(monkeypatch, failures=('connect', 'connect'))
|
||||
|
||||
with pytest.raises(EndpointError) as exc:
|
||||
open_connected_udp_socket('device.example', 5684)
|
||||
|
||||
formatted = ''.join(traceback.format_exception(exc.value))
|
||||
assert all(sock.closed for sock in created)
|
||||
assert exc.value.__context__ is None
|
||||
assert 'UDP socket setup failed' in formatted
|
||||
assert 'synthetic connect failure' not in formatted
|
||||
assert '192.0.2.10' not in formatted
|
||||
assert '198.51.100.20' not in formatted
|
||||
|
||||
|
||||
def test_same_port_different_hosts_remain_distinct_with_source_reuse(
|
||||
monkeypatch):
|
||||
addresses = {
|
||||
'first.example': '192.0.2.10',
|
||||
'second.example': '198.51.100.20',
|
||||
}
|
||||
|
||||
def resolve(host, port, *_args):
|
||||
return [_addrinfo(socket.AF_INET, (addresses[host], port))]
|
||||
|
||||
monkeypatch.setattr(
|
||||
'smartthings_local.protocol.endpoint.socket.getaddrinfo', resolve)
|
||||
created = _socket_factory(monkeypatch)
|
||||
|
||||
first, _ = open_connected_udp_socket(
|
||||
'first.example', 5684, local_port=55000)
|
||||
second, _ = open_connected_udp_socket(
|
||||
'second.example', 5684, local_port=55000)
|
||||
|
||||
assert first.peer == ('192.0.2.10', 5684)
|
||||
assert second.peer == ('198.51.100.20', 5684)
|
||||
assert first.peer != second.peer
|
||||
assert [sock.bound for sock in created] == [('', 55000), ('', 55000)]
|
||||
|
||||
|
||||
def test_connected_udp_socket_filters_datagrams_from_another_peer():
|
||||
expected_peer = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
other_peer = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
client = None
|
||||
try:
|
||||
expected_peer.bind(('127.0.0.1', 0))
|
||||
other_peer.bind(('127.0.0.1', 0))
|
||||
expected_peer.settimeout(0.5)
|
||||
client, endpoint = open_connected_udp_socket(
|
||||
'127.0.0.1',
|
||||
expected_peer.getsockname()[1],
|
||||
family=socket.AF_INET,
|
||||
timeout=0.5,
|
||||
)
|
||||
|
||||
assert endpoint.sockaddr == expected_peer.getsockname()
|
||||
assert client.send(b'client datagram') == len(b'client datagram')
|
||||
payload, source = expected_peer.recvfrom(64)
|
||||
assert payload == b'client datagram'
|
||||
assert source == client.getsockname()
|
||||
|
||||
other_peer.sendto(b'unrelated', client.getsockname())
|
||||
expected_peer.sendto(b'expected', client.getsockname())
|
||||
assert client.recv(64) == b'expected'
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
expected_peer.close()
|
||||
other_peer.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('host', 'port', 'family'),
|
||||
(
|
||||
('', 5684, socket.AF_UNSPEC),
|
||||
('device.example', 0, socket.AF_UNSPEC),
|
||||
('device.example', 65536, socket.AF_UNSPEC),
|
||||
('device.example', 5684, 9999),
|
||||
),
|
||||
)
|
||||
def test_invalid_endpoint_inputs_fail_before_resolution(host, port, family):
|
||||
with pytest.raises(ValueError):
|
||||
resolve_udp_endpoint(host, port, family=family)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('host', 'port', 'family'),
|
||||
(
|
||||
(None, 5684, socket.AF_UNSPEC),
|
||||
('device.example', '5684', socket.AF_UNSPEC),
|
||||
('device.example', 5684, 'AF_INET'),
|
||||
),
|
||||
)
|
||||
def test_endpoint_input_types_are_explicit(host, port, family):
|
||||
with pytest.raises(TypeError):
|
||||
resolve_udp_endpoint(host, port, family=family)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('timeout', (-1, float('nan'), float('inf')))
|
||||
def test_socket_timeout_must_be_finite_and_non_negative(timeout):
|
||||
with pytest.raises(ValueError):
|
||||
open_connected_udp_socket('device.example', 5684, timeout=timeout)
|
||||
|
||||
|
||||
def test_session_uses_connected_socket_send_and_recv(monkeypatch):
|
||||
endpoint = ResolvedUdpEndpoint(
|
||||
socket.AF_INET6, ('2001:db8::10', 5684, 0, 0))
|
||||
sock = FakeSocket(socket.AF_INET6)
|
||||
sock.peer = endpoint.sockaddr
|
||||
sock.inbound.append(b'synthetic server flight')
|
||||
outbound_record = (
|
||||
b'\x16\xfe\xfd' + b'\x00' * 8 + b'\x00\x01' + b'x')
|
||||
|
||||
class FakeContext:
|
||||
def load_verify_locations(self, *args):
|
||||
pass
|
||||
|
||||
def set_verify(self, *args):
|
||||
pass
|
||||
|
||||
def set_cipher_list(self, *args):
|
||||
pass
|
||||
|
||||
def use_certificate_chain_file(self, *args):
|
||||
pass
|
||||
|
||||
def use_privatekey_file(self, *args):
|
||||
pass
|
||||
|
||||
def check_privatekey(self):
|
||||
pass
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.handshake_calls = 0
|
||||
self.bio_reads = 0
|
||||
self.bio_writes = []
|
||||
|
||||
def set_connect_state(self):
|
||||
pass
|
||||
|
||||
def set_ciphertext_mtu(self, *args):
|
||||
pass
|
||||
|
||||
def do_handshake(self):
|
||||
self.handshake_calls += 1
|
||||
if self.handshake_calls == 1:
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_read(self, _size):
|
||||
self.bio_reads += 1
|
||||
if self.bio_reads == 1:
|
||||
return outbound_record
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_write(self, data):
|
||||
self.bio_writes.append(data)
|
||||
|
||||
connection = FakeConnection()
|
||||
open_calls = []
|
||||
|
||||
def open_socket(*args, **kwargs):
|
||||
open_calls.append((args, kwargs))
|
||||
return sock, endpoint
|
||||
|
||||
monkeypatch.setattr(dtls_session.SSL, 'Context', lambda *args: FakeContext())
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL, 'Connection', lambda *args: connection)
|
||||
monkeypatch.setattr(
|
||||
dtls_session,
|
||||
'open_connected_udp_socket',
|
||||
open_socket,
|
||||
)
|
||||
monkeypatch.setattr(dtls_session.time, 'sleep', lambda _delay: None)
|
||||
|
||||
session = dtls_session.DtlsCoapSession(
|
||||
'device.example', 5684,
|
||||
cert_path='/synthetic/client.pem',
|
||||
key_path='/synthetic/client.key',
|
||||
family=socket.AF_INET6,
|
||||
)
|
||||
session.connect()
|
||||
|
||||
assert sock.sent == [outbound_record]
|
||||
assert connection.bio_writes == [b'synthetic server flight']
|
||||
assert session.endpoint is endpoint
|
||||
assert session.dest == endpoint.sockaddr
|
||||
assert open_calls == [(('device.example', 5684), {
|
||||
'family': socket.AF_INET6,
|
||||
'local_port': None,
|
||||
'timeout': 0.5,
|
||||
})]
|
||||
|
||||
session.close()
|
||||
assert session.endpoint is None
|
||||
assert session.dest is None
|
||||
|
||||
|
||||
def test_session_send_failure_has_no_raw_exception_context():
|
||||
outbound_record = (
|
||||
b'\x16\xfe\xfd' + b'\x00' * 8 + b'\x00\x01' + b'x')
|
||||
|
||||
class FakeConnection:
|
||||
def __init__(self):
|
||||
self.bio_reads = 0
|
||||
|
||||
def send(self, _data):
|
||||
pass
|
||||
|
||||
def bio_read(self, _size):
|
||||
self.bio_reads += 1
|
||||
if self.bio_reads == 1:
|
||||
return outbound_record
|
||||
raise SSL.WantReadError()
|
||||
|
||||
class FailingSocket:
|
||||
def send(self, _data):
|
||||
raise OSError('credential-value at device.example')
|
||||
|
||||
session = dtls_session.DtlsCoapSession(
|
||||
'device.example', 5684,
|
||||
cert_path='/synthetic/client.pem',
|
||||
key_path='/synthetic/client.key',
|
||||
)
|
||||
session.conn = FakeConnection()
|
||||
session.sock = FailingSocket()
|
||||
|
||||
with pytest.raises(EndpointError) as exc:
|
||||
session._send_dgram(b'payload')
|
||||
|
||||
formatted = ''.join(traceback.format_exception(exc.value))
|
||||
assert exc.value.__context__ is None
|
||||
assert 'UDP send failed' in formatted
|
||||
assert 'credential-value' not in formatted
|
||||
assert 'device.example' not in formatted
|
||||
@@ -0,0 +1,172 @@
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import (
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BlockwiseError,
|
||||
EndpointError,
|
||||
MalformedMessageError,
|
||||
ObserveError,
|
||||
ProbeError,
|
||||
SessionClosedError,
|
||||
SessionError,
|
||||
SessionTimeoutError,
|
||||
SmartThingsLocalError,
|
||||
)
|
||||
from smartthings_local.protocol import dtls_session
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from smartthings_local.protocol.endpoint import ResolvedUdpEndpoint
|
||||
|
||||
ERROR_TYPES = (
|
||||
EndpointError,
|
||||
ProbeError,
|
||||
SessionError,
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
SessionTimeoutError,
|
||||
SessionClosedError,
|
||||
MalformedMessageError,
|
||||
BlockwiseError,
|
||||
ObserveError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('error_type', 'legacy_type'),
|
||||
(
|
||||
(EndpointError, OSError),
|
||||
(ProbeError, ConnectionError),
|
||||
(SessionError, ConnectionError),
|
||||
(AuthenticationError, ConnectionError),
|
||||
(AuthorizationError, PermissionError),
|
||||
(SessionTimeoutError, TimeoutError),
|
||||
(SessionClosedError, ConnectionError),
|
||||
(MalformedMessageError, ValueError),
|
||||
(BlockwiseError, ConnectionError),
|
||||
(ObserveError, ConnectionError),
|
||||
),
|
||||
)
|
||||
def test_errors_preserve_legacy_builtin_catches(error_type, legacy_type):
|
||||
error = error_type()
|
||||
assert isinstance(error, SmartThingsLocalError)
|
||||
assert isinstance(error, legacy_type)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('error_type', ERROR_TYPES)
|
||||
def test_error_text_is_fixed_and_redacted(error_type):
|
||||
error = error_type()
|
||||
text = f'{error!s} {error!r}'
|
||||
assert error.code
|
||||
assert str(error) == error.message
|
||||
assert repr(error) == f'{error_type.__name__}(code={error.code!r})'
|
||||
assert 'device.example' not in text
|
||||
assert '/synthetic/client.key' not in text
|
||||
assert 'credential-value' not in text
|
||||
with pytest.raises(TypeError):
|
||||
error_type('credential-value')
|
||||
|
||||
|
||||
def test_chained_backend_error_is_not_copied_into_public_text():
|
||||
backend = ConnectionError('DTLS backend failed')
|
||||
|
||||
try:
|
||||
raise SessionError() from backend
|
||||
except SessionError as error:
|
||||
assert error.__cause__ is backend
|
||||
formatted = ''.join(traceback.format_exception(error))
|
||||
assert 'DTLS backend failed' in formatted
|
||||
assert 'device.example' not in formatted
|
||||
assert 'credential-value' not in formatted
|
||||
|
||||
|
||||
def test_handshake_error_is_classified_without_backend_text(monkeypatch):
|
||||
class FakeContext:
|
||||
def load_verify_locations(self, *args):
|
||||
pass
|
||||
|
||||
def set_verify(self, *args):
|
||||
pass
|
||||
|
||||
def set_cipher_list(self, *args):
|
||||
pass
|
||||
|
||||
def use_certificate_chain_file(self, *args):
|
||||
pass
|
||||
|
||||
def use_privatekey_file(self, *args):
|
||||
pass
|
||||
|
||||
def check_privatekey(self):
|
||||
pass
|
||||
|
||||
class FakeConnection:
|
||||
def set_connect_state(self):
|
||||
pass
|
||||
|
||||
def set_ciphertext_mtu(self, *args):
|
||||
pass
|
||||
|
||||
def do_handshake(self):
|
||||
raise SSL.Error('credential-value at device.example')
|
||||
|
||||
class FakeSocket:
|
||||
closed = False
|
||||
|
||||
def settimeout(self, *args):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
fake_socket = FakeSocket()
|
||||
monkeypatch.setattr(dtls_session.SSL, 'Context', lambda *args: FakeContext())
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL, 'Connection', lambda *args: FakeConnection())
|
||||
endpoint = ResolvedUdpEndpoint(
|
||||
dtls_session.socket.AF_INET, ('192.0.2.10', 5684))
|
||||
monkeypatch.setattr(
|
||||
dtls_session,
|
||||
'open_connected_udp_socket',
|
||||
lambda *args, **kwargs: (fake_socket, endpoint),
|
||||
)
|
||||
|
||||
session = DtlsCoapSession(
|
||||
'device.example', 5684,
|
||||
cert_path='/synthetic/client.pem',
|
||||
key_path='/synthetic/client.key',
|
||||
)
|
||||
with pytest.raises(SessionError) as exc:
|
||||
session.connect()
|
||||
|
||||
assert fake_socket.closed
|
||||
assert isinstance(exc.value, ConnectionError)
|
||||
assert isinstance(exc.value.__cause__, ConnectionError)
|
||||
assert exc.value.__context__ is None
|
||||
formatted = ''.join(traceback.format_exception(exc.value))
|
||||
assert 'DTLS backend failed' in formatted
|
||||
assert 'device.example' not in formatted
|
||||
assert 'credential-value' not in formatted
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'operation',
|
||||
(
|
||||
lambda session: session.get(['resource']),
|
||||
lambda session: session.post(['resource'], b'payload'),
|
||||
lambda session: session.ping(),
|
||||
lambda session: session.refresh_observes([]),
|
||||
lambda session: session.subscribe(['resource']),
|
||||
),
|
||||
)
|
||||
def test_closed_session_operations_raise_classified_error(operation):
|
||||
session = DtlsCoapSession(
|
||||
'device.example', 5684,
|
||||
cert_path='/synthetic/client.pem',
|
||||
key_path='/synthetic/client.key',
|
||||
)
|
||||
|
||||
with pytest.raises(SessionClosedError):
|
||||
operation(session)
|
||||
@@ -0,0 +1,380 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import traceback
|
||||
import weakref
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import asdict
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import SessionError
|
||||
from smartthings_local.protocol import auth as auth_module
|
||||
from smartthings_local.protocol import dtls_session as session_module
|
||||
from smartthings_local.protocol.auth import PskAuth
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
|
||||
_IDENTITY = b"i" * 16
|
||||
_KEY = b"k" * 16
|
||||
_OTHER_IDENTITY = b"j" * 16
|
||||
_OTHER_KEY = b"l" * 32
|
||||
|
||||
|
||||
class _BytesSubclass(bytes):
|
||||
pass
|
||||
|
||||
|
||||
def _fake_openssl_util(setter):
|
||||
return SimpleNamespace(
|
||||
ffi=auth_module._util.ffi,
|
||||
lib=SimpleNamespace(SSL_CTX_set_psk_client_callback=setter),
|
||||
)
|
||||
|
||||
|
||||
def _invoke_callback(callback, identity_size: int, key_size: int):
|
||||
ffi = auth_module._util.ffi
|
||||
identity_buffer = ffi.new("char[]", max(identity_size, 1))
|
||||
key_buffer = ffi.new("unsigned char[]", max(key_size, 1))
|
||||
copied = callback(
|
||||
ffi.NULL,
|
||||
ffi.NULL,
|
||||
identity_buffer,
|
||||
identity_size,
|
||||
key_buffer,
|
||||
key_size,
|
||||
)
|
||||
return (
|
||||
copied,
|
||||
bytes(ffi.buffer(identity_buffer, max(identity_size, 1))),
|
||||
bytes(ffi.buffer(key_buffer, max(key_size, 1))),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key_length", [16, 32])
|
||||
def test_psk_auth_accepts_exact_supported_credential_lengths(key_length):
|
||||
provider = PskAuth(identity=_IDENTITY, key=b"k" * key_length)
|
||||
|
||||
assert repr(provider) == "PskAuth()"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("identity", "key"),
|
||||
[
|
||||
("i" * 16, _KEY),
|
||||
(bytearray(_IDENTITY), _KEY),
|
||||
(memoryview(_IDENTITY), _KEY),
|
||||
(_BytesSubclass(_IDENTITY), _KEY),
|
||||
(_IDENTITY, "k" * 16),
|
||||
(_IDENTITY, bytearray(_KEY)),
|
||||
(_IDENTITY, memoryview(_KEY)),
|
||||
(_IDENTITY, _BytesSubclass(_KEY)),
|
||||
],
|
||||
)
|
||||
def test_psk_auth_rejects_non_bytes_credentials(identity, key):
|
||||
with pytest.raises(TypeError, match="identity and key must be bytes"):
|
||||
PskAuth(identity=identity, key=key)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("identity_length", [0, 15, 17])
|
||||
def test_psk_auth_rejects_invalid_identity_lengths(identity_length):
|
||||
with pytest.raises(ValueError, match="raw 16-byte OCF UUID"):
|
||||
PskAuth(identity=b"i" * identity_length, key=_KEY)
|
||||
|
||||
|
||||
def test_psk_auth_rejects_identity_with_nul_byte():
|
||||
with pytest.raises(ValueError, match="cannot contain a NUL"):
|
||||
PskAuth(identity=b"i" * 15 + b"\x00", key=_KEY)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key_length", [0, 15, 17, 31, 33])
|
||||
def test_psk_auth_rejects_invalid_key_lengths(key_length):
|
||||
with pytest.raises(ValueError, match="16 or 32 bytes"):
|
||||
PskAuth(identity=_IDENTITY, key=b"k" * key_length)
|
||||
|
||||
|
||||
def test_psk_auth_is_immutable_and_has_no_public_credential_surface():
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
|
||||
rendered = repr(provider)
|
||||
assert rendered == "PskAuth()"
|
||||
assert str(provider) == rendered
|
||||
assert _IDENTITY.decode() not in rendered
|
||||
assert _KEY.decode() not in rendered
|
||||
assert not hasattr(provider, "identity")
|
||||
assert not hasattr(provider, "key")
|
||||
assert not hasattr(provider, "_identity")
|
||||
assert not hasattr(provider, "_key")
|
||||
with pytest.raises(TypeError):
|
||||
vars(provider)
|
||||
with pytest.raises(TypeError):
|
||||
asdict(provider)
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
provider.identity = _OTHER_IDENTITY
|
||||
with pytest.raises(AttributeError, match="immutable"):
|
||||
del provider._callback
|
||||
|
||||
|
||||
def test_psk_auth_identity_equality_does_not_compare_credentials():
|
||||
first = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
second = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
|
||||
assert first != second
|
||||
assert len({first, second}) == 2
|
||||
|
||||
|
||||
def test_psk_callback_copies_exact_identity_and_key():
|
||||
installed = {}
|
||||
|
||||
def setter(context_handle, callback):
|
||||
installed["context"] = context_handle
|
||||
installed["callback"] = callback
|
||||
|
||||
context_handle = object()
|
||||
context = MagicMock()
|
||||
context._context = context_handle
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
|
||||
with patch.object(auth_module, "_util", _fake_openssl_util(setter)):
|
||||
provider.configure_context(context)
|
||||
|
||||
assert installed["context"] is context_handle
|
||||
callback = installed["callback"]
|
||||
copied, identity_bytes, key_bytes = _invoke_callback(callback, 17, 16)
|
||||
assert copied == 16
|
||||
assert identity_bytes == _IDENTITY + b"\x00"
|
||||
assert key_bytes == _KEY
|
||||
context.set_cipher_list.assert_called_once_with(
|
||||
b"ECDHE-PSK-AES128-CBC-SHA256:@SECLEVEL=0"
|
||||
)
|
||||
context.load_verify_locations.assert_not_called()
|
||||
context.set_verify.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("identity_size", "key_size"),
|
||||
[(16, 16), (17, 15)],
|
||||
)
|
||||
def test_psk_callback_rejects_short_buffers_without_partial_copy(
|
||||
identity_size,
|
||||
key_size,
|
||||
):
|
||||
installed = {}
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
context = MagicMock()
|
||||
context._context = object()
|
||||
|
||||
with patch.object(
|
||||
auth_module,
|
||||
"_util",
|
||||
_fake_openssl_util(
|
||||
lambda _context, callback: installed.setdefault(
|
||||
"callback", callback
|
||||
)
|
||||
),
|
||||
):
|
||||
provider.configure_context(context)
|
||||
|
||||
ffi = auth_module._util.ffi
|
||||
identity_buffer = ffi.new("char[]", 17)
|
||||
key_buffer = ffi.new("unsigned char[]", 16)
|
||||
ffi.memmove(identity_buffer, b"I" * 17, 17)
|
||||
ffi.memmove(key_buffer, b"K" * 16, 16)
|
||||
copied = installed["callback"](
|
||||
ffi.NULL,
|
||||
ffi.NULL,
|
||||
identity_buffer,
|
||||
identity_size,
|
||||
key_buffer,
|
||||
key_size,
|
||||
)
|
||||
|
||||
assert copied == 0
|
||||
assert bytes(ffi.buffer(identity_buffer, 17)) == b"I" * 17
|
||||
assert bytes(ffi.buffer(key_buffer, 16)) == b"K" * 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("null_buffer", ["identity", "key"])
|
||||
def test_psk_callback_rejects_null_buffers(null_buffer):
|
||||
installed = {}
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
context = MagicMock()
|
||||
context._context = object()
|
||||
|
||||
with patch.object(
|
||||
auth_module,
|
||||
"_util",
|
||||
_fake_openssl_util(
|
||||
lambda _context, callback: installed.setdefault(
|
||||
"callback", callback
|
||||
)
|
||||
),
|
||||
):
|
||||
provider.configure_context(context)
|
||||
|
||||
ffi = auth_module._util.ffi
|
||||
identity_buffer = ffi.new("char[]", 17)
|
||||
key_buffer = ffi.new("unsigned char[]", 16)
|
||||
ffi.memmove(identity_buffer, b"I" * 17, 17)
|
||||
ffi.memmove(key_buffer, b"K" * 16, 16)
|
||||
if null_buffer == "identity":
|
||||
identity_buffer = ffi.NULL
|
||||
else:
|
||||
key_buffer = ffi.NULL
|
||||
|
||||
copied = installed["callback"](
|
||||
ffi.NULL,
|
||||
ffi.NULL,
|
||||
identity_buffer,
|
||||
17,
|
||||
key_buffer,
|
||||
16,
|
||||
)
|
||||
assert copied == 0
|
||||
if null_buffer == "identity":
|
||||
assert bytes(ffi.buffer(key_buffer, 16)) == b"K" * 16
|
||||
else:
|
||||
assert bytes(ffi.buffer(identity_buffer, 17)) == b"I" * 17
|
||||
|
||||
|
||||
def test_psk_auth_unsupported_binding_error_contains_no_credentials():
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
context = MagicMock()
|
||||
context._context = object()
|
||||
unsupported_util = SimpleNamespace(
|
||||
ffi=auth_module._util.ffi,
|
||||
lib=SimpleNamespace(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(auth_module, "_util", unsupported_util),
|
||||
pytest.raises(RuntimeError) as captured,
|
||||
):
|
||||
provider.configure_context(context)
|
||||
|
||||
rendered = (
|
||||
str(captured.value)
|
||||
+ repr(captured.value)
|
||||
+ "".join(traceback.format_exception(captured.value))
|
||||
)
|
||||
assert _IDENTITY.decode() not in rendered
|
||||
assert _KEY.decode() not in rendered
|
||||
context.set_cipher_list.assert_not_called()
|
||||
|
||||
|
||||
def test_psk_auth_configures_real_openssl_context():
|
||||
context = SSL.Context(SSL.DTLS_METHOD)
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
|
||||
assert provider.configure_context(context) is None
|
||||
|
||||
|
||||
def test_distinct_psk_providers_do_not_share_callback_credentials():
|
||||
callbacks = []
|
||||
|
||||
def setter(_context, callback):
|
||||
callbacks.append(callback)
|
||||
|
||||
first = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
second = PskAuth(identity=_OTHER_IDENTITY, key=_OTHER_KEY)
|
||||
first_context = MagicMock()
|
||||
first_context._context = object()
|
||||
second_context = MagicMock()
|
||||
second_context._context = object()
|
||||
|
||||
with patch.object(auth_module, "_util", _fake_openssl_util(setter)):
|
||||
first.configure_context(first_context)
|
||||
second.configure_context(second_context)
|
||||
|
||||
assert callbacks[0] is not callbacks[1]
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
first_future = executor.submit(_invoke_callback, callbacks[0], 17, 16)
|
||||
second_future = executor.submit(
|
||||
_invoke_callback,
|
||||
callbacks[1],
|
||||
17,
|
||||
32,
|
||||
)
|
||||
first_result = first_future.result()
|
||||
second_result = second_future.result()
|
||||
assert first_result == (16, _IDENTITY + b"\x00", _KEY)
|
||||
assert second_result == (32, _OTHER_IDENTITY + b"\x00", _OTHER_KEY)
|
||||
|
||||
|
||||
def test_session_retains_psk_callback_only_with_provider_lifetime():
|
||||
callback_reference = None
|
||||
|
||||
def setter(_context, callback):
|
||||
nonlocal callback_reference
|
||||
callback_reference = weakref.ref(callback)
|
||||
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
session = DtlsCoapSession(
|
||||
"appliance.invalid",
|
||||
49154,
|
||||
auth=provider,
|
||||
)
|
||||
context = MagicMock()
|
||||
context._context = object()
|
||||
with patch.object(auth_module, "_util", _fake_openssl_util(setter)):
|
||||
session.auth.configure_context(context)
|
||||
|
||||
del provider
|
||||
gc.collect()
|
||||
assert callback_reference is not None
|
||||
assert callback_reference() is not None
|
||||
assert _invoke_callback(callback_reference(), 17, 16) == (
|
||||
16,
|
||||
_IDENTITY + b"\x00",
|
||||
_KEY,
|
||||
)
|
||||
|
||||
del session
|
||||
gc.collect()
|
||||
assert callback_reference() is None
|
||||
|
||||
|
||||
def test_session_accepts_psk_provider_without_legacy_certificate_material():
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
session = DtlsCoapSession("appliance.invalid", 49154, auth=provider)
|
||||
|
||||
assert session.auth is provider
|
||||
assert session.cert_path is None
|
||||
assert session.key_path is None
|
||||
assert session.cert_pem is None
|
||||
assert session.key_pem is None
|
||||
|
||||
|
||||
def test_psk_handshake_rejection_does_not_expose_credentials():
|
||||
provider = PskAuth(identity=_IDENTITY, key=_KEY)
|
||||
session = DtlsCoapSession("appliance.invalid", 49154, auth=provider)
|
||||
context = MagicMock()
|
||||
context._context = object()
|
||||
connection = MagicMock()
|
||||
connection.do_handshake.side_effect = SSL.Error()
|
||||
udp_socket = MagicMock()
|
||||
endpoint = SimpleNamespace(sockaddr=("192.0.2.100", 49154))
|
||||
|
||||
with (
|
||||
patch.object(auth_module, "_util", _fake_openssl_util(lambda *_: None)),
|
||||
patch.object(session_module.SSL, "Context", return_value=context),
|
||||
patch.object(session_module.SSL, "Connection", return_value=connection),
|
||||
patch.object(
|
||||
session_module,
|
||||
"open_connected_udp_socket",
|
||||
return_value=(udp_socket, endpoint),
|
||||
),
|
||||
pytest.raises(SessionError) as captured,
|
||||
):
|
||||
session.connect()
|
||||
|
||||
rendered = (
|
||||
str(captured.value)
|
||||
+ repr(captured.value)
|
||||
+ "".join(traceback.format_exception(captured.value))
|
||||
)
|
||||
assert _IDENTITY.decode() not in rendered
|
||||
assert _KEY.decode() not in rendered
|
||||
udp_socket.close.assert_called_once_with()
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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.auth import (
|
||||
AuthenticationProvider,
|
||||
CertificateAuth,
|
||||
PskAuth,
|
||||
SamsungServerProfile,
|
||||
SamsungServerRole,
|
||||
ServerCertificateAuth,
|
||||
)
|
||||
from smartthings_local.protocol.dtls_session import (
|
||||
ConnectCancellation,
|
||||
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",
|
||||
],
|
||||
)
|
||||
auth_parameter = inspect.signature(DtlsCoapSession).parameters["auth"]
|
||||
assert auth_parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert auth_parameter.default is None
|
||||
|
||||
|
||||
def test_certificate_auth_is_a_public_authentication_provider():
|
||||
provider = CertificateAuth.from_files("/synthetic/cert.pem", "/synthetic/key")
|
||||
assert isinstance(provider, AuthenticationProvider)
|
||||
|
||||
for factory in (CertificateAuth.from_files, CertificateAuth.from_memory):
|
||||
profile_parameter = inspect.signature(factory).parameters["server_profile"]
|
||||
assert profile_parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert profile_parameter.default is None
|
||||
|
||||
|
||||
def test_samsung_server_profile_is_public_and_explicitly_bound():
|
||||
parameters = inspect.signature(SamsungServerProfile.bound_device).parameters
|
||||
assert list(parameters) == [
|
||||
"expected_certificate_identity",
|
||||
"role",
|
||||
"additional_ca_pem",
|
||||
]
|
||||
assert (
|
||||
parameters["expected_certificate_identity"].default
|
||||
is inspect.Parameter.empty
|
||||
)
|
||||
assert parameters["role"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert parameters["role"].default is SamsungServerRole.HOME_APPLIANCE
|
||||
assert parameters["additional_ca_pem"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert parameters["additional_ca_pem"].default is None
|
||||
|
||||
|
||||
def test_server_certificate_auth_is_a_public_authentication_provider():
|
||||
profile = SamsungServerProfile.bound_device(
|
||||
"abababab-abab-abab-abab-abababababab",
|
||||
role=SamsungServerRole.VD_DEVICE,
|
||||
)
|
||||
provider = ServerCertificateAuth(server_profile=profile)
|
||||
assert isinstance(provider, AuthenticationProvider)
|
||||
session = DtlsCoapSession("device.example", 5684, auth=provider)
|
||||
assert session.auth is provider
|
||||
assert session.cert_path is None
|
||||
assert session.key_path is None
|
||||
assert session.cert_pem is None
|
||||
assert session.key_pem is None
|
||||
parameters = inspect.signature(ServerCertificateAuth).parameters
|
||||
assert list(parameters) == ["server_profile"]
|
||||
assert parameters["server_profile"].kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert parameters["server_profile"].default is inspect.Parameter.empty
|
||||
|
||||
|
||||
def test_psk_auth_is_a_public_authentication_provider():
|
||||
provider = PskAuth(identity=b"i" * 16, key=b"k" * 16)
|
||||
assert isinstance(provider, AuthenticationProvider)
|
||||
parameters = inspect.signature(PskAuth).parameters
|
||||
assert list(parameters) == ["identity", "key"]
|
||||
assert all(
|
||||
parameter.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
and parameter.default is inspect.Parameter.empty
|
||||
for parameter in parameters.values()
|
||||
)
|
||||
|
||||
|
||||
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 "abort" not in DtlsCoapSession.__dict__
|
||||
assert "quiesce_for_close" not in DtlsCoapSession.__dict__
|
||||
_assert_compatible_signature(DtlsCoapSession.connect, ["self"])
|
||||
connect_timeout = inspect.signature(DtlsCoapSession.connect).parameters[
|
||||
"timeout"
|
||||
]
|
||||
assert connect_timeout.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert connect_timeout.default is None
|
||||
connect_cancel = inspect.signature(DtlsCoapSession.connect).parameters[
|
||||
"cancel"
|
||||
]
|
||||
assert connect_cancel.kind is inspect.Parameter.KEYWORD_ONLY
|
||||
assert connect_cancel.default is None
|
||||
assert callable(ConnectCancellation().set)
|
||||
_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"],
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Deterministic tests for bounded DTLS handshake timing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import SessionTimeoutError
|
||||
from smartthings_local.protocol import dtls_session
|
||||
from smartthings_local.protocol.dtls_session import DtlsCoapSession
|
||||
from smartthings_local.protocol.endpoint import ResolvedUdpEndpoint
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self):
|
||||
self.now = 100.0
|
||||
|
||||
def monotonic(self):
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds):
|
||||
self.now += seconds
|
||||
|
||||
|
||||
class _Auth:
|
||||
def __init__(self, clock=None, configure_delay=0.0):
|
||||
self.clock = clock
|
||||
self.configure_delay = configure_delay
|
||||
|
||||
def configure_context(self, _context):
|
||||
if self.clock is not None:
|
||||
self.clock.advance(self.configure_delay)
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, outcomes=None, outputs=None, timer=None):
|
||||
self.outcomes = list(outcomes or ())
|
||||
self.outputs = list(outputs or ())
|
||||
self.timer = timer
|
||||
self.bio_writes = []
|
||||
self.timeout_calls = 0
|
||||
|
||||
def set_connect_state(self):
|
||||
return None
|
||||
|
||||
def set_ciphertext_mtu(self, _mtu):
|
||||
return None
|
||||
|
||||
def do_handshake(self):
|
||||
outcome = self.outcomes.pop(0) if self.outcomes else "want-read"
|
||||
if outcome == "want-read":
|
||||
raise SSL.WantReadError()
|
||||
if isinstance(outcome, Exception):
|
||||
raise outcome
|
||||
|
||||
def bio_read(self, _size):
|
||||
if self.outputs:
|
||||
return self.outputs.pop(0)
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_write(self, data):
|
||||
self.bio_writes.append(data)
|
||||
|
||||
def DTLSv1_get_timeout(self):
|
||||
return self.timer
|
||||
|
||||
def DTLSv1_handle_timeout(self):
|
||||
self.timeout_calls += 1
|
||||
|
||||
|
||||
class _Socket:
|
||||
def __init__(self, clock, inbound=()):
|
||||
self.clock = clock
|
||||
self.inbound = list(inbound)
|
||||
self.timeouts = []
|
||||
self.sent = []
|
||||
self.closed = False
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeouts.append(timeout)
|
||||
|
||||
def send(self, data):
|
||||
self.sent.append(data)
|
||||
return len(data)
|
||||
|
||||
def recv(self, _size):
|
||||
if self.inbound:
|
||||
result = self.inbound.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
self.clock.advance(self.timeouts[-1])
|
||||
raise result
|
||||
return result
|
||||
self.clock.advance(self.timeouts[-1])
|
||||
raise TimeoutError()
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _session(auth=None):
|
||||
return DtlsCoapSession(
|
||||
"device.example",
|
||||
5684,
|
||||
auth=auth or _Auth(),
|
||||
)
|
||||
|
||||
|
||||
def _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
*,
|
||||
outcomes=(),
|
||||
outputs=(),
|
||||
inbound=(),
|
||||
timer=None,
|
||||
):
|
||||
connection = _Connection(outcomes, outputs, timer)
|
||||
sock = _Socket(clock, inbound)
|
||||
endpoint = ResolvedUdpEndpoint(
|
||||
socket.AF_INET,
|
||||
("192.0.2.10", 5684),
|
||||
)
|
||||
open_calls = []
|
||||
|
||||
def open_socket(*args, **kwargs):
|
||||
open_calls.append((args, kwargs))
|
||||
sock.settimeout(kwargs["timeout"])
|
||||
return sock, endpoint
|
||||
|
||||
monkeypatch.setattr(dtls_session.SSL, "Context", lambda *_args: object())
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL,
|
||||
"Connection",
|
||||
lambda *_args: connection,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dtls_session,
|
||||
"open_connected_udp_socket",
|
||||
open_socket,
|
||||
)
|
||||
monkeypatch.setattr(dtls_session.time, "monotonic", clock.monotonic)
|
||||
monkeypatch.setattr(dtls_session.time, "sleep", clock.advance)
|
||||
monkeypatch.setattr(
|
||||
dtls_session.time,
|
||||
"time",
|
||||
lambda: pytest.fail("wall clock must not control handshake deadlines"),
|
||||
)
|
||||
return connection, sock, endpoint, open_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", (True, "1", object()))
|
||||
def test_connect_timeout_type_is_explicit(timeout):
|
||||
with pytest.raises(TypeError, match="number or None"):
|
||||
_session().connect(timeout=timeout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"timeout",
|
||||
(
|
||||
0,
|
||||
-1,
|
||||
float("nan"),
|
||||
float("inf"),
|
||||
float("-inf"),
|
||||
10**1000,
|
||||
),
|
||||
)
|
||||
def test_connect_timeout_must_be_positive_and_finite(timeout):
|
||||
with pytest.raises(ValueError, match="positive finite"):
|
||||
_session().connect(timeout=timeout)
|
||||
|
||||
|
||||
def test_connect_timeout_caps_every_blocking_poll(monkeypatch):
|
||||
clock = _Clock()
|
||||
_connection, sock, _endpoint, open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
)
|
||||
session = _session()
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
session.connect(timeout=4.75)
|
||||
|
||||
assert clock.now == pytest.approx(104.75)
|
||||
assert sock.closed
|
||||
assert open_calls == [
|
||||
(
|
||||
("device.example", 5684),
|
||||
{
|
||||
"family": socket.AF_UNSPEC,
|
||||
"local_port": None,
|
||||
"timeout": 0.5,
|
||||
},
|
||||
)
|
||||
]
|
||||
assert max(sock.timeouts) <= 0.5
|
||||
assert sock.timeouts[-1] == pytest.approx(0.25)
|
||||
|
||||
|
||||
def test_short_timeout_is_not_rounded_up_to_poll_interval(monkeypatch):
|
||||
clock = _Clock()
|
||||
_connection, sock, _endpoint, open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
)
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
_session().connect(timeout=0.125)
|
||||
|
||||
assert clock.now == pytest.approx(100.125)
|
||||
assert open_calls[0][1]["timeout"] == pytest.approx(0.125)
|
||||
assert sock.timeouts == pytest.approx([0.125, 0.125])
|
||||
|
||||
|
||||
def test_default_timeout_uses_session_constant(monkeypatch):
|
||||
clock = _Clock()
|
||||
_connection, sock, _endpoint, _open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
)
|
||||
session = _session()
|
||||
session.HANDSHAKE_TIMEOUT_S = 0.2
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
session.connect()
|
||||
|
||||
assert clock.now == pytest.approx(100.2)
|
||||
assert sock.closed
|
||||
|
||||
|
||||
def test_context_setup_consumes_the_same_deadline(monkeypatch):
|
||||
clock = _Clock()
|
||||
socket_opened = False
|
||||
|
||||
def open_socket(*_args, **_kwargs):
|
||||
nonlocal socket_opened
|
||||
socket_opened = True
|
||||
raise AssertionError("expired setup must not open a socket")
|
||||
|
||||
monkeypatch.setattr(dtls_session.SSL, "Context", lambda *_args: object())
|
||||
monkeypatch.setattr(dtls_session.SSL, "Connection", lambda *_args: _Connection())
|
||||
monkeypatch.setattr(dtls_session, "open_connected_udp_socket", open_socket)
|
||||
monkeypatch.setattr(dtls_session.time, "monotonic", clock.monotonic)
|
||||
session = _session(_Auth(clock, configure_delay=0.2))
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
session.connect(timeout=0.1)
|
||||
|
||||
assert not socket_opened
|
||||
|
||||
|
||||
def test_socket_setup_consumes_the_same_deadline(monkeypatch):
|
||||
clock = _Clock()
|
||||
connection = _Connection()
|
||||
connection.do_handshake = lambda: pytest.fail(
|
||||
"expired socket setup must not start a handshake"
|
||||
)
|
||||
sock = _Socket(clock)
|
||||
endpoint = ResolvedUdpEndpoint(
|
||||
socket.AF_INET,
|
||||
("192.0.2.10", 5684),
|
||||
)
|
||||
|
||||
def open_socket(*_args, **kwargs):
|
||||
sock.settimeout(kwargs["timeout"])
|
||||
clock.advance(0.2)
|
||||
return sock, endpoint
|
||||
|
||||
monkeypatch.setattr(dtls_session.SSL, "Context", lambda *_args: object())
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL,
|
||||
"Connection",
|
||||
lambda *_args: connection,
|
||||
)
|
||||
monkeypatch.setattr(dtls_session, "open_connected_udp_socket", open_socket)
|
||||
monkeypatch.setattr(dtls_session.time, "monotonic", clock.monotonic)
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
_session().connect(timeout=0.1)
|
||||
|
||||
assert sock.closed
|
||||
|
||||
|
||||
def test_successful_handshake_preserves_connected_session_state(monkeypatch):
|
||||
clock = _Clock()
|
||||
connection, sock, endpoint, _open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
outcomes=("want-read", "success"),
|
||||
inbound=(b"synthetic server flight",),
|
||||
)
|
||||
session = _session()
|
||||
|
||||
session.connect(timeout=1.0)
|
||||
|
||||
assert connection.bio_writes == [b"synthetic server flight"]
|
||||
assert session.conn is connection
|
||||
assert session.sock is sock
|
||||
assert session.endpoint is endpoint
|
||||
assert session.dest == endpoint.sockaddr
|
||||
assert not sock.closed
|
||||
|
||||
|
||||
def test_connect_services_openssl_retransmit_timer(monkeypatch):
|
||||
clock = _Clock()
|
||||
outbound = b"\x16\xfe\xfd" + b"\x00" * 8 + b"\x00\x01x"
|
||||
connection, sock, _endpoint, _open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
outcomes=("want-read", "want-read", "success"),
|
||||
outputs=(outbound, outbound),
|
||||
inbound=(TimeoutError(), b"synthetic server flight"),
|
||||
timer=0.0,
|
||||
)
|
||||
|
||||
_session().connect(timeout=2.0)
|
||||
|
||||
assert connection.timeout_calls == 1
|
||||
assert sock.sent == [outbound, outbound]
|
||||
assert connection.bio_writes == [b"synthetic server flight"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("success_delay", (0.1, 0.2))
|
||||
def test_handshake_success_at_or_after_deadline_is_rejected(
|
||||
monkeypatch,
|
||||
success_delay,
|
||||
):
|
||||
clock = _Clock()
|
||||
connection, sock, _endpoint, _open_calls = _install_handshake(
|
||||
monkeypatch,
|
||||
clock,
|
||||
)
|
||||
|
||||
def late_success():
|
||||
clock.advance(success_delay)
|
||||
|
||||
connection.do_handshake = late_success
|
||||
|
||||
with pytest.raises(SessionTimeoutError):
|
||||
_session().connect(timeout=0.1)
|
||||
|
||||
assert sock.closed
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Deterministic tests for connection-attempt cancellation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import select
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
import pytest
|
||||
from OpenSSL import SSL
|
||||
|
||||
from smartthings_local.errors import SessionClosedError, SessionError
|
||||
from smartthings_local.protocol import dtls_session
|
||||
from smartthings_local.protocol.dtls_session import (
|
||||
ConnectCancellation,
|
||||
DtlsCoapSession,
|
||||
)
|
||||
from smartthings_local.protocol.endpoint import ResolvedUdpEndpoint
|
||||
|
||||
|
||||
class _Auth:
|
||||
def __init__(self, on_configure=None):
|
||||
self.on_configure = on_configure
|
||||
|
||||
def configure_context(self, _context):
|
||||
if self.on_configure is not None:
|
||||
self.on_configure()
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, *, started=None, on_success=None, succeed=False):
|
||||
self.started = started
|
||||
self.on_success = on_success
|
||||
self.succeed = succeed
|
||||
self.bio_writes = []
|
||||
self.handshake_calls = 0
|
||||
|
||||
def set_connect_state(self):
|
||||
return None
|
||||
|
||||
def set_ciphertext_mtu(self, _mtu):
|
||||
return None
|
||||
|
||||
def do_handshake(self):
|
||||
self.handshake_calls += 1
|
||||
if self.started is not None:
|
||||
self.started.set()
|
||||
if self.succeed:
|
||||
if self.on_success is not None:
|
||||
self.on_success()
|
||||
return
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_read(self, _size):
|
||||
raise SSL.WantReadError()
|
||||
|
||||
def bio_write(self, datagram):
|
||||
self.bio_writes.append(datagram)
|
||||
self.succeed = True
|
||||
|
||||
def DTLSv1_get_timeout(self):
|
||||
return None
|
||||
|
||||
def shutdown(self):
|
||||
return None
|
||||
|
||||
|
||||
def _session(auth=None):
|
||||
return DtlsCoapSession(
|
||||
"device.example",
|
||||
5684,
|
||||
auth=auth or _Auth(),
|
||||
)
|
||||
|
||||
|
||||
def _install_connection(monkeypatch, connection, data_socket, *, on_open=None):
|
||||
endpoint = ResolvedUdpEndpoint(
|
||||
socket.AF_INET,
|
||||
("192.0.2.10", 5684),
|
||||
)
|
||||
|
||||
def open_socket(*_args, **_kwargs):
|
||||
if on_open is not None:
|
||||
on_open()
|
||||
return data_socket, endpoint
|
||||
|
||||
monkeypatch.setattr(dtls_session.SSL, "Context", lambda *_args: object())
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL,
|
||||
"Connection",
|
||||
lambda *_args: connection,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dtls_session,
|
||||
"open_connected_udp_socket",
|
||||
open_socket,
|
||||
)
|
||||
return endpoint
|
||||
|
||||
|
||||
def _run_connect(session, cancel):
|
||||
outcome = {}
|
||||
|
||||
def worker():
|
||||
try:
|
||||
session.connect(timeout=2.0, cancel=cancel)
|
||||
except Exception as error: # noqa: BLE001 - captured for assertion
|
||||
outcome["error"] = error
|
||||
else:
|
||||
outcome["connected"] = True
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
return thread, outcome
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cancel", (True, threading.Event(), object(), "signal"))
|
||||
def test_connect_cancel_type_is_explicit(cancel):
|
||||
with pytest.raises(TypeError, match="ConnectCancellation or None"):
|
||||
_session().connect(cancel=cancel)
|
||||
|
||||
|
||||
def test_pre_cancelled_connect_stops_before_context_setup(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
cancel.set()
|
||||
monkeypatch.setattr(
|
||||
dtls_session.SSL,
|
||||
"Context",
|
||||
lambda *_args: pytest.fail("cancelled connect configured TLS"),
|
||||
)
|
||||
|
||||
with pytest.raises(SessionClosedError):
|
||||
_session().connect(cancel=cancel)
|
||||
|
||||
|
||||
def test_cancel_during_context_setup_stops_before_socket_setup(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
monkeypatch.setattr(dtls_session.SSL, "Context", lambda *_args: object())
|
||||
monkeypatch.setattr(
|
||||
dtls_session,
|
||||
"open_connected_udp_socket",
|
||||
lambda *_args, **_kwargs: pytest.fail(
|
||||
"cancelled connect opened a socket"
|
||||
),
|
||||
)
|
||||
|
||||
with pytest.raises(SessionClosedError):
|
||||
_session(_Auth(cancel.set)).connect(cancel=cancel)
|
||||
|
||||
|
||||
def test_cancel_during_socket_setup_closes_before_handshake(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
connection = _Connection(succeed=True)
|
||||
data_socket, peer = socket.socketpair()
|
||||
_install_connection(
|
||||
monkeypatch,
|
||||
connection,
|
||||
data_socket,
|
||||
on_open=cancel.set,
|
||||
)
|
||||
|
||||
try:
|
||||
with pytest.raises(SessionClosedError):
|
||||
_session().connect(cancel=cancel)
|
||||
assert data_socket.fileno() == -1
|
||||
assert connection.handshake_calls == 0
|
||||
finally:
|
||||
peer.close()
|
||||
|
||||
|
||||
def test_socket_signal_wakes_every_subscribed_waiter():
|
||||
cancel = ConnectCancellation()
|
||||
first = cancel._subscribe()
|
||||
second = cancel._subscribe()
|
||||
try:
|
||||
cancel.set()
|
||||
readable, _, _ = select.select(
|
||||
(first[0], second[0]),
|
||||
(),
|
||||
(),
|
||||
0,
|
||||
)
|
||||
assert set(readable) == {first[0], second[0]}
|
||||
assert cancel._unsubscribe(*first)
|
||||
assert cancel._unsubscribe(*second)
|
||||
finally:
|
||||
for reader, writer in (first, second):
|
||||
reader.close()
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_cancel_wakes_blocked_connect_without_poll_latency(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
started = threading.Event()
|
||||
connection = _Connection(started=started)
|
||||
data_socket, peer = socket.socketpair()
|
||||
_install_connection(monkeypatch, connection, data_socket)
|
||||
session = _session()
|
||||
thread, outcome = _run_connect(session, cancel)
|
||||
|
||||
try:
|
||||
assert started.wait(1.0)
|
||||
before = time.monotonic()
|
||||
cancel.set()
|
||||
thread.join(1.0)
|
||||
elapsed = time.monotonic() - before
|
||||
|
||||
assert not thread.is_alive()
|
||||
assert elapsed < 0.25
|
||||
assert isinstance(outcome.get("error"), SessionClosedError)
|
||||
assert data_socket.fileno() == -1
|
||||
assert session.sock is None
|
||||
assert session.conn is None
|
||||
assert not cancel._writers
|
||||
finally:
|
||||
cancel.set()
|
||||
thread.join(1.0)
|
||||
peer.close()
|
||||
|
||||
|
||||
def test_cancel_wins_race_with_reported_handshake_success(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
connection = _Connection(on_success=cancel.set, succeed=True)
|
||||
data_socket, peer = socket.socketpair()
|
||||
_install_connection(monkeypatch, connection, data_socket)
|
||||
session = _session()
|
||||
|
||||
try:
|
||||
with pytest.raises(SessionClosedError):
|
||||
session.connect(cancel=cancel)
|
||||
assert data_socket.fileno() == -1
|
||||
assert session.sock is None
|
||||
assert session.conn is None
|
||||
finally:
|
||||
peer.close()
|
||||
|
||||
|
||||
def test_successful_connect_does_not_set_cancel_or_close_session(monkeypatch):
|
||||
cancel = ConnectCancellation()
|
||||
connection = _Connection()
|
||||
data_socket, peer = socket.socketpair()
|
||||
endpoint = _install_connection(monkeypatch, connection, data_socket)
|
||||
peer.send(b"synthetic server flight")
|
||||
session = _session()
|
||||
|
||||
try:
|
||||
session.connect(cancel=cancel)
|
||||
assert not cancel.is_set()
|
||||
assert session.sock is data_socket
|
||||
assert session.conn is connection
|
||||
assert session.endpoint is endpoint
|
||||
assert connection.bio_writes == [b"synthetic server flight"]
|
||||
assert not cancel._writers
|
||||
finally:
|
||||
session.close()
|
||||
peer.close()
|
||||
|
||||
|
||||
def test_cancellation_socket_failure_is_redacted_and_closes_udp(monkeypatch):
|
||||
class FailingCancellation(ConnectCancellation):
|
||||
def _subscribe(self):
|
||||
raise OSError("credential-value at device.example")
|
||||
|
||||
connection = _Connection()
|
||||
data_socket, peer = socket.socketpair()
|
||||
_install_connection(monkeypatch, connection, data_socket)
|
||||
|
||||
try:
|
||||
with pytest.raises(SessionError) as exc:
|
||||
_session().connect(cancel=FailingCancellation())
|
||||
|
||||
formatted = "".join(traceback.format_exception(exc.value))
|
||||
assert data_socket.fileno() == -1
|
||||
assert exc.value.__context__ is None
|
||||
assert "credential-value" not in formatted
|
||||
assert "device.example" not in formatted
|
||||
finally:
|
||||
peer.close()
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
]
|
||||
@@ -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")
|
||||
Executable
+130
@@ -0,0 +1,130 @@
|
||||
#!/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}/licenses/NOTICE",
|
||||
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",
|
||||
"NOTICE",
|
||||
"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())
|
||||
Executable
+308
@@ -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())
|
||||
Reference in New Issue
Block a user