diff --git a/docs/design/openapi-client-contract.md b/docs/design/openapi-client-contract.md index bbe5b08d9..548801ae7 100644 --- a/docs/design/openapi-client-contract.md +++ b/docs/design/openapi-client-contract.md @@ -75,7 +75,7 @@ Werkzeug rules, including ``. | Method | Path | operationId | Named request fields | Named success response fields | `x-reason-codes` | |---|---|---|---|---|---| -| POST | `/app/network/pair-start` | `link.pairStart` | body: `device_label?`, `role?` (`solstone/apps/network/routes.py:530-535`) | `nonce`, `pair_link`, `expires_in`, `rotating`, `device_label`, `ca_fingerprint` (`routes.py:287-294`, `routes.py:594-602`) | `invalid_operation_for_state`, `pairing_request_invalid`, `pl_revoked` | +| POST | `/app/network/pair-start` | `link.pairStart` | body: `device_label?`, `role?` (`solstone/apps/network/routes.py:530-535`) | `nonce`, `pair_link`, `expires_in`, `device_label`, `ca_fingerprint` (`routes.py:287-294`, `routes.py:594-602`) | `invalid_operation_for_state`, `pairing_request_invalid`, `pl_revoked` | | POST | `/app/network/pair` | `link.pair` | query: `token?`; body: `csr`, `nonce?`, `device_label?`, `sender_instance_id?` (`routes.py:704-724`) | `client_cert`, `ca_chain`, `instance_id`, `home_label`, `home_attestation`, `fingerprint`, `local_endpoints?` (`routes.py:622-633`) | `missing_required_field`, `operation_no_longer_available`, `pairing_key_invalid`, `pairing_request_invalid`, `pl_revoked` | | POST | `/app/network/unpair` | `link.unpair` | body: `fingerprint?`, `device_label?`; one is required (`routes.py:797-818`) | `unpaired` (`routes.py:861`) | `missing_required_field`, `paired_device_not_found`, `pl_revoked` | | GET | `/app/network/local-endpoints` | `link.localEndpoints` | none | `v`, `endpoints`, `ttl_s`, `generated_at` (`routes.py:507-513`, `solstone/think/link/local_endpoints.py:34-40`) | `pl_revoked` | diff --git a/docs/openapi/convey-clients.json b/docs/openapi/convey-clients.json index 73b18351d..b4be1aecb 100644 --- a/docs/openapi/convey-clients.json +++ b/docs/openapi/convey-clients.json @@ -2757,8 +2757,7 @@ "device_label": "Jer iPhone", "expires_in": 300, "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", - "pair_link": "https://solstone.link/pair#0ABCD...", - "rotating": false + "pair_link": "https://solstone.link/pair#0ABCD..." }, "schema": { "additionalProperties": true, @@ -2777,16 +2776,12 @@ }, "pair_link": { "type": "string" - }, - "rotating": { - "type": "boolean" } }, "required": [ "nonce", "pair_link", "expires_in", - "rotating", "device_label", "ca_fingerprint" ], diff --git a/solstone/apps/network/call.py b/solstone/apps/network/call.py index a50b505d7..67204691d 100644 --- a/solstone/apps/network/call.py +++ b/solstone/apps/network/call.py @@ -60,9 +60,6 @@ PRIVATE_LINK_STATE_LABELS = { CLI_PAIR_LINK_LABEL = "pair-link" CLI_PAIR_JOIN_HINT = "link this device with:" CLI_PAIR_CA_FINGERPRINT_LABEL = "CA fingerprint" -CLI_PAIR_RELAY_FRESHNESS_HINT = ( - "pair-link freshness: use within about {duration}; relay codes rotate." -) CLI_PAIR_NO_LAN_ADDRESS = ( "can't start pairing — your solstone isn't reachable on a network address " "yet. turn on your private network to pair from anywhere, or connect this " @@ -325,12 +322,6 @@ def pair( join_cmd += ["--label", device_label] typer.echo(" " + shlex.join(join_cmd)) typer.echo(f"{CLI_PAIR_CA_FINGERPRINT_LABEL}: sha256:{ca_fp}") - if resp.get("rotating"): - typer.echo( - CLI_PAIR_RELAY_FRESHNESS_HINT.format( - duration=relative_time(int(resp["expires_in"])) - ) - ) if device_label: typer.echo(f"Device: {device_label}{' (peer)' if as_role == 'peer' else ''}") if no_wait: diff --git a/solstone/apps/network/contract.py b/solstone/apps/network/contract.py index 35a9a2428..35bc9ddb3 100644 --- a/solstone/apps/network/contract.py +++ b/solstone/apps/network/contract.py @@ -65,7 +65,6 @@ OPERATIONS: list[OperationSpec] = [ FieldSpec("nonce", "string", required=True), FieldSpec("pair_link", "string", required=True), FieldSpec("expires_in", "integer", required=True), - FieldSpec("rotating", "boolean", required=True), FieldSpec("device_label", "string", required=True), FieldSpec("ca_fingerprint", "string", required=True), ), @@ -73,7 +72,6 @@ OPERATIONS: list[OperationSpec] = [ "nonce": "5f0d8c8b9f1e48b0a5f80b98f3d5e9b0", "pair_link": "https://solstone.link/pair#0ABCD...", "expires_in": 300, - "rotating": False, "device_label": "Jer iPhone", "ca_fingerprint": "9c5f2e0c8e6a42f0a32e55e5cf7f5b4a", }, diff --git a/solstone/apps/network/copy.py b/solstone/apps/network/copy.py index 997f2e7a9..5c7f117e2 100644 --- a/solstone/apps/network/copy.py +++ b/solstone/apps/network/copy.py @@ -18,9 +18,6 @@ STEP_3 = "tap the link to open solstone" PAIR_NETWORK_LINE = ( "this device needs to be on your network (or your VPN) to pair. expires in 5:00." ) -PAIR_ROTATE_NOTE = ( - "this code refreshes on its own — keep this page open while you pair." -) DETAILS_DISCLOSURE = "verify this is really your home" CA_FP_LABEL = "fingerprint" CA_FP_NOTE = ( diff --git a/solstone/apps/network/relay_link.py b/solstone/apps/network/relay_link.py index 1e4a0dcb8..c93ea2936 100644 --- a/solstone/apps/network/relay_link.py +++ b/solstone/apps/network/relay_link.py @@ -1,78 +1,111 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Relay-form pair-link encoding for spl connectivity posture.""" +"""Relay-form pair-window (0x06) link encoding + RK derivation for spl posture. + +One authoritative home for the cross-side primitives: the home (routes.py) and the +sol CLI relay-pairing client both import derive_rk + the 0x06 codec from here. +""" from __future__ import annotations -import base64 import hashlib import hmac -import uuid +from dataclasses import dataclass from solstone.apps.network.copy import PAIR_LINK_HOST, PAIR_LINK_PATH +from solstone.apps.network.crockford32 import decode as crockford_decode from solstone.apps.network.crockford32 import encode as crockford_encode -from solstone.think.link.ca import RELAY_NONCE_BYTES -RELAY_VERSION = 0x03 +PAIR_WINDOW_VERSION = 0x06 CA_FP_TAG_SPKI_SHA256 = 0x01 -TOTP_STEP_SECONDS = 30 -TOTP_DIGITS = 6 - - -def compute_current_totp(secret_b32: str, now: int) -> int: - padded = secret_b32 + "=" * ((8 - len(secret_b32) % 8) % 8) - secret = base64.b32decode(padded) - counter = now // TOTP_STEP_SECONDS - message = counter.to_bytes(8, "big") - mac = hmac.new(secret, message, hashlib.sha1).digest() - offset = mac[19] & 0x0F - value = ( - ((mac[offset] & 0x7F) << 24) - | (mac[offset + 1] << 16) - | (mac[offset + 2] << 8) - | mac[offset + 3] - ) - return value % (10**TOTP_DIGITS) +RK_INFO = b"spl-pair-window-v1" +RK_LENGTH = 16 +S_BYTES = 8 + + +@dataclass(frozen=True) +class ParsedPairWindow: + s: bytes + ca_fp_spki: bytes # first 16 bytes of SHA-256 over CA DER SPKI + relay_origin: str | None # None => well-known default relay + + +def _hkdf_sha256(ikm: bytes, salt: bytes, info: bytes, length: int) -> bytes: + if not salt: + salt = b"\x00" * hashlib.sha256().digest_size + prk = hmac.new(salt, ikm, hashlib.sha256).digest() + okm = b"" + block = b"" + counter = 1 + while len(okm) < length: + block = hmac.new(prk, block + info + bytes([counter]), hashlib.sha256).digest() + okm += block + counter += 1 + return okm[:length] + +def derive_rk(s: bytes) -> bytes: + """RK = HKDF-SHA256(IKM=S, salt="", info="spl-pair-window-v1", L=16).""" + if len(s) != S_BYTES: + raise ValueError(f"S must be {S_BYTES} bytes") + return _hkdf_sha256(s, b"", RK_INFO, RK_LENGTH) -def encode_relay_pair_link( - instance_id: str, - totp: int, - nonce: str, + +def encode_pair_window_link( + s: bytes, ca_fp_spki: str, *, relay_origin: str | None, ) -> str: - if not 0 <= totp <= 999999: - raise ValueError("totp must be in range 0..999999") - - instance_bytes = uuid.UUID(instance_id).bytes - nonce_bytes = bytes.fromhex(nonce) - if len(nonce_bytes) != RELAY_NONCE_BYTES: - raise ValueError(f"nonce must be {RELAY_NONCE_BYTES} bytes") - + if len(s) != S_BYTES: + raise ValueError(f"S must be {S_BYTES} bytes") ca_fp_bytes = bytes.fromhex(ca_fp_spki) if len(ca_fp_bytes) < 16: raise ValueError("ca_fp_spki must contain at least 16 bytes") if relay_origin is None: - relay_origin_bytes = b"\x00" + origin_field = b"\x00" else: origin_bytes = relay_origin.encode("utf-8") if not origin_bytes: raise ValueError("relay_origin must not be empty") if len(origin_bytes) > 255: raise ValueError("relay_origin must be 255 bytes or fewer") - relay_origin_bytes = bytes([len(origin_bytes)]) + origin_bytes + origin_field = bytes([len(origin_bytes)]) + origin_bytes blob = ( - bytes([RELAY_VERSION]) - + instance_bytes - + totp.to_bytes(3, "big") - + nonce_bytes + bytes([PAIR_WINDOW_VERSION]) + + s + bytes([CA_FP_TAG_SPKI_SHA256]) + ca_fp_bytes[:16] - + relay_origin_bytes + + origin_field ) return f"https://{PAIR_LINK_HOST}{PAIR_LINK_PATH}#{crockford_encode(blob)}" + + +def decode_pair_window_link(link: str) -> ParsedPairWindow: + fragment = link.rsplit("#", 1)[-1].strip() + blob = crockford_decode(fragment) + # version(1) + S(8) + tag(1) + ca_fp(16) + selector(1) = 27 minimum + if len(blob) < 27: + raise ValueError("pair-window blob too short") + if blob[0] != PAIR_WINDOW_VERSION: + raise ValueError(f"unsupported pair-link version: {blob[0]:#04x}") + s = blob[1:9] + if blob[9] != CA_FP_TAG_SPKI_SHA256: + raise ValueError(f"unsupported ca_fp tag: {blob[9]:#04x}") + ca_fp_spki = blob[10:26] + selector = blob[26] + if selector == 0x00: + if len(blob) != 27: + raise ValueError("default-origin blob has trailing bytes") + relay_origin: str | None = None + else: + origin_bytes = blob[27 : 27 + selector] + if len(origin_bytes) != selector: + raise ValueError("relay_origin length mismatch") + if len(blob) != 27 + selector: + raise ValueError("pair-window blob has trailing bytes") + relay_origin = origin_bytes.decode("utf-8") + return ParsedPairWindow(s=s, ca_fp_spki=ca_fp_spki, relay_origin=relay_origin) diff --git a/solstone/apps/network/routes.py b/solstone/apps/network/routes.py index 2a38563f6..ea7de3390 100644 --- a/solstone/apps/network/routes.py +++ b/solstone/apps/network/routes.py @@ -31,7 +31,6 @@ import json as _json import logging import re import socket -import time from collections.abc import Callable from dataclasses import asdict, dataclass from importlib import import_module @@ -48,9 +47,8 @@ from solstone.apps.network.copy import ( ) from solstone.apps.network.crockford32 import encode as crockford_encode from solstone.apps.network.relay_link import ( - TOTP_STEP_SECONDS, - compute_current_totp, - encode_relay_pair_link, + derive_rk, + encode_pair_window_link, ) from solstone.apps.utils import log_app_action from solstone.convey import emit @@ -73,7 +71,7 @@ from solstone.think.link import establish, interface_watcher from solstone.think.link.auth import AuthorizedClients, ClientEntry, is_peer from solstone.think.link.ca import ( generate_nonce, - generate_relay_nonce, + generate_pair_window_nonce, load_or_generate_ca, mint_attestation, sign_csr, @@ -92,7 +90,6 @@ from solstone.think.link.paths import ( authorized_clients_path, ca_dir, load_service_token, - load_totp_secret, nonces_path, relay_url, ) @@ -107,6 +104,7 @@ from solstone.think.pairing.config import ( from solstone.think.services import operations, spl, spl_handoff from solstone.think.services import status as service_status from solstone.think.spl.health import OFFLINE_TUNNEL_REASONS +from solstone.think.spl.relay_client import start_pair_window from solstone.think.utils import get_journal, now_ms logger = logging.getLogger(__name__) @@ -328,7 +326,6 @@ class PairStartResponse: nonce: str pair_link: str expires_in: int - rotating: bool device_label: str ca_fingerprint: str @@ -646,33 +643,30 @@ def pair_start() -> Any: if not isinstance(role, str) or role not in VALID_ROLES: return error_response(PAIRING_REQUEST_INVALID, detail="invalid role") - nonce_ttl: int | None = None if read_posture() == "spl": - secret = load_totp_secret() - if secret is None: + service_token = load_service_token() + if service_token is None: return error_response( INVALID_OPERATION_FOR_STATE, - detail="spl posture requires a relay TOTP secret; none is configured", + detail="spl posture requires a relay service token; none is configured", ) ca = load_or_generate_ca(ca_dir()) ca_fp = ca.fingerprint_sha256() - now = int(time.time()) - totp = compute_current_totp(secret, now) - nonce = generate_relay_nonce() + s = generate_pair_window_nonce() origin = relay_url() relay_origin = None if origin == DEFAULT_RELAY_URL else origin - instance_id = LinkState.load_or_create().instance_id - pair_link = encode_relay_pair_link( - instance_id, - totp, - nonce, + pair_link = encode_pair_window_link( + s, ca.spki_fingerprint_sha256(), relay_origin=relay_origin, ) - expires_in = TOTP_STEP_SECONDS - rotating = True - nonce_ttl = TOTP_STEP_SECONDS + nonce = s.hex() + start_pair_window( + rk=derive_rk(s), + service_token=service_token, + relay_endpoint=origin, + ) else: ca_fp = _ca_fingerprint() port = _secure_listener_port() @@ -691,23 +685,16 @@ def pair_start() -> Any: pair_link = _build_pair_link(candidates[0], port, nonce, ca_fp) else: pair_link = _build_pair_link_v05(candidates, port, nonce, ca_fp) - expires_in = 300 - rotating = False - add_kwargs: dict[str, Any] = {} - if nonce_ttl is not None: - add_kwargs["ttl"] = nonce_ttl _nonces().add( nonce, device_label, role=role, - **add_kwargs, ) response = PairStartResponse( nonce=nonce, pair_link=pair_link, - expires_in=expires_in, - rotating=rotating, + expires_in=300, device_label=device_label, ca_fingerprint=ca_fp, ) diff --git a/solstone/apps/network/tests/conftest.py b/solstone/apps/network/tests/conftest.py index 0364908f0..f56f455cb 100644 --- a/solstone/apps/network/tests/conftest.py +++ b/solstone/apps/network/tests/conftest.py @@ -27,7 +27,7 @@ def link_env(tmp_path, monkeypatch): def _create( *, posture: str | None = None, - totp_secret: str | None = None, + service_token: str | None = None, provision: bool = True, local_endpoints: list[LocalEndpoint] | None = None, ): @@ -51,10 +51,10 @@ def link_env(tmp_path, monkeypatch): from solstone.think.link.paths import LinkState LinkState.load_or_create() - if totp_secret is not None: - from solstone.think.link.paths import save_totp_secret + if service_token is not None: + from solstone.think.link.paths import save_service_token - save_totp_secret(totp_secret) + save_service_token(service_token) from solstone.convey import create_app @@ -72,12 +72,24 @@ def link_env(tmp_path, monkeypatch): "get_interface_watcher", lambda: _StubWatcher(endpoints), ) + pair_window_calls: list[dict] = [] + + def _record_start_pair_window(**kwargs): + pair_window_calls.append(kwargs) + return None + + monkeypatch.setattr( + link_routes, + "start_pair_window", + _record_start_pair_window, + ) class Env: def __init__(self): self.journal = journal self.client = client self.app = app + self.pair_window_calls = pair_window_calls return Env() diff --git a/solstone/apps/network/tests/test_api_status.py b/solstone/apps/network/tests/test_api_status.py index 38a33c701..f08cd68d8 100644 --- a/solstone/apps/network/tests/test_api_status.py +++ b/solstone/apps/network/tests/test_api_status.py @@ -27,7 +27,6 @@ from solstone.think.spl.health import ( REASON_SERVICE_TOKEN_REJECTED, ) -TOTP_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" NOW = 1_700_000_000_000 STATUS_FIELD_SET = { "instance_id", @@ -292,7 +291,7 @@ def test_spl_status_health_matrix( expected_relay_state: str, expected_reachability: str, ) -> None: - env = link_env(posture="spl", totp_secret=TOTP_SECRET) + env = link_env(posture="spl") if token_present: _write_service_token(env) monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") @@ -522,7 +521,6 @@ def test_api_status_does_not_mint_pairing_nonces(link_env, monkeypatch) -> None: def test_no_secrets_in_response(link_env, monkeypatch) -> None: env = link_env() - _write_config(env, link={"totp": "TOPSECRET_TOTP_VALUE"}) _write_service_token(env, "TOPSECRET_TOKEN_VALUE") monkeypatch.setattr(link_routes, "_detect_lan_ip", lambda: "192.168.1.50") @@ -532,7 +530,6 @@ def test_no_secrets_in_response(link_env, monkeypatch) -> None: for forbidden in ( "topsecret_token_value", "token", - "totp", "attestation", "account_token", "service_token", diff --git a/solstone/apps/network/tests/test_copy.py b/solstone/apps/network/tests/test_copy.py index 2a8f02356..e9c366a94 100644 --- a/solstone/apps/network/tests/test_copy.py +++ b/solstone/apps/network/tests/test_copy.py @@ -19,10 +19,6 @@ def test_copy_constants_are_locked() -> None: "this device needs to be on your network (or your VPN) to pair. " "expires in 5:00." ) - assert ( - copy.PAIR_ROTATE_NOTE - == "this code refreshes on its own — keep this page open while you pair." - ) assert copy.DETAILS_DISCLOSURE == "verify this is really your home" assert copy.CA_FP_LABEL == "fingerprint" assert copy.CA_FP_NOTE == ( diff --git a/solstone/apps/network/tests/test_pair_copy.py b/solstone/apps/network/tests/test_pair_copy.py index ce423d85c..84155fef8 100644 --- a/solstone/apps/network/tests/test_pair_copy.py +++ b/solstone/apps/network/tests/test_pair_copy.py @@ -34,7 +34,6 @@ U4_COPY_VALUES = [ ] U8_COPY_VALUES = [ - copy.PAIR_ROTATE_NOTE, copy.WINDOW_CLOSED_BUTTON, copy.SUCCESS_VERIFY_NOTE_ANYWHERE, copy.RECENT_NETWORK_LABEL_ANYWHERE, @@ -107,10 +106,6 @@ def test_u4_copy_values_are_locked() -> None: def test_u8_copy_values_are_locked() -> None: - assert ( - copy.PAIR_ROTATE_NOTE - == "this code refreshes on its own — keep this page open while you pair." - ) assert copy.WINDOW_CLOSED_BUTTON == "pairing window closed — open a new one" assert copy.SUCCESS_VERIFY_NOTE_ANYWHERE == ( "this device can now reach home from anywhere. check it now — " diff --git a/solstone/apps/network/tests/test_pair_modal_spl.py b/solstone/apps/network/tests/test_pair_modal_spl.py index cd49d083f..f149dd2d8 100644 --- a/solstone/apps/network/tests/test_pair_modal_spl.py +++ b/solstone/apps/network/tests/test_pair_modal_spl.py @@ -7,8 +7,6 @@ from __future__ import annotations from solstone.apps.network import copy -SPL_TEST_TOTP_SECRET = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" - def _link_pair_script(body: str) -> str: start = body.index("const SPL_PAIR_WINDOW_MS") @@ -16,18 +14,17 @@ def _link_pair_script(body: str) -> str: return body[start:end] -def test_spl_pair_modal_is_qr_only_with_rotation_affordance(link_env) -> None: - env = link_env(posture="spl", totp_secret=SPL_TEST_TOTP_SECRET) +def test_spl_pair_modal_is_qr_only_with_five_minute_window(link_env) -> None: + env = link_env(posture="spl", service_token="svc-token") response = env.client.get("/app/network/") assert response.status_code == 200 body = response.get_data(as_text=True) assert 'id="link-pair-manual-code"' not in body - assert 'id="link-pair-rotation-ring"' in body - assert 'id="link-pair-rotation"' in body + assert 'id="link-pair-rotation-ring"' not in body + assert 'id="link-pair-rotation"' not in body assert 'id="link-pair-network"' in body - assert copy.PAIR_ROTATE_NOTE in body assert copy.PAIR_NETWORK_LINE in body assert copy.WINDOW_CLOSED_BUTTON in body assert copy.EXPIRED_BUTTON not in body @@ -35,16 +32,20 @@ def test_spl_pair_modal_is_qr_only_with_rotation_affordance(link_env) -> None: assert "countdown-number" not in body assert "LINK_POSTURE" not in body pair_script = _link_pair_script(body) - assert "data.rotating" in pair_script + assert "data.rotating" not in pair_script + assert "rotationTimer" not in pair_script + assert "startRotationRing" not in pair_script assert "LINK_POSTURE" not in pair_script assert "Number(data.expires_in) || 300" in pair_script - assert "rotationTimer = setTimeout" in pair_script - assert "expiresIn * 1000" in pair_script assert pair_script.count("5 * 60 * 1000") == 1 - rot = pair_script[pair_script.index("rotationTimer = setTimeout") :] - block = rot[:400] - assert "requestPairCode().catch" in block - assert "showPairError" in block + assert "function startSplPairWindow" in pair_script + assert "function closeSplPairWindow" in pair_script + assert ( + "windowTimer = setTimeout(closeSplPairWindow, SPL_PAIR_WINDOW_MS)" + in pair_script + ) + assert "latestStatus?.posture === 'spl'" in pair_script + assert "startSplPairWindow();" in pair_script def test_direct_pair_modal_keeps_network_and_expired_copy( @@ -58,8 +59,7 @@ def test_direct_pair_modal_keeps_network_and_expired_copy( body = response.get_data(as_text=True) assert 'id="link-pair-manual-code"' not in body assert copy.PAIR_NETWORK_LINE in body - assert copy.PAIR_ROTATE_NOTE in body - assert 'id="link-pair-rotation"' in body + assert 'id="link-pair-rotation"' not in body assert 'id="link-pair-network"' in body assert copy.EXPIRED_BUTTON in body assert "LINK_POSTURE" not in body diff --git a/solstone/apps/network/tests/test_pair_presentation.py b/solstone/apps/network/tests/test_pair_presentation.py index cb2a8c7ae..914769950 100644 --- a/solstone/apps/network/tests/test_pair_presentation.py +++ b/solstone/apps/network/tests/test_pair_presentation.py @@ -121,7 +121,7 @@ def test_pair_render_seam_uses_current_pair_without_label_clobber(link_env) -> N assert "renderQr(currentPair.pair_link);" in render_body assert "caFpEl.textContent = currentPair.ca_fingerprint;" in render_body assert "pairLinkInput.value = currentPair.pair_link;" in render_body - assert "setPairCopy(currentPair.rotating);" in render_body + assert "setPairCopy();" in render_body assert "updatePresentationSelector();" in render_body assert "deviceLabelInput.value" not in render_body @@ -135,7 +135,6 @@ def test_pair_render_seam_uses_current_pair_without_label_clobber(link_env) -> N assert "ca_fingerprint: data.ca_fingerprint" in request_body assert "device_label: data.device_label" in request_body assert "expires_in: Number(data.expires_in) || 300" in request_body - assert "rotating: Boolean(data.rotating)" in request_body assert "deviceLabelInput.value = data.device_label;" in request_body assert "renderPairPresentation();" in request_body assert body.count("deviceLabelInput.value = data.device_label;") == 1 diff --git a/solstone/apps/network/tests/test_pair_start.py b/solstone/apps/network/tests/test_pair_start.py index e3c046fb7..1832c6ab7 100644 --- a/solstone/apps/network/tests/test_pair_start.py +++ b/solstone/apps/network/tests/test_pair_start.py @@ -8,21 +8,18 @@ from __future__ import annotations import ipaddress import json import re -import time -import uuid from solstone.apps.network import routes as link_routes from solstone.apps.network.crockford32 import decode as crockford_decode -from solstone.apps.network.relay_link import TOTP_STEP_SECONDS, compute_current_totp +from solstone.apps.network.relay_link import decode_pair_window_link, derive_rk from solstone.think.link.ca import load_or_generate_ca from solstone.think.link.nonces import NONCE_TTL_SECONDS -from solstone.think.link.paths import LinkState, ca_dir +from solstone.think.link.paths import ca_dir PAIR_START_KEYS = [ "nonce", "pair_link", "expires_in", - "rotating", "device_label", "ca_fingerprint", ] @@ -45,7 +42,6 @@ def test_pair_start_shape_and_locked_order(link_env) -> None: ) snap = link_routes._nonces().snapshot() assert payload["expires_in"] == NONCE_TTL_SECONDS - assert payload["rotating"] is False assert len(snap) == 1 assert snap[0].expires_at - snap[0].issued_at == NONCE_TTL_SECONDS assert "pair_url" not in payload @@ -182,8 +178,7 @@ def _decode_pair_link(pair_link: str) -> bytes: def test_pair_start_spl_mints_relay_form_pair_link(link_env) -> None: - secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" - env = link_env(posture="spl", totp_secret=secret) + env = link_env(posture="spl", service_token="svc-token-xyz") response = env.client.post( "/app/network/pair-start", @@ -193,24 +188,31 @@ def test_pair_start_spl_mints_relay_form_pair_link(link_env) -> None: assert response.status_code == 200 payload = response.get_json() decoded = _decode_pair_link(payload["pair_link"]) - instance_id = LinkState.load_or_create().instance_id ca = load_or_generate_ca(ca_dir()) - now = int(time.time()) - assert decoded[0] == 0x03 - assert decoded[1:17] == uuid.UUID(instance_id).bytes - assert int.from_bytes(decoded[17:20], "big") in { - compute_current_totp(secret, now + delta) for delta in (-1, 0, 1) - } - assert len(decoded[20:36]) == 16 - assert decoded[36] == 0x01 - assert decoded[37:53] == bytes.fromhex(ca.spki_fingerprint_sha256())[:16] - assert decoded[53] == 0x00 - assert len(decoded) == 54 + assert decoded[0] == 0x06 + assert len(decoded) == 27 + assert decoded[9] == 0x01 + assert decoded[10:26] == bytes.fromhex(ca.spki_fingerprint_sha256())[:16] + assert decoded[26] == 0x00 + parsed = decode_pair_window_link(payload["pair_link"]) + assert parsed.relay_origin is None + assert len(parsed.s) == 8 + + snap = link_routes._nonces().snapshot() + assert len(snap) == 1 + assert snap[0].value == parsed.s.hex() + + assert len(env.pair_window_calls) == 1 + call = env.pair_window_calls[0] + assert call["rk"] == derive_rk(parsed.s) + assert call["service_token"] == "svc-token-xyz" + assert call["relay_endpoint"] == link_routes.relay_url() -def test_pair_start_spl_uses_thirty_second_expiry_and_nonce_ttl(link_env) -> None: - env = link_env(posture="spl", totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + +def test_pair_start_spl_uses_five_minute_expiry_and_nonce_ttl(link_env) -> None: + env = link_env(posture="spl", service_token="svc") response = env.client.post( "/app/network/pair-start", @@ -220,14 +222,13 @@ def test_pair_start_spl_uses_thirty_second_expiry_and_nonce_ttl(link_env) -> Non assert response.status_code == 200 payload = response.get_json() snap = link_routes._nonces().snapshot() - assert payload["expires_in"] == TOTP_STEP_SECONDS + assert payload["expires_in"] == NONCE_TTL_SECONDS assert len(snap) == 1 - assert snap[0].expires_at - snap[0].issued_at == TOTP_STEP_SECONDS + assert snap[0].expires_at - snap[0].issued_at == NONCE_TTL_SECONDS -def test_pair_start_spl_keeps_role_less_home_private(link_env, monkeypatch) -> None: - env = link_env(posture="spl", totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") - monkeypatch.setattr(link_routes, "generate_relay_nonce", lambda: "00" * 16) +def test_pair_start_spl_keeps_role_less_home_private(link_env) -> None: + env = link_env(posture="spl", service_token="svc") response = env.client.post( "/app/network/pair-start", @@ -241,7 +242,7 @@ def test_pair_start_spl_keeps_role_less_home_private(link_env, monkeypatch) -> N assert b"phone" not in _decode_pair_link(payload["pair_link"]) -def test_pair_start_spl_missing_totp_secret_errors_without_nonce(link_env) -> None: +def test_pair_start_spl_missing_service_token_errors_without_nonce(link_env) -> None: env = link_env(posture="spl") response = env.client.post( @@ -253,10 +254,11 @@ def test_pair_start_spl_missing_totp_secret_errors_without_nonce(link_env) -> No payload = response.get_json() assert payload["reason_code"] == "invalid_operation_for_state" assert link_routes._nonces().snapshot() == [] + assert env.pair_window_calls == [] def test_pair_start_spl_response_order_and_display_fingerprint(link_env) -> None: - env = link_env(posture="spl", totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ") + env = link_env(posture="spl", service_token="svc") response = env.client.post( "/app/network/pair-start", @@ -267,6 +269,5 @@ def test_pair_start_spl_response_order_and_display_fingerprint(link_env) -> None payload = response.get_json() ca = load_or_generate_ca(ca_dir()) assert list(payload.keys()) == PAIR_START_KEYS - assert payload["rotating"] is True assert payload["ca_fingerprint"] == ca.fingerprint_sha256() assert payload["ca_fingerprint"] != ca.spki_fingerprint_sha256() diff --git a/solstone/apps/network/tests/test_private_link_routes.py b/solstone/apps/network/tests/test_private_link_routes.py index 7abb54984..c586db8df 100644 --- a/solstone/apps/network/tests/test_private_link_routes.py +++ b/solstone/apps/network/tests/test_private_link_routes.py @@ -18,7 +18,6 @@ from solstone.think.link.paths import ( authorized_clients_path, nonces_path, save_service_token, - save_totp_secret, service_token_path, ) from solstone.think.services import operations @@ -277,12 +276,9 @@ def test_private_link_status_secret_free(link_env): env = link_env() _set_posture(env, "spl") save_service_token("secret-service-token") - save_totp_secret("secret-totp-value") response = env.client.get("/app/network/api/private-link") serialized = json.dumps(response.get_json()) assert response.status_code == 200 assert "secret-service-token" not in serialized - assert "secret-totp-value" not in serialized - assert "totp" not in serialized.lower() diff --git a/solstone/apps/network/tests/test_relay_link.py b/solstone/apps/network/tests/test_relay_link.py index 81eaea0c7..eb5384b84 100644 --- a/solstone/apps/network/tests/test_relay_link.py +++ b/solstone/apps/network/tests/test_relay_link.py @@ -3,156 +3,101 @@ from __future__ import annotations -import re -import uuid - import pytest from solstone.apps.network.crockford32 import decode as crockford_decode +from solstone.apps.network.crockford32 import encode as crockford_encode from solstone.apps.network.relay_link import ( CA_FP_TAG_SPKI_SHA256, - RELAY_VERSION, - compute_current_totp, - encode_relay_pair_link, + PAIR_WINDOW_VERSION, + decode_pair_window_link, + derive_rk, + encode_pair_window_link, ) -from solstone.apps.network.routes import _build_pair_link -INSTANCE_ID = "12345678-1234-5678-1234-567812345678" -TOTP = 123456 -NONCE = "0123456789abcdef0123456789abcdef" -CA_FP_SPKI = "deadbeefcafebabe0123456789abcdef0123456789abcdef0123456789abcdef" -WELL_KNOWN_URL = ( - "https://go.solstone.app/p#" - "0C938NKR28T5CY0J6HB7G4HMASW03RJ004HMASW9NF6YY0938NKRKAYDXW0XXBDYXZ5" - "FXENY04HMASW9NF6YY00" +S = bytes.fromhex("0123456789abcdef") +CA_FP_SPKI = "deadbeefcafebabe0123456789abcdef" +RK_HEX = "e34481a4cde647ba9c9fb29a59e18271" +DEFAULT_BLOB_HEX = "060123456789abcdef01deadbeefcafebabe0123456789abcdef00" +DEFAULT_LINK = "https://go.solstone.app/p#0R0J6HB7H6NWVVR1VTPVXVYAZTXBW0938NKRKAYDXW00" +CUSTOM_ORIGIN = "https://relay.example" +CUSTOM_BLOB_HEX = ( + "060123456789abcdef01deadbeefcafebabe0123456789abcdef" + "1568747470733a2f2f72656c61792e6578616d706c65" ) -CUSTOM_URL = ( +CUSTOM_LINK = ( "https://go.solstone.app/p#" - "0C938NKR28T5CY0J6HB7G4HMASW03RJ004HMASW9NF6YY0938NKRKAYDXW0XXBDYXZ5" - "FXENY04HMASW9NF6YY5B8EHT70WST5WQQ4SBCC5WJWSBRC5PQ0V35" + "0R0J6HB7H6NWVVR1VTPVXVYAZTXBW0938NKRKAYDXWAPGX3ME1SKMBSFE9JPRRBS5SJQGRBDE1P6A" ) -def _expected_relay_blob(relay_origin: str | None) -> bytes: - prefix = ( - bytes([RELAY_VERSION]) - + uuid.UUID(INSTANCE_ID).bytes - + TOTP.to_bytes(3, "big") - + bytes.fromhex(NONCE) - + bytes([CA_FP_TAG_SPKI_SHA256]) - + bytes.fromhex(CA_FP_SPKI)[:16] - ) - if relay_origin is None: - return prefix + bytes([0x00]) - origin_bytes = relay_origin.encode("utf-8") - return prefix + bytes([len(origin_bytes)]) + origin_bytes +def _fragment(link: str) -> str: + return link.rsplit("#", 1)[1] -def _fragment(url: str) -> str: - return url.rsplit("#", 1)[1] +def test_derive_rk_matches_conformance_vector() -> None: + assert derive_rk(S).hex() == RK_HEX -def test_relay_pair_link_reference_vector_well_known() -> None: - expected = _expected_relay_blob(None) +def test_derive_rk_requires_eight_byte_s() -> None: + with pytest.raises(ValueError): + derive_rk(b"too short") - url = encode_relay_pair_link( - INSTANCE_ID, - TOTP, - NONCE, - CA_FP_SPKI, - relay_origin=None, - ) - assert len(expected) == 54 - assert url == WELL_KNOWN_URL - assert crockford_decode(_fragment(url)) == expected +def test_default_origin_encode_matches_conformance_vector() -> None: + link = encode_pair_window_link(S, CA_FP_SPKI, relay_origin=None) + assert link == DEFAULT_LINK + assert crockford_decode(_fragment(link)).hex() == DEFAULT_BLOB_HEX -def test_relay_pair_link_reference_vector_custom_origin() -> None: - relay_origin = "https://relay.example" - expected = _expected_relay_blob(relay_origin) - url = encode_relay_pair_link( - INSTANCE_ID, - TOTP, - NONCE, - CA_FP_SPKI, - relay_origin=relay_origin, - ) +def test_custom_origin_encode_matches_conformance_vector() -> None: + link = encode_pair_window_link(S, CA_FP_SPKI, relay_origin=CUSTOM_ORIGIN) - assert len(expected) == 75 - assert expected.endswith(bytes([21]) + b"https://relay.example") - assert url == CUSTOM_URL - assert crockford_decode(_fragment(url)) == expected + assert link == CUSTOM_LINK + assert crockford_decode(_fragment(link)).hex() == CUSTOM_BLOB_HEX -@pytest.mark.parametrize( - ("now", "expected"), - [ - (59, "287082"), - (1111111109, "081804"), - (1234567890, "005924"), - ], -) -def test_totp_matches_rfc6238_sha1_vectors(now: int, expected: str) -> None: - secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" +def test_decode_pair_window_link_round_trips_default_origin() -> None: + parsed = decode_pair_window_link(DEFAULT_LINK) - assert f"{compute_current_totp(secret, now):06d}" == expected + assert parsed.s == S + assert parsed.ca_fp_spki == bytes.fromhex(CA_FP_SPKI) + assert parsed.relay_origin is None + assert derive_rk(parsed.s).hex() == RK_HEX -@pytest.mark.parametrize( - ("kwargs", "match"), - [ - ({"totp": 1_000_000}, "totp"), - ({"totp": -1}, "totp"), - ({"nonce": "00" * 15}, "nonce"), - ({"relay_origin": "a" * 256}, "relay_origin"), - ({"instance_id": "not-a-uuid"}, "badly formed hexadecimal UUID string"), - ], -) -def test_relay_pair_link_validates_inputs( - kwargs: dict[str, object], - match: str, -) -> None: - params: dict[str, object] = { - "instance_id": INSTANCE_ID, - "totp": TOTP, - "nonce": NONCE, - "ca_fp_spki": CA_FP_SPKI, - "relay_origin": None, - } - params.update(kwargs) - - with pytest.raises(ValueError, match=re.escape(match)): - encode_relay_pair_link(**params) # type: ignore[arg-type] - - -def test_version_byte_disambiguates_direct_and_relay_forms() -> None: - relay_blob = crockford_decode(_fragment(WELL_KNOWN_URL)) - direct_url = _build_pair_link( - "192.0.2.42", - 7070, - "a1b2c3d4e5f607181122334455667788", - "deadbeefcafebabe0123456789abcdef", - ) - direct_blob = crockford_decode(_fragment(direct_url)) +def test_decode_pair_window_link_round_trips_custom_origin() -> None: + parsed = decode_pair_window_link(CUSTOM_LINK) + + assert parsed.s == S + assert parsed.ca_fp_spki == bytes.fromhex(CA_FP_SPKI) + assert parsed.relay_origin == CUSTOM_ORIGIN + assert derive_rk(parsed.s).hex() == RK_HEX + - assert len(relay_blob) == 54 - assert relay_blob[0] == 0x03 - assert not (len(relay_blob) == 40 and relay_blob[0] == 0x04) +def test_decode_pair_window_link_rejects_wrong_version() -> None: + blob = bytes([0x05]) + bytes.fromhex(DEFAULT_BLOB_HEX)[1:] - assert len(direct_blob) == 40 - assert direct_blob[0] == 0x04 - assert not (len(direct_blob) >= 54 and direct_blob[0] == 0x03) + with pytest.raises(ValueError): + decode_pair_window_link(f"https://go.solstone.app/p#{crockford_encode(blob)}") -def test_relay_origin_selector_defines_blob_end() -> None: - well_known = crockford_decode(_fragment(WELL_KNOWN_URL)) - custom = crockford_decode(_fragment(CUSTOM_URL)) +def test_decode_pair_window_link_rejects_truncated_blob() -> None: + blob = bytes.fromhex(DEFAULT_BLOB_HEX)[:-1] - assert len(well_known) == 54 - assert well_known[53] == 0x00 + with pytest.raises(ValueError): + decode_pair_window_link(f"https://go.solstone.app/p#{crockford_encode(blob)}") + + +def test_decode_pair_window_link_rejects_wrong_ca_fp_tag() -> None: + blob = ( + bytes([PAIR_WINDOW_VERSION]) + + S + + bytes([CA_FP_TAG_SPKI_SHA256 + 1]) + + bytes.fromhex(CA_FP_SPKI) + + b"\x00" + ) - assert len(custom) == 54 + 21 - assert custom[53] == 21 - assert custom[54:] == b"https://relay.example" + with pytest.raises(ValueError): + decode_pair_window_link(f"https://go.solstone.app/p#{crockford_encode(blob)}") diff --git a/solstone/apps/network/tests/test_workspace_reach.py b/solstone/apps/network/tests/test_workspace_reach.py index 42f1726cc..6ec7615f8 100644 --- a/solstone/apps/network/tests/test_workspace_reach.py +++ b/solstone/apps/network/tests/test_workspace_reach.py @@ -142,7 +142,7 @@ def test_workspace_renders_reach_shell_copy_and_static_guards(link_env) -> None: def test_workspace_renders_hosted_mode_and_states(link_env) -> None: env = link_env( posture="spl", - totp_secret="GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", + service_token="svc-token", ) response = env.client.get("/app/network/") diff --git a/solstone/apps/network/workspace.html b/solstone/apps/network/workspace.html index 4d42d935b..d58fcb47d 100644 --- a/solstone/apps/network/workspace.html +++ b/solstone/apps/network/workspace.html @@ -229,15 +229,6 @@
  • {{ link_copy.STEP_3 }}
  • -