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:
@@ -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
|
||||
@@ -17,6 +17,11 @@ __pycache__/
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# Packaging / build artifacts
|
||||
/smartthings_local/_version.py
|
||||
/dist/
|
||||
*.egg-info/
|
||||
|
||||
# Bridge runtime
|
||||
bridge.log
|
||||
course_mapper.log
|
||||
|
||||
@@ -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
@@ -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
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 -----------------------------------------------------
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -31,7 +31,7 @@ from ..descriptor import (
|
||||
device_block,
|
||||
encode,
|
||||
)
|
||||
from ocf.poll_scheduler import PollTier
|
||||
from smartthings_local.ocf.poll_scheduler import PollTier
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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,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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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,5 +1,5 @@
|
||||
# tests/test_state_cache.py
|
||||
from ocf.state_cache import StateCache
|
||||
from smartthings_local.ocf.state_cache import StateCache
|
||||
|
||||
|
||||
class _FakeDescriptor:
|
||||
|
||||
Reference in New Issue
Block a user