From 7c7cae77618fc855d001314164518bcec2ec9d43 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Mon, 5 Oct 2026 15:52:53 -0400 Subject: [PATCH 1/4] Add opt-in TLS certificate verification LIVEPEER_GATEWAY_VERIFY_TLS=1, or livepeer_gateway.http.VERIFY_TLS = True, verifies HTTPS certificates against the system trust store on every HTTP call the SDK makes: the shared request helpers (signer, discovery, runners, live payments), BYOC, the lv2v /payment call, and trickle publish/subscribe. The setting is read at call time and both SSL contexts are built once. The default is unchanged. Orchestrators serve self-signed certificates and go-livepeer itself skips verification for orchestrator-facing calls, so verifying by default would break runner calls, BYOC, payments and trickle against stock orchestrators. Split from BlueClawNetwork/livepeer-python-gateway#1, without per-call verify_tls arguments. Co-authored-by: Mike Zupper Co-Authored-By: Claude Opus 5.5 --- README | 14 ++ src/livepeer_gateway/byoc.py | 15 +- src/livepeer_gateway/http.py | 34 +++- src/livepeer_gateway/remote_signer.py | 5 +- src/livepeer_gateway/trickle_publisher.py | 4 +- src/livepeer_gateway/trickle_subscriber.py | 3 +- tests/fixtures/selfsigned.crt | 19 +++ tests/fixtures/selfsigned.key | 28 ++++ tests/test_tls.py | 172 +++++++++++++++++++++ 9 files changed, 273 insertions(+), 21 deletions(-) create mode 100644 tests/fixtures/selfsigned.crt create mode 100644 tests/fixtures/selfsigned.key create mode 100644 tests/test_tls.py diff --git a/README b/README index af5018d..2c06bc8 100644 --- a/README +++ b/README @@ -173,3 +173,17 @@ Selection/discovery precedence (highest -> lowest): 5) signer-derived discovery endpoint `signer_headers` are sent with requests to the signer service. `discovery_headers` are only used when an explicit `discovery_url` is provided (and not when using the signer service as a discovery fallback). + +## TLS certificate verification + +Orchestrators serve self-signed certificates, so by default the SDK does not +verify HTTPS certificates (the gRPC client does not either). When every HTTPS +endpoint the SDK reaches (signer, discovery, orchestrators, runners, trickle) +presents a publicly trusted certificate, turn verification on for the process: + +```bash +export LIVEPEER_GATEWAY_VERIFY_TLS=1 # "1", "true" or "yes" +``` + +or at runtime with `livepeer_gateway.http.VERIFY_TLS = True`. Verification then +uses the system trust store. diff --git a/src/livepeer_gateway/byoc.py b/src/livepeer_gateway/byoc.py index 272d668..0bc7d51 100644 --- a/src/livepeer_gateway/byoc.py +++ b/src/livepeer_gateway/byoc.py @@ -33,7 +33,6 @@ import base64 import json import logging -import ssl import uuid from dataclasses import dataclass, field from typing import Any, Optional, Sequence @@ -43,14 +42,10 @@ from .orchestrator import _http_origin, discover_orchestrators from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection +from .http import _ssl_context _LOG = logging.getLogger(__name__) -# Reusable SSL context (skip verification for self-signed certs) -_ssl_ctx = ssl.create_default_context() -_ssl_ctx.check_hostname = False -_ssl_ctx.verify_mode = ssl.CERT_NONE - # --------------------------------------------------------------------------- # Data classes @@ -402,7 +397,7 @@ def submit_byoc_job( _LOG.info("BYOC job %s: trying orchestrator %s", job_id, orch_origin) try: - with urlopen(http_req, timeout=http_timeout, context=_ssl_ctx) as resp: + with urlopen(http_req, timeout=http_timeout, context=_ssl_context()) as resp: raw_body = resp.read() resp_headers = {k: v for k, v in resp.headers.items()} @@ -688,7 +683,7 @@ def submit_training_job( _LOG.info("Training job %s: trying orchestrator %s", job_id, orch_origin) try: - with urlopen(http_req, timeout=http_timeout, context=_ssl_ctx) as resp: + with urlopen(http_req, timeout=http_timeout, context=_ssl_context()) as resp: raw_body = resp.read() data = json.loads(raw_body.decode("utf-8")) @@ -820,7 +815,7 @@ def refresh_training_payment( # Empty JSON body — refresh carries everything in headers http_req = Request(url, data=b"{}", headers=headers, method="POST") try: - with urlopen(http_req, timeout=timeout, context=_ssl_ctx) as resp: + with urlopen(http_req, timeout=timeout, context=_ssl_context()) as resp: body = resp.read().decode("utf-8") if resp.status not in (200, 202): raise LivepeerGatewayError( @@ -881,7 +876,7 @@ def get_training_status( http_req = Request(url, headers={"Accept": "application/json"}) try: - with urlopen(http_req, timeout=timeout, context=_ssl_ctx) as resp: + with urlopen(http_req, timeout=timeout, context=_ssl_context()) as resp: data = json.loads(resp.read().decode("utf-8")) return ByocTrainingStatus( job_id=data.get("job_id", job_id), diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index 699d042..ee7bdbb 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -1,6 +1,8 @@ from __future__ import annotations +import functools import json +import os import ssl from typing import Any from urllib.error import HTTPError, URLError @@ -18,6 +20,30 @@ _REFRESH_SESSION_ORCHESTRATOR_URL_HEADER = "Livepeer-Orchestrator-URL" +VERIFY_TLS_ENV = "LIVEPEER_GATEWAY_VERIFY_TLS" + + +def _verify_tls_from_env() -> bool: + """``LIVEPEER_GATEWAY_VERIFY_TLS``: ``1``, ``true`` or ``yes`` turn verification on.""" + return os.environ.get(VERIFY_TLS_ENV, "").strip().lower() in {"1", "true", "yes"} + + +# Orchestrators serve self-signed certificates, so HTTPS certificates are not +# verified by default, as with the gRPC client. Set LIVEPEER_GATEWAY_VERIFY_TLS=1, +# or assign VERIFY_TLS, when every HTTPS endpoint the SDK calls (signer, +# discovery, orchestrators, runners, trickle) has a publicly trusted certificate. +VERIFY_TLS: bool = _verify_tls_from_env() + + +@functools.cache +def _ssl_context_for(verify: bool) -> ssl.SSLContext: + return ssl.create_default_context() if verify else ssl._create_unverified_context() + + +def _ssl_context() -> ssl.SSLContext: + """TLS context for an outgoing HTTPS request, following ``VERIFY_TLS`` at call time.""" + return _ssl_context_for(VERIFY_TLS) + def _truncate(s: str, max_len: int = 2000) -> str: if len(s) <= max_len: @@ -160,9 +186,7 @@ def request_json_sync( headers=headers, ) req = Request(url, data=body, headers=req_headers, method=resolved_method) - - # Always ignore HTTPS certificate validation (matches our gRPC behavior). - ssl_ctx = ssl._create_unverified_context() + ssl_ctx = _ssl_context() try: with urlopen(req, timeout=timeout, context=ssl_ctx) as resp: @@ -266,7 +290,7 @@ async def _request_body( try: client_timeout = aiohttp.ClientTimeout(total=timeout) - connector = aiohttp.TCPConnector(ssl=False) + connector = aiohttp.TCPConnector(ssl=_ssl_context()) async with aiohttp.ClientSession(timeout=client_timeout, connector=connector) as session: async with session.request(resolved_method, url, data=body, headers=req_headers) as resp: raw = await resp.read() @@ -356,7 +380,7 @@ async def open_stream( ) timeout = aiohttp.ClientTimeout(total=None, sock_connect=connect_timeout, sock_read=None) - session = aiohttp.ClientSession(timeout=timeout, connector=aiohttp.TCPConnector(ssl=False)) + session = aiohttp.ClientSession(timeout=timeout, connector=aiohttp.TCPConnector(ssl=_ssl_context())) try: resp = await session.request(resolved_method, url, data=body, headers=req_headers) except (TimeoutError, aiohttp.ClientError) as e: diff --git a/src/livepeer_gateway/remote_signer.py b/src/livepeer_gateway/remote_signer.py index dd4554f..05f6b64 100644 --- a/src/livepeer_gateway/remote_signer.py +++ b/src/livepeer_gateway/remote_signer.py @@ -5,7 +5,6 @@ import json import logging import re -import ssl from dataclasses import dataclass, replace from functools import lru_cache from typing import Any, Optional @@ -488,7 +487,7 @@ def send_payment(self) -> None: Generate a payment (via get_payment) and forward it to the orchestrator via POST {orch}/payment. """ - from .http import _extract_error_message, _http_origin + from .http import _extract_error_message, _http_origin, _ssl_context p = self.get_payment() if not self._info.transcoder: @@ -500,7 +499,7 @@ def send_payment(self) -> None: "Livepeer-Segment": p.seg_creds or "", } req = Request(url, data=b"", headers=headers, method="POST") - ssl_ctx = ssl._create_unverified_context() + ssl_ctx = _ssl_context() try: with urlopen(req, timeout=5.0, context=ssl_ctx) as resp: resp.read() diff --git a/src/livepeer_gateway/trickle_publisher.py b/src/livepeer_gateway/trickle_publisher.py index 1419720..17617ed 100644 --- a/src/livepeer_gateway/trickle_publisher.py +++ b/src/livepeer_gateway/trickle_publisher.py @@ -9,6 +9,7 @@ import aiohttp from .errors import LivepeerGatewayError +from .http import _ssl_context _LOG = logging.getLogger(__name__) @@ -164,8 +165,7 @@ async def _ensure_runtime(self) -> None: if self._lock is None: self._lock = asyncio.Lock() if self._session is None: - # Ignore TLS validation (matches the rest of this repo). - connector = aiohttp.TCPConnector(ssl=False) + connector = aiohttp.TCPConnector(ssl=_ssl_context()) self._session = aiohttp.ClientSession(connector=connector) def _stream_url(self, seq: int) -> str: diff --git a/src/livepeer_gateway/trickle_subscriber.py b/src/livepeer_gateway/trickle_subscriber.py index 9869c2f..ff48f4d 100644 --- a/src/livepeer_gateway/trickle_subscriber.py +++ b/src/livepeer_gateway/trickle_subscriber.py @@ -8,6 +8,7 @@ import aiohttp +from .http import _ssl_context from .segment_reader import SegmentReader @@ -104,7 +105,7 @@ async def _ensure_runtime(self) -> None: if self._lock is None: self._lock = asyncio.Lock() if self._session is None: - connector = aiohttp.TCPConnector(ssl=False) + connector = aiohttp.TCPConnector(ssl=_ssl_context()) self._session = aiohttp.ClientSession(connector=connector) def _segment_url(self, seq: int) -> str: diff --git a/tests/fixtures/selfsigned.crt b/tests/fixtures/selfsigned.crt new file mode 100644 index 0000000..a413a30 --- /dev/null +++ b/tests/fixtures/selfsigned.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUPCp4k7DrfWrA5xnKB1phajA+eEMwDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMCAXDTI2MDkzMDEwNDc0M1oYDzIxMjYw +OTA2MTA0NzQzWjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQCZOvxndoTX50V3eXpK+Dkh99XPEqEYB7OePmIWabF0 +DxlI+sWQ5EGUxdfIlFpH9ZnnnaGt3jw+fe5eC6kvGL0XL/CfDvLHD66KpeL/uwaU ++1BYrL6ElhCsX5qQW314CenxAIX317sjt9GWmgkToghcxuLIN7CTayz07rvm5xz8 +YDkvaWzGvEaHLKZqm5fwq4jN72ufBuVXLC3gG/Y9FjwFKoMcewROilfAwsb2cdsP +ECb/0hgq/p09q+VUB02mg8HBL5v5OE8reGhzP90QDLyXpoMgQQL905+HO4nMDVGs +ZXTWIP1T5qxpY0mJLOFs2F+Jc1t00ZjeowrYNK3aPrcXAgMBAAGjbzBtMB0GA1Ud +DgQWBBT2kGfGknw5p4OMeEQM9mv/GNrq8TAfBgNVHSMEGDAWgBT2kGfGknw5p4OM +eEQM9mv/GNrq8TAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv +Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAZz0+4QnscE+tnrJ5/z4n+YiOeUo6 +c7jgPFb59oM0vsM60AM6T4X0F0cx9tV15BaKkZHtkDhsceidTP+VQIqGi6dqmyOB +zSvEZS5IXczDn51ABeM0JDMIxc7617sU88t/bDlHg+6NTSK7tcFX3tuOzmaShlnf +oNGufrWkpG/ZV+eJDN9mP3M/Jwf5MT3z6qNuCzzAieGLE3ENBuxmHQQEyMgE0DdD +eZgz9i9XV35OcDxqR1azNLHkNfG8+sNZ1DOb5zxT0qf1SN41S6dqZXTDo71fcdP0 +dqVflfKZO2l/d1GBqNhK3cmviJnAOS/Q5Wql/RYYXkHwF+o2lm/MpGF70A== +-----END CERTIFICATE----- diff --git a/tests/fixtures/selfsigned.key b/tests/fixtures/selfsigned.key new file mode 100644 index 0000000..d339a8d --- /dev/null +++ b/tests/fixtures/selfsigned.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZOvxndoTX50V3 +eXpK+Dkh99XPEqEYB7OePmIWabF0DxlI+sWQ5EGUxdfIlFpH9ZnnnaGt3jw+fe5e +C6kvGL0XL/CfDvLHD66KpeL/uwaU+1BYrL6ElhCsX5qQW314CenxAIX317sjt9GW +mgkToghcxuLIN7CTayz07rvm5xz8YDkvaWzGvEaHLKZqm5fwq4jN72ufBuVXLC3g +G/Y9FjwFKoMcewROilfAwsb2cdsPECb/0hgq/p09q+VUB02mg8HBL5v5OE8reGhz +P90QDLyXpoMgQQL905+HO4nMDVGsZXTWIP1T5qxpY0mJLOFs2F+Jc1t00ZjeowrY +NK3aPrcXAgMBAAECggEABDlGhNmU/tiOpyTvlIp0FLYerCcQzrdDobyJeqGuNQtH +gKX89Sn9jsrHUG2acXpZJDPJpZ/HHFffTrNWlghCChYVtWS1l3wcrRC2iaMPouKs +i80zMTdrvTyjZ5BGDJ2FV9HSX4sQSwNBoTucvXBNd7U5whga421kX1oHTu2W9HPt +oCw+FvBr1lXBTJI1meFK3ph2sozPrjeOZI8wa25nkcdSChPwSjaRpgltv999jT2s +wBeX080Z6o+Aj69SVabvZhNl4jdOzN1J/i70sBhUn12klIBANA7RPhRbCTPh8ux8 +dRoMSxnnCA3eHuCO4NcaBHNFAMa9BuMdFkK45Ym2JQKBgQDRMoR4cP/A7+zFTTJS +3WdKyEsAjLOldPf5+zabVadzqYf/WozodwDBRQ2SctWdI1VFlyQvyowTBTrQVxJN +LW33Dnoy+wCvqlZ/V4LgkWs8rPX13eFWc8zYgdVmJBjFJA2e0VROzZKNURzb3ZJc +SZQKnSJP2Flu8VMPE2pOQBDoZQKBgQC7gwrBAo0CeOkQC7I58DVTylws29YWJVdU +00/sot6puPd+9Ctq0vOtfwbud2OjotvUVW3LjbvqRvqGHhAi+7Q3h7pyVGvFLM1U +YxjDGD4PFl6mmuVZT7WtLG0jOQHrSyIlAogJsbwwY8AP5OZw0RPjDScbDRhTE3vG +kwGeRbRDywKBgCpo2V4pWk/c090zxy3xYI1SiZH09D5othThjrJy7N2P0tg7nTFL +XsXPn+6+hPu0PAc9msJth3QbrU6rODSZLx/UuYU4SW1TVPG/5fJsyJamYb6UWqhS +FYcW2xI34yWkJ3a47SSEnF81wGn6Qz+eUvrL3Dbfn1HUbogx4W03o7XZAoGBAKbM +yPIsjLcYS2pEnURA0483wYr9+5Ezol4Io15C+4NK0I6UMVMTWESLbOUQOwhDRP27 +CLI74F9kyFIpx/z2nreO+2HciwmY8wYQKOTTWdEd+xCFVWN/LoNfs63TdkuSSEtX +1x+YhWkeMqlw2zScJ+6fspuzqGGuQTrBZn0eUF7FAoGAVH0b/CRjpGFdT+9/Rl1C +3d5bYo3iwBaM858FuBMbR+FPUBboKealHmdErEWHniAUy72rfGbiiZrSsTJsq2md +mLkkJkONXPnCQMpE6hFwZ0eWL2nI7oXLFwhs1bakdv2tGDqM+NSO4Z7Et8eUyNqK +S07mF1s36FsIFL8t+q3/vcE= +-----END PRIVATE KEY----- diff --git a/tests/test_tls.py b/tests/test_tls.py new file mode 100644 index 0000000..827ed1f --- /dev/null +++ b/tests/test_tls.py @@ -0,0 +1,172 @@ +"""Opt-in TLS certificate verification. + +The tests serve a real aiohttp application over a self-signed certificate +(``tests/fixtures/selfsigned.*``, valid until 2126). By default the SDK accepts +it, as it accepts the self-signed certificates orchestrators serve; with +``VERIFY_TLS`` on, every HTTP path refuses it before sending a request. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import pathlib +import ssl +from collections.abc import AsyncIterator, Iterator + +import pytest +from aiohttp import web + +from livepeer_gateway import http +from livepeer_gateway.discovery import discover_runners +from livepeer_gateway.errors import LivepeerGatewayError +from livepeer_gateway.live_runner import call_runner +from livepeer_gateway.remote_signer import RemoteSignerError, get_signer_info + +_FIXTURES = pathlib.Path(__file__).parent / "fixtures" + + +class TestVerifyTlsSetting: + @pytest.mark.parametrize("value", ["1", "true", "yes", " TRUE ", "Yes"]) + def test_env_turns_verification_on(self, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + monkeypatch.setenv(http.VERIFY_TLS_ENV, value) + assert http._verify_tls_from_env() is True + + @pytest.mark.parametrize("value", [None, "", "0", "false", "no", "off"]) + def test_anything_else_leaves_it_off( + self, monkeypatch: pytest.MonkeyPatch, value: str | None + ) -> None: + if value is None: + monkeypatch.delenv(http.VERIFY_TLS_ENV, raising=False) + else: + monkeypatch.setenv(http.VERIFY_TLS_ENV, value) + assert http._verify_tls_from_env() is False + + def test_context_follows_the_setting_at_call_time(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(http, "VERIFY_TLS", False) + assert http._ssl_context().verify_mode == ssl.CERT_NONE + assert http._ssl_context().check_hostname is False + monkeypatch.setattr(http, "VERIFY_TLS", True) + assert http._ssl_context().verify_mode == ssl.CERT_REQUIRED + assert http._ssl_context().check_hostname is True + + +@pytest.fixture +def verify_off(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setattr(http, "VERIFY_TLS", False) + get_signer_info.cache_clear() # type: ignore[attr-defined] + yield + get_signer_info.cache_clear() # type: ignore[attr-defined] + + +@pytest.fixture +def verify_on(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + monkeypatch.setattr(http, "VERIFY_TLS", True) + get_signer_info.cache_clear() # type: ignore[attr-defined] + yield + get_signer_info.cache_clear() # type: ignore[attr-defined] + + +@contextlib.asynccontextmanager +async def _serve_tls(app: web.Application) -> AsyncIterator[str]: + """Serve ``app`` over the self-signed certificate on an ephemeral port.""" + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(_FIXTURES / "selfsigned.crt", _FIXTURES / "selfsigned.key") + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_ctx) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + try: + yield f"https://127.0.0.1:{port}" + finally: + await runner.cleanup() + + +def _app(calls: list[str]) -> web.Application: + """A runner, signer and discovery endpoint in one.""" + + async def call(request: web.Request) -> web.Response: + calls.append(request.path) + return web.json_response({"text": "hello"}) + + async def sse(request: web.Request) -> web.StreamResponse: + calls.append(request.path) + resp = web.StreamResponse(headers={"Content-Type": "text/event-stream"}) + await resp.prepare(request) + await resp.write(b"data: one\n\n") + await resp.write_eof() + return resp + + async def sign(request: web.Request) -> web.Response: + calls.append(request.path) + return web.json_response({"address": "0xpayer", "signature": "0xsig"}) + + async def discover(request: web.Request) -> web.Response: + calls.append(request.path) + return web.json_response( + [{"address": "https://orch.example.com", "runners": [{"url": "https://orch.example.com/r", "app": "a"}]}] + ) + + app = web.Application() + app.router.add_post("/call", call) + app.router.add_post("/sse", sse) + app.router.add_post("/sign-orchestrator-info", sign) + app.router.add_get("/discover-orchestrators", discover) + return app + + +class TestDefaultAcceptsSelfSigned: + async def test_runner_signer_and_discovery(self, verify_off: None) -> None: + calls: list[str] = [] + async with _serve_tls(_app(calls)) as base: + result = await call_runner(f"{base}/call", payload={"x": 1}) + async with await call_runner(f"{base}/sse", payload={"x": 1}, stream=True) as stream: + lines = [line async for line in stream.aiter_lines() if line] + signer = await get_signer_info(base) + entries = await discover_runners(signer_url=base) + assert result.data == {"text": "hello"} + assert lines == ["data: one"] + assert signer.address == "0xpayer" + assert entries[0]["runners"][0]["app"] == "a" + assert calls == ["/call", "/sse", "/sign-orchestrator-info", "/discover-orchestrators"] + + +class TestOptInRejectsSelfSigned: + async def test_call_runner(self, verify_on: None) -> None: + calls: list[str] = [] + async with _serve_tls(_app(calls)) as base: + with pytest.raises(LivepeerGatewayError, match="certificate"): + await call_runner(f"{base}/call", payload={"x": 1}) + with pytest.raises(LivepeerGatewayError, match="certificate"): + await call_runner(f"{base}/sse", payload={"x": 1}, stream=True) + assert calls == [] + + async def test_signer(self, verify_on: None) -> None: + calls: list[str] = [] + async with _serve_tls(_app(calls)) as base: + with pytest.raises(LivepeerGatewayError, match="certificate"): + await get_signer_info(base) + assert calls == [] + + async def test_discovery(self, verify_on: None) -> None: + calls: list[str] = [] + async with _serve_tls(_app(calls)) as base: + with pytest.raises(RemoteSignerError, match="certificate"): + await discover_runners(signer_url=base) + assert calls == [] + + async def test_sync_request(self, verify_on: None) -> None: + calls: list[str] = [] + + def probe(base: str) -> str: + try: + http.post_json_sync(f"{base}/sign-orchestrator-info", {}) + except LivepeerGatewayError as e: + return str(e) + return "" + + async with _serve_tls(_app(calls)) as base: + message = await asyncio.to_thread(probe, base) + assert "certificate" in message + assert calls == [] From 759547b21a6508d2a5f54c0e129dd29a681ce79c Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Tue, 6 Oct 2026 15:26:37 -0400 Subject: [PATCH 2/4] Remove tracked TLS test certificates Generate a throwaway self-signed certificate in the TLS tests instead of committing the fixture certificate and key. --- tests/fixtures/selfsigned.crt | 19 ---------- tests/fixtures/selfsigned.key | 28 --------------- tests/test_tls.py | 65 ++++++++++++++++++++++++++--------- 3 files changed, 49 insertions(+), 63 deletions(-) delete mode 100644 tests/fixtures/selfsigned.crt delete mode 100644 tests/fixtures/selfsigned.key diff --git a/tests/fixtures/selfsigned.crt b/tests/fixtures/selfsigned.crt deleted file mode 100644 index a413a30..0000000 --- a/tests/fixtures/selfsigned.crt +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDJzCCAg+gAwIBAgIUPCp4k7DrfWrA5xnKB1phajA+eEMwDQYJKoZIhvcNAQEL -BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMCAXDTI2MDkzMDEwNDc0M1oYDzIxMjYw -OTA2MTA0NzQzWjAUMRIwEAYDVQQDDAkxMjcuMC4wLjEwggEiMA0GCSqGSIb3DQEB -AQUAA4IBDwAwggEKAoIBAQCZOvxndoTX50V3eXpK+Dkh99XPEqEYB7OePmIWabF0 -DxlI+sWQ5EGUxdfIlFpH9ZnnnaGt3jw+fe5eC6kvGL0XL/CfDvLHD66KpeL/uwaU -+1BYrL6ElhCsX5qQW314CenxAIX317sjt9GWmgkToghcxuLIN7CTayz07rvm5xz8 -YDkvaWzGvEaHLKZqm5fwq4jN72ufBuVXLC3gG/Y9FjwFKoMcewROilfAwsb2cdsP -ECb/0hgq/p09q+VUB02mg8HBL5v5OE8reGhzP90QDLyXpoMgQQL905+HO4nMDVGs -ZXTWIP1T5qxpY0mJLOFs2F+Jc1t00ZjeowrYNK3aPrcXAgMBAAGjbzBtMB0GA1Ud -DgQWBBT2kGfGknw5p4OMeEQM9mv/GNrq8TAfBgNVHSMEGDAWgBT2kGfGknw5p4OM -eEQM9mv/GNrq8TAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv -Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAZz0+4QnscE+tnrJ5/z4n+YiOeUo6 -c7jgPFb59oM0vsM60AM6T4X0F0cx9tV15BaKkZHtkDhsceidTP+VQIqGi6dqmyOB -zSvEZS5IXczDn51ABeM0JDMIxc7617sU88t/bDlHg+6NTSK7tcFX3tuOzmaShlnf -oNGufrWkpG/ZV+eJDN9mP3M/Jwf5MT3z6qNuCzzAieGLE3ENBuxmHQQEyMgE0DdD -eZgz9i9XV35OcDxqR1azNLHkNfG8+sNZ1DOb5zxT0qf1SN41S6dqZXTDo71fcdP0 -dqVflfKZO2l/d1GBqNhK3cmviJnAOS/Q5Wql/RYYXkHwF+o2lm/MpGF70A== ------END CERTIFICATE----- diff --git a/tests/fixtures/selfsigned.key b/tests/fixtures/selfsigned.key deleted file mode 100644 index d339a8d..0000000 --- a/tests/fixtures/selfsigned.key +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZOvxndoTX50V3 -eXpK+Dkh99XPEqEYB7OePmIWabF0DxlI+sWQ5EGUxdfIlFpH9ZnnnaGt3jw+fe5e -C6kvGL0XL/CfDvLHD66KpeL/uwaU+1BYrL6ElhCsX5qQW314CenxAIX317sjt9GW -mgkToghcxuLIN7CTayz07rvm5xz8YDkvaWzGvEaHLKZqm5fwq4jN72ufBuVXLC3g -G/Y9FjwFKoMcewROilfAwsb2cdsPECb/0hgq/p09q+VUB02mg8HBL5v5OE8reGhz -P90QDLyXpoMgQQL905+HO4nMDVGsZXTWIP1T5qxpY0mJLOFs2F+Jc1t00ZjeowrY -NK3aPrcXAgMBAAECggEABDlGhNmU/tiOpyTvlIp0FLYerCcQzrdDobyJeqGuNQtH -gKX89Sn9jsrHUG2acXpZJDPJpZ/HHFffTrNWlghCChYVtWS1l3wcrRC2iaMPouKs -i80zMTdrvTyjZ5BGDJ2FV9HSX4sQSwNBoTucvXBNd7U5whga421kX1oHTu2W9HPt -oCw+FvBr1lXBTJI1meFK3ph2sozPrjeOZI8wa25nkcdSChPwSjaRpgltv999jT2s -wBeX080Z6o+Aj69SVabvZhNl4jdOzN1J/i70sBhUn12klIBANA7RPhRbCTPh8ux8 -dRoMSxnnCA3eHuCO4NcaBHNFAMa9BuMdFkK45Ym2JQKBgQDRMoR4cP/A7+zFTTJS -3WdKyEsAjLOldPf5+zabVadzqYf/WozodwDBRQ2SctWdI1VFlyQvyowTBTrQVxJN -LW33Dnoy+wCvqlZ/V4LgkWs8rPX13eFWc8zYgdVmJBjFJA2e0VROzZKNURzb3ZJc -SZQKnSJP2Flu8VMPE2pOQBDoZQKBgQC7gwrBAo0CeOkQC7I58DVTylws29YWJVdU -00/sot6puPd+9Ctq0vOtfwbud2OjotvUVW3LjbvqRvqGHhAi+7Q3h7pyVGvFLM1U -YxjDGD4PFl6mmuVZT7WtLG0jOQHrSyIlAogJsbwwY8AP5OZw0RPjDScbDRhTE3vG -kwGeRbRDywKBgCpo2V4pWk/c090zxy3xYI1SiZH09D5othThjrJy7N2P0tg7nTFL -XsXPn+6+hPu0PAc9msJth3QbrU6rODSZLx/UuYU4SW1TVPG/5fJsyJamYb6UWqhS -FYcW2xI34yWkJ3a47SSEnF81wGn6Qz+eUvrL3Dbfn1HUbogx4W03o7XZAoGBAKbM -yPIsjLcYS2pEnURA0483wYr9+5Ezol4Io15C+4NK0I6UMVMTWESLbOUQOwhDRP27 -CLI74F9kyFIpx/z2nreO+2HciwmY8wYQKOTTWdEd+xCFVWN/LoNfs63TdkuSSEtX -1x+YhWkeMqlw2zScJ+6fspuzqGGuQTrBZn0eUF7FAoGAVH0b/CRjpGFdT+9/Rl1C -3d5bYo3iwBaM858FuBMbR+FPUBboKealHmdErEWHniAUy72rfGbiiZrSsTJsq2md -mLkkJkONXPnCQMpE6hFwZ0eWL2nI7oXLFwhs1bakdv2tGDqM+NSO4Z7Et8eUyNqK -S07mF1s36FsIFL8t+q3/vcE= ------END PRIVATE KEY----- diff --git a/tests/test_tls.py b/tests/test_tls.py index 827ed1f..921ae3b 100644 --- a/tests/test_tls.py +++ b/tests/test_tls.py @@ -1,9 +1,9 @@ """Opt-in TLS certificate verification. The tests serve a real aiohttp application over a self-signed certificate -(``tests/fixtures/selfsigned.*``, valid until 2126). By default the SDK accepts -it, as it accepts the self-signed certificates orchestrators serve; with -``VERIFY_TLS`` on, every HTTP path refuses it before sending a request. +generated for the test. By default the SDK accepts it, as it accepts the +self-signed certificates orchestrators serve; with ``VERIFY_TLS`` on, every +HTTP path refuses it before sending a request. """ from __future__ import annotations @@ -12,6 +12,8 @@ import contextlib import pathlib import ssl +import subprocess +import tempfile from collections.abc import AsyncIterator, Iterator import pytest @@ -23,7 +25,36 @@ from livepeer_gateway.live_runner import call_runner from livepeer_gateway.remote_signer import RemoteSignerError, get_signer_info -_FIXTURES = pathlib.Path(__file__).parent / "fixtures" + +def _write_self_signed(directory: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]: + """Write a throwaway self-signed cert and key for ``127.0.0.1``.""" + cert = directory / "selfsigned.crt" + key = directory / "selfsigned.key" + subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "ec", + "-pkeyopt", + "ec_paramgen_curve:prime256v1", + "-keyout", + str(key), + "-out", + str(cert), + "-days", + "1", + "-nodes", + "-subj", + "/CN=127.0.0.1", + "-addext", + "subjectAltName=IP:127.0.0.1,DNS:localhost", + ], + check=True, + capture_output=True, + ) + return cert, key class TestVerifyTlsSetting: @@ -69,18 +100,20 @@ def verify_on(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: @contextlib.asynccontextmanager async def _serve_tls(app: web.Application) -> AsyncIterator[str]: - """Serve ``app`` over the self-signed certificate on an ephemeral port.""" - ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) - ssl_ctx.load_cert_chain(_FIXTURES / "selfsigned.crt", _FIXTURES / "selfsigned.key") - runner = web.AppRunner(app) - await runner.setup() - site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_ctx) - await site.start() - port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] - try: - yield f"https://127.0.0.1:{port}" - finally: - await runner.cleanup() + """Serve ``app`` over a generated self-signed certificate on an ephemeral port.""" + with tempfile.TemporaryDirectory() as directory: + cert, key = _write_self_signed(pathlib.Path(directory)) + ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_ctx.load_cert_chain(cert, key) + runner = web.AppRunner(app) + await runner.setup() + site = web.TCPSite(runner, "127.0.0.1", 0, ssl_context=ssl_ctx) + await site.start() + port = site._server.sockets[0].getsockname()[1] # type: ignore[union-attr] + try: + yield f"https://127.0.0.1:{port}" + finally: + await runner.cleanup() def _app(calls: list[str]) -> web.Application: From e886fbfbb25bc97e34978eff9d12fa41907100f8 Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 9 Oct 2026 16:39:41 -0400 Subject: [PATCH 3/4] revert changes to byoc.py as they are out of scope (to be removed in upstream PR) --- src/livepeer_gateway/byoc.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/livepeer_gateway/byoc.py b/src/livepeer_gateway/byoc.py index 0bc7d51..272d668 100644 --- a/src/livepeer_gateway/byoc.py +++ b/src/livepeer_gateway/byoc.py @@ -33,6 +33,7 @@ import base64 import json import logging +import ssl import uuid from dataclasses import dataclass, field from typing import Any, Optional, Sequence @@ -42,10 +43,14 @@ from .orchestrator import _http_origin, discover_orchestrators from .errors import LivepeerGatewayError, NoOrchestratorAvailableError, OrchestratorRejection -from .http import _ssl_context _LOG = logging.getLogger(__name__) +# Reusable SSL context (skip verification for self-signed certs) +_ssl_ctx = ssl.create_default_context() +_ssl_ctx.check_hostname = False +_ssl_ctx.verify_mode = ssl.CERT_NONE + # --------------------------------------------------------------------------- # Data classes @@ -397,7 +402,7 @@ def submit_byoc_job( _LOG.info("BYOC job %s: trying orchestrator %s", job_id, orch_origin) try: - with urlopen(http_req, timeout=http_timeout, context=_ssl_context()) as resp: + with urlopen(http_req, timeout=http_timeout, context=_ssl_ctx) as resp: raw_body = resp.read() resp_headers = {k: v for k, v in resp.headers.items()} @@ -683,7 +688,7 @@ def submit_training_job( _LOG.info("Training job %s: trying orchestrator %s", job_id, orch_origin) try: - with urlopen(http_req, timeout=http_timeout, context=_ssl_context()) as resp: + with urlopen(http_req, timeout=http_timeout, context=_ssl_ctx) as resp: raw_body = resp.read() data = json.loads(raw_body.decode("utf-8")) @@ -815,7 +820,7 @@ def refresh_training_payment( # Empty JSON body — refresh carries everything in headers http_req = Request(url, data=b"{}", headers=headers, method="POST") try: - with urlopen(http_req, timeout=timeout, context=_ssl_context()) as resp: + with urlopen(http_req, timeout=timeout, context=_ssl_ctx) as resp: body = resp.read().decode("utf-8") if resp.status not in (200, 202): raise LivepeerGatewayError( @@ -876,7 +881,7 @@ def get_training_status( http_req = Request(url, headers={"Accept": "application/json"}) try: - with urlopen(http_req, timeout=timeout, context=_ssl_context()) as resp: + with urlopen(http_req, timeout=timeout, context=_ssl_ctx) as resp: data = json.loads(resp.read().decode("utf-8")) return ByocTrainingStatus( job_id=data.get("job_id", job_id), From 0c354d582bf31704336d82ca5a39ff9324d8ad3e Mon Sep 17 00:00:00 2001 From: John | Elite Encoder Date: Fri, 9 Oct 2026 16:46:30 -0400 Subject: [PATCH 4/4] Verify TLS by default with an opt-out; drop BYOC changes Default to verifying HTTPS certificates against the system trust store; LIVEPEER_GATEWAY_VERIFY_TLS=0 (or http.VERIFY_TLS=False) turns it off for self-signed orchestrators. Build the opt-out context explicitly instead of the private ssl._create_unverified_context(), matching orch_info.py and clearing the Snyk no-hostname-verification finding. Revert byoc.py: those TLS changes are out of scope since #69 removes the BYOC client. Document the process-wide, all-or-nothing scope. --- README | 17 ++++++++++------- src/livepeer_gateway/http.py | 25 +++++++++++++++++-------- tests/test_tls.py | 24 ++++++++++++------------ 3 files changed, 39 insertions(+), 27 deletions(-) diff --git a/README b/README index 2c06bc8..9145f27 100644 --- a/README +++ b/README @@ -176,14 +176,17 @@ Selection/discovery precedence (highest -> lowest): ## TLS certificate verification -Orchestrators serve self-signed certificates, so by default the SDK does not -verify HTTPS certificates (the gRPC client does not either). When every HTTPS -endpoint the SDK reaches (signer, discovery, orchestrators, runners, trickle) -presents a publicly trusted certificate, turn verification on for the process: +By default the SDK verifies HTTPS certificates against the system trust store. +Orchestrators serve self-signed certificates, so to reach a stock orchestrator +(and the signer, discovery, runner and trickle endpoints behind it) turn +verification off for the process: ```bash -export LIVEPEER_GATEWAY_VERIFY_TLS=1 # "1", "true" or "yes" +export LIVEPEER_GATEWAY_VERIFY_TLS=0 # "0", "false", "no" or "off" ``` -or at runtime with `livepeer_gateway.http.VERIFY_TLS = True`. Verification then -uses the system trust store. +or at runtime with `livepeer_gateway.http.VERIFY_TLS = False`. + +The setting is process-wide and all-or-nothing: there is no per-endpoint or +per-call override, so leave it on only when every HTTPS endpoint the SDK reaches +presents a publicly trusted certificate. diff --git a/src/livepeer_gateway/http.py b/src/livepeer_gateway/http.py index ee7bdbb..f94d227 100644 --- a/src/livepeer_gateway/http.py +++ b/src/livepeer_gateway/http.py @@ -24,24 +24,33 @@ def _verify_tls_from_env() -> bool: - """``LIVEPEER_GATEWAY_VERIFY_TLS``: ``1``, ``true`` or ``yes`` turn verification on.""" - return os.environ.get(VERIFY_TLS_ENV, "").strip().lower() in {"1", "true", "yes"} + """``LIVEPEER_GATEWAY_VERIFY_TLS``: ``0``, ``false``, ``no`` or ``off`` turn verification off.""" + return os.environ.get(VERIFY_TLS_ENV, "").strip().lower() not in {"0", "false", "no", "off"} -# Orchestrators serve self-signed certificates, so HTTPS certificates are not -# verified by default, as with the gRPC client. Set LIVEPEER_GATEWAY_VERIFY_TLS=1, -# or assign VERIFY_TLS, when every HTTPS endpoint the SDK calls (signer, -# discovery, orchestrators, runners, trickle) has a publicly trusted certificate. +# HTTPS certificates are verified against the system trust store by default. Set +# LIVEPEER_GATEWAY_VERIFY_TLS=0, or assign VERIFY_TLS, to turn verification off +# for the process when any HTTPS endpoint the SDK calls (signer, discovery, +# orchestrators, runners, trickle) serves a self-signed certificate, as stock +# orchestrators do. VERIFY_TLS: bool = _verify_tls_from_env() @functools.cache def _ssl_context_for(verify: bool) -> ssl.SSLContext: - return ssl.create_default_context() if verify else ssl._create_unverified_context() + ctx = ssl.create_default_context() + if not verify: + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx def _ssl_context() -> ssl.SSLContext: - """TLS context for an outgoing HTTPS request, following ``VERIFY_TLS`` at call time.""" + """TLS context for an outgoing HTTPS request, following ``VERIFY_TLS`` at call time. + + ``VERIFY_TLS`` is process-wide and all-or-nothing: it governs every HTTPS + request the SDK makes, with no per-endpoint or per-call override. + """ return _ssl_context_for(VERIFY_TLS) diff --git a/tests/test_tls.py b/tests/test_tls.py index 921ae3b..97d9ada 100644 --- a/tests/test_tls.py +++ b/tests/test_tls.py @@ -1,9 +1,9 @@ -"""Opt-in TLS certificate verification. +"""Opt-out TLS certificate verification. The tests serve a real aiohttp application over a self-signed certificate -generated for the test. By default the SDK accepts it, as it accepts the -self-signed certificates orchestrators serve; with ``VERIFY_TLS`` on, every -HTTP path refuses it before sending a request. +generated for the test. By default the SDK verifies certificates, so every HTTP +path refuses it before sending a request; with ``VERIFY_TLS`` off, the SDK +accepts it, as it accepts the self-signed certificates orchestrators serve. """ from __future__ import annotations @@ -58,20 +58,20 @@ def _write_self_signed(directory: pathlib.Path) -> tuple[pathlib.Path, pathlib.P class TestVerifyTlsSetting: - @pytest.mark.parametrize("value", ["1", "true", "yes", " TRUE ", "Yes"]) - def test_env_turns_verification_on(self, monkeypatch: pytest.MonkeyPatch, value: str) -> None: + @pytest.mark.parametrize("value", ["0", "false", "no", "off", " FALSE ", "Off"]) + def test_env_turns_verification_off(self, monkeypatch: pytest.MonkeyPatch, value: str) -> None: monkeypatch.setenv(http.VERIFY_TLS_ENV, value) - assert http._verify_tls_from_env() is True + assert http._verify_tls_from_env() is False - @pytest.mark.parametrize("value", [None, "", "0", "false", "no", "off"]) - def test_anything_else_leaves_it_off( + @pytest.mark.parametrize("value", [None, "", "1", "true", "yes", "anything"]) + def test_anything_else_leaves_it_on( self, monkeypatch: pytest.MonkeyPatch, value: str | None ) -> None: if value is None: monkeypatch.delenv(http.VERIFY_TLS_ENV, raising=False) else: monkeypatch.setenv(http.VERIFY_TLS_ENV, value) - assert http._verify_tls_from_env() is False + assert http._verify_tls_from_env() is True def test_context_follows_the_setting_at_call_time(self, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(http, "VERIFY_TLS", False) @@ -149,7 +149,7 @@ async def discover(request: web.Request) -> web.Response: return app -class TestDefaultAcceptsSelfSigned: +class TestVerifyOffAcceptsSelfSigned: async def test_runner_signer_and_discovery(self, verify_off: None) -> None: calls: list[str] = [] async with _serve_tls(_app(calls)) as base: @@ -165,7 +165,7 @@ async def test_runner_signer_and_discovery(self, verify_off: None) -> None: assert calls == ["/call", "/sse", "/sign-orchestrator-info", "/discover-orchestrators"] -class TestOptInRejectsSelfSigned: +class TestDefaultRejectsSelfSigned: async def test_call_runner(self, verify_on: None) -> None: calls: list[str] = [] async with _serve_tls(_app(calls)) as base: