From 3fdc735141e309d0e5545f67b866ae74fcb5de4d Mon Sep 17 00:00:00 2001 From: Jack Nagy Date: Mon, 6 Jul 2026 20:29:19 +0100 Subject: [PATCH 1/2] feat(packaging): nest protocol/ + ocf/ under smartthings_local, add PyPI packaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nest the two library packages under a single import namespace so they can ship as one distribution: protocol/ -> smartthings_local/protocol/ ocf/ -> smartthings_local/ocf/ (git mv, history preserved) - Rewrite all imports protocol.* -> smartthings_local.protocol.*, ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/ (bridge, descriptor, samples), and tests. - Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning from v* tags, wheel ships only smartthings_local/. - Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on v* tags (OIDC, no stored token). - Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts: it is tracked but matches .gitignore's *.pem, so hatchling's VCS file selection would drop it — and dtls_session.py loads it at runtime. - Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the single smartthings_local/ package. - .gitignore: build artifacts (_version.py, dist/, *.egg-info/). Validated: pytest tests/ (11 passed), python -m build produces sdist + wheel with the pem bundled, fresh pip install resolves all nested imports with the pem readable from site-packages. --- .github/workflows/publish.yml | 50 +++++++++++++++ .gitignore | 5 ++ mqtt_demo/Dockerfile | 5 +- mqtt_demo/bridge.py | 10 +-- mqtt_demo/deploy.sh | 9 ++- mqtt_demo/descriptor.py | 2 +- mqtt_demo/samples/dryer.py | 2 +- mqtt_demo/samples/fridge.py | 2 +- mqtt_demo/samples/oven.py | 2 +- pyproject.toml | 64 +++++++++++++++++++ smartthings_local/__init__.py | 2 + {ocf => smartthings_local/ocf}/__init__.py | 0 {ocf => smartthings_local/ocf}/keepalive.py | 2 +- .../ocf}/observe_refresh.py | 2 +- .../ocf}/poll_scheduler.py | 2 +- {ocf => smartthings_local/ocf}/state_cache.py | 0 .../protocol}/__init__.py | 0 .../protocol}/coap.py | 0 .../protocol}/dtls_session.py | 0 .../protocol}/ocf_root_ca.pem | 0 tests/test_coap_wire.py | 2 +- tests/test_import_isolation.py | 29 ++++----- tests/test_state_cache.py | 2 +- 23 files changed, 155 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 pyproject.toml create mode 100644 smartthings_local/__init__.py rename {ocf => smartthings_local/ocf}/__init__.py (100%) rename {ocf => smartthings_local/ocf}/keepalive.py (98%) rename {ocf => smartthings_local/ocf}/observe_refresh.py (96%) rename {ocf => smartthings_local/ocf}/poll_scheduler.py (99%) rename {ocf => smartthings_local/ocf}/state_cache.py (100%) rename {protocol => smartthings_local/protocol}/__init__.py (100%) rename {protocol => smartthings_local/protocol}/coap.py (100%) rename {protocol => smartthings_local/protocol}/dtls_session.py (100%) rename {protocol => smartthings_local/protocol}/ocf_root_ca.pem (100%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..76e0337 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.gitignore b/.gitignore index e43687b..621bfb5 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,11 @@ __pycache__/ .venv/ venv/ +# Packaging / build artifacts +/smartthings_local/_version.py +/dist/ +*.egg-info/ + # Bridge runtime bridge.log course_mapper.log diff --git a/mqtt_demo/Dockerfile b/mqtt_demo/Dockerfile index 8510c75..da12450 100644 --- a/mqtt_demo/Dockerfile +++ b/mqtt_demo/Dockerfile @@ -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 diff --git a/mqtt_demo/bridge.py b/mqtt_demo/bridge.py index 36b8747..b5f6039 100644 --- a/mqtt_demo/bridge.py +++ b/mqtt_demo/bridge.py @@ -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 diff --git a/mqtt_demo/deploy.sh b/mqtt_demo/deploy.sh index c160a49..9748e9a 100755 --- a/mqtt_demo/deploy.sh +++ b/mqtt_demo/deploy.sh @@ -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 \ diff --git a/mqtt_demo/descriptor.py b/mqtt_demo/descriptor.py index ed335d2..34984d3 100644 --- a/mqtt_demo/descriptor.py +++ b/mqtt_demo/descriptor.py @@ -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 diff --git a/mqtt_demo/samples/dryer.py b/mqtt_demo/samples/dryer.py index 533b97d..aded197 100644 --- a/mqtt_demo/samples/dryer.py +++ b/mqtt_demo/samples/dryer.py @@ -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 ----------------------------------------------------- diff --git a/mqtt_demo/samples/fridge.py b/mqtt_demo/samples/fridge.py index 49b48c3..82e5567 100644 --- a/mqtt_demo/samples/fridge.py +++ b/mqtt_demo/samples/fridge.py @@ -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' diff --git a/mqtt_demo/samples/oven.py b/mqtt_demo/samples/oven.py index 48c6ce8..83b4068 100644 --- a/mqtt_demo/samples/oven.py +++ b/mqtt_demo/samples/oven.py @@ -31,7 +31,7 @@ from ..descriptor import ( device_block, encode, ) -from ocf.poll_scheduler import PollTier +from smartthings_local.ocf.poll_scheduler import PollTier # --------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ae1273 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/smartthings_local/__init__.py b/smartthings_local/__init__.py new file mode 100644 index 0000000..aaf55fd --- /dev/null +++ b/smartthings_local/__init__.py @@ -0,0 +1,2 @@ +"""smartthings-local: local CoAP-over-DTLS (OCF) transport + polling layer +for Samsung SmartThings appliances.""" diff --git a/ocf/__init__.py b/smartthings_local/ocf/__init__.py similarity index 100% rename from ocf/__init__.py rename to smartthings_local/ocf/__init__.py diff --git a/ocf/keepalive.py b/smartthings_local/ocf/keepalive.py similarity index 98% rename from ocf/keepalive.py rename to smartthings_local/ocf/keepalive.py index 9543770..0101866 100644 --- a/ocf/keepalive.py +++ b/smartthings_local/ocf/keepalive.py @@ -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: diff --git a/ocf/observe_refresh.py b/smartthings_local/ocf/observe_refresh.py similarity index 96% rename from ocf/observe_refresh.py rename to smartthings_local/ocf/observe_refresh.py index 15a9ca0..47cd591 100644 --- a/ocf/observe_refresh.py +++ b/smartthings_local/ocf/observe_refresh.py @@ -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: diff --git a/ocf/poll_scheduler.py b/smartthings_local/ocf/poll_scheduler.py similarity index 99% rename from ocf/poll_scheduler.py rename to smartthings_local/ocf/poll_scheduler.py index 6634957..6704cf5 100644 --- a/ocf/poll_scheduler.py +++ b/smartthings_local/ocf/poll_scheduler.py @@ -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 diff --git a/ocf/state_cache.py b/smartthings_local/ocf/state_cache.py similarity index 100% rename from ocf/state_cache.py rename to smartthings_local/ocf/state_cache.py diff --git a/protocol/__init__.py b/smartthings_local/protocol/__init__.py similarity index 100% rename from protocol/__init__.py rename to smartthings_local/protocol/__init__.py diff --git a/protocol/coap.py b/smartthings_local/protocol/coap.py similarity index 100% rename from protocol/coap.py rename to smartthings_local/protocol/coap.py diff --git a/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py similarity index 100% rename from protocol/dtls_session.py rename to smartthings_local/protocol/dtls_session.py diff --git a/protocol/ocf_root_ca.pem b/smartthings_local/protocol/ocf_root_ca.pem similarity index 100% rename from protocol/ocf_root_ca.pem rename to smartthings_local/protocol/ocf_root_ca.pem diff --git a/tests/test_coap_wire.py b/tests/test_coap_wire.py index e76fa98..6f5f0eb 100644 --- a/tests/test_coap_wire.py +++ b/tests/test_coap_wire.py @@ -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, ) diff --git a/tests/test_import_isolation.py b/tests/test_import_isolation.py index d2d6119..8efcdbb 100644 --- a/tests/test_import_isolation.py +++ b/tests/test_import_isolation.py @@ -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 diff --git a/tests/test_state_cache.py b/tests/test_state_cache.py index a505176..82c9807 100644 --- a/tests/test_state_cache.py +++ b/tests/test_state_cache.py @@ -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: From a2dc524c0b0468cca88f6c457518f15d9f0e9853 Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 6 Jul 2026 15:04:20 -0500 Subject: [PATCH 2/2] feat: support in-memory PEM cert/key alongside file paths in DtlsCoapSession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit localthings mints its client cert at runtime through the HA config flow and never writes it to disk. DtlsCoapSession only accepted cert_path/ key_path (file-based), which would have forced localthings to write its in-memory cert/key to disk on every connect just to migrate off its vendored copy of this transport layer. Adds an alternate cert_pem/key_pem constructor path (ported from localthings' own _load_pem_chain), validated so exactly one cert source (file pair or PEM pair) is required. Existing file-path callers (mqtt_demo, setup_cert.py) are unaffected — verified against both real appliances with each constructor path. --- smartthings_local/protocol/dtls_session.py | 62 +++++++++++++++++++--- tests/test_dtls_session_cert_loading.py | 58 ++++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 tests/test_dtls_session_cert_loading.py diff --git a/smartthings_local/protocol/dtls_session.py b/smartthings_local/protocol/dtls_session.py index 014f242..6adb31d 100644 --- a/smartthings_local/protocol/dtls_session.py +++ b/smartthings_local/protocol/dtls_session.py @@ -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() diff --git a/tests/test_dtls_session_cert_loading.py b/tests/test_dtls_session_cert_loading.py new file mode 100644 index 0000000..2891e1b --- /dev/null +++ b/tests/test_dtls_session_cert_loading.py @@ -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