quorum.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
| 1 | """Knowtation identity / org-quorum provider — Phase 7.7. |
| 2 | |
| 3 | Identity-record resolver and helpers for org-quorum signing in the |
| 4 | Knowtation domain. Implements :class:`muse.core.attestation.MuseIdentityProvider` |
| 5 | backed by a local JSON cache under ``<repo>/.muse/identity/<handle>.json`` |
| 6 | (falling back to ``$MUSE_IDENTITY_DIR``). In production deployments the |
| 7 | cache is hydrated by the MuseHub identity API; in tests the cache is |
| 8 | populated directly. |
| 9 | |
| 10 | Identity record schema |
| 11 | ---------------------- |
| 12 | |
| 13 | Identity records are stored as JSON files keyed by handle:: |
| 14 | |
| 15 | { |
| 16 | "handle": "@my-org", |
| 17 | "kind": "org", |
| 18 | "public_key": "", (orgs have no key) |
| 19 | "members": ["alice", "bob", "carol"], |
| 20 | "quorum_threshold": 2, |
| 21 | "spawned_by": "@root-org", (optional) |
| 22 | "revoked_at": "" (empty = active) |
| 23 | } |
| 24 | |
| 25 | For human/agent records the ``public_key`` is a base64url-encoded raw |
| 26 | Ed25519 public key (32 bytes → 43 chars no padding). ``members`` and |
| 27 | ``quorum_threshold`` MUST be absent or empty for non-org records. |
| 28 | |
| 29 | Threat model |
| 30 | ------------ |
| 31 | |
| 32 | The cycle-detection step in :func:`muse.core.verify.verify_identity_chain` |
| 33 | is **defensive in depth**. The identity domain at staging.musehub.ai |
| 34 | already rejects cycles at write time, but a stale or tampered local cache |
| 35 | could re-introduce one. The verifier walks with a depth-bounded BFS and |
| 36 | a seen-set so any cycle is reported as |
| 37 | ``IdentityChainResult.cycle_detected=True`` and the chain is marked |
| 38 | invalid — never silently terminates the walk. |
| 39 | """ |
| 40 | |
| 41 | from __future__ import annotations |
| 42 | |
| 43 | import json |
| 44 | import logging |
| 45 | import os |
| 46 | import pathlib |
| 47 | import re |
| 48 | from typing import Any |
| 49 | |
| 50 | from muse.core.attestation import ( |
| 51 | AttestationVerifyError, |
| 52 | IdentityRecord, |
| 53 | MuseIdentityProvider, |
| 54 | register_identity_provider, |
| 55 | ) |
| 56 | |
| 57 | logger = logging.getLogger(__name__) |
| 58 | |
| 59 | |
| 60 | PROVIDER_DOMAIN: str = "knowtation" |
| 61 | """Domain string this provider registers under. Same as the HMAC |
| 62 | attestation provider so a single domain owns both identity and attestation |
| 63 | concerns; the registries are separate so this is purely conventional.""" |
| 64 | |
| 65 | #: Maximum identity-record file size (bytes). 16 KiB is generous for a |
| 66 | #: handful of fields with member lists; anything larger is treated as |
| 67 | #: corrupt. |
| 68 | _MAX_RECORD_BYTES: int = 16_384 |
| 69 | |
| 70 | #: Maximum number of members allowed in a single org record. Bounds the |
| 71 | #: cost of a quorum walk and prevents DoS via a record with millions of |
| 72 | #: members. Keep loose enough to support real-world orgs (≤ 1024). |
| 73 | _MAX_MEMBERS: int = 1024 |
| 74 | |
| 75 | #: Identity handle character set. Allows alphanumerics, dots, dashes, |
| 76 | #: underscores; org handles also allow a leading ``@``. This excludes |
| 77 | #: filesystem path-traversal characters (``/``, ``\\``, ``..``, NUL). |
| 78 | _HANDLE_RE: re.Pattern[str] = re.compile(r"\A@?[a-zA-Z0-9_.\-]{1,128}\Z") |
| 79 | |
| 80 | |
| 81 | def is_valid_handle(handle: object) -> bool: |
| 82 | """Return True iff *handle* is a syntactically valid identity handle. |
| 83 | |
| 84 | Validates against :data:`_HANDLE_RE` so handles cannot contain path |
| 85 | separators, embedded NULs, or other characters that would let a |
| 86 | malicious handle escape the identity directory at lookup time. |
| 87 | |
| 88 | Additional defensive rules layered on top of the regex: |
| 89 | |
| 90 | * The body (after a leading ``@``) MUST NOT be ``.`` or ``..``. |
| 91 | * The body MUST NOT start with ``.`` (would render hidden files / |
| 92 | special FS entries). |
| 93 | * The body MUST NOT contain ``..`` as a substring (path-segment |
| 94 | traversal even when ``/`` is whitelisted out). |
| 95 | |
| 96 | Args: |
| 97 | handle: Candidate handle (any type — non-strings always return |
| 98 | ``False``). |
| 99 | |
| 100 | Returns: |
| 101 | ``True`` when the handle matches the strict character whitelist; |
| 102 | ``False`` otherwise. |
| 103 | """ |
| 104 | if not isinstance(handle, str) or not _HANDLE_RE.match(handle): |
| 105 | return False |
| 106 | body = handle[1:] if handle.startswith("@") else handle |
| 107 | if body in (".", "..") or body.startswith(".") or ".." in body: |
| 108 | return False |
| 109 | return True |
| 110 | |
| 111 | |
| 112 | def _resolve_identity_dir(repo_root: pathlib.Path | None) -> pathlib.Path: |
| 113 | """Resolve the directory holding identity-record JSON files. |
| 114 | |
| 115 | Resolution order: |
| 116 | |
| 117 | 1. ``$MUSE_IDENTITY_DIR`` environment variable (absolute path). |
| 118 | 2. ``<repo_root>/.muse/identity/`` when ``repo_root`` is supplied. |
| 119 | 3. ``~/.muse/identity/`` as the user-global default. |
| 120 | |
| 121 | Args: |
| 122 | repo_root: Optional repo root for the per-repo cache. |
| 123 | |
| 124 | Returns: |
| 125 | :class:`pathlib.Path` to the directory. Existence is NOT enforced — |
| 126 | a missing directory simply means "no identities cached". |
| 127 | """ |
| 128 | env_dir = os.environ.get("MUSE_IDENTITY_DIR", "").strip() |
| 129 | if env_dir: |
| 130 | return pathlib.Path(env_dir) |
| 131 | if repo_root is not None: |
| 132 | return repo_root / ".muse" / "identity" |
| 133 | return pathlib.Path.home() / ".muse" / "identity" |
| 134 | |
| 135 | |
| 136 | def _handle_to_filename(handle: str) -> str: |
| 137 | """Encode *handle* into a safe filename. |
| 138 | |
| 139 | Replaces the leading ``@`` with ``_at_`` so org handles round-trip on |
| 140 | case-insensitive filesystems (macOS HFS+ default) without colliding |
| 141 | with single-actor handles. |
| 142 | |
| 143 | Args: |
| 144 | handle: Validated identity handle. |
| 145 | |
| 146 | Returns: |
| 147 | Filename string (no extension); callers append ``.json``. |
| 148 | """ |
| 149 | return ("_at_" + handle[1:]) if handle.startswith("@") else handle |
| 150 | |
| 151 | |
| 152 | def _normalise_record(raw: Any, handle: str) -> IdentityRecord: |
| 153 | """Coerce a parsed JSON object into an :class:`IdentityRecord`. |
| 154 | |
| 155 | Performs strict shape validation: |
| 156 | |
| 157 | * Top-level must be a dict. |
| 158 | * ``handle`` (when present) must equal the lookup handle exactly — |
| 159 | mismatches raise to surface tampering of the JSON filename ↔ content |
| 160 | relationship. |
| 161 | * ``kind`` must be one of ``human|agent|org``. |
| 162 | * ``members`` must be a list of valid handles, length ≤ ``_MAX_MEMBERS``. |
| 163 | * ``quorum_threshold`` must be an int in ``[1, len(members)]`` for |
| 164 | org records; absent/zero for non-org. |
| 165 | |
| 166 | Args: |
| 167 | raw: Parsed JSON value from the identity file. |
| 168 | handle: The handle that was looked up (used to cross-check the |
| 169 | ``handle`` field on the parsed object). |
| 170 | |
| 171 | Returns: |
| 172 | A typed :class:`IdentityRecord` with all consumed fields populated. |
| 173 | |
| 174 | Raises: |
| 175 | AttestationVerifyError: For any shape violation. Distinct from |
| 176 | "record not found" (which returns ``None`` to the caller of |
| 177 | :meth:`KnowtationFsIdentityProvider.get_identity`). |
| 178 | """ |
| 179 | if not isinstance(raw, dict): |
| 180 | raise AttestationVerifyError( |
| 181 | f"identity record for {handle!r} is not a JSON object", |
| 182 | code="MALFORMED_IDENTITY", |
| 183 | ) |
| 184 | |
| 185 | rec_handle = raw.get("handle", handle) |
| 186 | if rec_handle != handle: |
| 187 | raise AttestationVerifyError( |
| 188 | f"identity file content handle {rec_handle!r} does not match " |
| 189 | f"lookup handle {handle!r} — possible tampering", |
| 190 | code="HANDLE_MISMATCH", |
| 191 | ) |
| 192 | |
| 193 | kind = raw.get("kind", "") |
| 194 | if kind not in ("human", "agent", "org"): |
| 195 | raise AttestationVerifyError( |
| 196 | f"identity {handle!r}: kind must be 'human'|'agent'|'org' " |
| 197 | f"(got {kind!r})", |
| 198 | code="INVALID_KIND", |
| 199 | ) |
| 200 | |
| 201 | record: IdentityRecord = { |
| 202 | "handle": handle, |
| 203 | "kind": kind, |
| 204 | "public_key": str(raw.get("public_key", "") or ""), |
| 205 | "members": [], |
| 206 | "quorum_threshold": 0, |
| 207 | "spawned_by": str(raw.get("spawned_by", "") or ""), |
| 208 | "revoked_at": str(raw.get("revoked_at", "") or ""), |
| 209 | } |
| 210 | |
| 211 | if kind == "org": |
| 212 | members = raw.get("members", []) |
| 213 | if not isinstance(members, list): |
| 214 | raise AttestationVerifyError( |
| 215 | f"org {handle!r}: members must be a list", |
| 216 | code="MALFORMED_IDENTITY", |
| 217 | ) |
| 218 | if len(members) > _MAX_MEMBERS: |
| 219 | raise AttestationVerifyError( |
| 220 | f"org {handle!r}: members list exceeds {_MAX_MEMBERS} entries", |
| 221 | code="MEMBERS_TOO_LARGE", |
| 222 | ) |
| 223 | for m in members: |
| 224 | if not is_valid_handle(m): |
| 225 | raise AttestationVerifyError( |
| 226 | f"org {handle!r}: member {m!r} is not a valid handle", |
| 227 | code="MALFORMED_IDENTITY", |
| 228 | ) |
| 229 | record["members"] = list(members) |
| 230 | |
| 231 | threshold = raw.get("quorum_threshold", 0) |
| 232 | if not isinstance(threshold, int) or threshold < 1: |
| 233 | raise AttestationVerifyError( |
| 234 | f"org {handle!r}: quorum_threshold must be an int >= 1 " |
| 235 | f"(got {threshold!r})", |
| 236 | code="INVALID_THRESHOLD", |
| 237 | ) |
| 238 | if threshold > len(members): |
| 239 | raise AttestationVerifyError( |
| 240 | f"org {handle!r}: quorum_threshold {threshold} exceeds " |
| 241 | f"member count {len(members)}", |
| 242 | code="INVALID_THRESHOLD", |
| 243 | ) |
| 244 | record["quorum_threshold"] = int(threshold) |
| 245 | |
| 246 | return record |
| 247 | |
| 248 | |
| 249 | class KnowtationFsIdentityProvider: |
| 250 | """Filesystem-backed identity provider for the Knowtation domain. |
| 251 | |
| 252 | Reads identity records from JSON files under |
| 253 | :func:`_resolve_identity_dir`. Suitable for both local development |
| 254 | (records hand-written for tests) and production deployments where a |
| 255 | sync daemon hydrates the cache from MuseHub's identity API. |
| 256 | |
| 257 | Thread-safety: stateless — each :meth:`get_identity` call performs an |
| 258 | independent file-system read. Multiple threads can call concurrently |
| 259 | without coordination. |
| 260 | |
| 261 | Attributes: |
| 262 | repo_root: Optional repo root used by :func:`_resolve_identity_dir` |
| 263 | when ``$MUSE_IDENTITY_DIR`` is unset. ``None`` falls back to |
| 264 | the user-global directory. |
| 265 | """ |
| 266 | |
| 267 | repo_root: pathlib.Path | None |
| 268 | |
| 269 | def __init__(self, repo_root: pathlib.Path | None = None) -> None: |
| 270 | """Construct a provider rooted at *repo_root* (or user-global). |
| 271 | |
| 272 | Args: |
| 273 | repo_root: Optional path to the repo whose ``.muse/identity/`` |
| 274 | directory should be searched first. When ``None``, only |
| 275 | ``$MUSE_IDENTITY_DIR`` and ``~/.muse/identity/`` are |
| 276 | consulted. |
| 277 | """ |
| 278 | self.repo_root = repo_root |
| 279 | |
| 280 | def get_identity(self, handle: str) -> IdentityRecord | None: |
| 281 | """Read the identity record for *handle*, or return ``None``. |
| 282 | |
| 283 | Walks ``<repo_root>/.muse/identity/<file>.json`` first then falls |
| 284 | back to ``$MUSE_IDENTITY_DIR``/``~/.muse/identity/<file>.json``. |
| 285 | |
| 286 | Args: |
| 287 | handle: Identity handle to resolve. |
| 288 | |
| 289 | Returns: |
| 290 | :class:`IdentityRecord` when the file exists and parses; ``None`` |
| 291 | when no record was found in any of the searched directories. |
| 292 | |
| 293 | Raises: |
| 294 | AttestationVerifyError: For malformed JSON, oversized files, or |
| 295 | shape violations. Distinct from "not found" (returns |
| 296 | ``None``). Callers must surface as ``partial=True``. |
| 297 | """ |
| 298 | if not is_valid_handle(handle): |
| 299 | raise AttestationVerifyError( |
| 300 | f"identity handle {handle!r} fails strict validation; " |
| 301 | f"rejecting to prevent path traversal", |
| 302 | code="INVALID_HANDLE", |
| 303 | ) |
| 304 | |
| 305 | filename = _handle_to_filename(handle) + ".json" |
| 306 | candidates: list[pathlib.Path] = [] |
| 307 | if self.repo_root is not None: |
| 308 | candidates.append(self.repo_root / ".muse" / "identity" / filename) |
| 309 | candidates.append(_resolve_identity_dir(self.repo_root) / filename) |
| 310 | |
| 311 | for path in candidates: |
| 312 | try: |
| 313 | if not path.is_file(): |
| 314 | continue |
| 315 | size = path.stat().st_size |
| 316 | if size > _MAX_RECORD_BYTES: |
| 317 | raise AttestationVerifyError( |
| 318 | f"identity record {path} exceeds {_MAX_RECORD_BYTES}" |
| 319 | f" bytes ({size}); refusing to read", |
| 320 | code="RECORD_TOO_LARGE", |
| 321 | ) |
| 322 | raw_bytes = path.read_bytes() |
| 323 | except OSError as exc: |
| 324 | raise AttestationVerifyError( |
| 325 | f"cannot read identity record {path}: {exc}", |
| 326 | code="IO_ERROR", |
| 327 | ) from exc |
| 328 | try: |
| 329 | parsed = json.loads(raw_bytes.decode("utf-8")) |
| 330 | except (UnicodeDecodeError, json.JSONDecodeError) as exc: |
| 331 | raise AttestationVerifyError( |
| 332 | f"identity record {path} is not valid JSON: {exc}", |
| 333 | code="MALFORMED_IDENTITY", |
| 334 | ) from exc |
| 335 | return _normalise_record(parsed, handle) |
| 336 | |
| 337 | return None |
| 338 | |
| 339 | |
| 340 | def register_knowtation_identity_provider( |
| 341 | repo_root: pathlib.Path | None = None, |
| 342 | ) -> KnowtationFsIdentityProvider: |
| 343 | """Construct + register a :class:`KnowtationFsIdentityProvider`. |
| 344 | |
| 345 | Idempotent — a subsequent call replaces the prior registration with a |
| 346 | new instance scoped to *repo_root*. |
| 347 | |
| 348 | Args: |
| 349 | repo_root: Optional repo root for per-repo identity caches. |
| 350 | |
| 351 | Returns: |
| 352 | The newly-registered provider instance. |
| 353 | """ |
| 354 | provider = KnowtationFsIdentityProvider(repo_root=repo_root) |
| 355 | register_identity_provider(PROVIDER_DOMAIN, provider) |
| 356 | return provider |
| 357 | |
| 358 | |
| 359 | # --------------------------------------------------------------------------- |
| 360 | # Quorum metadata helpers — used by both the commit and verify code paths |
| 361 | # --------------------------------------------------------------------------- |
| 362 | |
| 363 | |
| 364 | def build_quorum_metadata( |
| 365 | org_handle: str, |
| 366 | threshold: int, |
| 367 | signers: list[dict[str, str]], |
| 368 | ) -> dict[str, object]: |
| 369 | """Build the canonical ``commit.metadata['quorum']`` value. |
| 370 | |
| 371 | The shape is fixed across producers and verifiers so that the JSON |
| 372 | serialisation is deterministic — important because the canonical |
| 373 | metadata bytes are hashed into the parent commit graph downstream. |
| 374 | |
| 375 | Args: |
| 376 | org_handle: Handle of the org commit was made on behalf of. MUST |
| 377 | start with ``@`` (validated). |
| 378 | threshold: Quorum threshold copied from the org's identity record |
| 379 | at sign time — surfaced for audit trails (the verifier |
| 380 | re-fetches the live threshold so a stripped-down threshold |
| 381 | here cannot bypass quorum). |
| 382 | signers: List of signer dicts. Each dict MUST have ``handle``, |
| 383 | ``public_key`` (base64url no padding), and ``signature`` |
| 384 | (base64url no padding). |
| 385 | |
| 386 | Returns: |
| 387 | A dict ready to JSON-serialise into ``commit.metadata["quorum"]``. |
| 388 | |
| 389 | Raises: |
| 390 | ValueError: For invalid org handle, non-positive threshold, or |
| 391 | malformed signer entries. |
| 392 | """ |
| 393 | if not is_valid_handle(org_handle) or not org_handle.startswith("@"): |
| 394 | raise ValueError(f"org_handle must start with '@': {org_handle!r}") |
| 395 | if not isinstance(threshold, int) or threshold < 1: |
| 396 | raise ValueError(f"threshold must be a positive int (got {threshold!r})") |
| 397 | if not isinstance(signers, list) or not signers: |
| 398 | raise ValueError("signers must be a non-empty list") |
| 399 | |
| 400 | cleaned: list[dict[str, str]] = [] |
| 401 | seen: set[str] = set() |
| 402 | for s in signers: |
| 403 | if not isinstance(s, dict): |
| 404 | raise ValueError("each signer entry must be a dict") |
| 405 | h = s.get("handle", "") |
| 406 | pk = s.get("public_key", "") |
| 407 | sig = s.get("signature", "") |
| 408 | if not is_valid_handle(h): |
| 409 | raise ValueError(f"signer handle {h!r} is not valid") |
| 410 | if h in seen: |
| 411 | raise ValueError( |
| 412 | f"signer handle {h!r} appears more than once — " |
| 413 | "duplicate signatures cannot count toward quorum" |
| 414 | ) |
| 415 | seen.add(h) |
| 416 | if not isinstance(pk, str) or not pk: |
| 417 | raise ValueError(f"signer {h!r}: public_key must be a non-empty string") |
| 418 | if not isinstance(sig, str) or not sig: |
| 419 | raise ValueError(f"signer {h!r}: signature must be a non-empty string") |
| 420 | cleaned.append({"handle": h, "public_key": pk, "signature": sig}) |
| 421 | |
| 422 | return { |
| 423 | "org_handle": org_handle, |
| 424 | "threshold": int(threshold), |
| 425 | "signers": cleaned, |
| 426 | } |
| 427 | |
| 428 | |
| 429 | __all__ = [ |
| 430 | "KnowtationFsIdentityProvider", |
| 431 | "PROVIDER_DOMAIN", |
| 432 | "build_quorum_metadata", |
| 433 | "is_valid_handle", |
| 434 | "register_knowtation_identity_provider", |
| 435 | ] |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
34 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
34 days ago