attestation.py python
882 lines 33.0 KB
Raw
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