Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/modulr_core/errors/codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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"
3 changes: 3 additions & 0 deletions src/modulr_core/http/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
45 changes: 45 additions & 0 deletions src/modulr_core/http/envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
288 changes: 288 additions & 0 deletions src/modulr_core/http/genesis.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading
Loading