quorum.py python
435 lines 16.0 KB
Raw
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