From 72d4fced89b0aa7ba0d600e15c37d901ee5490ed Mon Sep 17 00:00:00 2001 From: Undline <103777919+Undline@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:34:58 -0400 Subject: [PATCH] feat(genesis): Stage 5 wizard completion via POST /genesis/complete Add complete_genesis (900s window after verify), root org single-label validation, name_bindings row with org Ed25519 pubkey as resolved_id, bootstrap_signing_pubkey_hex and optional bootstrap_operator_display_name, genesis_complete in one transaction. Migration 009 adds display name column. Extend ErrorCode/SuccessCode and http_status_for_error_code; tests for HTTP, completion validation, and migration 9. Note: plan/genesis_wizard_core.md is updated locally under plan/ but plan/ is gitignored; copy or track separately if needed on GitHub. Made-with: Cursor --- src/modulr_core/errors/codes.py | 4 + src/modulr_core/genesis/completion.py | 200 ++++++++++++++ src/modulr_core/http/genesis.py | 202 +++++++++++++- src/modulr_core/http/status_map.py | 1 + .../009_core_genesis_operator_display.sql | 4 + src/modulr_core/repositories/core_genesis.py | 28 +- tests/test_core_genesis_repository.py | 13 + tests/test_genesis_completion.py | 24 ++ tests/test_genesis_http.py | 252 ++++++++++++++++++ 9 files changed, 725 insertions(+), 3 deletions(-) create mode 100644 src/modulr_core/genesis/completion.py create mode 100644 src/modulr_core/persistence/migrations/009_core_genesis_operator_display.sql create mode 100644 tests/test_genesis_completion.py diff --git a/src/modulr_core/errors/codes.py b/src/modulr_core/errors/codes.py index 94f0da1..d81ca1c 100644 --- a/src/modulr_core/errors/codes.py +++ b/src/modulr_core/errors/codes.py @@ -68,6 +68,9 @@ class ErrorCode(StrEnum): GENESIS_CHALLENGE_NOT_FOUND = "GENESIS_CHALLENGE_NOT_FOUND" GENESIS_CHALLENGE_CONSUMED = "GENESIS_CHALLENGE_CONSUMED" GENESIS_CHALLENGE_EXPIRED = "GENESIS_CHALLENGE_EXPIRED" + GENESIS_CHALLENGE_NOT_CONSUMED = "GENESIS_CHALLENGE_NOT_CONSUMED" + GENESIS_COMPLETION_WINDOW_EXPIRED = "GENESIS_COMPLETION_WINDOW_EXPIRED" + GENESIS_OPERATOR_SUBJECT_MISMATCH = "GENESIS_OPERATOR_SUBJECT_MISMATCH" class SuccessCode(StrEnum): @@ -90,3 +93,4 @@ class SuccessCode(StrEnum): MODULE_STATE_SNAPSHOT_RETURNED = "MODULE_STATE_SNAPSHOT_RETURNED" GENESIS_CHALLENGE_ISSUED = "GENESIS_CHALLENGE_ISSUED" GENESIS_CHALLENGE_VERIFIED = "GENESIS_CHALLENGE_VERIFIED" + GENESIS_WIZARD_COMPLETED = "GENESIS_WIZARD_COMPLETED" diff --git a/src/modulr_core/genesis/completion.py b/src/modulr_core/genesis/completion.py new file mode 100644 index 0000000..0b2566e --- /dev/null +++ b/src/modulr_core/genesis/completion.py @@ -0,0 +1,200 @@ +"""Finalize genesis: bind root org name, operator + org keys, ``genesis_complete``.""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from typing import Any + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from modulr_core.repositories.core_genesis import CoreGenesisRepository +from modulr_core.repositories.genesis_challenge import GenesisChallengeRepository +from modulr_core.repositories.name_bindings import NameBindingsRepository +from modulr_core.validation.hex_codec import InvalidHexEncoding, decode_hex_fixed + +# Max seconds after challenge consume during which ``complete`` is allowed. +GENESIS_COMPLETION_WINDOW_SECONDS = 900 + +_ROOT_LABEL_RE = re.compile( + r"^([a-z0-9]|[a-z0-9][a-z0-9-]{0,61}[a-z0-9])$", +) + + +class GenesisCompletionError(Exception): + """Invalid completion request or inconsistent genesis state.""" + + +def validate_genesis_root_organization_label(raw: str) -> str: + """ + Normalize and validate a single DNS label for the genesis root org name. + + Examples: ``modulr`` — no dots; not the same rule as ``register_org`` dotted + domains. + + Args: + raw: Operator-supplied root organization label. + + Returns: + Lowercased label string. + + Raises: + GenesisCompletionError: If the label is empty, too long, or not a valid + single label. + """ + s = raw.strip().lower() + if not s: + raise GenesisCompletionError("root_organization_name must be non-empty") + if len(s) > 63: + raise GenesisCompletionError( + "root_organization_name must be at most 63 characters", + ) + if not _ROOT_LABEL_RE.match(s): + raise GenesisCompletionError( + "root_organization_name must be a single DNS label " + "(e.g. modulr): letters, digits, interior hyphens only; no dots", + ) + return s + + +def _normalize_ed25519_pubkey_hex(raw: str) -> str: + s = raw.strip().lower() + try: + decode_hex_fixed(s, byte_length=32) + except InvalidHexEncoding as e: + raise GenesisCompletionError( + "root_organization_signing_public_key_hex must be a valid " + f"lowercase Ed25519 public key (64 hex chars): {e}", + ) from e + try: + Ed25519PublicKey.from_public_bytes(bytes.fromhex(s)) + except ValueError as e: + raise GenesisCompletionError( + "invalid Ed25519 public key for organization", + ) from e + return s + + +def _validate_operator_display_name(raw: str | None) -> str | None: + if raw is None: + return None + if not isinstance(raw, str): + raise GenesisCompletionError("operator_display_name must be a string or null") + s = raw.strip() + if not s: + return None + if len(s) > 256: + raise GenesisCompletionError( + "operator_display_name must be at most 256 characters", + ) + return s + + +def _binding_matches_existing( + row: dict[str, Any], + *, + resolved_id: str, +) -> bool: + rj = row.get("route_json") + mj = row.get("metadata_json") + rj_n = None if rj in (None, "") else str(rj) + mj_n = None if mj in (None, "") else str(mj) + return ( + str(row["resolved_id"]) == resolved_id + and rj_n is None + and mj_n is None + ) + + +def complete_genesis( + *, + genesis_repo: CoreGenesisRepository, + challenge_repo: GenesisChallengeRepository, + name_repo: NameBindingsRepository, + clock: Callable[[], int], + challenge_id: str, + subject_signing_pubkey_hex: str, + root_organization_name: str, + root_organization_signing_public_key_hex: str, + operator_display_name: str | None, +) -> None: + """ + Atomically complete the genesis wizard (caller commits). + + Requires a consumed challenge for ``subject_signing_pubkey_hex`` within + :data:`GENESIS_COMPLETION_WINDOW_SECONDS` after consume. Binds the root + org name to the organization signing public key (``resolved_id``), stores + the bootstrap operator key, optional display name, and sets + ``genesis_complete``. + + Args: + genesis_repo: Singleton ``core_genesis`` row. + challenge_repo: ``genesis_challenge`` rows. + name_repo: ``name_bindings`` repository. + clock: Unix seconds callable. + challenge_id: 64-hex challenge id from verify step. + subject_signing_pubkey_hex: Operator key (must match consumed + challenge). + root_organization_name: Single-label root org (e.g. ``modulr``). + root_organization_signing_public_key_hex: Org Ed25519 public key hex; + stored as ``name_bindings.resolved_id``. + operator_display_name: Optional operator display string (e.g. ``Chris``). + + Raises: + GenesisCompletionError: Validation or state errors. + """ + snap = genesis_repo.get() + if snap.genesis_complete: + raise GenesisCompletionError("genesis already complete") + + cid = challenge_id.strip().lower() + if len(cid) != 64 or any(c not in "0123456789abcdef" for c in cid): + raise GenesisCompletionError("invalid challenge_id") + + row = challenge_repo.get_by_id(cid) + if row is None: + raise GenesisCompletionError("unknown challenge_id") + if row.consumed_at is None: + raise GenesisCompletionError( + "challenge not verified; call POST /genesis/challenge/verify first", + ) + + subj = subject_signing_pubkey_hex.strip().lower() + if subj != row.subject_signing_pubkey_hex: + raise GenesisCompletionError( + "subject_signing_pubkey_hex does not match the verified challenge", + ) + + now = int(clock()) + if now - int(row.consumed_at) > GENESIS_COMPLETION_WINDOW_SECONDS: + raise GenesisCompletionError( + "genesis completion window expired; verify the challenge again", + ) + + root_label = validate_genesis_root_organization_label(root_organization_name) + org_resolved_id = _normalize_ed25519_pubkey_hex( + root_organization_signing_public_key_hex, + ) + display = _validate_operator_display_name(operator_display_name) + + existing = name_repo.get_by_name(root_label) + if existing is not None: + if not _binding_matches_existing(existing, resolved_id=org_resolved_id): + raise GenesisCompletionError( + "root organization name is already bound to different data", + ) + else: + name_repo.insert( + name=root_label, + resolved_id=org_resolved_id, + route_json=None, + metadata_json=None, + created_at=now, + ) + + genesis_repo.set_bootstrap_signing_pubkey_hex(pubkey_hex=subj, updated_at=now) + genesis_repo.set_bootstrap_operator_display_name( + display_name=display, + updated_at=now, + ) + genesis_repo.set_genesis_complete(complete=True, updated_at=now) diff --git a/src/modulr_core/http/genesis.py b/src/modulr_core/http/genesis.py index 379a09e..f43fd55 100644 --- a/src/modulr_core/http/genesis.py +++ b/src/modulr_core/http/genesis.py @@ -11,6 +11,11 @@ from modulr_core.errors.codes import ErrorCode, SuccessCode from modulr_core.genesis.challenge import GenesisChallengeError, GenesisChallengeService +from modulr_core.genesis.completion import ( + GenesisCompletionError, + complete_genesis, + validate_genesis_root_organization_label, +) from modulr_core.http.envelope import ( error_response_envelope, unsigned_success_response_envelope, @@ -18,6 +23,7 @@ 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 +from modulr_core.repositories.name_bindings import NameBindingsRepository logger = logging.getLogger(__name__) @@ -36,6 +42,43 @@ } +def wire_error_for_genesis_completion(exc: GenesisCompletionError) -> ErrorCode: + """ + Map a genesis completion exception to a wire ``ErrorCode``. + + Args: + exc: Exception from ``complete_genesis``. + + Returns: + Stable error code for the HTTP envelope. + """ + msg = str(exc) + if msg == "genesis already complete": + return ErrorCode.GENESIS_ALREADY_COMPLETE + if msg == "unknown challenge_id": + return ErrorCode.GENESIS_CHALLENGE_NOT_FOUND + if msg.startswith("challenge not verified"): + return ErrorCode.GENESIS_CHALLENGE_NOT_CONSUMED + if "does not match the verified challenge" in msg: + return ErrorCode.GENESIS_OPERATOR_SUBJECT_MISMATCH + if msg.startswith("genesis completion window expired"): + return ErrorCode.GENESIS_COMPLETION_WINDOW_EXPIRED + if "invalid challenge_id" in msg: + return ErrorCode.INVALID_REQUEST + if "root_organization_name" in msg or "single DNS label" in msg: + return ErrorCode.INVALID_NAME + if ( + "root_organization_signing_public_key_hex" in msg + or "invalid Ed25519 public key for organization" in msg + ): + return ErrorCode.PUBLIC_KEY_INVALID + if "operator_display_name" in msg: + return ErrorCode.INVALID_REQUEST + if "already bound" in msg: + return ErrorCode.NAME_ALREADY_BOUND + return ErrorCode.INVALID_REQUEST + + def wire_error_for_genesis_challenge(exc: GenesisChallengeError) -> ErrorCode: """ Map a genesis challenge exception to a wire ``ErrorCode``. @@ -111,6 +154,60 @@ def _parse_verify_json(data: Any) -> tuple[str, str]: return challenge_id, signature_hex +def _parse_complete_json(data: Any) -> tuple[str, str, str, str, str | None]: + """ + Extract genesis completion fields from a parsed JSON body. + + Args: + data: Value returned by ``json.loads`` (must be a dict). + + Returns: + Tuple of ``challenge_id``, ``subject_signing_pubkey_hex``, + ``root_organization_name``, ``root_organization_signing_public_key_hex``, + and optional ``operator_display_name`` (``None`` if absent or empty). + + Raises: + ValueError: If required fields are missing or wrong types. + """ + if not isinstance(data, dict): + raise ValueError("request body must be a JSON object") + cid = data.get("challenge_id") + subj = data.get("subject_signing_pubkey_hex") + root_name = data.get("root_organization_name") + org_pk = data.get("root_organization_signing_public_key_hex") + disp_raw = data.get("operator_display_name") + if not isinstance(cid, str) or not isinstance(subj, str): + raise ValueError("challenge_id and subject_signing_pubkey_hex must be strings") + if not isinstance(root_name, str) or not isinstance(org_pk, str): + raise ValueError( + "root_organization_name and root_organization_signing_public_key_hex " + "must be strings", + ) + if cid.strip() == "" or subj.strip() == "": + raise ValueError( + "challenge_id and subject_signing_pubkey_hex must be non-empty", + ) + if root_name.strip() == "" or org_pk.strip() == "": + raise ValueError( + "root_organization_name and root_organization_signing_public_key_hex " + "must be non-empty", + ) + display: str | None + if disp_raw is None: + display = None + elif isinstance(disp_raw, str): + display = disp_raw.strip() or None + else: + raise ValueError("operator_display_name must be a string or null") + return ( + cid.strip().lower(), + subj.strip(), + root_name.strip(), + org_pk.strip(), + display, + ) + + @router.post("/challenge") async def post_genesis_challenge(request: Request) -> JSONResponse: """ @@ -202,14 +299,115 @@ async def post_genesis_challenge(request: Request) -> JSONResponse: ) +@router.post("/complete") +async def post_genesis_complete(request: Request) -> JSONResponse: + """ + Finish genesis: bind root org name, operator and org keys, ``genesis_complete``. + + Requires a verified challenge (``POST /genesis/challenge/verify``) within the + completion window. Gated by ``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: + ( + challenge_id, + subject_hex, + root_org_name, + org_pk_hex, + operator_display, + ) = _parse_complete_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: + complete_genesis( + genesis_repo=CoreGenesisRepository(conn), + challenge_repo=GenesisChallengeRepository(conn), + name_repo=NameBindingsRepository(conn), + clock=clock, + challenge_id=challenge_id, + subject_signing_pubkey_hex=subject_hex, + root_organization_name=root_org_name, + root_organization_signing_public_key_hex=org_pk_hex, + operator_display_name=operator_display, + ) + conn.commit() + except GenesisCompletionError as e: + conn.rollback() + code = wire_error_for_genesis_completion(e) + return _json_error(code=code, detail=str(e)) + except Exception: + logger.exception("unhandled error during genesis completion") + conn.rollback() + return _json_error( + code=ErrorCode.INTERNAL_ERROR, + detail="Internal server error.", + ) + + g = CoreGenesisRepository(conn).get() + norm_root = validate_genesis_root_organization_label(root_org_name) + out_payload: dict[str, Any] = { + "genesis_complete": True, + "root_organization_name": norm_root, + "root_organization_resolved_id": org_pk_hex.strip().lower(), + "bootstrap_signing_pubkey_hex": g.bootstrap_signing_pubkey_hex, + "operator_display_name": g.bootstrap_operator_display_name, + } + return JSONResponse( + unsigned_success_response_envelope( + operation_response="genesis_wizard_completed_response", + success_code=SuccessCode.GENESIS_WIZARD_COMPLETED, + detail="Genesis wizard completed; this deployment is live.", + payload=out_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. """ diff --git a/src/modulr_core/http/status_map.py b/src/modulr_core/http/status_map.py index ff7d570..95b24c6 100644 --- a/src/modulr_core/http/status_map.py +++ b/src/modulr_core/http/status_map.py @@ -22,6 +22,7 @@ ErrorCode.GENESIS_ALREADY_COMPLETE, ErrorCode.GENESIS_CHALLENGE_CONSUMED, ErrorCode.GENESIS_CHALLENGE_EXPIRED, + ErrorCode.GENESIS_COMPLETION_WINDOW_EXPIRED, }) _FORBIDDEN = frozenset({ diff --git a/src/modulr_core/persistence/migrations/009_core_genesis_operator_display.sql b/src/modulr_core/persistence/migrations/009_core_genesis_operator_display.sql new file mode 100644 index 0000000..b02edae --- /dev/null +++ b/src/modulr_core/persistence/migrations/009_core_genesis_operator_display.sql @@ -0,0 +1,4 @@ +-- Optional bootstrap operator display name (wizard-collected; not a wire handle). +PRAGMA foreign_keys = ON; + +ALTER TABLE core_genesis ADD COLUMN bootstrap_operator_display_name TEXT; diff --git a/src/modulr_core/repositories/core_genesis.py b/src/modulr_core/repositories/core_genesis.py index 3507087..322d6a5 100644 --- a/src/modulr_core/repositories/core_genesis.py +++ b/src/modulr_core/repositories/core_genesis.py @@ -21,6 +21,7 @@ class CoreGenesisSnapshot: genesis_complete: bool bootstrap_signing_pubkey_hex: str | None + bootstrap_operator_display_name: str | None modulr_apex_domain: str | None instance_id: str | None updated_at: int @@ -57,7 +58,8 @@ def _validate_apex_domain(domain: str) -> str: class CoreGenesisRepository: """Read/update the single ``core_genesis`` row. - Migration ``007`` seeds the row; ``008`` adds ``instance_id``. + Migration ``007`` seeds the row; ``008`` adds ``instance_id``; ``009`` adds + ``bootstrap_operator_display_name``. """ def __init__(self, conn: sqlite3.Connection) -> None: @@ -67,6 +69,7 @@ def get(self) -> CoreGenesisSnapshot: cur = self._conn.execute( """ SELECT genesis_complete, bootstrap_signing_pubkey_hex, + bootstrap_operator_display_name, modulr_apex_domain, instance_id, updated_at FROM core_genesis WHERE singleton = 1 @@ -78,6 +81,8 @@ def get(self) -> CoreGenesisSnapshot: complete = int(row["genesis_complete"]) == 1 pk = row["bootstrap_signing_pubkey_hex"] pk_s = str(pk) if pk is not None else None + disp = row["bootstrap_operator_display_name"] + disp_s = str(disp).strip() if disp is not None and str(disp).strip() else None apex = row["modulr_apex_domain"] apex_s = str(apex).strip() if apex is not None and str(apex).strip() else None iid = row["instance_id"] @@ -85,6 +90,7 @@ def get(self) -> CoreGenesisSnapshot: return CoreGenesisSnapshot( genesis_complete=complete, bootstrap_signing_pubkey_hex=pk_s, + bootstrap_operator_display_name=disp_s, modulr_apex_domain=apex_s, instance_id=iid_s, updated_at=int(row["updated_at"]), @@ -131,6 +137,26 @@ def set_genesis_complete(self, *, complete: bool, updated_at: int) -> None: (1 if complete else 0, updated_at), ) + def set_bootstrap_operator_display_name( + self, + *, + display_name: str | None, + updated_at: int, + ) -> None: + """Set optional wizard display name for the bootstrap operator (UTF-8 text).""" + if display_name is not None and len(display_name) > 256: + raise ValueError( + "bootstrap_operator_display_name must be at most 256 characters", + ) + self._conn.execute( + """ + UPDATE core_genesis + SET bootstrap_operator_display_name = ?, updated_at = ? + WHERE singleton = 1 + """, + (display_name, updated_at), + ) + def set_bootstrap_signing_pubkey_hex( self, *, diff --git a/tests/test_core_genesis_repository.py b/tests/test_core_genesis_repository.py index b1f9f33..695bfcf 100644 --- a/tests/test_core_genesis_repository.py +++ b/tests/test_core_genesis_repository.py @@ -45,6 +45,7 @@ def test_core_genesis_default_after_migration() -> None: assert s.bootstrap_signing_pubkey_hex is None assert s.modulr_apex_domain is None assert s.instance_id is None + assert s.bootstrap_operator_display_name is None assert s.updated_at == 0 @@ -114,6 +115,18 @@ def test_schema_migrations_includes_007() -> None: assert cur.fetchone() is not None +def test_schema_migrations_includes_009_operator_display() -> None: + """Migration 009 adds ``bootstrap_operator_display_name`` to ``core_genesis``.""" + conn = _conn() + cur = conn.execute("SELECT 1 FROM schema_migrations WHERE version = 9") + assert cur.fetchone() is not None + cur2 = conn.execute( + "PRAGMA table_info(core_genesis)", + ) + cols = {row[1] for row in cur2.fetchall()} + assert "bootstrap_operator_display_name" in cols + + def test_schema_migrations_includes_008_genesis_challenge() -> None: """ Assert migration 008 applied and ``genesis_challenge`` table exists. diff --git a/tests/test_genesis_completion.py b/tests/test_genesis_completion.py new file mode 100644 index 0000000..bff2f60 --- /dev/null +++ b/tests/test_genesis_completion.py @@ -0,0 +1,24 @@ +"""Genesis completion validation (root org label, org Ed25519 id).""" + +from __future__ import annotations + +import pytest + +from modulr_core.genesis.completion import ( + GenesisCompletionError, + validate_genesis_root_organization_label, +) + + +def test_validate_genesis_root_organization_label_modulr() -> None: + assert validate_genesis_root_organization_label("Modulr") == "modulr" + + +def test_validate_genesis_root_organization_label_rejects_dotted() -> None: + with pytest.raises(GenesisCompletionError, match="single DNS label"): + validate_genesis_root_organization_label("modulr.network") + + +def test_validate_genesis_root_organization_label_rejects_empty() -> None: + with pytest.raises(GenesisCompletionError, match="non-empty"): + validate_genesis_root_organization_label(" ") diff --git a/tests/test_genesis_http.py b/tests/test_genesis_http.py index 69f5e64..e3a4de5 100644 --- a/tests/test_genesis_http.py +++ b/tests/test_genesis_http.py @@ -18,6 +18,7 @@ from modulr_core.http import create_app from modulr_core.persistence import apply_migrations, connect_memory from modulr_core.repositories.core_genesis import CoreGenesisRepository +from modulr_core.repositories.name_bindings import NameBindingsRepository def _settings(**overrides: Any) -> Settings: @@ -113,6 +114,17 @@ def test_genesis_routes_forbidden_on_production() -> None: ) assert rv.status_code == 403 assert rv.json()["code"] == ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED + rc = client.post( + "/genesis/complete", + json={ + "challenge_id": "c" * 64, + "subject_signing_pubkey_hex": "a" * 64, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": "b" * 64, + }, + ) + assert rc.status_code == 403 + assert rc.json()["code"] == ErrorCode.GENESIS_OPERATIONS_NOT_ALLOWED def test_genesis_challenge_malformed_json() -> None: @@ -220,6 +232,246 @@ def test_genesis_challenge_body_too_large() -> None: assert r.json()["code"] == ErrorCode.MESSAGE_TOO_LARGE +def _operator_and_org_keys() -> tuple[Ed25519PrivateKey, str, Ed25519PrivateKey, str]: + op_priv = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"genesis-http-operator").digest(), + ) + org_priv = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"genesis-http-org").digest(), + ) + op_pub = op_priv.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ).hex() + org_pub = org_priv.public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ).hex() + return op_priv, op_pub, org_priv, org_pub + + +def test_genesis_complete_happy_path() -> None: + op_priv, op_pub, _org_priv, org_pub = _operator_and_org_keys() + t = {"now": 1_700_000_000} + conn = _conn() + app = create_app( + settings=_settings(), + conn=conn, + clock=lambda: t["now"], + ) + client = TestClient(app) + d1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = op_priv.sign(body.encode("utf-8")).hex() + assert ( + client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ).status_code + == 200 + ) + r3 = client.post( + "/genesis/complete", + json={ + "challenge_id": cid, + "subject_signing_pubkey_hex": op_pub, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": org_pub, + "operator_display_name": "Chris", + }, + ) + assert r3.status_code == 200 + out = r3.json() + assert out["code"] == str(SuccessCode.GENESIS_WIZARD_COMPLETED) + assert out["payload"]["root_organization_name"] == "modulr" + assert out["payload"]["root_organization_resolved_id"] == org_pub + assert out["payload"]["operator_display_name"] == "Chris" + assert out["payload"]["bootstrap_signing_pubkey_hex"] == op_pub + snap = CoreGenesisRepository(conn).get() + assert snap.genesis_complete is True + assert snap.bootstrap_operator_display_name == "Chris" + row = NameBindingsRepository(conn).get_by_name("modulr") + assert row is not None + assert row["resolved_id"] == org_pub + + +def test_genesis_complete_without_verify_returns_error() -> None: + op_priv, op_pub, _o, org_pub = _operator_and_org_keys() + 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": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + r = client.post( + "/genesis/complete", + json={ + "challenge_id": cid, + "subject_signing_pubkey_hex": op_pub, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": org_pub, + }, + ) + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.GENESIS_CHALLENGE_NOT_CONSUMED + + +def test_genesis_complete_second_time_returns_409() -> None: + op_priv, op_pub, _o, org_pub = _operator_and_org_keys() + t = {"now": 1_700_000_000} + conn = _conn() + app = create_app( + settings=_settings(), + conn=conn, + clock=lambda: t["now"], + ) + client = TestClient(app) + d1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = op_priv.sign(body.encode("utf-8")).hex() + client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ) + complete_body = { + "challenge_id": cid, + "subject_signing_pubkey_hex": op_pub, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": org_pub, + } + assert client.post("/genesis/complete", json=complete_body).status_code == 200 + r2 = client.post("/genesis/complete", json=complete_body) + assert r2.status_code == 409 + assert r2.json()["code"] == ErrorCode.GENESIS_ALREADY_COMPLETE + + +def test_genesis_complete_stale_after_window() -> None: + op_priv, op_pub, _o, org_pub = _operator_and_org_keys() + t = {"now": 1_700_000_000} + conn = _conn() + app = create_app( + settings=_settings(), + conn=conn, + clock=lambda: t["now"], + ) + client = TestClient(app) + d1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = op_priv.sign(body.encode("utf-8")).hex() + client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ) + t["now"] += 901 + r = client.post( + "/genesis/complete", + json={ + "challenge_id": cid, + "subject_signing_pubkey_hex": op_pub, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": org_pub, + }, + ) + assert r.status_code == 409 + assert r.json()["code"] == ErrorCode.GENESIS_COMPLETION_WINDOW_EXPIRED + + +def test_genesis_complete_wrong_subject_pubkey() -> None: + op_priv, op_pub, _o, org_pub = _operator_and_org_keys() + other_pub = Ed25519PrivateKey.generate().public_key().public_bytes( + encoding=Encoding.Raw, + format=PublicFormat.Raw, + ).hex() + t = {"now": 1_700_000_000} + app = create_app( + settings=_settings(), + conn=_conn(), + clock=lambda: t["now"], + ) + client = TestClient(app) + d1 = client.post( + "/genesis/challenge", + content=json.dumps({"subject_signing_pubkey_hex": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = op_priv.sign(body.encode("utf-8")).hex() + client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ) + r = client.post( + "/genesis/complete", + json={ + "challenge_id": cid, + "subject_signing_pubkey_hex": other_pub, + "root_organization_name": "modulr", + "root_organization_signing_public_key_hex": org_pub, + }, + ) + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.GENESIS_OPERATOR_SUBJECT_MISMATCH + + +def test_genesis_complete_invalid_root_label() -> None: + op_priv, op_pub, _o, org_pub = _operator_and_org_keys() + 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": op_pub}).encode("utf-8"), + ).json() + cid = d1["payload"]["challenge_id"] + body = d1["payload"]["challenge_body"] + sig = op_priv.sign(body.encode("utf-8")).hex() + client.post( + "/genesis/challenge/verify", + content=json.dumps( + {"challenge_id": cid, "signature_hex": sig}, + ).encode("utf-8"), + ) + r = client.post( + "/genesis/complete", + json={ + "challenge_id": cid, + "subject_signing_pubkey_hex": op_pub, + "root_organization_name": "modulr.network", + "root_organization_signing_public_key_hex": org_pub, + }, + ) + assert r.status_code == 400 + assert r.json()["code"] == ErrorCode.INVALID_NAME + + 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)