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
23 changes: 23 additions & 0 deletions src/modulr_core/genesis/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Genesis challenge format (v1) and verify helpers."""

from modulr_core.genesis.challenge import (
CHALLENGE_PURPOSE,
CHALLENGE_TTL_SECONDS,
GENESIS_CHALLENGE_FORMAT_VERSION,
GenesisChallengeError,
GenesisChallengeService,
IssuedGenesisChallenge,
build_genesis_challenge_v1_body,
verify_genesis_challenge_signature,
)

__all__ = [
"CHALLENGE_PURPOSE",
"CHALLENGE_TTL_SECONDS",
"GENESIS_CHALLENGE_FORMAT_VERSION",
"GenesisChallengeError",
"GenesisChallengeService",
"IssuedGenesisChallenge",
"build_genesis_challenge_v1_body",
"verify_genesis_challenge_signature",
]
182 changes: 182 additions & 0 deletions src/modulr_core/genesis/challenge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Genesis challenge v1: multiline UTF-8 body, Ed25519 verify, 5-minute TTL."""

from __future__ import annotations

import re
import secrets
from collections.abc import Callable
from dataclasses import dataclass

from cryptography.exceptions import InvalidSignature
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.validation.hex_codec import InvalidHexEncoding, decode_hex_fixed

GENESIS_CHALLENGE_FORMAT_VERSION = "modulr-genesis-challenge-v1"
CHALLENGE_PURPOSE = "prove_bootstrap_operator"
CHALLENGE_TTL_SECONDS = 300

_NONCE_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
_INSTANCE_ID_RE = re.compile(
r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
re.IGNORECASE,
)


class GenesisChallengeError(Exception):
"""Invalid challenge, signature, expiry, or bootstrap state."""


@dataclass(frozen=True, slots=True)
class IssuedGenesisChallenge:
"""A newly issued challenge (store `body` and sign it with the operator key)."""

challenge_id: str
body: str
issued_at_unix: int
expires_at_unix: int


def _validate_instance_id(instance_id: str) -> None:
s = instance_id.strip()
if not s or len(s) > 128:
raise GenesisChallengeError("invalid instance_id")
if not _INSTANCE_ID_RE.match(s):
raise GenesisChallengeError("invalid instance_id")


def _validate_nonce_hex(nonce_hex: str) -> None:
if not _NONCE_HEX_RE.match(nonce_hex):
raise GenesisChallengeError("invalid challenge nonce")


def _validate_bootstrap_pubkey_hex(pubkey_hex: str) -> bytes:
try:
raw = decode_hex_fixed(pubkey_hex, byte_length=32)
except InvalidHexEncoding as e:
raise GenesisChallengeError("invalid subject_signing_pubkey_hex") from e
try:
Ed25519PublicKey.from_public_bytes(raw)
except ValueError as e:
raise GenesisChallengeError("invalid Ed25519 public key") from e
return raw


def build_genesis_challenge_v1_body(
*,
instance_id: str,
nonce_hex: str,
issued_at_unix: int,
expires_at_unix: int,
subject_signing_pubkey_hex: str,
) -> str:
"""Build the canonical multiline UTF-8 challenge body (no trailing newline).

Keymaster must sign ``body.encode("utf-8")`` with the bootstrap operator key.
"""
_validate_instance_id(instance_id)
_validate_nonce_hex(nonce_hex)
pk = subject_signing_pubkey_hex.strip().lower()
_validate_bootstrap_pubkey_hex(pk)
if issued_at_unix < 0 or expires_at_unix < 0:
raise GenesisChallengeError("invalid unix timestamps")
if expires_at_unix <= issued_at_unix:
raise GenesisChallengeError("expires_at_unix must be after issued_at_unix")

lines = [
GENESIS_CHALLENGE_FORMAT_VERSION,
f"instance_id: {instance_id.strip()}",
f"nonce: {nonce_hex}",
f"issued_at_unix: {issued_at_unix}",
f"expires_at_unix: {expires_at_unix}",
f"subject_signing_pubkey_hex: {pk}",
f"purpose: {CHALLENGE_PURPOSE}",
]
return "\n".join(lines)


def verify_genesis_challenge_signature(
*,
body: str,
signature_hex: str,
expected_subject_pubkey_hex: str,
) -> None:
"""Verify Ed25519 signature over UTF-8 bytes of ``body``."""
pk_hex = expected_subject_pubkey_hex.strip().lower()
pk_raw = _validate_bootstrap_pubkey_hex(pk_hex)
try:
sig = decode_hex_fixed(signature_hex, byte_length=64)
except InvalidHexEncoding as e:
raise GenesisChallengeError("invalid signature hex") from e
pub = Ed25519PublicKey.from_public_bytes(pk_raw)
try:
pub.verify(sig, body.encode("utf-8"))
except InvalidSignature as e:
raise GenesisChallengeError("signature verification failed") from e


class GenesisChallengeService:
"""Issue and verify one-shot genesis challenges (SQLite-backed)."""

def __init__(
self,
*,
genesis_repo: CoreGenesisRepository,
challenge_repo: GenesisChallengeRepository,
clock: Callable[[], int],
) -> None:
self._genesis = genesis_repo
self._challenges = challenge_repo
self._clock = clock

def issue(self, *, subject_signing_pubkey_hex: str) -> IssuedGenesisChallenge:
snap = self._genesis.get()
if snap.genesis_complete:
raise GenesisChallengeError("genesis already complete")
now = int(self._clock())
self._genesis.touch(updated_at=now)
instance_id = self._genesis.get_or_create_instance_id(updated_at=now)
challenge_id = secrets.token_hex(32)
expires_at = now + CHALLENGE_TTL_SECONDS
body = build_genesis_challenge_v1_body(
instance_id=instance_id,
nonce_hex=challenge_id,
issued_at_unix=now,
expires_at_unix=expires_at,
subject_signing_pubkey_hex=subject_signing_pubkey_hex,
)
self._challenges.insert(
challenge_id=challenge_id,
subject_signing_pubkey_hex=subject_signing_pubkey_hex.strip().lower(),
body=body,
issued_at=now,
expires_at=expires_at,
)
return IssuedGenesisChallenge(
challenge_id=challenge_id,
body=body,
issued_at_unix=now,
expires_at_unix=expires_at,
)

def verify_and_consume(self, *, challenge_id: str, signature_hex: str) -> None:
snap = self._genesis.get()
if snap.genesis_complete:
raise GenesisChallengeError("genesis already complete")
now = int(self._clock())
row = self._challenges.get_by_id(challenge_id)
if row is None:
raise GenesisChallengeError("unknown challenge_id")
if row.consumed_at is not None:
raise GenesisChallengeError("challenge already consumed")
if now >= row.expires_at:
raise GenesisChallengeError("challenge expired")
verify_genesis_challenge_signature(
body=row.body,
signature_hex=signature_hex,
expected_subject_pubkey_hex=row.subject_signing_pubkey_hex,
)
self._challenges.mark_consumed(challenge_id, consumed_at=now)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Map consume race failures to GenesisChallengeError

When two verification requests for the same challenge_id run concurrently, both can read consumed_at IS NULL, but only one UPDATE ... WHERE consumed_at IS NULL succeeds; the loser triggers RuntimeError("challenge not found or already consumed") from mark_consumed. Because verify_and_consume does not catch that exception, this race surfaces as an internal error instead of the expected domain-level rejection (challenge already consumed), which will produce flaky 500s under retries or duplicate submissions.

Useful? React with 👍 / 👎.

self._genesis.touch(updated_at=now)
16 changes: 16 additions & 0 deletions src/modulr_core/persistence/migrations/008_genesis_challenge.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- Stable Core instance id (UUID) for binding challenges to this deployment.
-- genesis_challenge: one-shot Ed25519 proof records (anti-replay, TTL).
PRAGMA foreign_keys = ON;

ALTER TABLE core_genesis ADD COLUMN instance_id TEXT;

CREATE TABLE IF NOT EXISTS genesis_challenge (
challenge_id TEXT PRIMARY KEY,
subject_signing_pubkey_hex TEXT NOT NULL,
body TEXT NOT NULL,
issued_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER
);

CREATE INDEX IF NOT EXISTS idx_genesis_challenge_expires ON genesis_challenge (expires_at);
2 changes: 2 additions & 0 deletions src/modulr_core/repositories/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from modulr_core.repositories.core_genesis import CoreGenesisRepository
from modulr_core.repositories.dial_route_entry import DialRouteEntryRepository
from modulr_core.repositories.genesis_challenge import GenesisChallengeRepository
from modulr_core.repositories.heartbeat import HeartbeatRepository
from modulr_core.repositories.message_dedup import MessageDedupRepository
from modulr_core.repositories.modules import ModulesRepository
Expand All @@ -10,6 +11,7 @@
__all__ = [
"CoreGenesisRepository",
"DialRouteEntryRepository",
"GenesisChallengeRepository",
"HeartbeatRepository",
"MessageDedupRepository",
"ModulesRepository",
Expand Down
54 changes: 51 additions & 3 deletions src/modulr_core/repositories/core_genesis.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
from __future__ import annotations

import sqlite3
import uuid
from dataclasses import dataclass

from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey

from modulr_core.errors.exceptions import WireValidationError
from modulr_core.validation.hex_codec import InvalidHexEncoding, decode_hex_fixed
from modulr_core.validation.names import validate_modulr_org_domain

_MODULR_APEX_DOMAIN_MAX_LEN = 253

Expand All @@ -19,6 +22,7 @@ class CoreGenesisSnapshot:
genesis_complete: bool
bootstrap_signing_pubkey_hex: str | None
modulr_apex_domain: str | None
instance_id: str | None
updated_at: int


Expand All @@ -34,17 +38,27 @@ def _validate_bootstrap_pubkey_hex(pub_hex: str) -> None:


def _validate_apex_domain(domain: str) -> str:
"""Enforce dotted DNS-style apex (``validate_modulr_org_domain``), max 253 chars."""
d = domain.strip()
if not d:
raise ValueError("modulr_apex_domain must be non-empty when set")
if len(d) > _MODULR_APEX_DOMAIN_MAX_LEN:
mx = _MODULR_APEX_DOMAIN_MAX_LEN
raise ValueError(f"modulr_apex_domain must be at most {mx} characters")
return d
try:
return validate_modulr_org_domain(d)
except WireValidationError as e:
raise ValueError(
"modulr_apex_domain must be a dotted DNS-style domain "
"(same label rules as register_org; e.g. modulr.network)",
) from e


class CoreGenesisRepository:
"""Read/update the single ``core_genesis`` row (migration ``007`` seeds it)."""
"""Read/update the single ``core_genesis`` row.

Migration ``007`` seeds the row; ``008`` adds ``instance_id``.
"""

def __init__(self, conn: sqlite3.Connection) -> None:
self._conn = conn
Expand All @@ -53,7 +67,7 @@ def get(self) -> CoreGenesisSnapshot:
cur = self._conn.execute(
"""
SELECT genesis_complete, bootstrap_signing_pubkey_hex,
modulr_apex_domain, updated_at
modulr_apex_domain, instance_id, updated_at
FROM core_genesis
WHERE singleton = 1
""",
Expand All @@ -66,13 +80,47 @@ def get(self) -> CoreGenesisSnapshot:
pk_s = str(pk) if pk is not None 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"]
iid_s = str(iid).strip() if iid is not None and str(iid).strip() else None
return CoreGenesisSnapshot(
genesis_complete=complete,
bootstrap_signing_pubkey_hex=pk_s,
modulr_apex_domain=apex_s,
instance_id=iid_s,
updated_at=int(row["updated_at"]),
)

def touch(self, *, updated_at: int) -> None:
"""Bump ``updated_at`` on the singleton row (activity / wizard progress)."""
self._conn.execute(
"""
UPDATE core_genesis SET updated_at = ? WHERE singleton = 1
""",
(updated_at,),
)

def get_or_create_instance_id(self, *, updated_at: int) -> str:
"""Return stable Core ``instance_id`` (UUID); allocate once on first use."""
cur = self._conn.execute(
"SELECT instance_id FROM core_genesis WHERE singleton = 1",
)
row = cur.fetchone()
if row is None:
raise RuntimeError("core_genesis singleton missing; run apply_migrations")
existing = row["instance_id"]
if existing is not None and str(existing).strip():
return str(existing).strip()
new_id = str(uuid.uuid4())
self._conn.execute(
"""
UPDATE core_genesis
SET instance_id = ?, updated_at = ?
WHERE singleton = 1
""",
(new_id, updated_at),
)
return new_id

def set_genesis_complete(self, *, complete: bool, updated_at: int) -> None:
self._conn.execute(
"""
Expand Down
Loading
Loading