Skip to content

Commit d0a0304

Browse files
authored
Merge pull request #49 from ModulrCloud/dev
feat(genesis): Stage 5 wizard completion via POST /genesis/complete
2 parents 96f0df1 + 72d4fce commit d0a0304

9 files changed

Lines changed: 725 additions & 3 deletions

File tree

src/modulr_core/errors/codes.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ class ErrorCode(StrEnum):
6868
GENESIS_CHALLENGE_NOT_FOUND = "GENESIS_CHALLENGE_NOT_FOUND"
6969
GENESIS_CHALLENGE_CONSUMED = "GENESIS_CHALLENGE_CONSUMED"
7070
GENESIS_CHALLENGE_EXPIRED = "GENESIS_CHALLENGE_EXPIRED"
71+
GENESIS_CHALLENGE_NOT_CONSUMED = "GENESIS_CHALLENGE_NOT_CONSUMED"
72+
GENESIS_COMPLETION_WINDOW_EXPIRED = "GENESIS_COMPLETION_WINDOW_EXPIRED"
73+
GENESIS_OPERATOR_SUBJECT_MISMATCH = "GENESIS_OPERATOR_SUBJECT_MISMATCH"
7174

7275

7376
class SuccessCode(StrEnum):
@@ -90,3 +93,4 @@ class SuccessCode(StrEnum):
9093
MODULE_STATE_SNAPSHOT_RETURNED = "MODULE_STATE_SNAPSHOT_RETURNED"
9194
GENESIS_CHALLENGE_ISSUED = "GENESIS_CHALLENGE_ISSUED"
9295
GENESIS_CHALLENGE_VERIFIED = "GENESIS_CHALLENGE_VERIFIED"
96+
GENESIS_WIZARD_COMPLETED = "GENESIS_WIZARD_COMPLETED"
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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

Comments
 (0)