diff --git a/src/modulr_core/errors/codes.py b/src/modulr_core/errors/codes.py index 4f0b9b6..94f0da1 100644 --- a/src/modulr_core/errors/codes.py +++ b/src/modulr_core/errors/codes.py @@ -62,6 +62,13 @@ class ErrorCode(StrEnum): # Idempotency / replay REPLAY_RESPONSE_UNAVAILABLE = "REPLAY_RESPONSE_UNAVAILABLE" + # Genesis wizard (unsigned HTTP routes; local/testnet only) + GENESIS_OPERATIONS_NOT_ALLOWED = "GENESIS_OPERATIONS_NOT_ALLOWED" + GENESIS_ALREADY_COMPLETE = "GENESIS_ALREADY_COMPLETE" + GENESIS_CHALLENGE_NOT_FOUND = "GENESIS_CHALLENGE_NOT_FOUND" + GENESIS_CHALLENGE_CONSUMED = "GENESIS_CHALLENGE_CONSUMED" + GENESIS_CHALLENGE_EXPIRED = "GENESIS_CHALLENGE_EXPIRED" + class SuccessCode(StrEnum): """Success `code` values for MVP operations.""" @@ -81,3 +88,5 @@ class SuccessCode(StrEnum): HEARTBEAT_RECORDED = "HEARTBEAT_RECORDED" MODULE_STATE_REPORTED = "MODULE_STATE_REPORTED" MODULE_STATE_SNAPSHOT_RETURNED = "MODULE_STATE_SNAPSHOT_RETURNED" + GENESIS_CHALLENGE_ISSUED = "GENESIS_CHALLENGE_ISSUED" + GENESIS_CHALLENGE_VERIFIED = "GENESIS_CHALLENGE_VERIFIED" diff --git a/src/modulr_core/http/app.py b/src/modulr_core/http/app.py index 8c8f738..3a09fe3 100644 --- a/src/modulr_core/http/app.py +++ b/src/modulr_core/http/app.py @@ -26,6 +26,7 @@ from modulr_core.errors.exceptions import WireValidationError from modulr_core.http.config_resolve import resolve_config_path from modulr_core.http.envelope import error_response_envelope, try_parse_message_id +from modulr_core.http.genesis import router as genesis_router from modulr_core.http.replay_cache import parse_stored_response_envelope from modulr_core.http.status_map import http_status_for_error_code from modulr_core.messages import validate_inbound_request @@ -116,6 +117,8 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.state.conn_lock = threading.Lock() app.state.clock = clock or now_epoch_seconds + app.include_router(genesis_router) + cors_origins = _cors_allow_origins(settings) if cors_origins: app.add_middleware( diff --git a/src/modulr_core/http/envelope.py b/src/modulr_core/http/envelope.py index 4128471..c316bb4 100644 --- a/src/modulr_core/http/envelope.py +++ b/src/modulr_core/http/envelope.py @@ -29,6 +29,51 @@ def try_parse_message_id(body: bytes) -> str | None: return mid if isinstance(mid, str) else None +def unsigned_success_response_envelope( + *, + operation_response: str, + success_code: SuccessCode, + detail: str, + payload: dict[str, Any], + clock: EpochClock, +) -> dict[str, Any]: + """ + Success-shaped JSON for unsigned HTTP handlers (no request ``message_id``). + + Same top-level fields as :func:`success_response_envelope`, with + ``message_id`` and ``correlation_id`` set to ``None``. + + Args: + operation_response: Wire ``operation`` for this response (e.g. + ``genesis_challenge_issued_response``). + success_code: Stable success code string. + detail: Human-readable summary. + payload: Response ``payload`` object (hashed like signed flows). + clock: Unix epoch seconds callable. + + Returns: + Response body dict suitable for JSON encoding. + """ + now = float(clock()) + ts = datetime.fromtimestamp(now, tz=UTC).strftime( + "%Y-%m-%dT%H:%M:%SZ", + ) + return { + "protocol_version": MODULE_VERSION, + "message_id": None, + "correlation_id": None, + "target_module": TARGET_MODULE_CORE, + "target_module_version": MODULE_VERSION, + "operation": operation_response, + "timestamp": ts, + "status": "success", + "code": str(success_code), + "detail": detail, + "payload": payload, + "payload_hash": payload_hash(payload), + } + + def success_response_envelope( *, request_message_id: str, diff --git a/src/modulr_core/http/genesis.py b/src/modulr_core/http/genesis.py new file mode 100644 index 0000000..379a09e --- /dev/null +++ b/src/modulr_core/http/genesis.py @@ -0,0 +1,288 @@ +"""Unsigned JSON routes for the genesis challenge wizard (local/testnet only).""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from modulr_core.errors.codes import ErrorCode, SuccessCode +from modulr_core.genesis.challenge import GenesisChallengeError, GenesisChallengeService +from modulr_core.http.envelope import ( + error_response_envelope, + unsigned_success_response_envelope, +) +from modulr_core.http.status_map import http_status_for_error_code +from modulr_core.repositories.core_genesis import CoreGenesisRepository +from modulr_core.repositories.genesis_challenge import GenesisChallengeRepository + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/genesis", tags=["genesis"]) + +# Maps :class:`GenesisChallengeError` messages to wire error codes (stable strings). +_GENESIS_CHALLENGE_MSG_TO_CODE: dict[str, ErrorCode] = { + "genesis already complete": ErrorCode.GENESIS_ALREADY_COMPLETE, + "unknown challenge_id": ErrorCode.GENESIS_CHALLENGE_NOT_FOUND, + "challenge already consumed": ErrorCode.GENESIS_CHALLENGE_CONSUMED, + "challenge expired": ErrorCode.GENESIS_CHALLENGE_EXPIRED, + "invalid subject_signing_pubkey_hex": ErrorCode.PUBLIC_KEY_INVALID, + "invalid Ed25519 public key": ErrorCode.PUBLIC_KEY_INVALID, + "invalid signature hex": ErrorCode.INVALID_FIELD, + "signature verification failed": ErrorCode.SIGNATURE_INVALID, +} + + +def wire_error_for_genesis_challenge(exc: GenesisChallengeError) -> ErrorCode: + """ + Map a genesis challenge exception to a wire ``ErrorCode``. + + Args: + exc: Exception raised by ``GenesisChallengeService``. + + Returns: + Stable error code for the HTTP envelope. + """ + msg = str(exc) + return _GENESIS_CHALLENGE_MSG_TO_CODE.get(msg, ErrorCode.INVALID_REQUEST) + + +def _json_error( + *, + code: ErrorCode, + detail: str, +) -> JSONResponse: + return JSONResponse( + error_response_envelope(code=code, detail=detail, message_id=None), + status_code=http_status_for_error_code(code), + ) + + +def _parse_issue_json(data: Any) -> str: + """ + Extract ``subject_signing_pubkey_hex`` from a parsed JSON body. + + Args: + data: Value returned by ``json.loads`` (must be a dict). + + Returns: + Non-empty stripped subject signing pubkey hex string. + + Raises: + ValueError: If the shape is invalid or the field is missing/empty. + """ + if not isinstance(data, dict): + raise ValueError("request body must be a JSON object") + raw = data.get("subject_signing_pubkey_hex") + if not isinstance(raw, str): + raise ValueError("subject_signing_pubkey_hex must be a string") + pk = raw.strip() + if not pk: + raise ValueError("subject_signing_pubkey_hex must be non-empty") + return pk + + +def _parse_verify_json(data: Any) -> tuple[str, str]: + """ + Extract ``challenge_id`` and ``signature_hex`` from a parsed JSON body. + + Args: + data: Value returned by ``json.loads`` (must be a dict). + + Returns: + Tuple of stripped ``challenge_id`` and ``signature_hex``. + + Raises: + ValueError: If the shape is invalid or a field is missing/empty. + """ + if not isinstance(data, dict): + raise ValueError("request body must be a JSON object") + cid_raw = data.get("challenge_id") + sig_raw = data.get("signature_hex") + if not isinstance(cid_raw, str) or not isinstance(sig_raw, str): + raise ValueError("challenge_id and signature_hex must be strings") + challenge_id = cid_raw.strip() + signature_hex = sig_raw.strip() + if not challenge_id or not signature_hex: + raise ValueError("challenge_id and signature_hex must be non-empty") + return challenge_id, signature_hex + + +@router.post("/challenge") +async def post_genesis_challenge(request: Request) -> JSONResponse: + """ + Issue a one-shot genesis challenge bound to ``subject_signing_pubkey_hex``. + + Gated by ``settings.genesis_operations_allowed()`` (403 otherwise). + + Returns: + JSON success or error envelope matching ``POST /message`` error shape. + """ + settings = request.app.state.settings + if not settings.genesis_operations_allowed(): + return _json_error( + code=ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED, + detail="Genesis operations are not allowed for this deployment.", + ) + + body = await request.body() + if len(body) > settings.max_http_body_bytes: + return _json_error( + code=ErrorCode.MESSAGE_TOO_LARGE, + detail=( + "request body exceeds max_http_body_bytes " + f"({settings.max_http_body_bytes})" + ), + ) + + try: + parsed: Any = json.loads(body.decode("utf-8")) + except UnicodeDecodeError: + return _json_error( + code=ErrorCode.MALFORMED_JSON, + detail="Request body must be UTF-8 JSON.", + ) + except json.JSONDecodeError as e: + return _json_error( + code=ErrorCode.MALFORMED_JSON, + detail=f"Invalid JSON: {e}", + ) + + try: + pubkey_hex = _parse_issue_json(parsed) + except ValueError as e: + return _json_error(code=ErrorCode.INVALID_REQUEST, detail=str(e)) + + conn = request.app.state.conn + clock = request.app.state.clock + lock = request.app.state.conn_lock + + with lock: + try: + svc = GenesisChallengeService( + genesis_repo=CoreGenesisRepository(conn), + challenge_repo=GenesisChallengeRepository(conn), + clock=clock, + ) + issued = svc.issue(subject_signing_pubkey_hex=pubkey_hex) + conn.commit() + except GenesisChallengeError as e: + conn.rollback() + code = wire_error_for_genesis_challenge(e) + return _json_error(code=code, detail=str(e)) + except Exception: + logger.exception("unhandled error during genesis challenge issue") + conn.rollback() + return _json_error( + code=ErrorCode.INTERNAL_ERROR, + detail="Internal server error.", + ) + + payload = { + "challenge_id": issued.challenge_id, + "challenge_body": issued.body, + "issued_at_unix": issued.issued_at_unix, + "expires_at_unix": issued.expires_at_unix, + } + return JSONResponse( + unsigned_success_response_envelope( + operation_response="genesis_challenge_issued_response", + success_code=SuccessCode.GENESIS_CHALLENGE_ISSUED, + detail=( + "Genesis challenge issued; sign challenge_body " + "with the operator key." + ), + payload=payload, + clock=clock, + ), + status_code=200, + ) + + +@router.post("/challenge/verify") +async def post_genesis_challenge_verify(request: Request) -> JSONResponse: + """ + Verify an Ed25519 signature and consume the challenge (one shot). + + Gated by ``settings.genesis_operations_allowed()`` (403 otherwise). + Wizard completion (user/org) is a later stage. + + Returns: + JSON success or error envelope matching ``POST /message`` error shape. + """ + settings = request.app.state.settings + if not settings.genesis_operations_allowed(): + return _json_error( + code=ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED, + detail="Genesis operations are not allowed for this deployment.", + ) + + body = await request.body() + if len(body) > settings.max_http_body_bytes: + return _json_error( + code=ErrorCode.MESSAGE_TOO_LARGE, + detail=( + "request body exceeds max_http_body_bytes " + f"({settings.max_http_body_bytes})" + ), + ) + + try: + parsed: Any = json.loads(body.decode("utf-8")) + except UnicodeDecodeError: + return _json_error( + code=ErrorCode.MALFORMED_JSON, + detail="Request body must be UTF-8 JSON.", + ) + except json.JSONDecodeError as e: + return _json_error( + code=ErrorCode.MALFORMED_JSON, + detail=f"Invalid JSON: {e}", + ) + + try: + challenge_id, signature_hex = _parse_verify_json(parsed) + except ValueError as e: + return _json_error(code=ErrorCode.INVALID_REQUEST, detail=str(e)) + + conn = request.app.state.conn + clock = request.app.state.clock + lock = request.app.state.conn_lock + + with lock: + try: + svc = GenesisChallengeService( + genesis_repo=CoreGenesisRepository(conn), + challenge_repo=GenesisChallengeRepository(conn), + clock=clock, + ) + svc.verify_and_consume( + challenge_id=challenge_id, + signature_hex=signature_hex, + ) + conn.commit() + except GenesisChallengeError as e: + conn.rollback() + code = wire_error_for_genesis_challenge(e) + return _json_error(code=code, detail=str(e)) + except Exception: + logger.exception("unhandled error during genesis challenge verify") + conn.rollback() + return _json_error( + code=ErrorCode.INTERNAL_ERROR, + detail="Internal server error.", + ) + + return JSONResponse( + unsigned_success_response_envelope( + operation_response="genesis_challenge_verified_response", + success_code=SuccessCode.GENESIS_CHALLENGE_VERIFIED, + detail="Genesis challenge signature verified and consumed.", + payload={"verified": True}, + clock=clock, + ), + status_code=200, + ) diff --git a/src/modulr_core/http/status_map.py b/src/modulr_core/http/status_map.py index 4218188..ff7d570 100644 --- a/src/modulr_core/http/status_map.py +++ b/src/modulr_core/http/status_map.py @@ -12,17 +12,22 @@ ErrorCode.MODULE_NOT_FOUND, ErrorCode.NAME_NOT_FOUND, ErrorCode.IDENTITY_NOT_FOUND, + ErrorCode.GENESIS_CHALLENGE_NOT_FOUND, }) _CONFLICT = frozenset({ ErrorCode.MODULE_ALREADY_REGISTERED, ErrorCode.NAME_ALREADY_BOUND, ErrorCode.MESSAGE_ID_CONFLICT, ErrorCode.REPLAY_RESPONSE_UNAVAILABLE, + ErrorCode.GENESIS_ALREADY_COMPLETE, + ErrorCode.GENESIS_CHALLENGE_CONSUMED, + ErrorCode.GENESIS_CHALLENGE_EXPIRED, }) _FORBIDDEN = frozenset({ ErrorCode.UNAUTHORIZED, ErrorCode.IDENTITY_MISMATCH, + ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED, }) diff --git a/tests/test_genesis_http.py b/tests/test_genesis_http.py new file mode 100644 index 0000000..69f5e64 --- /dev/null +++ b/tests/test_genesis_http.py @@ -0,0 +1,235 @@ +"""HTTP genesis challenge routes (unsigned JSON, envelope errors).""" + +from __future__ import annotations + +import hashlib +import json +import sqlite3 +from dataclasses import replace +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from fastapi.testclient import TestClient + +from modulr_core import MODULE_VERSION, ErrorCode, SuccessCode +from modulr_core.config.schema import NetworkEnvironment, Settings +from modulr_core.http import create_app +from modulr_core.persistence import apply_migrations, connect_memory +from modulr_core.repositories.core_genesis import CoreGenesisRepository + + +def _settings(**overrides: Any) -> Settings: + base = Settings( + bootstrap_public_keys=(), + database_path=Path("unused.sqlite"), + max_http_body_bytes=2_097_152, + max_payload_bytes=1_048_576, + max_expiry_window_seconds=604_800, + future_timestamp_skew_seconds=300, + replay_window_seconds=86_400, + dev_mode=True, + network_environment=NetworkEnvironment.LOCAL, + network_name="", + ) + return replace(base, **overrides) + + +def _conn() -> sqlite3.Connection: + c = connect_memory(check_same_thread=False) + apply_migrations(c) + return c + + +def _test_pubkey_hex() -> str: + seed = hashlib.sha256(b"genesis-http-test").digest() + priv = Ed25519PrivateKey.from_private_bytes(seed) + return priv.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ).hex() + + +def _priv_for_test_pubkey() -> Ed25519PrivateKey: + seed = hashlib.sha256(b"genesis-http-test").digest() + return Ed25519PrivateKey.from_private_bytes(seed) + + +def test_genesis_challenge_issue_and_verify_happy_path() -> None: + pk_hex = _test_pubkey_hex() + priv = _priv_for_test_pubkey() + app = create_app( + settings=_settings(), + conn=_conn(), + clock=lambda: 1_700_000_000, + ) + client = TestClient(app) + r1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": pk_hex}).encode("utf-8"), + ) + assert r1.status_code == 200 + d1 = r1.json() + assert d1["status"] == "success" + assert d1["code"] == str(SuccessCode.GENESIS_CHALLENGE_ISSUED) + assert d1["payload"]["challenge_body"] + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = priv.sign(body.encode("utf-8")).hex() + r2 = client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ) + assert r2.status_code == 200 + d2 = r2.json() + assert d2["status"] == "success" + assert d2["code"] == str(SuccessCode.GENESIS_CHALLENGE_VERIFIED) + assert d2["payload"]["verified"] is True + + +def test_genesis_routes_forbidden_on_production() -> None: + app = create_app( + settings=_settings(network_environment=NetworkEnvironment.PRODUCTION), + conn=_conn(), + clock=lambda: 1.0, + ) + client = TestClient(app) + r = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": "a" * 64}).encode( + "utf-8", + ), + ) + assert r.status_code == 403 + assert r.json()["code"] == ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED + rv = client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": "c" * 64, "signature_hex": "s" * 128}, + ).encode("utf-8"), + ) + assert rv.status_code == 403 + assert rv.json()["code"] == ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED + + +def test_genesis_challenge_malformed_json() -> None: + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1.0) + client = TestClient(app) + r = client.post("/genesis/challenge", content=b"{") + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.MALFORMED_JSON + + +def test_genesis_challenge_invalid_request_missing_field() -> None: + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1.0) + client = TestClient(app) + r = client.post( + "/genesis/challenge", + content=json.dumps({}).encode("utf-8"), + ) + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.INVALID_REQUEST + + +def test_genesis_challenge_invalid_pubkey() -> None: + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1.0) + client = TestClient(app) + r = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": "not-hex"}).encode( + "utf-8", + ), + ) + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.PUBLIC_KEY_INVALID + + +def test_genesis_verify_unknown_challenge_404() -> None: + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1.0) + client = TestClient(app) + r = client.post( + "/genesis/challenge/verify", + content=json.dumps( + { + "challenge_id": "f" * 64, + "signature_hex": "a" * 128, + }, + ).encode("utf-8"), + ) + assert r.status_code == 404 + assert r.json()["code"] == ErrorCode.GENESIS_CHALLENGE_NOT_FOUND + + +def test_genesis_verify_twice_second_is_409() -> None: + pk_hex = _test_pubkey_hex() + priv = _priv_for_test_pubkey() + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1_700_000_000) + client = TestClient(app) + d1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": pk_hex}).encode("utf-8"), + ).json() + body = d1["payload"]["challenge_body"] + cid = d1["payload"]["challenge_id"] + sig = priv.sign(body.encode("utf-8")).hex() + payload = {"challenge_id": cid, "signature_hex": sig} + b = json.dumps(payload).encode("utf-8") + assert client.post("/genesis/challenge/verify", content=b).status_code == 200 + r2 = client.post("/genesis/challenge/verify", content=b) + assert r2.status_code == 409 + assert r2.json()["code"] == ErrorCode.GENESIS_CHALLENGE_CONSUMED + + +def test_genesis_issue_when_already_complete_409() -> None: + conn = _conn() + CoreGenesisRepository(conn).set_genesis_complete(complete=True, updated_at=1) + conn.commit() + app = create_app( + settings=_settings(), + conn=conn, + clock=lambda: 10.0, + ) + client = TestClient(app) + r = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": _test_pubkey_hex()}).encode( + "utf-8", + ), + ) + assert r.status_code == 409 + assert r.json()["code"] == ErrorCode.GENESIS_ALREADY_COMPLETE + + +def test_genesis_challenge_body_too_large() -> None: + app = create_app( + settings=_settings(max_http_body_bytes=20), + conn=_conn(), + clock=lambda: 1.0, + ) + client = TestClient(app) + r = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": "x" * 64}).encode( + "utf-8", + ), + ) + assert r.status_code == 413 + assert r.json()["code"] == ErrorCode.MESSAGE_TOO_LARGE + + +def test_genesis_success_envelope_has_protocol_fields() -> None: + pk_hex = _test_pubkey_hex() + app = create_app(settings=_settings(), conn=_conn(), clock=lambda: 1_700_000_000) + client = TestClient(app) + d = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": pk_hex}).encode("utf-8"), + ).json() + assert d["protocol_version"] == MODULE_VERSION + assert d["target_module"] == "modulr.core" + assert d["message_id"] is None + assert d["correlation_id"] is None + assert "payload_hash" in d