From 6dd2e66de585098deb580aa285fa4a258e3381ea Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 13 Jun 2026 18:23:03 -0600 Subject: [PATCH] feat(link): admit LAN-direct cert-less connections to the pairing window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new device pairing over the LAN has no client cert yet — the ceremony is how it earns one. Make LAN-direct cert-less pairing first-class on the secure listener, with the same structural confinement the loopback/relay path already has and truthful per-origin provenance. - window_open() is now posture-agnostic: the live, unconsumed nonce bounds the cert-less window in both direct and spl postures (fail-closed on unreadable nonce state preserved). Posture no longer gates the window. - Secure-listener admission collapses to "window open" for any PL peer via a pure certless_admission_mode() seam; a closed window keeps the strict, client-cert-required context. PeerMode is threaded through the classifier and pump so the stamp is type-true. - The admitted cert-less identity is stamped with its true origin (pl-direct for LAN, pl-via-spl for relay), so _rough_network records LAN pairs as "network" and relay pairs as "anywhere". - The HTTP login-gate pairing exemption covers both cert-less origins; the non-pairing surface stays fully gated (keys on fingerprint is None). This widens which peers/posture may host an in-window pairing, not what a cert-less peer may do: the relaxed TLS context stays certificate-optional (unauthorized cert => TlsError, never cert-less), wsgi confines cert-less requests to app:link.pair, and CERTLESS_TUNNEL_CAP is unchanged. Reaping is now driven by nonce expiry/consumption, not posture. disable_spl()'s docstring is corrected to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/link/tests/test_pair_network.py | 11 +++--- solstone/convey/root.py | 2 +- solstone/convey/secure_listener/accept.py | 34 +++++++++++++----- solstone/think/link/window.py | 2 -- solstone/think/services/spl.py | 6 ++-- tests/link/certless_helpers.py | 8 +++-- tests/link/test_certless_pair.py | 8 +++-- tests/link/test_certless_structural_gate.py | 8 +++-- tests/link/test_pairing_window.py | 20 +++++++---- tests/link/test_tls_contexts.py | 23 ++++++++++++ tests/test_secure_listener_runtime.py | 36 +++++++++++++++---- 11 files changed, 121 insertions(+), 37 deletions(-) diff --git a/solstone/apps/link/tests/test_pair_network.py b/solstone/apps/link/tests/test_pair_network.py index 0907f20cb..9de5c380e 100644 --- a/solstone/apps/link/tests/test_pair_network.py +++ b/solstone/apps/link/tests/test_pair_network.py @@ -5,6 +5,8 @@ from __future__ import annotations +from typing import Literal + import pytest from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -34,9 +36,9 @@ def _start_pair(env) -> dict: return response.get_json() -def _spl_identity() -> ConveyIdentity: +def _certless_identity(mode: Literal["pl-via-spl", "pl-direct"]) -> ConveyIdentity: return ConveyIdentity( - mode="pl-via-spl", + mode=mode, fingerprint=None, device_label=None, paired_at=None, @@ -67,7 +69,8 @@ def _assert_network_result(env, calls, expected_network: str, response) -> None: ("environ_overrides", "expected_network"), [ (None, "network"), - ({"pl.identity": _spl_identity()}, "anywhere"), + ({"pl.identity": _certless_identity("pl-via-spl")}, "anywhere"), + ({"pl.identity": _certless_identity("pl-direct")}, "network"), ], ) def test_pair_route_network_persists_to_devices_and_pair_complete_event( @@ -127,7 +130,7 @@ def test_by_code_certless_pairing_is_confined(link_env) -> None: response = env.client.post( "/app/link/by-code", json={"code": started["manual_code"], "csr": _make_csr("by-code-confined")}, - environ_overrides={"pl.identity": _spl_identity()}, + environ_overrides={"pl.identity": _certless_identity("pl-via-spl")}, ) assert response.status_code == 403 diff --git a/solstone/convey/root.py b/solstone/convey/root.py index 58855b2ed..1e32fc848 100644 --- a/solstone/convey/root.py +++ b/solstone/convey/root.py @@ -128,7 +128,7 @@ def require_login() -> Any: if ( request.endpoint == "app:link.pair" and identity is not None - and identity.mode == "pl-via-spl" + and identity.mode in {"pl-via-spl", "pl-direct"} and identity.fingerprint is None ): # Cert-less pairing stream; structurally confined to /pair by the C2 gate. diff --git a/solstone/convey/secure_listener/accept.py b/solstone/convey/secure_listener/accept.py index dc56746d8..7837bba0c 100644 --- a/solstone/convey/secure_listener/accept.py +++ b/solstone/convey/secure_listener/accept.py @@ -14,7 +14,7 @@ import uuid from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field -from typing import Any +from typing import Any, Literal from OpenSSL import SSL @@ -34,6 +34,23 @@ CERTLESS_PAIR_FAILURE_CAP = 3 CERTLESS_INVALID_NONCE_BACKOFF_SECONDS = 1.0 CERTLESS_WINDOW_POLL_SECONDS = 5.0 +PeerMode = Literal["pl-direct", "pl-via-spl"] + + +def certless_admission_mode( + peer_mode: PeerMode, window_is_open: bool +) -> PeerMode | None: + """Cert-less admission for the secure listener. + + An open pairing window admits a cert-less peer of EITHER origin + (loopback ``pl-via-spl`` or LAN-direct ``pl-direct``); the admitted + identity is stamped with its true origin. A closed window admits none + (the strict, client-cert-required context applies). Admission is + origin-agnostic by design: the window bounds which peers may pair, the + peer address does not. + """ + return peer_mode if window_is_open else None + @dataclass(frozen=True) class CertlessConnection: @@ -189,9 +206,10 @@ class SecureListener: tcp_reader: asyncio.StreamReader, tcp_writer: asyncio.StreamWriter, connection_id: str, - mode: str, + mode: PeerMode, ) -> None: - admitted_certless = mode == "pl-via-spl" and window_open() + certless_mode = certless_admission_mode(mode, window_open()) + admitted_certless = certless_mode is not None tls_ctx = self._relaxed_tls_ctx if admitted_certless else self._strict_tls_ctx tls = new_server(tls_ctx) send_queue: asyncio.Queue[bytes] = asyncio.Queue() @@ -304,7 +322,7 @@ class SecureListener: certless_handle = maybe_handle certless_registered = True identity = ConveyIdentity( - mode="pl-via-spl", + mode=mode, fingerprint=None, device_label=None, paired_at=None, @@ -342,10 +360,10 @@ class SecureListener: await writer_task await mux.close() - def _identity_for_peer(self, mode: str, fingerprint: str) -> ConveyIdentity: + def _identity_for_peer(self, mode: PeerMode, fingerprint: str) -> ConveyIdentity: entry = self._authorized.get(fingerprint) return ConveyIdentity( - mode=mode, # type: ignore[arg-type] + mode=mode, fingerprint=fingerprint, device_label=entry.device_label if entry else None, paired_at=entry.paired_at if entry else None, @@ -440,7 +458,7 @@ async def _drain_send_queue( await result -def _mode_from_peername(peername: object) -> str: +def _mode_from_peername(peername: object) -> PeerMode: host = "" if isinstance(peername, tuple) and peername: host = str(peername[0]) @@ -461,4 +479,4 @@ def _tls_close_reason(exc: TlsError) -> str: return "tls_alert" -__all__ = ["SecureListener"] +__all__ = ["SecureListener", "certless_admission_mode"] diff --git a/solstone/think/link/window.py b/solstone/think/link/window.py index 53573425e..801c06744 100644 --- a/solstone/think/link/window.py +++ b/solstone/think/link/window.py @@ -30,8 +30,6 @@ def window_open(now: float | None = None) -> bool: """Return whether cert-less pairing admission is currently allowed.""" # Unreadable or corrupt nonce state closes the cert-less pairing window. window_open() returns False on any read error. try: - if read_posture() != "spl": - return False ts = time.time() if now is None else now nonces = NonceStore(nonces_path()).snapshot() return any(not nonce.used and nonce.expires_at > ts for nonce in nonces) diff --git a/solstone/think/services/spl.py b/solstone/think/services/spl.py index 19a20f823..cdb428e5f 100644 --- a/solstone/think/services/spl.py +++ b/solstone/think/services/spl.py @@ -117,9 +117,9 @@ def enable_spl() -> None: def disable_spl() -> SplDisableOutcome: """Set SPL posture to direct without clearing local or relay-side state. - Sets `link.posture="direct"` (the authoritative reach gate - - `window.read_posture()`/`window_open()` immediately stop admitting cert-less - off-LAN pairs, and the status surface reports `direct`). The supervised + Sets `link.posture="direct"` (the authoritative reach/status gate). The + cert-less pairing window remains bounded by live nonce existence, not + posture. The supervised `journal spl` daemon observes the posture change and closes its listen WS within its poll interval. It does NOT clear the local `totp.json` (kept for quick re-enable) or revoke the relay-side copy of the secret (a later lode). diff --git a/tests/link/certless_helpers.py b/tests/link/certless_helpers.py index 604dd17a0..b4cc29d4a 100644 --- a/tests/link/certless_helpers.py +++ b/tests/link/certless_helpers.py @@ -8,7 +8,7 @@ import json from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from pathlib import Path -from typing import Any +from typing import Any, Literal import pytest @@ -88,9 +88,11 @@ def write_config( config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") -def certless_identity() -> ConveyIdentity: +def certless_identity( + mode: Literal["pl-via-spl", "pl-direct"] = "pl-via-spl", +) -> ConveyIdentity: return ConveyIdentity( - mode="pl-via-spl", + mode=mode, fingerprint=None, device_label=None, paired_at=None, diff --git a/tests/link/test_certless_pair.py b/tests/link/test_certless_pair.py index eb7c80104..7473ef475 100644 --- a/tests/link/test_certless_pair.py +++ b/tests/link/test_certless_pair.py @@ -20,9 +20,11 @@ from tests.link.certless_helpers import ( @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["pl-via-spl", "pl-direct"]) async def test_certless_pair_request_executes_handler_and_authorizes_client( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mode: str, ) -> None: app, _journal = make_convey_app(tmp_path, monkeypatch, link={"posture": "spl"}) nonce = "0123456789abcdef" @@ -39,7 +41,7 @@ async def test_certless_pair_request_executes_handler_and_authorizes_client( response = await dispatch_request( app, - certless_identity(), + certless_identity(mode), "POST", "/app/link/pair", body=body, @@ -55,16 +57,18 @@ async def test_certless_pair_request_executes_handler_and_authorizes_client( @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["pl-via-spl", "pl-direct"]) async def test_certless_identity_is_refused_at_non_pair_route( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mode: str, ) -> None: app, _journal = make_convey_app(tmp_path, monkeypatch, link={"posture": "spl"}) NonceStore(nonces_path()).add("fedcba9876543210", "phone") response = await dispatch_request( app, - certless_identity(), + certless_identity(mode), "GET", "/app/link/api/status", ) diff --git a/tests/link/test_certless_structural_gate.py b/tests/link/test_certless_structural_gate.py index b41a2765c..178352400 100644 --- a/tests/link/test_certless_structural_gate.py +++ b/tests/link/test_certless_structural_gate.py @@ -84,9 +84,11 @@ def test_named_non_pair_endpoints_are_refused( @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["pl-via-spl", "pl-direct"]) async def test_evasion_paths_are_refused( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mode: str, ) -> None: app, _journal = make_convey_app(tmp_path, monkeypatch, link={"posture": "spl"}) NonceStore(nonces_path()).add("live", "phone") @@ -103,7 +105,7 @@ async def test_evasion_paths_are_refused( response = await dispatch_request( app, - certless_identity(), + certless_identity(mode), "POST", "/app/link%2Fpair", body=b"{}", @@ -115,9 +117,11 @@ async def test_evasion_paths_are_refused( @pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["pl-via-spl", "pl-direct"]) async def test_window_recheck_refuses_before_handler( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + mode: str, ) -> None: app, _journal = make_convey_app(tmp_path, monkeypatch, link={"posture": "spl"}) called = False @@ -131,7 +135,7 @@ async def test_window_recheck_refuses_before_handler( response = await dispatch_request( app, - certless_identity(), + certless_identity(mode), "POST", "/app/link/pair", body=b"{}", diff --git a/tests/link/test_pairing_window.py b/tests/link/test_pairing_window.py index b769167a2..16cc34312 100644 --- a/tests/link/test_pairing_window.py +++ b/tests/link/test_pairing_window.py @@ -43,14 +43,18 @@ def test_read_posture_exact_match_only( assert read_posture() == "spl" -def test_window_open_requires_spl_and_live_unused_nonce( +@pytest.mark.parametrize("posture", ["spl", "direct"]) +def test_window_open_requires_live_unused_nonce_in_any_posture( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + posture: str, ) -> None: journal = _journal(tmp_path, monkeypatch) - write_config(journal, link={"posture": "spl"}) + write_config(journal, link={"posture": posture}) store = NonceStore(nonces_path()) + assert window_open(now=1000) is False + store.add("live", "phone", now=1000) assert window_open(now=1000 + NONCE_TTL_SECONDS - 1) is True @@ -61,7 +65,7 @@ def test_window_open_requires_spl_and_live_unused_nonce( assert window_open(now=2000 + NONCE_TTL_SECONDS) is False -def test_window_closed_when_posture_not_spl( +def test_window_open_in_direct_posture_with_live_nonce( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -69,15 +73,17 @@ def test_window_closed_when_posture_not_spl( write_config(journal, link={"posture": "direct"}) NonceStore(nonces_path()).add("live", "phone", now=1000) - assert window_open(now=1001) is False + assert window_open(now=1001) is True +@pytest.mark.parametrize("posture", ["spl", "direct"]) def test_window_open_fail_closed_on_corrupt_nonce_state( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + posture: str, ) -> None: journal = _journal(tmp_path, monkeypatch) - write_config(journal, link={"posture": "spl"}) + write_config(journal, link={"posture": posture}) path = nonces_path() path.parent.mkdir(parents=True, exist_ok=True) path.write_text("{not json", encoding="utf-8") @@ -85,13 +91,15 @@ def test_window_open_fail_closed_on_corrupt_nonce_state( assert window_open(now=1000) is False +@pytest.mark.parametrize("posture", ["spl", "direct"]) def test_window_open_fail_closed_on_read_exception( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, + posture: str, ) -> None: journal = _journal(tmp_path, monkeypatch) - write_config(journal, link={"posture": "spl"}) + write_config(journal, link={"posture": posture}) class BrokenNonceStore: def __init__(self, _path: Path) -> None: diff --git a/tests/link/test_tls_contexts.py b/tests/link/test_tls_contexts.py index 9b4882d43..2acb85bba 100644 --- a/tests/link/test_tls_contexts.py +++ b/tests/link/test_tls_contexts.py @@ -73,6 +73,29 @@ def test_relaxed_context_keeps_allowlisted_cert_fingerprint(tmp_path: Path) -> N assert server.peer_fingerprint == fingerprint +def test_relaxed_context_rejects_unauthorized_cert(tmp_path: Path) -> None: + ca, server_cert, server_key, authorized = _server_material(tmp_path) + private_key_pem, csr_pem = _build_csr("pytest stranger") + client_cert_pem, fingerprint = sign_csr(ca, csr_pem, "pytest stranger") + server_ctx = build_relaxed_server_context(ca, server_cert, server_key, authorized) + client_ctx = _build_tls_client_ctx( + ClientIdentity( + private_key_pem=private_key_pem, + client_cert_pem=client_cert_pem, + ca_chain_pem=ca.cert.public_bytes(serialization.Encoding.PEM).decode( + "ascii" + ), + fingerprint=fingerprint, + home_instance_id="inst-1", + home_label="home", + home_attestation="attest", + ) + ) + + with pytest.raises((TlsError, ClientTlsError)): + _complete_handshake(server_ctx, client_ctx) + + def _server_material( tmp_path: Path, ) -> tuple[LoadedCa, object, bytes, AuthorizedClients]: diff --git a/tests/test_secure_listener_runtime.py b/tests/test_secure_listener_runtime.py index 00b153ab7..507ec90e8 100644 --- a/tests/test_secure_listener_runtime.py +++ b/tests/test_secure_listener_runtime.py @@ -12,7 +12,11 @@ from unittest.mock import MagicMock import pytest -from solstone.convey.secure_listener.accept import CERTLESS_TUNNEL_CAP, SecureListener +from solstone.convey.secure_listener.accept import ( + CERTLESS_TUNNEL_CAP, + SecureListener, + certless_admission_mode, +) from solstone.think.link.nonces import NONCE_TTL_SECONDS, NonceStore from solstone.think.link.paths import nonces_path from tests.link.certless_helpers import write_config @@ -89,6 +93,23 @@ def test_stop_all_after_loop_closed_does_not_raise(): executor.shutdown(wait=True, cancel_futures=True) +@pytest.mark.parametrize( + ("mode", "window_is_open", "expected"), + [ + ("pl-direct", True, "pl-direct"), + ("pl-via-spl", True, "pl-via-spl"), + ("pl-direct", False, None), + ("pl-via-spl", False, None), + ], +) +def test_certless_admission_mode( + mode: str, + window_is_open: bool, + expected: str | None, +) -> None: + assert certless_admission_mode(mode, window_is_open) == expected + + @pytest.mark.asyncio async def test_certless_reap_tears_down_on_passive_expiry( tmp_path: Path, @@ -129,7 +150,7 @@ async def test_certless_reap_tears_down_after_nonce_consume( @pytest.mark.asyncio -async def test_certless_reap_tears_down_when_posture_leaves_spl( +async def test_certless_not_reaped_while_nonce_live( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -138,11 +159,14 @@ async def test_certless_reap_tears_down_when_posture_leaves_spl( listener = _listener() handle, writer, mux, _task = _register_fake_certless(listener) - await listener._reap_certless_if_window_closed(now=1001) + try: + await listener._reap_certless_if_window_closed(now=1001) - assert handle.connection_id not in listener._certless_connections - assert writer.closed is True - assert mux.closed is True + assert handle.connection_id in listener._certless_connections + assert writer.closed is False + assert mux.closed is False + finally: + await listener._close_certless_connection(handle) @pytest.mark.asyncio -- 2.51.2