attestation.py
python
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
| 1 | """Generic attestation provider framework — Phase 7.1. |
| 2 | |
| 3 | A small extension point that lets domain plugins anchor commit provenance |
| 4 | in *external trust roots* — HMAC AIR endpoints, blockchain canisters, TSA |
| 5 | timestamping authorities, etc. — without coupling the Muse engine to any |
| 6 | specific provider implementation. |
| 7 | |
| 8 | The first consumer is the Knowtation domain (see |
| 9 | :mod:`muse.plugins.knowtation.attestation`), which implements an HMAC AIR |
| 10 | provider that is functionally equivalent to the JS ``knowtation/lib/air.mjs`` |
| 11 | module. The second is the ICP push hook (Phase 7.4) that anchors the |
| 12 | attestation record on the live Knowtation attestation canister |
| 13 | (``dejku-syaaa-aaaaa-qgy3q-cai``). |
| 14 | |
| 15 | Design contract — security-critical |
| 16 | ----------------------------------- |
| 17 | |
| 18 | 1. **Misuse-resistance: partial verifications are hard errors.** |
| 19 | :meth:`MuseAttestationProvider.verify` MUST raise |
| 20 | :class:`AttestationVerifyError` whenever it cannot complete the verification |
| 21 | chain (e.g. ICP unreachable, signature parse failure, missing key material). |
| 22 | Returning ``VerifyResult(valid=False)`` is reserved for the case of a |
| 23 | *known-bad* record where the verifier was able to complete the check. |
| 24 | Silent ``True`` returns are *never* permitted. |
| 25 | |
| 26 | 2. **Determinism.** :meth:`MuseAttestationProvider.compute_attestation` MUST |
| 27 | be deterministic for the same ``(snapshot_id, commit_meta)`` pair — no |
| 28 | random salts in the interface, no wall-clock timestamps in the ID. This |
| 29 | enables idempotent re-attestation: replaying a commit on a fresh node |
| 30 | produces the same attestation ID, so the canister will reject the second |
| 31 | write with ``"already exists"`` and the system stays in a consistent state. |
| 32 | |
| 33 | 3. **Concurrency safety.** :meth:`MuseAttestationProvider.anchor` and |
| 34 | :meth:`MuseAttestationProvider.verify` MUST be safe to call from |
| 35 | concurrent threads without sharing mutable state. The registry itself is |
| 36 | serialised by an internal :class:`threading.Lock` so concurrent |
| 37 | registrations cannot lose entries. |
| 38 | |
| 39 | 4. **Write-once receipts.** :class:`AnchorReceipt.tx_id` is write-once after |
| 40 | :meth:`MuseAttestationProvider.anchor` returns. This matches the live |
| 41 | ICP canister design — the attestation ledger is immutable and append-only; |
| 42 | re-anchoring the same record raises |
| 43 | :class:`ImmutableReceiptError` rather than silently mutating state. |
| 44 | |
| 45 | 5. **No silent exception swallowing.** Errors at the framework layer are |
| 46 | propagated or wrapped in typed errors (:class:`AttestationError` and |
| 47 | subclasses). Provider implementations are free to convert their |
| 48 | transport-level errors into these typed errors but MUST NOT log-and-ignore. |
| 49 | |
| 50 | Threat model documented inline |
| 51 | ------------------------------ |
| 52 | |
| 53 | * **Replay across branches** — :func:`muse.core.provenance.provenance_payload` |
| 54 | binds the commit_id, so the same payload cannot be re-used for a different |
| 55 | commit. Different commits have different commit_ids by construction. |
| 56 | * **Anchor forgery** — the canister's ``storeAttestation`` requires an |
| 57 | authorised principal; unauthorised calls are rejected at the canister |
| 58 | boundary. The :meth:`MuseAttestationProvider.verify` method MUST cross-check |
| 59 | the receipt against the canister's HTTP query interface to rule out |
| 60 | spoofed receipts. |
| 61 | * **Identity-chain depth manipulation** — :class:`VerifyResult.depth` records |
| 62 | how many identity-record hops were validated during verification (e.g. for |
| 63 | org commits with quorum signing — Phase 7.7). A low depth on a high-stakes |
| 64 | commit is an audit signal but not a verification failure on its own. |
| 65 | """ |
| 66 | |
| 67 | from __future__ import annotations |
| 68 | |
| 69 | import threading |
| 70 | from typing import Protocol, TypedDict, runtime_checkable |
| 71 | |
| 72 | |
| 73 | # --------------------------------------------------------------------------- |
| 74 | # Typed records — flat TypedDicts are JSON-serialisable for storage in |
| 75 | # CommitRecord.metadata and for transport over the MCP/CLI boundary. |
| 76 | # --------------------------------------------------------------------------- |
| 77 | |
| 78 | |
| 79 | class AttestationRecord(TypedDict, total=False): |
| 80 | """A single attestation record produced by a provider. |
| 81 | |
| 82 | Stored under ``commit.metadata["attestation"]`` after a successful |
| 83 | ``muse commit --attest``. All fields are strings to keep the record |
| 84 | JSON-clean and forward-compatible across provider implementations. |
| 85 | |
| 86 | Attributes: |
| 87 | id: Globally-unique attestation identifier — typically |
| 88 | ``SHA-256(snapshot_id + "\\x00" + path)`` so the ID is deterministic |
| 89 | and content-addressed. Must be stable across provider invocations |
| 90 | with the same inputs. |
| 91 | action: The action being attested (``"write"``, ``"export"``, |
| 92 | ``"merge"``, …). Provider-defined; the Knowtation HMAC provider |
| 93 | uses the same vocabulary as ``knowtation/lib/air.mjs``. |
| 94 | path: The vault-relative or repo-relative path being attested. |
| 95 | Empty string when the attestation is repo-scoped rather than |
| 96 | file-scoped. |
| 97 | timestamp: ISO-8601 UTC timestamp of attestation creation. Bound |
| 98 | into the signature so back-dating cannot alter verification. |
| 99 | content_hash: SHA-256 hex of the content being attested. Lower-case, |
| 100 | 64 hex characters. |
| 101 | sig: Provider-specific signature/MAC over the canonical record bytes. |
| 102 | Format and verification semantics are owned by the provider. |
| 103 | algorithm: Stable identifier for the signature algorithm |
| 104 | (e.g. ``"hmac-sha256"``, ``"ed25519"``). Required so verifiers |
| 105 | can reject records using a deprecated algorithm. |
| 106 | provider_id: The domain string the provider is registered under |
| 107 | (e.g. ``"knowtation"``). Required so verifiers can dispatch |
| 108 | to the correct provider on disconnected systems. |
| 109 | """ |
| 110 | |
| 111 | id: str |
| 112 | action: str |
| 113 | path: str |
| 114 | timestamp: str |
| 115 | content_hash: str |
| 116 | sig: str |
| 117 | algorithm: str |
| 118 | provider_id: str |
| 119 | |
| 120 | |
| 121 | class AnchorReceipt(TypedDict, total=False): |
| 122 | """Receipt returned by :meth:`MuseAttestationProvider.anchor`. |
| 123 | |
| 124 | Embeds the off-chain transaction identifier and a public anchor URL that |
| 125 | third parties can use to independently verify the record without contacting |
| 126 | the original signer. |
| 127 | |
| 128 | Attributes: |
| 129 | tx_id: Transaction identifier from the anchor (e.g. ICP canister seq |
| 130 | number, blockchain tx hash). Write-once: re-anchoring the same |
| 131 | record MUST raise :class:`ImmutableReceiptError`, not produce a |
| 132 | second receipt. |
| 133 | anchor_url: HTTPS URL where the record can be independently fetched |
| 134 | (e.g. ``https://<canister_id>.ic0.app/attest/<id>``). MUST be |
| 135 | HTTPS — verifiers MUST reject non-HTTPS anchor URLs. |
| 136 | anchored_at: ISO-8601 UTC timestamp of when the anchor was written. |
| 137 | For audit trails; not bound into the signature. |
| 138 | provider_id: Same domain string as the corresponding |
| 139 | :class:`AttestationRecord.provider_id`. Required because a |
| 140 | single repo may hold receipts from multiple providers. |
| 141 | """ |
| 142 | |
| 143 | tx_id: str |
| 144 | anchor_url: str |
| 145 | anchored_at: str |
| 146 | provider_id: str |
| 147 | |
| 148 | |
| 149 | class VerifyResult(TypedDict, total=False): |
| 150 | """Result of a complete verification check by a provider. |
| 151 | |
| 152 | A complete verification check is one where the provider was able to |
| 153 | validate every link in the chain (signature, anchor receipt, identity |
| 154 | record). When the provider cannot complete the chain — for any reason |
| 155 | that is not "the record is known-bad" — it MUST raise |
| 156 | :class:`AttestationVerifyError` instead of returning this dict. |
| 157 | |
| 158 | Attributes: |
| 159 | valid: ``True`` iff every link was validated and matched. Any value |
| 160 | of ``False`` means the provider proved the record bad — it does |
| 161 | *not* mean "could not check". ``False``-results MUST be |
| 162 | accompanied by a non-empty :attr:`reason`. |
| 163 | reason: Human-readable diagnostic. Empty when ``valid=True``. When |
| 164 | ``valid=False``, must explain *which* link broke (e.g. |
| 165 | ``"hmac signature mismatch"``, ``"anchor URL resolves to |
| 166 | different content_hash"``). Logged in audit trails. |
| 167 | depth: Identity-chain depth validated. ``0`` for a single-signer |
| 168 | commit, ``N`` for an N-deep org-quorum commit. This is an audit |
| 169 | signal, not a flow-control flag. |
| 170 | provider_id: Same domain string used to register the provider. |
| 171 | """ |
| 172 | |
| 173 | valid: bool |
| 174 | reason: str |
| 175 | depth: int |
| 176 | provider_id: str |
| 177 | |
| 178 | |
| 179 | # --------------------------------------------------------------------------- |
| 180 | # Typed error hierarchy — verify() must raise these, never silent True. |
| 181 | # --------------------------------------------------------------------------- |
| 182 | |
| 183 | |
| 184 | class AttestationError(Exception): |
| 185 | """Base exception for the attestation framework. |
| 186 | |
| 187 | All framework- and provider-level errors derive from this class so callers |
| 188 | can catch the entire family with one ``except AttestationError`` handler. |
| 189 | """ |
| 190 | |
| 191 | |
| 192 | class AttestationRequiredError(AttestationError): |
| 193 | """Raised when ``air.required=True`` and attestation could not be obtained. |
| 194 | |
| 195 | Functionally equivalent to the JS ``AttestationRequiredError`` defined in |
| 196 | ``knowtation/lib/air.mjs``. Callers (typically the ``muse commit --attest`` |
| 197 | code path) MUST surface this as a hard write rejection — the commit must |
| 198 | not be persisted to the ref. |
| 199 | |
| 200 | Attributes: |
| 201 | code: Stable error code string ``"ATTESTATION_REQUIRED"``. Used by |
| 202 | CLI/MCP layers to map back to a structured error result without |
| 203 | string-matching the message. |
| 204 | """ |
| 205 | |
| 206 | code: str = "ATTESTATION_REQUIRED" |
| 207 | |
| 208 | |
| 209 | class AttestationVerifyError(AttestationError): |
| 210 | """Raised when :meth:`MuseAttestationProvider.verify` cannot complete. |
| 211 | |
| 212 | Distinct from a returned ``VerifyResult(valid=False)``: this exception |
| 213 | means the verifier was unable to *prove* the record valid or invalid — |
| 214 | e.g. the canister was unreachable, the signature could not be parsed, |
| 215 | a required key was missing. Callers MUST treat this as |
| 216 | "verification status unknown", never as "valid". |
| 217 | |
| 218 | Attributes: |
| 219 | code: Stable error code string for programmatic handling. Defaults |
| 220 | to ``"VERIFY_INCOMPLETE"`` but providers may set a more specific |
| 221 | code (e.g. ``"ICP_UNREACHABLE"``, ``"KEY_MISSING"``). |
| 222 | """ |
| 223 | |
| 224 | code: str = "VERIFY_INCOMPLETE" |
| 225 | |
| 226 | def __init__(self, message: str, *, code: str | None = None) -> None: |
| 227 | """Construct a verify error with an optional override code. |
| 228 | |
| 229 | Args: |
| 230 | message: Human-readable description of the incomplete verification. |
| 231 | code: Optional override for the default ``"VERIFY_INCOMPLETE"`` |
| 232 | code. Should be a short uppercase token (e.g. |
| 233 | ``"ICP_UNREACHABLE"``). |
| 234 | """ |
| 235 | super().__init__(message) |
| 236 | if code is not None: |
| 237 | self.code = code |
| 238 | |
| 239 | |
| 240 | class ImmutableReceiptError(AttestationError): |
| 241 | """Raised when re-anchoring a record that already has a receipt. |
| 242 | |
| 243 | Receipts are write-once by contract — see the canister's |
| 244 | ``storeAttestation`` which rejects duplicate IDs with |
| 245 | ``#err("Attestation X already exists (records are immutable)")``. |
| 246 | The Python framework mirrors that semantics so application code sees the |
| 247 | same error regardless of where it was raised. |
| 248 | |
| 249 | Attributes: |
| 250 | code: Stable error code string ``"IMMUTABLE_RECEIPT"``. |
| 251 | """ |
| 252 | |
| 253 | code: str = "IMMUTABLE_RECEIPT" |
| 254 | |
| 255 | |
| 256 | # --------------------------------------------------------------------------- |
| 257 | # Provider protocol — runtime_checkable so isinstance() works for tests. |
| 258 | # --------------------------------------------------------------------------- |
| 259 | |
| 260 | |
| 261 | @runtime_checkable |
| 262 | class MuseAttestationProvider(Protocol): |
| 263 | """Structural type for an attestation provider. |
| 264 | |
| 265 | A provider is any object that exposes the three methods below. Domain |
| 266 | plugins typically expose a class implementing this protocol in |
| 267 | ``muse/plugins/<domain>/attestation.py`` and register a singleton via |
| 268 | :func:`register_attestation_provider`. |
| 269 | |
| 270 | Implementations MUST: |
| 271 | |
| 272 | * Be safe to call from any thread (no shared mutable state between |
| 273 | :meth:`anchor` and :meth:`verify`). |
| 274 | * Make :meth:`compute_attestation` deterministic for the same input. |
| 275 | * Raise :class:`AttestationVerifyError` from :meth:`verify` whenever |
| 276 | verification cannot be completed; never return ``valid=True`` on |
| 277 | partial-verification. |
| 278 | * Raise :class:`ImmutableReceiptError` when :meth:`anchor` is called |
| 279 | with a record whose ID is already anchored. |
| 280 | """ |
| 281 | |
| 282 | def compute_attestation( |
| 283 | self, |
| 284 | snapshot_id: str, |
| 285 | commit_meta: dict[str, object], |
| 286 | ) -> AttestationRecord: |
| 287 | """Compute a deterministic attestation record for a commit. |
| 288 | |
| 289 | Called from ``muse commit --attest`` after the commit object has been |
| 290 | constructed but before it is written to ``.muse/refs/heads/<branch>``. |
| 291 | |
| 292 | Args: |
| 293 | snapshot_id: SHA-256 hex of the snapshot being attested. Bound |
| 294 | into the deterministic record ID so the same snapshot always |
| 295 | produces the same attestation. |
| 296 | commit_meta: A free-form dict of commit metadata. Provider |
| 297 | implementations should treat it as opaque except for keys |
| 298 | they explicitly document (e.g. the Knowtation HMAC provider |
| 299 | reads ``"path"`` and ``"action"``). |
| 300 | |
| 301 | Returns: |
| 302 | A :class:`AttestationRecord` with all required fields set. The |
| 303 | record is *not* yet anchored — call :meth:`anchor` to write it |
| 304 | to the external trust root. |
| 305 | |
| 306 | Raises: |
| 307 | AttestationRequiredError: If a required configuration field is |
| 308 | missing and the provider's policy requires attestation. |
| 309 | """ |
| 310 | ... |
| 311 | |
| 312 | def anchor(self, record: AttestationRecord) -> AnchorReceipt: |
| 313 | """Anchor *record* in the provider's external trust root. |
| 314 | |
| 315 | Typically calls a network endpoint (HTTP POST to AIR daemon, ICP |
| 316 | canister update call, blockchain transaction). May spawn a background |
| 317 | thread for long-running anchors, in which case the returned receipt's |
| 318 | ``tx_id`` may be a placeholder until the background work completes — |
| 319 | see :class:`AnchorReceipt` semantics in each provider's docstring. |
| 320 | |
| 321 | Args: |
| 322 | record: A :class:`AttestationRecord` from |
| 323 | :meth:`compute_attestation`. Must include all required |
| 324 | fields; providers MAY validate this and raise |
| 325 | :class:`AttestationError` on malformed input. |
| 326 | |
| 327 | Returns: |
| 328 | An :class:`AnchorReceipt` containing the off-chain transaction |
| 329 | ID and a public anchor URL. |
| 330 | |
| 331 | Raises: |
| 332 | ImmutableReceiptError: If a receipt already exists for |
| 333 | ``record["id"]`` (write-once semantics). |
| 334 | AttestationError: For any other anchoring failure. |
| 335 | """ |
| 336 | ... |
| 337 | |
| 338 | def verify( |
| 339 | self, |
| 340 | record: AttestationRecord, |
| 341 | receipt: AnchorReceipt, |
| 342 | ) -> VerifyResult: |
| 343 | """Verify that *record* and *receipt* are valid and consistent. |
| 344 | |
| 345 | Performs the full verification chain: |
| 346 | |
| 347 | 1. Re-compute the canonical bytes of *record* and check the signature |
| 348 | against the provider's known signing key. |
| 349 | 2. Fetch the anchored copy from ``receipt["anchor_url"]`` and check |
| 350 | that it matches *record* (same ID, same content_hash, same path). |
| 351 | 3. (Org commits — Phase 7.7) Walk the identity chain and validate |
| 352 | each member's signature; record the depth in the result. |
| 353 | |
| 354 | Args: |
| 355 | record: The :class:`AttestationRecord` to verify. |
| 356 | receipt: The :class:`AnchorReceipt` returned by :meth:`anchor`. |
| 357 | |
| 358 | Returns: |
| 359 | A :class:`VerifyResult` with ``valid=True`` iff every link in |
| 360 | the chain validated. ``valid=False`` iff the verifier proved |
| 361 | the record bad — never returned for "could not check". |
| 362 | |
| 363 | Raises: |
| 364 | AttestationVerifyError: If the verifier cannot complete the |
| 365 | check (network unreachable, key missing, malformed record, |
| 366 | etc.). Distinct from a returned ``valid=False``, which |
| 367 | means the verifier *proved* the record bad. |
| 368 | """ |
| 369 | ... |
| 370 | |
| 371 | |
| 372 | # --------------------------------------------------------------------------- |
| 373 | # Registry — same threading pattern as muse/core/merge_hooks.py |
| 374 | # --------------------------------------------------------------------------- |
| 375 | |
| 376 | |
| 377 | _REGISTRY_LOCK: threading.Lock = threading.Lock() |
| 378 | _PROVIDERS: dict[str, MuseAttestationProvider] = {} |
| 379 | |
| 380 | |
| 381 | def register_attestation_provider( |
| 382 | domain: str, |
| 383 | provider: MuseAttestationProvider, |
| 384 | ) -> None: |
| 385 | """Register *provider* under *domain* in the process-wide registry. |
| 386 | |
| 387 | Idempotent — calling this function more than once with the same domain |
| 388 | replaces the previous registration. Safe to call eagerly at import time |
| 389 | (e.g. from ``muse/plugins/<domain>/__init__.py``) as well as lazily on |
| 390 | first need. |
| 391 | |
| 392 | Concurrency: |
| 393 | Serialised by a module-level :class:`threading.Lock` so concurrent |
| 394 | registrations from different threads cannot lose entries. |
| 395 | |
| 396 | Domain isolation: |
| 397 | Domains are isolated — :func:`get_attestation_provider` for domain A |
| 398 | will never return the provider registered for domain B, even if |
| 399 | domain A has no registered provider. This guarantees that a |
| 400 | misconfigured commit cannot accidentally invoke a provider for an |
| 401 | unrelated domain. |
| 402 | |
| 403 | Args: |
| 404 | domain: Non-empty domain name string (e.g. ``"knowtation"``). |
| 405 | Case-sensitive. |
| 406 | provider: An object implementing the :class:`MuseAttestationProvider` |
| 407 | protocol. Validated structurally via :func:`isinstance` against |
| 408 | the runtime-checkable protocol — duck-typing accepted because |
| 409 | only the three public methods are ever called. |
| 410 | |
| 411 | Raises: |
| 412 | ValueError: If *domain* is empty or not a string. |
| 413 | TypeError: If *provider* does not conform to |
| 414 | :class:`MuseAttestationProvider`. |
| 415 | """ |
| 416 | if not isinstance(domain, str) or not domain: |
| 417 | raise ValueError("domain must be a non-empty string") |
| 418 | if not isinstance(provider, MuseAttestationProvider): |
| 419 | raise TypeError( |
| 420 | f"provider must implement MuseAttestationProvider " |
| 421 | f"(got {type(provider).__name__})" |
| 422 | ) |
| 423 | with _REGISTRY_LOCK: |
| 424 | _PROVIDERS[domain] = provider |
| 425 | |
| 426 | |
| 427 | def get_attestation_provider(domain: str) -> MuseAttestationProvider | None: |
| 428 | """Return the registered provider for *domain* or ``None``. |
| 429 | |
| 430 | Never raises. When *domain* has no registered provider, returns ``None`` |
| 431 | so the caller can decide whether to treat absence as a soft skip |
| 432 | (``muse commit --attest`` when no provider is registered) or a hard |
| 433 | failure (``muse verify-commit --attest --required``). |
| 434 | |
| 435 | Args: |
| 436 | domain: Domain name string. |
| 437 | |
| 438 | Returns: |
| 439 | The :class:`MuseAttestationProvider` registered for *domain*, or |
| 440 | ``None`` when no provider has been registered. Domain isolation is |
| 441 | enforced — this function never falls back to a provider registered |
| 442 | under a different domain. |
| 443 | """ |
| 444 | with _REGISTRY_LOCK: |
| 445 | return _PROVIDERS.get(domain) |
| 446 | |
| 447 | |
| 448 | def unregister_attestation_provider(domain: str) -> bool: |
| 449 | """Remove the provider registered under *domain*, if any. |
| 450 | |
| 451 | Useful in tests that need to restore a clean registry state between |
| 452 | cases. Production code should not need to call this. |
| 453 | |
| 454 | Args: |
| 455 | domain: Domain name string. |
| 456 | |
| 457 | Returns: |
| 458 | ``True`` if a provider was removed, ``False`` when no provider was |
| 459 | registered for *domain*. |
| 460 | """ |
| 461 | with _REGISTRY_LOCK: |
| 462 | return _PROVIDERS.pop(domain, None) is not None |
| 463 | |
| 464 | |
| 465 | def _registered_domains() -> list[str]: |
| 466 | """Return a snapshot of registered domains, sorted for determinism. |
| 467 | |
| 468 | Internal helper for tests and diagnostic CLIs. Not part of the public |
| 469 | API — production callers should use :func:`get_attestation_provider` |
| 470 | to look up by name. |
| 471 | """ |
| 472 | with _REGISTRY_LOCK: |
| 473 | return sorted(_PROVIDERS.keys()) |
| 474 | |
| 475 | |
| 476 | # --------------------------------------------------------------------------- |
| 477 | # Phase 7.7 — Identity / quorum types and provider Protocol |
| 478 | # --------------------------------------------------------------------------- |
| 479 | |
| 480 | |
| 481 | class IdentityRecord(TypedDict, total=False): |
| 482 | """A single identity record from the identity domain. |
| 483 | |
| 484 | Identity records describe agents, humans, and orgs. Org records carry a |
| 485 | ``quorum_threshold`` and a list of member ``handles`` so that downstream |
| 486 | quorum verification can fan out and check member signatures. |
| 487 | |
| 488 | Attributes: |
| 489 | handle: Stable identity handle. Org handles MUST start with ``@``; |
| 490 | single-actor handles MUST NOT. Case-sensitive. |
| 491 | kind: One of ``"human"``, ``"agent"``, ``"org"``. Verifiers MUST |
| 492 | reject quorum signing for any handle whose ``kind != "org"``. |
| 493 | public_key: Base64url-encoded raw Ed25519 public key (32 bytes → |
| 494 | 43 chars no padding). Empty for org records (orgs have no |
| 495 | standalone signing key — they're verified through their members). |
| 496 | members: For org records — list of member handles. Each member |
| 497 | MUST itself resolve to an :class:`IdentityRecord` (recursive |
| 498 | lookup, depth-bounded by the verifier). |
| 499 | quorum_threshold: For org records — minimum number of valid member |
| 500 | signatures required to consider the quorum satisfied. MUST be |
| 501 | an integer in ``[1, len(members)]``. |
| 502 | spawned_by: Optional handle of the identity that spawned this one |
| 503 | (for audit trails of the SPAWNS edge in the identity graph). |
| 504 | revoked_at: Optional ISO-8601 timestamp when the identity was |
| 505 | revoked. Verifiers MUST treat any non-empty ``revoked_at`` as |
| 506 | "identity invalid" — signatures by revoked identities do not |
| 507 | count toward quorum. |
| 508 | """ |
| 509 | |
| 510 | handle: str |
| 511 | kind: str |
| 512 | public_key: str |
| 513 | members: list[str] |
| 514 | quorum_threshold: int |
| 515 | spawned_by: str |
| 516 | revoked_at: str |
| 517 | |
| 518 | |
| 519 | class IdentityChainResult(TypedDict, total=False): |
| 520 | """Result of walking an identity chain to validate a handle. |
| 521 | |
| 522 | Returned by :func:`muse.core.verify.verify_identity_chain` and surfaced |
| 523 | in :class:`QuorumVerifyResult.depth`. |
| 524 | |
| 525 | Attributes: |
| 526 | valid: ``True`` iff every node in the walked chain resolved and was |
| 527 | non-revoked. ``False`` for any verifiable failure. |
| 528 | depth: Number of identity-graph hops walked (1 for a direct lookup, |
| 529 | N for an N-deep org-of-orgs chain). Zero means the root handle |
| 530 | could not even be resolved. |
| 531 | reason: Human-readable diagnostic. Empty when ``valid=True``. |
| 532 | cycle_detected: ``True`` iff a cycle was detected during the walk |
| 533 | (member A → member B → member A). Cycles are a security |
| 534 | failure — the identity domain rejects them at write time, but |
| 535 | the verifier re-checks defensively in case a malicious actor |
| 536 | was able to inject a cycle into a stale local cache. |
| 537 | """ |
| 538 | |
| 539 | valid: bool |
| 540 | depth: int |
| 541 | reason: str |
| 542 | cycle_detected: bool |
| 543 | |
| 544 | |
| 545 | @runtime_checkable |
| 546 | class MuseIdentityProvider(Protocol): |
| 547 | """Protocol for resolving identity handles into :class:`IdentityRecord`s. |
| 548 | |
| 549 | Distinct from :class:`MuseAttestationProvider` because identity lookups |
| 550 | have different concurrency, caching, and trust-root semantics than |
| 551 | attestation anchoring. A domain MAY register both providers (knowtation |
| 552 | does — see :mod:`muse.plugins.knowtation.quorum`). |
| 553 | |
| 554 | Implementations MUST: |
| 555 | |
| 556 | * Be safe to call from any thread. |
| 557 | * Be deterministic given a fixed backing store (the same handle always |
| 558 | resolves to the same record between cache invalidations). |
| 559 | * Return ``None`` for unknown handles — never raise for "not found". |
| 560 | * Raise :class:`AttestationVerifyError` for transport/IO errors that |
| 561 | prevent the resolver from determining whether the handle exists. |
| 562 | """ |
| 563 | |
| 564 | def get_identity(self, handle: str) -> "IdentityRecord | None": |
| 565 | """Resolve *handle* to its :class:`IdentityRecord`, or ``None``. |
| 566 | |
| 567 | Args: |
| 568 | handle: Identity handle string. Case-sensitive. |
| 569 | |
| 570 | Returns: |
| 571 | The :class:`IdentityRecord` for *handle*, or ``None`` if no such |
| 572 | identity exists in the provider's backing store. |
| 573 | |
| 574 | Raises: |
| 575 | AttestationVerifyError: If the provider cannot determine |
| 576 | whether the handle exists (e.g. backing store unreachable). |
| 577 | Callers MUST surface this as ``partial=True`` in the |
| 578 | outer verification result, never silently treat as |
| 579 | ``None``. |
| 580 | """ |
| 581 | ... |
| 582 | |
| 583 | |
| 584 | _IDENTITY_REGISTRY_LOCK: threading.Lock = threading.Lock() |
| 585 | _IDENTITY_PROVIDERS: dict[str, MuseIdentityProvider] = {} |
| 586 | |
| 587 | |
| 588 | def register_identity_provider(domain: str, provider: MuseIdentityProvider) -> None: |
| 589 | """Register *provider* under *domain* in the identity provider registry. |
| 590 | |
| 591 | Mirrors :func:`register_attestation_provider` exactly — same locking, |
| 592 | same domain-isolation guarantees. Kept as a separate registry so that |
| 593 | a domain can implement attestation without identity (or vice versa). |
| 594 | |
| 595 | Args: |
| 596 | domain: Non-empty domain name. |
| 597 | provider: Object implementing :class:`MuseIdentityProvider`. |
| 598 | |
| 599 | Raises: |
| 600 | ValueError: If *domain* is empty. |
| 601 | TypeError: If *provider* does not implement |
| 602 | :class:`MuseIdentityProvider`. |
| 603 | """ |
| 604 | if not isinstance(domain, str) or not domain: |
| 605 | raise ValueError("domain must be a non-empty string") |
| 606 | if not isinstance(provider, MuseIdentityProvider): |
| 607 | raise TypeError( |
| 608 | f"provider must implement MuseIdentityProvider " |
| 609 | f"(got {type(provider).__name__})" |
| 610 | ) |
| 611 | with _IDENTITY_REGISTRY_LOCK: |
| 612 | _IDENTITY_PROVIDERS[domain] = provider |
| 613 | |
| 614 | |
| 615 | def get_identity_provider(domain: str) -> "MuseIdentityProvider | None": |
| 616 | """Return the identity provider for *domain*, or ``None``. |
| 617 | |
| 618 | Domain isolation is enforced — never falls back across domains. |
| 619 | |
| 620 | Args: |
| 621 | domain: Domain name string. |
| 622 | |
| 623 | Returns: |
| 624 | :class:`MuseIdentityProvider` or ``None``. |
| 625 | """ |
| 626 | with _IDENTITY_REGISTRY_LOCK: |
| 627 | return _IDENTITY_PROVIDERS.get(domain) |
| 628 | |
| 629 | |
| 630 | def unregister_identity_provider(domain: str) -> bool: |
| 631 | """Remove the identity provider registered under *domain*, if any. |
| 632 | |
| 633 | Args: |
| 634 | domain: Domain name string. |
| 635 | |
| 636 | Returns: |
| 637 | ``True`` when a provider was removed; ``False`` when none was |
| 638 | registered. |
| 639 | """ |
| 640 | with _IDENTITY_REGISTRY_LOCK: |
| 641 | return _IDENTITY_PROVIDERS.pop(domain, None) is not None |
| 642 | |
| 643 | |
| 644 | __all__ = [ |
| 645 | "AnchorReceipt", |
| 646 | "AttestationError", |
| 647 | "AttestationRecord", |
| 648 | "AttestationRequiredError", |
| 649 | "AttestationVerifyError", |
| 650 | "IdentityChainResult", |
| 651 | "IdentityRecord", |
| 652 | "ImmutableReceiptError", |
| 653 | "MuseAttestationProvider", |
| 654 | "MuseIdentityProvider", |
| 655 | "VerifyResult", |
| 656 | "get_attestation_provider", |
| 657 | "get_identity_provider", |
| 658 | "register_attestation_provider", |
| 659 | "register_identity_provider", |
| 660 | "unregister_attestation_provider", |
| 661 | "unregister_identity_provider", |
| 662 | ] |
File History
2 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6
chore(tests): add docstring to tests/__init__.py so rc14 tr…
Human
32 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62
baseline: rc14 re-baseline after rc3 store corruption recovery
Human
patch
32 days ago