Merge pull request #12 from mbillow/cert-pem-support

Support in-memory PEM cert/key alongside file paths in DtlsCoapSession
This commit is contained in:
Quite Yellow
2026-07-07 17:33:15 +01:00
committed by GitHub
24 changed files with 268 additions and 44 deletions
+50
View File
@@ -0,0 +1,50 @@
name: Publish to PyPI
# Publishes smartthings-local to PyPI on a version tag (v1.2.3).
# Uses PyPI Trusted Publishing (OIDC) — no API token is stored in the repo.
# One-time setup on PyPI: add a trusted publisher for this repo pointing at
# workflow `publish.yml` and environment `pypi`.
on:
push:
tags:
- "v*"
jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # hatch-vcs needs full history + tags to derive the version
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python -m pip install --upgrade build
- run: python -m build
- name: Verify the built version matches the tag
run: |
ls -l dist/
version="${GITHUB_REF_NAME#v}"
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
with:
name: dist
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # required for Trusted Publishing (OIDC)
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
+5
View File
@@ -17,6 +17,11 @@ __pycache__/
.venv/
venv/
# Packaging / build artifacts
/smartthings_local/_version.py
/dist/
*.egg-info/
# Bridge runtime
bridge.log
course_mapper.log
+2 -3
View File
@@ -8,9 +8,8 @@ RUN pip install --no-cache-dir -r requirements.txt
# Application code — build context is the repo root (see
# docker-compose.yml's `context: ..`) since mqtt_demo/ depends on the
# protocol/ and ocf/ library packages that live alongside it.
COPY protocol/ ./protocol/
COPY ocf/ ./ocf/
# smartthings_local library package that lives alongside it.
COPY smartthings_local/ ./smartthings_local/
COPY mqtt_demo/ ./mqtt_demo/
# /config holds the ab0b0ac4 client cert + key. Mount from the host so
+5 -5
View File
@@ -23,12 +23,12 @@ import time
import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
from ocf.keepalive import KeepaliveTask
from ocf.observe_refresh import ObserveRefreshTask
from ocf.poll_scheduler import PollScheduler
from ocf.state_cache import StateCache
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 .descriptor import ApplianceDescriptor, bridge_diagnostic_discovery
from .config import ApplianceConfig, SharedConfig
+4 -5
View File
@@ -22,7 +22,7 @@ set -e
# This script lives in mqtt_demo/ but the build context is the repo
# root (mqtt_demo/docker-compose.yml uses `context: ..`, since the
# image needs the protocol/ and ocf/ library packages alongside
# image needs the smartthings_local library package alongside
# mqtt_demo/). Run everything from the repo root so the tar allowlist
# and remote layout line up with that context.
cd "$(dirname "$0")/.."
@@ -51,12 +51,11 @@ ssh "${SSH_HOST}" mkdir -p "${REMOTE_DIR}" "${APPDATA_DIR}"
# Source code — explicit allowlist instead of an excludelist. Anything
# else in the repo (research files, certs, logs, the .git dir) stays
# local. protocol/ and ocf/ are the library packages mqtt_demo/ imports
# from; they need to land as REMOTE_DIR's siblings of mqtt_demo/ so the
# local. smartthings_local/ is the library package mqtt_demo/ imports
# from; it needs to land as REMOTE_DIR's sibling of mqtt_demo/ so the
# compose file's `context: ..` resolves the same way it does locally.
COPYFILE_DISABLE=1 tar cz \
protocol/ \
ocf/ \
smartthings_local/ \
mqtt_demo/ \
README.md \
.gitignore \
+1 -1
View File
@@ -27,7 +27,7 @@ from dataclasses import dataclass, field
from typing import Callable, Optional, TYPE_CHECKING
if TYPE_CHECKING:
from ocf.poll_scheduler import PollTier
from smartthings_local.ocf.poll_scheduler import PollTier
@dataclass
+1 -1
View File
@@ -13,7 +13,7 @@ from ..descriptor import (
device_block,
encode,
)
from ocf.poll_scheduler import PollTier
from smartthings_local.ocf.poll_scheduler import PollTier
# --- OBSERVE paths -----------------------------------------------------
+1 -1
View File
@@ -30,7 +30,7 @@ from ..descriptor import (
device_block,
encode,
)
from ocf.poll_scheduler import PollTier
from smartthings_local.ocf.poll_scheduler import PollTier
MODEL = 'ARTIK051_REF_17K'
+1 -1
View File
@@ -31,7 +31,7 @@ from ..descriptor import (
device_block,
encode,
)
from ocf.poll_scheduler import PollTier
from smartthings_local.ocf.poll_scheduler import PollTier
# ---------------------------------------------------------------------
+64
View File
@@ -0,0 +1,64 @@
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "smartthings-local"
dynamic = ["version"]
description = "Local CoAP-over-DTLS (OCF) transport + polling layer for Samsung SmartThings appliances"
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
license-files = ["LICENSE"]
authors = [{ name = "Jack Nagy" }]
keywords = ["smartthings", "samsung", "ocf", "coap", "dtls", "home-assistant", "iot"]
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Home Automation",
"Topic :: System :: Networking",
"Programming Language :: Python :: 3",
]
dependencies = [
"cbor2>=5.6",
"pyOpenSSL>=23.0",
]
[project.urls]
Homepage = "https://github.com/QuiteYellow/SmartThings-Local"
Repository = "https://github.com/QuiteYellow/SmartThings-Local"
Issues = "https://github.com/QuiteYellow/SmartThings-Local/issues"
[project.optional-dependencies]
dev = ["pytest>=8.0"]
[tool.hatch.version]
source = "vcs"
# Derive the version from git tags of the form v1.2.3.
[tool.hatch.version.raw-options]
version_scheme = "no-guess-dev"
[tool.hatch.build.hooks.vcs]
version-file = "smartthings_local/_version.py"
# ocf_root_ca.pem is tracked, but the repo .gitignore's `*.pem` rule makes
# hatchling's VCS-based file selection drop it. It's loaded at runtime by
# protocol/dtls_session.py (load_verify_locations), so force it into every
# build target (wheel + sdist).
[tool.hatch.build]
artifacts = ["smartthings_local/protocol/ocf_root_ca.pem"]
[tool.hatch.build.targets.wheel]
# Ship only the library; the mqtt_demo/, tests/, and setup_cert.py stay
# in-repo and are not part of the distributable package.
packages = ["smartthings_local"]
[tool.hatch.build.targets.sdist]
include = [
"smartthings_local",
"tests",
"README.md",
"LICENSE",
"pyproject.toml",
]
+2
View File
@@ -0,0 +1,2 @@
"""smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer
for Samsung SmartThings appliances."""
@@ -20,7 +20,7 @@ from __future__ import annotations
import threading
from typing import Callable, Optional
from protocol.dtls_session import DtlsCoapSession
from smartthings_local.protocol.dtls_session import DtlsCoapSession
class KeepaliveTask:
@@ -17,7 +17,7 @@ from __future__ import annotations
import threading
from typing import Optional
from protocol.dtls_session import DtlsCoapSession
from smartthings_local.protocol.dtls_session import DtlsCoapSession
class ObserveRefreshTask:
@@ -36,7 +36,7 @@ from typing import Callable, Optional, TYPE_CHECKING
import cbor2
from protocol.dtls_session import DtlsCoapSession, fmt_code
from smartthings_local.protocol.dtls_session import DtlsCoapSession, fmt_code
if TYPE_CHECKING:
from .state_cache import StateCache
@@ -19,6 +19,7 @@ on a per-token Event the reader signals. OBSERVE notifications are
delivered via the on_notification callback.
"""
import os
import re as _re
import socket
import threading
import time
@@ -61,31 +62,75 @@ _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,
)
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.
"""
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()
class DtlsCoapSession:
"""Single sustained DTLS-CoAP session.
Caller drives lifecycle:
sess = DtlsCoapSession(host, port, cert, key)
sess = DtlsCoapSession(host, port, cert_path=cert, key_path=key)
sess.connect()
sess.start_reader()
sess.subscribe([...], on_notification=cb) # OBSERVE
code, body = sess.get(['device', '0']) # Block2 fetch
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.
"""
HANDSHAKE_TIMEOUT_S = 12.0
READER_RECV_TIMEOUT_S = 1.0 # short so stop_event propagates quickly
MAX_BLOCKS = 32 # safety bound for Block2 fetches
def __init__(self, host, port, cert_path, key_path,
def __init__(self, host, port, cert_path=None, key_path=None, *,
cert_pem=None, key_pem=None,
on_notification=None, mtu=1200,
rate_limit_rps: float = _DEFAULT_RATE_LIMIT_RPS):
if (cert_path is not None or key_path is not None) and \
(cert_pem is not None or key_pem is not None):
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 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:
raise ValueError(
"must pass either cert_path/key_path or cert_pem/key_pem")
self.host = host
self.port = port
self.cert_path = str(cert_path)
self.key_path = str(key_path)
self.cert_path = str(cert_path) if cert_path is not None else None
self.key_path = str(key_path) if key_path is not None else None
self.cert_pem = cert_pem
self.key_pem = key_pem
self.on_notification = on_notification # fn(href, payload_bytes)
self.mtu = mtu
self._min_req_interval = 1.0 / rate_limit_rps
@@ -136,9 +181,12 @@ class DtlsCoapSession:
# the OpenSSL instance cryptography bundles — ctypes and cffi bindings
# do not expose SSL_CTX_set_security_level on this build.
ctx.set_cipher_list(b'ECDHE-ECDSA-AES128-GCM-SHA256:@SECLEVEL=0')
ctx.use_certificate_chain_file(self.cert_path)
ctx.use_privatekey_file(self.key_path)
ctx.check_privatekey()
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()
conn = SSL.Connection(ctx, None)
conn.set_connect_state()
+1 -1
View File
@@ -1,4 +1,4 @@
from protocol.coap import (
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,
)
+58
View File
@@ -0,0 +1,58 @@
import pytest
from OpenSSL import SSL, crypto
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)
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")
cert_pem = crypto.dump_certificate(crypto.FILETYPE_PEM, cert).decode()
key_pem = crypto.dump_privatekey(crypto.FILETYPE_PEM, key).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()
ctx = SSL.Context(SSL.DTLS_METHOD)
_load_pem_chain(ctx, cert_pem, key_pem)
ctx.check_privatekey() # raises if cert/key don't match
def test_load_pem_chain_rejects_cert_pem_with_no_certificates():
with pytest.raises(ValueError):
_load_pem_chain(SSL.Context(SSL.DTLS_METHOD), "not a cert", "not a key")
def test_session_requires_exactly_one_cert_source():
cert_pem, key_pem = _make_self_signed_pem_pair()
with pytest.raises(ValueError):
DtlsCoapSession("host", 1234) # neither pair given
with pytest.raises(ValueError):
DtlsCoapSession("host", 1234, cert_path="/a", key_path="/b",
cert_pem=cert_pem, key_pem=key_pem) # both given
with pytest.raises(ValueError):
DtlsCoapSession("host", 1234, cert_pem=cert_pem) # key_pem missing
def test_session_accepts_pem_pair():
cert_pem, key_pem = _make_self_signed_pem_pair()
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
+14 -15
View File
@@ -1,8 +1,8 @@
"""protocol/ and ocf/ must be vendorable on their own — no dependency
on mqtt_demo/. This copies just those two directories into an empty
temp dir and imports every module in them there, so a stray
`from mqtt_demo... import ...` fails loudly instead of silently
passing because mqtt_demo/ happens to also be on sys.path in-repo."""
"""smartthings_local (protocol/ + ocf/) must be vendorable on its own — no
dependency on mqtt_demo/. This copies just the smartthings_local package into
an empty temp dir and imports every module in it there, so a stray
`from mqtt_demo... import ...` fails loudly instead of silently passing
because mqtt_demo/ happens to also be on sys.path in-repo."""
import os
import shutil
import subprocess
@@ -12,17 +12,16 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path):
for pkg in ('protocol', 'ocf'):
shutil.copytree(REPO_ROOT / pkg, tmp_path / pkg)
def test_smartthings_local_imports_without_mqtt_demo_present(tmp_path):
shutil.copytree(REPO_ROOT / "smartthings_local", tmp_path / "smartthings_local")
import_lines = [
"import protocol.coap",
"import protocol.dtls_session",
"import ocf.state_cache",
"import ocf.poll_scheduler",
"import ocf.keepalive",
"import ocf.observe_refresh",
"import smartthings_local.protocol.coap",
"import smartthings_local.protocol.dtls_session",
"import smartthings_local.ocf.state_cache",
"import smartthings_local.ocf.poll_scheduler",
"import smartthings_local.ocf.keepalive",
"import smartthings_local.ocf.observe_refresh",
]
script = "\n".join(import_lines) + "\nprint('OK')\n"
@@ -36,7 +35,7 @@ def test_protocol_and_ocf_import_without_mqtt_demo_present(tmp_path):
capture_output=True, text=True,
)
assert result.returncode == 0, (
f"protocol/ocf failed to import without mqtt_demo/ present:\n"
f"smartthings_local failed to import without mqtt_demo/ present:\n"
f"stdout: {result.stdout}\nstderr: {result.stderr}"
)
assert "OK" in result.stdout
+1 -1
View File
@@ -1,5 +1,5 @@
# tests/test_state_cache.py
from ocf.state_cache import StateCache
from smartthings_local.ocf.state_cache import StateCache
class _FakeDescriptor: