diff --git a/convey/__init__.py b/convey/__init__.py index ff52a0f79..6bfd76365 100644 --- a/convey/__init__.py +++ b/convey/__init__.py @@ -112,6 +112,7 @@ def create_app(journal: str = "") -> Flask: from think.push.runtime import start_push_runtime from think.voice.runtime import start_voice_runtime + from .pairing import pairing_bp, pairing_ui_bp from .push import push_bp from .voice import voice_bp @@ -155,6 +156,10 @@ def create_app(journal: str = "") -> Flask: # Register push API blueprint app.register_blueprint(push_bp) + # Register pairing API and UI blueprints + app.register_blueprint(pairing_bp) + app.register_blueprint(pairing_ui_bp) + # Initialize and register app system registry = AppRegistry() registry.discover() diff --git a/convey/pairing.py b/convey/pairing.py new file mode 100644 index 000000000..8b235bfd4 --- /dev/null +++ b/convey/pairing.py @@ -0,0 +1,230 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Root-level pairing API and UI.""" + +from __future__ import annotations + +import logging +import time +from typing import Any +from urllib.parse import quote + +from flask import Blueprint, g, jsonify, render_template, request +from werkzeug.exceptions import BadRequest + +from convey.auth import is_owner_authed, require_paired_device, resolve_paired_device +from think.pairing.config import get_host_url, get_owner_identity +from think.pairing.devices import ( + Device, + load_devices, + register_device, + remove_device, + status_view, + touch_last_seen, +) +from think.pairing.keys import ( + generate_session_key, + hash_session_key, + mask_session_key, + validate_public_key, +) +from think.pairing.tokens import consume_token, peek_token +from think.pairing.tokens import create_token as mint_pairing_token +from think.utils import get_journal + +logger = logging.getLogger(__name__) + +MAX_DEVICE_NAME_LENGTH = 128 + +pairing_bp = Blueprint("pairing", __name__, url_prefix="/api/pairing") +pairing_ui_bp = Blueprint("pairing_ui", __name__, url_prefix="/app/pairing") + + +def _error(message: str, status: int, reason: str): + return jsonify({"error": message, "reason": reason}), status + + +def _optional_json_object() -> tuple[dict[str, Any], Any | None]: + if not request.get_data(cache=True): + return {}, None + try: + data = request.get_json(silent=False) + except BadRequest: + return {}, _error("request body must be valid JSON", 400, "invalid_json") + if not isinstance(data, dict): + return {}, _error("request body must be a JSON object", 400, "invalid_request") + return data, None + + +def _required_json_object() -> tuple[dict[str, Any], Any | None]: + try: + data = request.get_json(silent=False) + except BadRequest: + return {}, _error("request body must be valid JSON", 400, "invalid_json") + if not isinstance(data, dict): + return {}, _error("request body must be a JSON object", 400, "invalid_request") + return data, None + + +def _require_field(body: dict[str, Any], field: str) -> str | None: + value = str(body.get(field) or "").strip() + if value: + return value + return None + + +def _resolve_owner_or_paired_device() -> tuple[Device | None, Any | None]: + device = resolve_paired_device() + if device is not None: + g.paired_device = device + return device, None + if is_owner_authed(): + return None, None + return None, _error("owner or paired device required", 401, "auth_required") + + +def _server_version() -> str: + try: + from think.version import __version__ + + return __version__ + except Exception: + return "unknown" + + +@pairing_bp.post("/create") +def create_token(): + _, error = _optional_json_object() + if error is not None: + return error + token = mint_pairing_token() + host_url = get_host_url() + pairing_url = f"solstone://pair?token={token.token}&host={quote(host_url, safe='')}" + logger.info("pairing token minted expires_at=%s", token.expires_at) + return jsonify( + { + "token": token.token, + "expires_at": token.expires_at, + "pairing_url": pairing_url, + "qr_data": pairing_url, + } + ) + + +@pairing_bp.post("/confirm") +def confirm_pairing(): + body, error = _required_json_object() + if error is not None: + return error + + token = _require_field(body, "token") + public_key = _require_field(body, "public_key") + device_name = _require_field(body, "device_name") + platform = _require_field(body, "platform") + bundle_id = _require_field(body, "bundle_id") + app_version = _require_field(body, "app_version") + + if token is None: + return _error("token is required", 400, "invalid_request") + if public_key is None: + return _error("public_key is required", 400, "invalid_request") + if device_name is None: + return _error("device_name is required", 400, "invalid_request") + if len(device_name) > MAX_DEVICE_NAME_LENGTH: + return _error("device_name is too long", 400, "invalid_request") + if platform != "ios": + return _error("platform must be ios", 400, "invalid_platform") + if bundle_id is None: + return _error("bundle_id is required", 400, "invalid_request") + if app_version is None: + return _error("app_version is required", 400, "invalid_request") + + try: + normalized_public_key = validate_public_key(public_key) + except ValueError: + return _error( + "public_key must be a valid ssh-ed25519 key", + 400, + "invalid_public_key", + ) + + now = int(time.time()) + entry = peek_token(token, now=now) + if entry is None: + return _error("pairing token is invalid", 400, "invalid_token") + if entry.expires_at <= now: + return _error("pairing token expired", 410, "token_expired") + if entry.consumed_at is not None: + return _error("pairing token already used", 410, "token_consumed") + + consumed = consume_token(token, now=now) + if consumed is None: + entry = peek_token(token, now=now) + if entry is not None and entry.expires_at <= now: + return _error("pairing token expired", 410, "token_expired") + if entry is not None and entry.consumed_at is not None: + return _error("pairing token already used", 410, "token_consumed") + return _error("pairing token is invalid", 400, "invalid_token") + + session_key = generate_session_key() + device = register_device( + name=device_name, + platform=platform, + public_key=normalized_public_key, + session_key_hash=hash_session_key(session_key), + bundle_id=bundle_id, + app_version=app_version, + ) + logger.info( + "pairing confirmed device_id=%s platform=%s session_key=%s", + device["id"], + device["platform"], + mask_session_key(session_key), + ) + return jsonify( + { + "session_key": session_key, + "device_id": device["id"], + "journal_root": str(get_journal()), + "owner_identity": get_owner_identity(), + "server_version": _server_version(), + } + ) + + +@pairing_bp.post("/heartbeat") +@require_paired_device +def heartbeat(): + _, error = _optional_json_object() + if error is not None: + return error + if not touch_last_seen(g.paired_device["id"]): + return _error("paired device not found", 404, "device_not_found") + return jsonify({"ok": True}) + + +@pairing_bp.get("/devices") +def list_devices(): + _, error = _resolve_owner_or_paired_device() + if error is not None: + return error + return jsonify({"devices": [status_view(device) for device in load_devices()]}) + + +@pairing_bp.delete("/devices/") +def unpair_device(device_id: str): + _, error = _resolve_owner_or_paired_device() + if error is not None: + return error + if not remove_device(device_id): + return _error("paired device not found", 404, "device_not_found") + return jsonify({"unpaired": True}) + + +@pairing_ui_bp.get("/") +def index(): + return render_template("pairing.html") + + +__all__ = ["pairing_bp", "pairing_ui_bp"] diff --git a/convey/root.py b/convey/root.py index bb25bb996..118e2446d 100644 --- a/convey/root.py +++ b/convey/root.py @@ -105,6 +105,11 @@ def require_login() -> Any: "app:import.ingest_facets", "app:import.ingest_imports", "app:import.ingest_config", + # Pairing endpoints with explicit bearer or mixed auth + "pairing.confirm_pairing", + "pairing.heartbeat", + "pairing.list_devices", + "pairing.unpair_device", }: return None diff --git a/tests/test_pairing_routes.py b/tests/test_pairing_routes.py new file mode 100644 index 000000000..d94bafc58 --- /dev/null +++ b/tests/test_pairing_routes.py @@ -0,0 +1,231 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import json + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from convey import create_app +from think.pairing.devices import find_device_by_id, load_devices +from think.pairing.tokens import create_token as mint_pairing_token + + +def _write_config(journal_copy, payload: dict) -> None: + (journal_copy / "config" / "journal.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + + +def _read_config(journal_copy) -> dict: + return json.loads((journal_copy / "config" / "journal.json").read_text("utf-8")) + + +def _owner_login(client) -> None: + with client.session_transaction() as session: + session["logged_in"] = True + session.permanent = True + + +def _public_key() -> str: + return ( + ed25519.Ed25519PrivateKey.generate() + .public_key() + .public_bytes( + encoding=serialization.Encoding.OpenSSH, + format=serialization.PublicFormat.OpenSSH, + ) + .decode("utf-8") + ) + + +@pytest.fixture +def pairing_app(journal_copy): + app = create_app(str(journal_copy)) + app.config["TESTING"] = True + return app + + +@pytest.fixture +def pairing_client(pairing_app): + client = pairing_app.test_client() + _owner_login(client) + return client + + +def test_create_token_happy_path(pairing_client): + response = pairing_client.post("/api/pairing/create", json={}) + + body = response.get_json() + assert response.status_code == 200 + assert body["token"].startswith("ptk_") + assert body["expires_at"] > 0 + assert body["pairing_url"].startswith("solstone://pair?token=") + assert body["qr_data"] == body["pairing_url"] + + +def test_create_token_rejects_non_object(pairing_client): + response = pairing_client.post("/api/pairing/create", json=["bad"]) + + assert response.status_code == 400 + assert response.get_json() == { + "error": "request body must be a JSON object", + "reason": "invalid_request", + } + + +def test_confirm_pairing_happy_path(pairing_client, journal_copy): + config = _read_config(journal_copy) + config["identity"] = {"name": "Sol", "preferred": "Preferred Sol"} + _write_config(journal_copy, config) + + create_response = pairing_client.post("/api/pairing/create", json={}) + token = create_response.get_json()["token"] + public_key = _public_key() + + response = pairing_client.post( + "/api/pairing/confirm", + json={ + "token": token, + "public_key": public_key, + "device_name": "Phone", + "platform": "ios", + "bundle_id": "org.solpbc.solstone-swift", + "app_version": "0.1.0", + }, + ) + + body = response.get_json() + assert response.status_code == 200 + assert body["session_key"].startswith("dsk_") + assert body["device_id"].startswith("dev_") + assert body["journal_root"] == str(journal_copy.resolve()) + assert body["owner_identity"] == "Preferred Sol" + assert body["server_version"] == "unknown" + stored = find_device_by_id(body["device_id"]) + assert stored is not None + assert stored["session_key_hash"] != body["session_key"] + + +def test_confirm_pairing_rejects_bad_public_key(pairing_client): + token = pairing_client.post("/api/pairing/create", json={}).get_json()["token"] + + response = pairing_client.post( + "/api/pairing/confirm", + json={ + "token": token, + "public_key": "ssh-ed25519 bad", + "device_name": "Phone", + "platform": "ios", + "bundle_id": "org.solpbc.solstone-swift", + "app_version": "0.1.0", + }, + ) + + assert response.status_code == 400 + assert response.get_json() == { + "error": "public_key must be a valid ssh-ed25519 key", + "reason": "invalid_public_key", + } + + +def test_confirm_pairing_distinguishes_expired_and_consumed_tokens( + pairing_client, monkeypatch +): + expired = mint_pairing_token(ttl_seconds=60, now=1000) + monkeypatch.setattr("convey.pairing.time.time", lambda: 1060) + + expired_response = pairing_client.post( + "/api/pairing/confirm", + json={ + "token": expired.token, + "public_key": _public_key(), + "device_name": "Phone", + "platform": "ios", + "bundle_id": "org.solpbc.solstone-swift", + "app_version": "0.1.0", + }, + ) + + assert expired_response.status_code == 410 + assert expired_response.get_json()["reason"] == "token_expired" + + token = pairing_client.post("/api/pairing/create", json={}).get_json()["token"] + body = { + "token": token, + "public_key": _public_key(), + "device_name": "Phone", + "platform": "ios", + "bundle_id": "org.solpbc.solstone-swift", + "app_version": "0.1.0", + } + assert pairing_client.post("/api/pairing/confirm", json=body).status_code == 200 + + consumed_response = pairing_client.post("/api/pairing/confirm", json=body) + + assert consumed_response.status_code == 410 + assert consumed_response.get_json()["reason"] == "token_consumed" + + +def test_heartbeat_requires_valid_bearer(pairing_client): + response = pairing_client.post("/api/pairing/heartbeat") + + assert response.status_code == 401 + assert response.get_json() == { + "error": "paired device required", + "reason": "auth_required", + } + + +def test_list_devices_allows_bearer_or_owner(pairing_client): + confirm = pairing_client.post( + "/api/pairing/confirm", + json={ + "token": pairing_client.post("/api/pairing/create", json={}).get_json()[ + "token" + ], + "public_key": _public_key(), + "device_name": "Phone", + "platform": "ios", + "bundle_id": "org.solpbc.solstone-swift", + "app_version": "0.1.0", + }, + ).get_json() + + owner_response = pairing_client.get("/api/pairing/devices") + bearer_response = pairing_client.get( + "/api/pairing/devices", + headers={"Authorization": f"Bearer {confirm['session_key']}"}, + ) + anon_client = pairing_client.application.test_client() + anon_response = anon_client.get( + "/api/pairing/devices", headers={"X-Forwarded-For": "1.2.3.4"} + ) + + assert owner_response.status_code == 200 + assert bearer_response.status_code == 200 + assert owner_response.get_json() == bearer_response.get_json() + assert owner_response.get_json()["devices"] == [ + { + "id": confirm["device_id"], + "name": "Phone", + "platform": "ios", + "paired_at": load_devices()[0]["paired_at"], + "last_seen_at": None, + } + ] + assert anon_response.status_code == 401 + assert anon_response.get_json()["reason"] == "auth_required" + + +def test_unpair_device_returns_404_for_unknown_device(pairing_client): + response = pairing_client.delete("/api/pairing/devices/dev_missing") + + assert response.status_code == 404 + assert response.get_json() == { + "error": "paired device not found", + "reason": "device_not_found", + }