attestation.py
python
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago
| 1 | """Knowtation HMAC AIR attestation provider — Phase 7.2. |
| 2 | |
| 3 | Python port of ``knowtation/lib/air.mjs``: implements |
| 4 | :class:`muse.core.attestation.MuseAttestationProvider` for the Knowtation |
| 5 | domain by calling the local AIR HMAC daemon, then anchoring the resulting |
| 6 | record on the immutable ICP attestation canister via Phase 7.4's push hook. |
| 7 | |
| 8 | Wire format — strict cross-language parity with air.mjs |
| 9 | ------------------------------------------------------- |
| 10 | |
| 11 | The HTTP body sent to the AIR endpoint is a JSON object with **exactly three |
| 12 | keys, always present, in canonical alphabetical order**:: |
| 13 | |
| 14 | {"action": "<action>", "content_hash": "<sha256-hex>", "path": "<path>"} |
| 15 | |
| 16 | This matches the upgraded ``knowtation/lib/air.mjs`` which now also forwards |
| 17 | ``content_hash``. The cross-language parity test in |
| 18 | ``tests/test_knowtation_attestation.py`` verifies that 200 random fixtures |
| 19 | serialise identically on both sides — see :func:`build_air_request_body`. |
| 20 | |
| 21 | Determinism |
| 22 | ----------- |
| 23 | |
| 24 | The :attr:`AttestationRecord.id` is computed as:: |
| 25 | |
| 26 | SHA-256(snapshot_id + "\\x00" + note_path).hexdigest() |
| 27 | |
| 28 | — deterministic, content-addressed, and globally unique within a commit |
| 29 | graph. Two replays of the same commit on different nodes produce identical |
| 30 | IDs, so the canister rejects the second write with the documented |
| 31 | ``"already exists"`` error and the system stays consistent. |
| 32 | |
| 33 | Inbox bypass |
| 34 | ------------ |
| 35 | |
| 36 | Notes whose vault-relative path matches :func:`is_inbox_path` are skipped — |
| 37 | the same regex as JS ``isInboxPath``: literal ``inbox``, ``inbox/...``, or |
| 38 | ``projects/<slug>/inbox(/...)?``. |
| 39 | |
| 40 | Required-mode rejection |
| 41 | ----------------------- |
| 42 | |
| 43 | When ``config.air.required=True`` and the AIR endpoint returns non-OK or is |
| 44 | unreachable, :meth:`KnowtationHmacAttestationProvider.compute_attestation` |
| 45 | raises :class:`muse.core.attestation.AttestationRequiredError` — same code, |
| 46 | same semantics as the JS ``AttestationRequiredError``. |
| 47 | |
| 48 | Verification |
| 49 | ------------ |
| 50 | |
| 51 | :meth:`KnowtationHmacAttestationProvider.verify` recomputes the canonical |
| 52 | HMAC bytes locally using a shared HMAC key loaded from |
| 53 | ``MUSE_AIR_HMAC_KEY`` (or ``config.air.hmac_key_path``) and constant-time |
| 54 | compares against ``record["sig"]``. Missing-key and partial-verify |
| 55 | scenarios raise :class:`muse.core.attestation.AttestationVerifyError` |
| 56 | with the appropriate code — never silently return ``valid=True``. |
| 57 | """ |
| 58 | |
| 59 | from __future__ import annotations |
| 60 | |
| 61 | import hashlib |
| 62 | import hmac |
| 63 | import json |
| 64 | import logging |
| 65 | import os |
| 66 | import pathlib |
| 67 | import re |
| 68 | import urllib.error |
| 69 | import urllib.request |
| 70 | from typing import TypedDict |
| 71 | |
| 72 | from muse.core.attestation import ( |
| 73 | AnchorReceipt, |
| 74 | AttestationError, |
| 75 | AttestationRecord, |
| 76 | AttestationRequiredError, |
| 77 | AttestationVerifyError, |
| 78 | ImmutableReceiptError, |
| 79 | MuseAttestationProvider, |
| 80 | VerifyResult, |
| 81 | register_attestation_provider, |
| 82 | ) |
| 83 | |
| 84 | logger = logging.getLogger(__name__) |
| 85 | |
| 86 | |
| 87 | # --------------------------------------------------------------------------- |
| 88 | # Configuration |
| 89 | # --------------------------------------------------------------------------- |
| 90 | |
| 91 | #: Domain name this provider registers under. |
| 92 | PROVIDER_DOMAIN: str = "knowtation" |
| 93 | |
| 94 | #: Stable algorithm identifier embedded in every record. |
| 95 | ALGORITHM: str = "hmac-sha256" |
| 96 | |
| 97 | #: Stable identifier for the placeholder sig used when the AIR endpoint is |
| 98 | #: unconfigured/unreachable AND ``air.required=False``. Exposed as a |
| 99 | #: constant so verifiers can recognise placeholders without string-matching. |
| 100 | PLACEHOLDER_SIG: str = "air-placeholder-write" |
| 101 | |
| 102 | #: HTTP timeout for AIR endpoint POSTs (seconds). |
| 103 | HTTP_TIMEOUT_SECONDS: float = 10.0 |
| 104 | |
| 105 | #: Field separator for canonical record bytes — same null-byte convention as |
| 106 | #: muse.core.provenance.provenance_payload to prevent separator-injection |
| 107 | #: attacks from field values containing the separator. |
| 108 | _CANONICAL_SEP: str = "\x00" |
| 109 | |
| 110 | #: Maximum HTTP response body bytes accepted from the AIR endpoint. |
| 111 | #: 64 KiB is generous for a JSON ID response and bounds memory. |
| 112 | _MAX_RESPONSE_BYTES: int = 65_536 |
| 113 | |
| 114 | #: Inbox-path regex — mirrors JS ``isInboxPath`` in air.mjs. |
| 115 | #: Matches: ``inbox``, ``inbox/...``, ``projects/<slug>/inbox(/...)?``. |
| 116 | _INBOX_RE: re.Pattern[str] = re.compile(r"^projects/[^/]+/inbox(?:/|$)") |
| 117 | |
| 118 | |
| 119 | class AirConfig(TypedDict, total=False): |
| 120 | """Subset of ``.muse/air.yaml`` consumed by this provider. |
| 121 | |
| 122 | Mirrors the shape of ``config.air`` consumed by ``knowtation/lib/air.mjs``, |
| 123 | plus the new ``hmac_key`` field used by the Python verifier. |
| 124 | |
| 125 | Attributes: |
| 126 | enabled: When ``False`` (the default), the provider is a no-op: |
| 127 | :meth:`compute_attestation` returns a placeholder record without |
| 128 | calling the endpoint. When ``True``, the endpoint is consulted. |
| 129 | required: When ``True``, an unreachable or non-OK endpoint causes |
| 130 | :meth:`compute_attestation` to raise |
| 131 | :class:`AttestationRequiredError`. When ``False`` (the default), |
| 132 | the provider degrades to a placeholder sig and logs a warning. |
| 133 | endpoint: Full HTTPS URL of the AIR daemon's POST endpoint. |
| 134 | Falls back to ``$KNOWTATION_AIR_ENDPOINT`` when omitted. |
| 135 | hmac_key: Hex-encoded shared HMAC key used by :meth:`verify`. |
| 136 | Falls back to ``$MUSE_AIR_HMAC_KEY`` when omitted. When neither |
| 137 | source is set, :meth:`verify` raises |
| 138 | :class:`AttestationVerifyError(code="KEY_MISSING")`. |
| 139 | """ |
| 140 | |
| 141 | enabled: bool |
| 142 | required: bool |
| 143 | endpoint: str |
| 144 | hmac_key: str |
| 145 | |
| 146 | |
| 147 | # --------------------------------------------------------------------------- |
| 148 | # Inbox detection — port of isInboxPath in air.mjs |
| 149 | # --------------------------------------------------------------------------- |
| 150 | |
| 151 | |
| 152 | def is_inbox_path(vault_relative_path: str) -> bool: |
| 153 | """Return True iff *vault_relative_path* is under an inbox directory. |
| 154 | |
| 155 | Identical semantics to the JS ``isInboxPath()`` in |
| 156 | ``knowtation/lib/air.mjs``:: |
| 157 | |
| 158 | - "inbox" → True |
| 159 | - "inbox/anything" → True |
| 160 | - "projects/foo/inbox" → True |
| 161 | - "projects/foo/inbox/note.md" → True |
| 162 | - "projects/foo/bar/inbox/note.md" → False (only direct children) |
| 163 | |
| 164 | Backslashes are normalised to forward slashes so paths originating on |
| 165 | Windows are classified the same way as POSIX paths. |
| 166 | |
| 167 | Args: |
| 168 | vault_relative_path: Vault-relative path of the note being attested. |
| 169 | May use ``/`` or ``\\`` as separators. |
| 170 | |
| 171 | Returns: |
| 172 | ``True`` if the path is in an inbox (and should bypass attestation), |
| 173 | ``False`` otherwise. |
| 174 | """ |
| 175 | n = vault_relative_path.replace("\\", "/") |
| 176 | if n == "inbox" or n.startswith("inbox/"): |
| 177 | return True |
| 178 | return bool(_INBOX_RE.match(n)) |
| 179 | |
| 180 | |
| 181 | # --------------------------------------------------------------------------- |
| 182 | # Cross-language wire-format builders — exact parity with air.mjs |
| 183 | # --------------------------------------------------------------------------- |
| 184 | |
| 185 | |
| 186 | def build_air_request_body( |
| 187 | action: str, |
| 188 | path: str, |
| 189 | content_hash: str, |
| 190 | ) -> bytes: |
| 191 | """Build the exact UTF-8 bytes POSTed to the AIR endpoint. |
| 192 | |
| 193 | The body is a JSON object with **exactly three keys** in canonical |
| 194 | alphabetical order: ``action``, ``content_hash``, ``path``. Producing the |
| 195 | same byte sequence on both Python and JS sides is what enables the |
| 196 | cross-language parity test in ``tests/test_knowtation_attestation.py``. |
| 197 | |
| 198 | Args: |
| 199 | action: The attested action (``"write"``, ``"export"``, …). Must be |
| 200 | a non-empty string; control characters are rejected. |
| 201 | path: Vault-relative path to the note. May be empty for repo-scoped |
| 202 | actions but must be a string. |
| 203 | content_hash: SHA-256 hex of the content (lowercase). Empty string |
| 204 | is accepted for actions that do not bind to specific bytes |
| 205 | (e.g. legacy export records). |
| 206 | |
| 207 | Returns: |
| 208 | UTF-8 encoded JSON bytes. Compact form (no whitespace), keys in |
| 209 | alphabetical order, no NaN/Infinity tokens. |
| 210 | |
| 211 | Raises: |
| 212 | ValueError: If any argument is not a ``str`` or contains an embedded |
| 213 | null byte (which would break canonical encoding). |
| 214 | """ |
| 215 | for name, val in (("action", action), ("path", path), ("content_hash", content_hash)): |
| 216 | if not isinstance(val, str): |
| 217 | raise ValueError(f"{name} must be a string (got {type(val).__name__})") |
| 218 | if "\x00" in val: |
| 219 | raise ValueError(f"{name} must not contain embedded null bytes") |
| 220 | payload = {"action": action, "content_hash": content_hash, "path": path} |
| 221 | return json.dumps( |
| 222 | payload, |
| 223 | sort_keys=True, |
| 224 | separators=(",", ":"), |
| 225 | ensure_ascii=False, |
| 226 | allow_nan=False, |
| 227 | ).encode("utf-8") |
| 228 | |
| 229 | |
| 230 | def compute_attestation_id(snapshot_id: str, note_path: str) -> str: |
| 231 | """Compute the deterministic attestation ID for a (snapshot, note) pair. |
| 232 | |
| 233 | Definition:: |
| 234 | |
| 235 | SHA-256(snapshot_id + "\\x00" + note_path).hexdigest() |
| 236 | |
| 237 | Lower-case hex, 64 characters. The null-byte separator prevents the |
| 238 | classic concatenation collision (``"abc" + "def"`` vs ``"ab" + "cdef"``). |
| 239 | |
| 240 | Args: |
| 241 | snapshot_id: SHA-256 hex of the snapshot containing the note. |
| 242 | note_path: Vault-relative path of the note. |
| 243 | |
| 244 | Returns: |
| 245 | 64-character lowercase hex SHA-256 digest. |
| 246 | |
| 247 | Raises: |
| 248 | ValueError: If either argument is not a string or contains a null |
| 249 | byte (which would create separator-injection collisions). |
| 250 | """ |
| 251 | for name, val in (("snapshot_id", snapshot_id), ("note_path", note_path)): |
| 252 | if not isinstance(val, str): |
| 253 | raise ValueError(f"{name} must be a string (got {type(val).__name__})") |
| 254 | if "\x00" in val: |
| 255 | raise ValueError(f"{name} must not contain embedded null bytes") |
| 256 | raw = (snapshot_id + _CANONICAL_SEP + note_path).encode("utf-8") |
| 257 | return hashlib.sha256(raw).hexdigest() |
| 258 | |
| 259 | |
| 260 | def canonical_record_bytes(record: AttestationRecord) -> bytes: |
| 261 | """Serialise a record into canonical bytes for HMAC signing/verification. |
| 262 | |
| 263 | Format (null-byte separated, fixed field order):: |
| 264 | |
| 265 | action || \\x00 || path || \\x00 || content_hash || \\x00 || timestamp || \\x00 || id |
| 266 | |
| 267 | Note: the ``sig`` field is intentionally excluded — it would create a |
| 268 | self-reference loop. ``algorithm`` and ``provider_id`` are excluded |
| 269 | because they are constants for this provider and including them would |
| 270 | not add discrimination over the algorithm choice already encoded by |
| 271 | the verifier's choice of HMAC-SHA256. |
| 272 | |
| 273 | Args: |
| 274 | record: An :class:`AttestationRecord` produced by this provider. |
| 275 | Missing ``action``/``path``/``content_hash``/``timestamp``/``id`` |
| 276 | keys are treated as empty strings. |
| 277 | |
| 278 | Returns: |
| 279 | UTF-8 encoded canonical bytes ready for HMAC. |
| 280 | """ |
| 281 | fields = [ |
| 282 | record.get("action", ""), |
| 283 | record.get("path", ""), |
| 284 | record.get("content_hash", ""), |
| 285 | record.get("timestamp", ""), |
| 286 | record.get("id", ""), |
| 287 | ] |
| 288 | return _CANONICAL_SEP.join(fields).encode("utf-8") |
| 289 | |
| 290 | |
| 291 | def _hmac_sha256_hex(key_hex: str, data: bytes) -> str: |
| 292 | """Compute HMAC-SHA256 over *data* using a hex-encoded key. |
| 293 | |
| 294 | Args: |
| 295 | key_hex: Hex-encoded shared HMAC key. Decoded with |
| 296 | :func:`bytes.fromhex` so non-hex input raises :class:`ValueError`. |
| 297 | data: Bytes to authenticate. |
| 298 | |
| 299 | Returns: |
| 300 | 64-character lowercase hex HMAC digest. |
| 301 | |
| 302 | Raises: |
| 303 | ValueError: If *key_hex* is empty or not valid hex. |
| 304 | """ |
| 305 | if not key_hex: |
| 306 | raise ValueError("HMAC key is empty") |
| 307 | key_bytes = bytes.fromhex(key_hex) |
| 308 | return hmac.new(key_bytes, data, hashlib.sha256).hexdigest() |
| 309 | |
| 310 | |
| 311 | # --------------------------------------------------------------------------- |
| 312 | # AIR endpoint client — port of attestBeforeWrite in air.mjs |
| 313 | # --------------------------------------------------------------------------- |
| 314 | |
| 315 | |
| 316 | def _resolve_endpoint(config: AirConfig | None) -> str: |
| 317 | """Resolve the AIR endpoint URL from config or environment. |
| 318 | |
| 319 | Mirrors the JS resolution: ``config.air.endpoint || process.env.KNOWTATION_AIR_ENDPOINT``. |
| 320 | |
| 321 | Args: |
| 322 | config: Optional :class:`AirConfig` dict. |
| 323 | |
| 324 | Returns: |
| 325 | Endpoint URL string, or ``""`` when neither source set it. |
| 326 | """ |
| 327 | if config and config.get("endpoint"): |
| 328 | return str(config["endpoint"]) |
| 329 | return os.environ.get("KNOWTATION_AIR_ENDPOINT", "") |
| 330 | |
| 331 | |
| 332 | def _resolve_hmac_key(config: AirConfig | None) -> str: |
| 333 | """Resolve the HMAC verification key from config or environment. |
| 334 | |
| 335 | Args: |
| 336 | config: Optional :class:`AirConfig` dict. |
| 337 | |
| 338 | Returns: |
| 339 | Hex-encoded HMAC key string, or ``""`` when neither source set it. |
| 340 | """ |
| 341 | if config and config.get("hmac_key"): |
| 342 | return str(config["hmac_key"]) |
| 343 | return os.environ.get("MUSE_AIR_HMAC_KEY", "") |
| 344 | |
| 345 | |
| 346 | def _validate_endpoint_url(url: str) -> None: |
| 347 | """Reject malformed or insecure endpoint URLs. |
| 348 | |
| 349 | Accepts ``http://localhost*`` (developer-friendly) and ``https://*`` URLs |
| 350 | only. Rejects ``file://``, ``ftp://``, ``javascript:``, and other |
| 351 | protocols that could turn a misconfigured endpoint into an arbitrary |
| 352 | file read or out-of-band request. |
| 353 | |
| 354 | Args: |
| 355 | url: Endpoint URL to validate. |
| 356 | |
| 357 | Raises: |
| 358 | AttestationError: If the URL is empty or not http(s). |
| 359 | """ |
| 360 | if not url: |
| 361 | raise AttestationError("AIR endpoint URL is empty") |
| 362 | lower = url.lower().strip() |
| 363 | if lower.startswith("https://"): |
| 364 | return |
| 365 | if lower.startswith("http://localhost") or lower.startswith("http://127.0.0.1"): |
| 366 | return |
| 367 | raise AttestationError( |
| 368 | f"AIR endpoint URL must be https:// (or http://localhost for development); " |
| 369 | f"got scheme {url.split('://', 1)[0]!r}" |
| 370 | ) |
| 371 | |
| 372 | |
| 373 | class _AirResponse(TypedDict, total=False): |
| 374 | """Parsed AIR endpoint response shape.""" |
| 375 | |
| 376 | id: str |
| 377 | air_id: str |
| 378 | air_sig: str |
| 379 | sig: str |
| 380 | timestamp: str |
| 381 | |
| 382 | |
| 383 | def _post_to_air( |
| 384 | endpoint: str, |
| 385 | body: bytes, |
| 386 | *, |
| 387 | timeout: float = HTTP_TIMEOUT_SECONDS, |
| 388 | ) -> _AirResponse: |
| 389 | """POST *body* to *endpoint* and return the parsed JSON response. |
| 390 | |
| 391 | The endpoint URL is validated by :func:`_validate_endpoint_url` before |
| 392 | use; only ``https://`` and ``http://localhost`` URLs are accepted. |
| 393 | |
| 394 | Args: |
| 395 | endpoint: Pre-validated AIR endpoint URL. |
| 396 | body: Pre-built request body bytes from :func:`build_air_request_body`. |
| 397 | timeout: HTTP socket timeout in seconds. |
| 398 | |
| 399 | Returns: |
| 400 | Parsed JSON response as a dict; missing/non-JSON returns ``{}``. |
| 401 | |
| 402 | Raises: |
| 403 | AttestationError: For any transport- or HTTP-level failure. The |
| 404 | caller decides whether to convert to AttestationRequiredError |
| 405 | based on ``config.air.required``. |
| 406 | """ |
| 407 | _validate_endpoint_url(endpoint) |
| 408 | request = urllib.request.Request( |
| 409 | url=endpoint, |
| 410 | data=body, |
| 411 | method="POST", |
| 412 | headers={ |
| 413 | "Content-Type": "application/json", |
| 414 | "User-Agent": "muse-attestation/1.0", |
| 415 | "Content-Length": str(len(body)), |
| 416 | }, |
| 417 | ) |
| 418 | try: |
| 419 | with urllib.request.urlopen( # noqa: S310 — URL pre-validated |
| 420 | request, timeout=timeout |
| 421 | ) as response: |
| 422 | status = getattr(response, "status", 200) |
| 423 | if status >= 400: |
| 424 | raise AttestationError( |
| 425 | f"AIR endpoint returned HTTP {status}" |
| 426 | ) |
| 427 | raw = response.read(_MAX_RESPONSE_BYTES + 1) |
| 428 | if len(raw) > _MAX_RESPONSE_BYTES: |
| 429 | raise AttestationError( |
| 430 | f"AIR response exceeds {_MAX_RESPONSE_BYTES} byte cap" |
| 431 | ) |
| 432 | except urllib.error.HTTPError as exc: |
| 433 | raise AttestationError( |
| 434 | f"AIR endpoint returned HTTP {exc.code}" |
| 435 | ) from exc |
| 436 | except urllib.error.URLError as exc: |
| 437 | raise AttestationError(f"AIR endpoint unreachable: {exc}") from exc |
| 438 | except TimeoutError as exc: |
| 439 | raise AttestationError(f"AIR endpoint timed out after {timeout}s") from exc |
| 440 | |
| 441 | try: |
| 442 | parsed = json.loads(raw.decode("utf-8")) |
| 443 | except (UnicodeDecodeError, json.JSONDecodeError): |
| 444 | return {} |
| 445 | if not isinstance(parsed, dict): |
| 446 | return {} |
| 447 | return parsed |
| 448 | |
| 449 | |
| 450 | # --------------------------------------------------------------------------- |
| 451 | # Provider implementation |
| 452 | # --------------------------------------------------------------------------- |
| 453 | |
| 454 | |
| 455 | class KnowtationHmacAttestationProvider: |
| 456 | """HMAC AIR attestation provider for the Knowtation domain. |
| 457 | |
| 458 | Implements :class:`muse.core.attestation.MuseAttestationProvider`. The |
| 459 | provider is constructed once per process (typically via the |
| 460 | :func:`register_knowtation_attestation_provider` module-level helper) and |
| 461 | then resolved from the registry at commit/verify time. |
| 462 | |
| 463 | Attributes: |
| 464 | config: The :class:`AirConfig` dict used by every method. Stored as |
| 465 | the provider's only mutable state; instances should be treated as |
| 466 | immutable after construction (no mutation post-init in practice). |
| 467 | """ |
| 468 | |
| 469 | config: AirConfig |
| 470 | _anchored_ids: set[str] |
| 471 | |
| 472 | def __init__(self, config: AirConfig | None = None) -> None: |
| 473 | """Initialise the provider with an optional configuration dict. |
| 474 | |
| 475 | Args: |
| 476 | config: Optional :class:`AirConfig` to override env-var defaults. |
| 477 | When ``None``, the provider falls back to environment |
| 478 | variables for endpoint and HMAC key on every call. |
| 479 | """ |
| 480 | self.config = dict(config) if config else {} |
| 481 | self._anchored_ids = set() |
| 482 | |
| 483 | # ------------------------------------------------------------------ |
| 484 | # MuseAttestationProvider methods |
| 485 | # ------------------------------------------------------------------ |
| 486 | |
| 487 | def compute_attestation( |
| 488 | self, |
| 489 | snapshot_id: str, |
| 490 | commit_meta: dict[str, object], |
| 491 | ) -> AttestationRecord: |
| 492 | """Compute the attestation record for a knowtation commit. |
| 493 | |
| 494 | Implementation outline (matches air.mjs ``attestBeforeWrite``): |
| 495 | |
| 496 | 1. Read ``commit_meta["path"]`` (vault-relative) and |
| 497 | ``commit_meta["content_hash"]`` (SHA-256 hex). Action defaults |
| 498 | to ``"write"`` if not provided. |
| 499 | 2. Compute the deterministic ID via :func:`compute_attestation_id`. |
| 500 | 3. If the path is an inbox path (per :func:`is_inbox_path`), return |
| 501 | the record with empty ``sig`` and ``algorithm="bypass-inbox"``. |
| 502 | 4. If ``config.air.enabled`` is False, return placeholder. |
| 503 | 5. Otherwise POST to the AIR endpoint via :func:`_post_to_air`. |
| 504 | - On HTTP 2xx: extract ``data.air_sig`` (or ``data.sig``) for the |
| 505 | record's ``sig`` field; ``data.id`` (or ``data.air_id``) is |
| 506 | cross-checked against our deterministic ID and a mismatch is |
| 507 | logged as a WARNING but does not fail. |
| 508 | - On any failure with ``required=True``: raise |
| 509 | :class:`AttestationRequiredError`. |
| 510 | - On any failure with ``required=False``: degrade to placeholder |
| 511 | and log a WARNING. |
| 512 | |
| 513 | Args: |
| 514 | snapshot_id: SHA-256 hex of the snapshot containing the note. |
| 515 | commit_meta: Dict with at least the keys ``path`` (str) and |
| 516 | ``content_hash`` (str). Optional: ``action`` (str, |
| 517 | default ``"write"``), ``timestamp`` (str ISO-8601, default |
| 518 | ``""``). Other keys are ignored. |
| 519 | |
| 520 | Returns: |
| 521 | An :class:`AttestationRecord` with all required fields set. |
| 522 | |
| 523 | Raises: |
| 524 | AttestationRequiredError: If ``config.air.required=True`` and the |
| 525 | endpoint cannot be reached or returns non-OK. |
| 526 | ValueError: If ``commit_meta["path"]`` or |
| 527 | ``commit_meta["content_hash"]`` is missing or not a string. |
| 528 | """ |
| 529 | path = commit_meta.get("path", "") |
| 530 | content_hash = commit_meta.get("content_hash", "") |
| 531 | action = commit_meta.get("action", "write") |
| 532 | timestamp = commit_meta.get("timestamp", "") |
| 533 | |
| 534 | if not isinstance(path, str): |
| 535 | raise ValueError("commit_meta['path'] must be a string") |
| 536 | if not isinstance(content_hash, str): |
| 537 | raise ValueError("commit_meta['content_hash'] must be a string") |
| 538 | if not isinstance(action, str): |
| 539 | raise ValueError("commit_meta['action'] must be a string") |
| 540 | if not isinstance(timestamp, str): |
| 541 | raise ValueError("commit_meta['timestamp'] must be a string") |
| 542 | |
| 543 | deterministic_id = compute_attestation_id(snapshot_id, path) |
| 544 | base_record: AttestationRecord = { |
| 545 | "id": deterministic_id, |
| 546 | "action": action, |
| 547 | "path": path, |
| 548 | "timestamp": timestamp, |
| 549 | "content_hash": content_hash, |
| 550 | "sig": "", |
| 551 | "algorithm": ALGORITHM, |
| 552 | "provider_id": PROVIDER_DOMAIN, |
| 553 | } |
| 554 | |
| 555 | # Inbox bypass — never call the endpoint, never raise. |
| 556 | if is_inbox_path(path): |
| 557 | base_record["sig"] = "" |
| 558 | base_record["algorithm"] = "bypass-inbox" |
| 559 | return base_record |
| 560 | |
| 561 | enabled = bool(self.config.get("enabled", False)) |
| 562 | required = bool(self.config.get("required", False)) |
| 563 | endpoint = _resolve_endpoint(self.config) |
| 564 | |
| 565 | if not enabled: |
| 566 | base_record["sig"] = PLACEHOLDER_SIG |
| 567 | base_record["algorithm"] = "placeholder-disabled" |
| 568 | return base_record |
| 569 | |
| 570 | if not endpoint: |
| 571 | if required: |
| 572 | raise AttestationRequiredError( |
| 573 | "knowtation: air.required=true but no AIR endpoint is " |
| 574 | "configured; commit rejected." |
| 575 | ) |
| 576 | base_record["sig"] = PLACEHOLDER_SIG |
| 577 | base_record["algorithm"] = "placeholder-no-endpoint" |
| 578 | logger.warning( |
| 579 | "knowtation-attestation: AIR enabled but endpoint unconfigured " |
| 580 | "and required=false; storing placeholder." |
| 581 | ) |
| 582 | return base_record |
| 583 | |
| 584 | body = build_air_request_body(action, path, content_hash) |
| 585 | try: |
| 586 | data = _post_to_air(endpoint, body) |
| 587 | except AttestationError as exc: |
| 588 | if required: |
| 589 | raise AttestationRequiredError( |
| 590 | f"knowtation: AIR endpoint failed and air.required=true; " |
| 591 | f"commit rejected. ({exc})" |
| 592 | ) from exc |
| 593 | base_record["sig"] = PLACEHOLDER_SIG |
| 594 | base_record["algorithm"] = "placeholder-endpoint-failure" |
| 595 | logger.warning( |
| 596 | "knowtation-attestation: AIR endpoint failed (%s) and " |
| 597 | "required=false; storing placeholder.", |
| 598 | exc, |
| 599 | ) |
| 600 | return base_record |
| 601 | |
| 602 | sig = str(data.get("air_sig") or data.get("sig") or "") |
| 603 | returned_id = str(data.get("id") or data.get("air_id") or "") |
| 604 | if returned_id and returned_id != deterministic_id: |
| 605 | logger.warning( |
| 606 | "knowtation-attestation: AIR endpoint returned id %r but " |
| 607 | "expected deterministic id %r; using deterministic id.", |
| 608 | returned_id, deterministic_id, |
| 609 | ) |
| 610 | if data.get("timestamp"): |
| 611 | base_record["timestamp"] = str(data["timestamp"]) |
| 612 | if not sig: |
| 613 | if required: |
| 614 | raise AttestationRequiredError( |
| 615 | "knowtation: AIR endpoint returned no signature and " |
| 616 | "air.required=true; commit rejected." |
| 617 | ) |
| 618 | base_record["sig"] = PLACEHOLDER_SIG |
| 619 | base_record["algorithm"] = "placeholder-no-sig" |
| 620 | return base_record |
| 621 | base_record["sig"] = sig |
| 622 | return base_record |
| 623 | |
| 624 | def anchor(self, record: AttestationRecord) -> AnchorReceipt: |
| 625 | """Return a local-only receipt — true on-chain anchoring is the push hook. |
| 626 | |
| 627 | For the HMAC AIR provider, the local AIR daemon already persists the |
| 628 | record in its append-only log when :meth:`compute_attestation` |
| 629 | returned successfully. Network-anchored persistence to the ICP |
| 630 | canister is the responsibility of |
| 631 | :class:`muse.plugins.knowtation.icp_push_hook.KnowtationICPPushHook` |
| 632 | which fires asynchronously on ``muse push``. |
| 633 | |
| 634 | This method therefore returns a synthetic receipt whose ``tx_id`` |
| 635 | encodes the record ID (so verifiers can correlate) and whose |
| 636 | ``anchor_url`` points to the AIR endpoint where the record was |
| 637 | persisted. Re-anchoring is rejected with |
| 638 | :class:`ImmutableReceiptError` to mirror the canister's write-once |
| 639 | semantics — see ``hub/icp/src/attestation/main.mo`` line 415. |
| 640 | |
| 641 | Args: |
| 642 | record: An :class:`AttestationRecord` from |
| 643 | :meth:`compute_attestation`. Must have a non-empty ``id``. |
| 644 | |
| 645 | Returns: |
| 646 | An :class:`AnchorReceipt` with ``tx_id="air-local:<id>"`` and |
| 647 | ``anchor_url`` pointing to the AIR endpoint. |
| 648 | |
| 649 | Raises: |
| 650 | ImmutableReceiptError: If this provider has already issued a |
| 651 | receipt for ``record["id"]`` in the current process. |
| 652 | AttestationError: If ``record["id"]`` is missing or empty. |
| 653 | """ |
| 654 | rid = record.get("id", "") |
| 655 | if not rid: |
| 656 | raise AttestationError("record['id'] is empty — cannot anchor") |
| 657 | if rid in self._anchored_ids: |
| 658 | raise ImmutableReceiptError( |
| 659 | f"record {rid!r} already anchored locally (write-once)" |
| 660 | ) |
| 661 | self._anchored_ids.add(rid) |
| 662 | endpoint = _resolve_endpoint(self.config) |
| 663 | return { |
| 664 | "tx_id": f"air-local:{rid}", |
| 665 | "anchor_url": endpoint or "", |
| 666 | "anchored_at": record.get("timestamp", ""), |
| 667 | "provider_id": PROVIDER_DOMAIN, |
| 668 | } |
| 669 | |
| 670 | def verify( |
| 671 | self, |
| 672 | record: AttestationRecord, |
| 673 | receipt: AnchorReceipt, |
| 674 | ) -> VerifyResult: |
| 675 | """Verify the record's HMAC and that the receipt belongs to it. |
| 676 | |
| 677 | Verification chain: |
| 678 | |
| 679 | 1. Cross-check ``record["provider_id"]`` and |
| 680 | ``receipt["provider_id"]`` — mismatch raises |
| 681 | :class:`AttestationVerifyError`. |
| 682 | 2. Recompute the deterministic ID from |
| 683 | ``(snapshot_id, path)`` — wait, we don't have snapshot_id at |
| 684 | verify-time. Instead we sanity-check the record's own ID format. |
| 685 | 3. If ``record["sig"] == PLACEHOLDER_SIG`` → return ``valid=False`` |
| 686 | with reason ``"placeholder-sig"`` (provably-bad: this isn't a |
| 687 | real attestation). |
| 688 | 4. Load the HMAC key from config or env; missing key raises |
| 689 | :class:`AttestationVerifyError(code="KEY_MISSING")` — never |
| 690 | silently returns ``valid=True``. |
| 691 | 5. Recompute HMAC over :func:`canonical_record_bytes` and |
| 692 | constant-time compare against ``record["sig"]``. |
| 693 | |
| 694 | Args: |
| 695 | record: The :class:`AttestationRecord` to verify. |
| 696 | receipt: The :class:`AnchorReceipt` to cross-check. |
| 697 | |
| 698 | Returns: |
| 699 | A :class:`VerifyResult` with ``valid=True`` iff the HMAC matched |
| 700 | and the receipt belongs to the record. ``valid=False`` only |
| 701 | for *provably bad* records (placeholder sigs, HMAC mismatch). |
| 702 | |
| 703 | Raises: |
| 704 | AttestationVerifyError: If the verifier cannot complete the |
| 705 | check — missing HMAC key, malformed sig, provider_id |
| 706 | mismatch. |
| 707 | """ |
| 708 | if record.get("provider_id") != PROVIDER_DOMAIN: |
| 709 | raise AttestationVerifyError( |
| 710 | f"record provider_id is {record.get('provider_id')!r}, " |
| 711 | f"expected {PROVIDER_DOMAIN!r}", |
| 712 | code="PROVIDER_MISMATCH", |
| 713 | ) |
| 714 | if receipt.get("provider_id") != PROVIDER_DOMAIN: |
| 715 | raise AttestationVerifyError( |
| 716 | f"receipt provider_id is {receipt.get('provider_id')!r}, " |
| 717 | f"expected {PROVIDER_DOMAIN!r}", |
| 718 | code="PROVIDER_MISMATCH", |
| 719 | ) |
| 720 | |
| 721 | rid = record.get("id", "") |
| 722 | if not rid or len(rid) != 64 or not all(c in "0123456789abcdef" for c in rid): |
| 723 | raise AttestationVerifyError( |
| 724 | f"record id is not a valid SHA-256 hex digest: {rid!r}", |
| 725 | code="INVALID_RECORD_ID", |
| 726 | ) |
| 727 | |
| 728 | sig = record.get("sig", "") |
| 729 | if sig == PLACEHOLDER_SIG or record.get("algorithm", "").startswith("placeholder-"): |
| 730 | return { |
| 731 | "valid": False, |
| 732 | "reason": "record contains a placeholder signature, not a real HMAC", |
| 733 | "depth": 0, |
| 734 | "provider_id": PROVIDER_DOMAIN, |
| 735 | } |
| 736 | |
| 737 | if record.get("algorithm") == "bypass-inbox": |
| 738 | return { |
| 739 | "valid": False, |
| 740 | "reason": "inbox-bypass record cannot be verified (no signature was issued)", |
| 741 | "depth": 0, |
| 742 | "provider_id": PROVIDER_DOMAIN, |
| 743 | } |
| 744 | |
| 745 | if not sig: |
| 746 | raise AttestationVerifyError( |
| 747 | "record sig is empty — cannot verify", |
| 748 | code="MISSING_SIG", |
| 749 | ) |
| 750 | |
| 751 | key_hex = _resolve_hmac_key(self.config) |
| 752 | if not key_hex: |
| 753 | raise AttestationVerifyError( |
| 754 | "no HMAC key configured (set MUSE_AIR_HMAC_KEY or " |
| 755 | "config.air.hmac_key); cannot complete verification", |
| 756 | code="KEY_MISSING", |
| 757 | ) |
| 758 | |
| 759 | try: |
| 760 | expected = _hmac_sha256_hex(key_hex, canonical_record_bytes(record)) |
| 761 | except ValueError as exc: |
| 762 | raise AttestationVerifyError( |
| 763 | f"HMAC computation failed: {exc}", |
| 764 | code="HMAC_COMPUTE_FAILED", |
| 765 | ) from exc |
| 766 | |
| 767 | if hmac.compare_digest(expected, sig): |
| 768 | return { |
| 769 | "valid": True, |
| 770 | "reason": "", |
| 771 | "depth": 0, |
| 772 | "provider_id": PROVIDER_DOMAIN, |
| 773 | } |
| 774 | return { |
| 775 | "valid": False, |
| 776 | "reason": "HMAC signature mismatch", |
| 777 | "depth": 0, |
| 778 | "provider_id": PROVIDER_DOMAIN, |
| 779 | } |
| 780 | |
| 781 | |
| 782 | |
| 783 | # --------------------------------------------------------------------------- |
| 784 | # Module-level registration helper — same pattern as register_knowtation_hook. |
| 785 | # --------------------------------------------------------------------------- |
| 786 | |
| 787 | |
| 788 | def register_knowtation_attestation_provider( |
| 789 | config: AirConfig | None = None, |
| 790 | ) -> KnowtationHmacAttestationProvider: |
| 791 | """Construct + register the Knowtation HMAC provider; return the instance. |
| 792 | |
| 793 | Idempotent: re-calling replaces the prior registration with a fresh |
| 794 | instance configured by *config*. Safe to call eagerly at import time |
| 795 | (typical) or lazily once configuration is loaded. |
| 796 | |
| 797 | Args: |
| 798 | config: Optional :class:`AirConfig`. When ``None``, the provider |
| 799 | relies on environment variables on every call. |
| 800 | |
| 801 | Returns: |
| 802 | The newly-registered :class:`KnowtationHmacAttestationProvider` |
| 803 | instance. |
| 804 | """ |
| 805 | provider = KnowtationHmacAttestationProvider(config) |
| 806 | register_attestation_provider(PROVIDER_DOMAIN, provider) |
| 807 | return provider |
| 808 | |
| 809 | |
| 810 | def load_air_config(config_path: pathlib.Path | None = None) -> AirConfig: |
| 811 | """Load AIR configuration from a YAML file or environment variables. |
| 812 | |
| 813 | Reads ``.muse/air.yaml`` by default. Falls back to ``MUSE_AIR_CONFIG`` |
| 814 | env var for the path. When neither file exists, returns an empty |
| 815 | :class:`AirConfig` that the provider will treat as "disabled". |
| 816 | |
| 817 | The YAML is parsed with ``yaml.safe_load`` so no arbitrary object |
| 818 | instantiation is possible. |
| 819 | |
| 820 | Args: |
| 821 | config_path: Optional explicit path to the YAML file. When omitted, |
| 822 | uses ``$MUSE_AIR_CONFIG`` if set, else ``.muse/air.yaml`` |
| 823 | relative to the current working directory. |
| 824 | |
| 825 | Returns: |
| 826 | An :class:`AirConfig` dict with whichever fields the file |
| 827 | contained; missing fields default to absent. |
| 828 | |
| 829 | Raises: |
| 830 | AttestationError: If the file exists but cannot be parsed or its |
| 831 | top-level shape is not a mapping with an ``air`` key. |
| 832 | """ |
| 833 | import yaml |
| 834 | |
| 835 | if config_path is None: |
| 836 | env_path = os.environ.get("MUSE_AIR_CONFIG", "") |
| 837 | if env_path: |
| 838 | config_path = pathlib.Path(env_path) |
| 839 | else: |
| 840 | config_path = pathlib.Path(".muse/air.yaml") |
| 841 | |
| 842 | if not config_path.exists(): |
| 843 | return AirConfig() |
| 844 | |
| 845 | try: |
| 846 | raw_text = config_path.read_text(encoding="utf-8") |
| 847 | parsed = yaml.safe_load(raw_text) or {} |
| 848 | except (OSError, yaml.YAMLError) as exc: |
| 849 | raise AttestationError(f"cannot read AIR config {config_path}: {exc}") from exc |
| 850 | |
| 851 | if not isinstance(parsed, dict): |
| 852 | raise AttestationError(f"AIR config root must be a mapping; got {type(parsed).__name__}") |
| 853 | air_section = parsed.get("air", {}) |
| 854 | if not isinstance(air_section, dict): |
| 855 | raise AttestationError(f"AIR config 'air' key must be a mapping; got {type(air_section).__name__}") |
| 856 | |
| 857 | cfg: AirConfig = {} |
| 858 | if "enabled" in air_section: |
| 859 | cfg["enabled"] = bool(air_section["enabled"]) |
| 860 | if "required" in air_section: |
| 861 | cfg["required"] = bool(air_section["required"]) |
| 862 | if isinstance(air_section.get("endpoint"), str): |
| 863 | cfg["endpoint"] = air_section["endpoint"] |
| 864 | if isinstance(air_section.get("hmac_key"), str): |
| 865 | cfg["hmac_key"] = air_section["hmac_key"] |
| 866 | return cfg |
| 867 | |
| 868 | |
| 869 | __all__ = [ |
| 870 | "ALGORITHM", |
| 871 | "AirConfig", |
| 872 | "HTTP_TIMEOUT_SECONDS", |
| 873 | "KnowtationHmacAttestationProvider", |
| 874 | "PLACEHOLDER_SIG", |
| 875 | "PROVIDER_DOMAIN", |
| 876 | "build_air_request_body", |
| 877 | "canonical_record_bytes", |
| 878 | "compute_attestation_id", |
| 879 | "is_inbox_path", |
| 880 | "load_air_config", |
| 881 | "register_knowtation_attestation_provider", |
| 882 | ] |
File History
1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8
docs(KD-STAGING): sync governance after KD-6b DONE
Human
13 days ago