|
| 1 | +"""Finalize genesis: bind root org name, operator + org keys, ``genesis_complete``.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import re |
| 6 | +from collections.abc import Callable |
| 7 | +from typing import Any |
| 8 | + |
| 9 | +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey |
| 10 | + |
| 11 | +from modulr_core.repositories.core_genesis import CoreGenesisRepository |
| 12 | +from modulr_core.repositories.genesis_challenge import GenesisChallengeRepository |
| 13 | +from modulr_core.repositories.name_bindings import NameBindingsRepository |
| 14 | +from modulr_core.validation.hex_codec import InvalidHexEncoding, decode_hex_fixed |
| 15 | + |
| 16 | +# Max seconds after challenge consume during which ``complete`` is allowed. |
| 17 | +GENESIS_COMPLETION_WINDOW_SECONDS = 900 |
| 18 | + |
| 19 | +_ROOT_LABEL_RE = re.compile( |
| 20 | + r"^([a-z0-9]|[a-z0-9][a-z0-9-]{0,61}[a-z0-9])$", |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +class GenesisCompletionError(Exception): |
| 25 | + """Invalid completion request or inconsistent genesis state.""" |
| 26 | + |
| 27 | + |
| 28 | +def validate_genesis_root_organization_label(raw: str) -> str: |
| 29 | + """ |
| 30 | + Normalize and validate a single DNS label for the genesis root org name. |
| 31 | +
|
| 32 | + Examples: ``modulr`` — no dots; not the same rule as ``register_org`` dotted |
| 33 | + domains. |
| 34 | +
|
| 35 | + Args: |
| 36 | + raw: Operator-supplied root organization label. |
| 37 | +
|
| 38 | + Returns: |
| 39 | + Lowercased label string. |
| 40 | +
|
| 41 | + Raises: |
| 42 | + GenesisCompletionError: If the label is empty, too long, or not a valid |
| 43 | + single label. |
| 44 | + """ |
| 45 | + s = raw.strip().lower() |
| 46 | + if not s: |
| 47 | + raise GenesisCompletionError("root_organization_name must be non-empty") |
| 48 | + if len(s) > 63: |
| 49 | + raise GenesisCompletionError( |
| 50 | + "root_organization_name must be at most 63 characters", |
| 51 | + ) |
| 52 | + if not _ROOT_LABEL_RE.match(s): |
| 53 | + raise GenesisCompletionError( |
| 54 | + "root_organization_name must be a single DNS label " |
| 55 | + "(e.g. modulr): letters, digits, interior hyphens only; no dots", |
| 56 | + ) |
| 57 | + return s |
| 58 | + |
| 59 | + |
| 60 | +def _normalize_ed25519_pubkey_hex(raw: str) -> str: |
| 61 | + s = raw.strip().lower() |
| 62 | + try: |
| 63 | + decode_hex_fixed(s, byte_length=32) |
| 64 | + except InvalidHexEncoding as e: |
| 65 | + raise GenesisCompletionError( |
| 66 | + "root_organization_signing_public_key_hex must be a valid " |
| 67 | + f"lowercase Ed25519 public key (64 hex chars): {e}", |
| 68 | + ) from e |
| 69 | + try: |
| 70 | + Ed25519PublicKey.from_public_bytes(bytes.fromhex(s)) |
| 71 | + except ValueError as e: |
| 72 | + raise GenesisCompletionError( |
| 73 | + "invalid Ed25519 public key for organization", |
| 74 | + ) from e |
| 75 | + return s |
| 76 | + |
| 77 | + |
| 78 | +def _validate_operator_display_name(raw: str | None) -> str | None: |
| 79 | + if raw is None: |
| 80 | + return None |
| 81 | + if not isinstance(raw, str): |
| 82 | + raise GenesisCompletionError("operator_display_name must be a string or null") |
| 83 | + s = raw.strip() |
| 84 | + if not s: |
| 85 | + return None |
| 86 | + if len(s) > 256: |
| 87 | + raise GenesisCompletionError( |
| 88 | + "operator_display_name must be at most 256 characters", |
| 89 | + ) |
| 90 | + return s |
| 91 | + |
| 92 | + |
| 93 | +def _binding_matches_existing( |
| 94 | + row: dict[str, Any], |
| 95 | + *, |
| 96 | + resolved_id: str, |
| 97 | +) -> bool: |
| 98 | + rj = row.get("route_json") |
| 99 | + mj = row.get("metadata_json") |
| 100 | + rj_n = None if rj in (None, "") else str(rj) |
| 101 | + mj_n = None if mj in (None, "") else str(mj) |
| 102 | + return ( |
| 103 | + str(row["resolved_id"]) == resolved_id |
| 104 | + and rj_n is None |
| 105 | + and mj_n is None |
| 106 | + ) |
| 107 | + |
| 108 | + |
| 109 | +def complete_genesis( |
| 110 | + *, |
| 111 | + genesis_repo: CoreGenesisRepository, |
| 112 | + challenge_repo: GenesisChallengeRepository, |
| 113 | + name_repo: NameBindingsRepository, |
| 114 | + clock: Callable[[], int], |
| 115 | + challenge_id: str, |
| 116 | + subject_signing_pubkey_hex: str, |
| 117 | + root_organization_name: str, |
| 118 | + root_organization_signing_public_key_hex: str, |
| 119 | + operator_display_name: str | None, |
| 120 | +) -> None: |
| 121 | + """ |
| 122 | + Atomically complete the genesis wizard (caller commits). |
| 123 | +
|
| 124 | + Requires a consumed challenge for ``subject_signing_pubkey_hex`` within |
| 125 | + :data:`GENESIS_COMPLETION_WINDOW_SECONDS` after consume. Binds the root |
| 126 | + org name to the organization signing public key (``resolved_id``), stores |
| 127 | + the bootstrap operator key, optional display name, and sets |
| 128 | + ``genesis_complete``. |
| 129 | +
|
| 130 | + Args: |
| 131 | + genesis_repo: Singleton ``core_genesis`` row. |
| 132 | + challenge_repo: ``genesis_challenge`` rows. |
| 133 | + name_repo: ``name_bindings`` repository. |
| 134 | + clock: Unix seconds callable. |
| 135 | + challenge_id: 64-hex challenge id from verify step. |
| 136 | + subject_signing_pubkey_hex: Operator key (must match consumed |
| 137 | + challenge). |
| 138 | + root_organization_name: Single-label root org (e.g. ``modulr``). |
| 139 | + root_organization_signing_public_key_hex: Org Ed25519 public key hex; |
| 140 | + stored as ``name_bindings.resolved_id``. |
| 141 | + operator_display_name: Optional operator display string (e.g. ``Chris``). |
| 142 | +
|
| 143 | + Raises: |
| 144 | + GenesisCompletionError: Validation or state errors. |
| 145 | + """ |
| 146 | + snap = genesis_repo.get() |
| 147 | + if snap.genesis_complete: |
| 148 | + raise GenesisCompletionError("genesis already complete") |
| 149 | + |
| 150 | + cid = challenge_id.strip().lower() |
| 151 | + if len(cid) != 64 or any(c not in "0123456789abcdef" for c in cid): |
| 152 | + raise GenesisCompletionError("invalid challenge_id") |
| 153 | + |
| 154 | + row = challenge_repo.get_by_id(cid) |
| 155 | + if row is None: |
| 156 | + raise GenesisCompletionError("unknown challenge_id") |
| 157 | + if row.consumed_at is None: |
| 158 | + raise GenesisCompletionError( |
| 159 | + "challenge not verified; call POST /genesis/challenge/verify first", |
| 160 | + ) |
| 161 | + |
| 162 | + subj = subject_signing_pubkey_hex.strip().lower() |
| 163 | + if subj != row.subject_signing_pubkey_hex: |
| 164 | + raise GenesisCompletionError( |
| 165 | + "subject_signing_pubkey_hex does not match the verified challenge", |
| 166 | + ) |
| 167 | + |
| 168 | + now = int(clock()) |
| 169 | + if now - int(row.consumed_at) > GENESIS_COMPLETION_WINDOW_SECONDS: |
| 170 | + raise GenesisCompletionError( |
| 171 | + "genesis completion window expired; verify the challenge again", |
| 172 | + ) |
| 173 | + |
| 174 | + root_label = validate_genesis_root_organization_label(root_organization_name) |
| 175 | + org_resolved_id = _normalize_ed25519_pubkey_hex( |
| 176 | + root_organization_signing_public_key_hex, |
| 177 | + ) |
| 178 | + display = _validate_operator_display_name(operator_display_name) |
| 179 | + |
| 180 | + existing = name_repo.get_by_name(root_label) |
| 181 | + if existing is not None: |
| 182 | + if not _binding_matches_existing(existing, resolved_id=org_resolved_id): |
| 183 | + raise GenesisCompletionError( |
| 184 | + "root organization name is already bound to different data", |
| 185 | + ) |
| 186 | + else: |
| 187 | + name_repo.insert( |
| 188 | + name=root_label, |
| 189 | + resolved_id=org_resolved_id, |
| 190 | + route_json=None, |
| 191 | + metadata_json=None, |
| 192 | + created_at=now, |
| 193 | + ) |
| 194 | + |
| 195 | + genesis_repo.set_bootstrap_signing_pubkey_hex(pubkey_hex=subj, updated_at=now) |
| 196 | + genesis_repo.set_bootstrap_operator_display_name( |
| 197 | + display_name=display, |
| 198 | + updated_at=now, |
| 199 | + ) |
| 200 | + genesis_repo.set_genesis_complete(complete=True, updated_at=now) |
0 commit comments