| @@ -41,9 +41,13 @@ | ||
| records fail the range request instead of being silently omitted from | ||
| monitor responses. The Python reference tlog now matches that behavior: | ||
| startup and `/tlog/range` fail closed on corrupt leaf records, and new | ||
| - | leaves carry `leaf_data_hex` so exact leaf bytes survive recovery. The | |
| - | registry v1 conformance harness now checks `/tlog/range` response shape, | |
| - | raising the live/in-process harness to 34 checks. | |
| + | leaves carry `leaf_data_hex` so exact leaf bytes survive recovery. | |
| + | - **Registry v1 error envelope parity.** Python and Rust registry errors now | |
| + | return the spec envelope `{error: {code, message}}` for registry failures | |
| + | instead of the framework-native string-only shapes. The conformance harness | |
| + | now checks `/tlog/range` response shape plus representative | |
| + | `signature_invalid`, `sidecar_mismatch`, `missing_field`, and `not_found` | |
| + | error envelopes, raising the live/in-process harness to 38 checks. | |
| - **GitHub Actions runtime hygiene.** Main CI workflows opt into the GitHub | ||
| Actions Node 24 runtime before the hosted runner default changes. | ||
| - **Rust policy test parity.** Fixed the `oversight-policy` crate's manifest |
| @@ -236,7 +236,7 @@ now exposes the full read-only and beacon surface | ||
| `/v/{token_id}`, `/candidates/semantic`) and ships strict CORS | ||
| restricted to the public browser-inspector origins with GET and | ||
| OPTIONS only. The Axum server now passes `tests/test_registry_conformance.py` | ||
| - | (34/34) in live-URL mode. `oversight-rust/oversight-manifest` learned | |
| + | (38/38) in live-URL mode. `oversight-rust/oversight-manifest` learned | |
| to verify Python-signed v0.4.5+ manifests by carrying | ||
| `canonical_content_hash` and `l3_policy` in the signed model, with | ||
| a fallback path for older manifests that lack those fields. | ||
| @@ -448,13 +448,13 @@ current stable line. | ||
| | Rust oversight-formats | 40 | green | | ||
| | Rust oversight-manifest | 3 | green | | ||
| | Rust oversight-policy | 7 | green | | ||
| - | | Rust oversight-registry | 11 | green | | |
| + | | Rust oversight-registry | 12 | green | | |
| | Rust oversight-rekor | 10 | green | | ||
| | Rust oversight-semantic | 8 | green | | ||
| | Rust oversight-tlog | 14 | green | | ||
| | Rust oversight-watermark | 4 | green | | ||
| | Cross-language conformance | 3 | green | | ||
| - | | Total automated Rust unit tests | 135 | all green | | |
| + | | Total automated Rust unit tests | 136 | all green | | |
| ## Design principles (what Oversight never does) | ||
| @@ -148,6 +148,9 @@ of disappearing from monitor output. | ||
| The Python reference registry uses the same fail-closed local tlog validation | ||
| for startup recovery and `/tlog/range`; newly appended records include | ||
| `leaf_data_hex` so exact event bytes can be recomputed by monitors. | ||
| + | Both reference registries return the registry v1 error envelope | |
| + | `{"error":{"code":"...","message":"..."}}` for registry failures, and the | |
| + | live conformance harness checks representative envelope codes. | |
| ## Rust Registry Burn-In Checklist | ||
| @@ -17,7 +17,7 @@ threat-model honesty, not on a calendar date. | ||
| at `docs/spec/registry-v1.md` is aligned against the reference server: | ||
| canonical-JSON algorithm, uniform error envelope, normative endpoint and | ||
| beacon paths, `/evidence` bundle shape, and `/tlog/head|proof|range` are | ||
| - | pinned. `tests/test_registry_conformance.py` runs 34 checks in-process | |
| + | pinned. `tests/test_registry_conformance.py` runs 38 checks in-process | |
| or against a live URL. An operator claims v1 compatibility with | ||
| `OVERSIGHT_REGISTRY_URL=https://registry.example.org python3 tests/test_registry_conformance.py`. | ||
| 4. **Browser inspector and classic-suite decrypt** shipped on | ||
| @@ -143,7 +143,7 @@ the reference server actually serves. The spec now pins: | ||
| - `/evidence/{file_id}` bundle fields | ||
| - `/tlog/head|proof|range` for federated verifiers | ||
| - | `tests/test_registry_conformance.py` is a 34-check harness with two | |
| + | `tests/test_registry_conformance.py` is a 38-check harness with two | |
| modes. In-process against a FastAPI TestClient for CI, or against a | ||
| live URL when `OVERSIGHT_REGISTRY_URL` is set. An independent operator | ||
| who passes the harness claims v1 compatibility. | ||
| @@ -235,7 +235,7 @@ require hardware backing for sensitive material. | ||
| `oversight-rust/oversight-registry` is scaffolded with all endpoints | ||
| implemented under `#![forbid(unsafe_code)]`. As of 2026-05-14, the Axum | ||
| - | server passes the existing 34-check `tests/test_registry_conformance.py` | |
| + | server passes the existing 38-check `tests/test_registry_conformance.py` | |
| harness in live-URL mode against the registry v1 surface with | ||
| `OVERSIGHT_OPERATOR_TOKEN` enabled. The Rust registry now matches the Python | ||
| reference for write-side operator-token auth and DNS bridge bearer/header | ||
| @@ -258,6 +258,9 @@ instead of parsing `leaves.jsonl` directly, so monitor responses fail closed | ||
| when an on-disk leaf is malformed or hash-mismatched. | ||
| The Python reference registry now mirrors that fail-closed tlog recovery and | ||
| range behavior, with `leaf_data_hex` on newly appended local tlog records. | ||
| + | Both registry implementations now return the registry v1 `{error: {code, | |
| + | message}}` envelope for representative client and server errors, and the | |
| + | conformance harness checks those envelopes. | |
| Remaining work: longer-running deployment tests and a wire-format stability | ||
| declaration before declaring v1.0 ready. | ||
| @@ -333,6 +333,7 @@ OVERSIGHT_REGISTRY_URL=https://registry.example.org \ | ||
| ``` | ||
| The harness uses a throwaway issuer identity, posts a minimal valid | ||
| - | manifest, and then validates the responses. Runs against the local | |
| - | reference registry are included in CI; operator-hosted runs are the | |
| + | manifest, and then validates the responses. It also checks representative | |
| + | error envelope codes for malformed or missing inputs. Runs against the | |
| + | local reference registry are included in CI; operator-hosted runs are the | |
| interop acceptance gate for federation. |
| @@ -27,6 +27,29 @@ pub enum RegistryError { | ||
| Internal(String), | ||
| } | ||
| + | impl RegistryError { | |
| + | fn code_for_bad_request(message: &str) -> &'static str { | |
| + | if message.contains("signature") { | |
| + | return "signature_invalid"; | |
| + | } | |
| + | if message.contains("beacons do not match") || message.contains("watermarks do not match") { | |
| + | return "sidecar_mismatch"; | |
| + | } | |
| + | "missing_field" | |
| + | } | |
| + | ||
| + | fn envelope_code(&self, message: &str) -> &'static str { | |
| + | match self { | |
| + | RegistryError::BadRequest(_) => Self::code_for_bad_request(message), | |
| + | RegistryError::NotFound(_) => "not_found", | |
| + | RegistryError::Conflict(_) => "issuer_mismatch", | |
| + | RegistryError::Unauthorized(_) => "auth_required", | |
| + | RegistryError::RateLimited => "rate_limited", | |
| + | RegistryError::Database(_) | RegistryError::Internal(_) => "server_error", | |
| + | } | |
| + | } | |
| + | } | |
| + | ||
| impl IntoResponse for RegistryError { | ||
| fn into_response(self) -> Response { | ||
| let (status, message) = match &self { | ||
| @@ -48,9 +71,43 @@ impl IntoResponse for RegistryError { | ||
| _ => tracing::error!(%status, %message, "server error"), | ||
| } | ||
| - | let body = serde_json::json!({ "error": message }); | |
| + | let code = self.envelope_code(&message); | |
| + | let body = serde_json::json!({ | |
| + | "error": { | |
| + | "code": code, | |
| + | "message": message, | |
| + | }, | |
| + | }); | |
| (status, Json(body)).into_response() | ||
| } | ||
| } | ||
| pub type Result<T> = std::result::Result<T, RegistryError>; | ||
| + | ||
| + | #[cfg(test)] | |
| + | mod tests { | |
| + | use super::*; | |
| + | ||
| + | #[test] | |
| + | fn registry_error_codes_match_v1_envelope() { | |
| + | assert_eq!( | |
| + | RegistryError::BadRequest("manifest signature invalid".into()) | |
| + | .envelope_code("manifest signature invalid"), | |
| + | "signature_invalid" | |
| + | ); | |
| + | assert_eq!( | |
| + | RegistryError::BadRequest("request beacons do not match signed manifest".into()) | |
| + | .envelope_code("request beacons do not match signed manifest"), | |
| + | "sidecar_mismatch" | |
| + | ); | |
| + | assert_eq!( | |
| + | RegistryError::Unauthorized("operator authentication required".into()) | |
| + | .envelope_code("operator authentication required"), | |
| + | "auth_required" | |
| + | ); | |
| + | assert_eq!( | |
| + | RegistryError::NotFound("unknown file_id".into()).envelope_code("unknown file_id"), | |
| + | "not_found" | |
| + | ); | |
| + | } | |
| + | } |
| @@ -32,6 +32,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import ( | ||
| ) | ||
| from cryptography.hazmat.primitives import serialization | ||
| from fastapi import FastAPI, Request, HTTPException | ||
| + | from fastapi.exceptions import RequestValidationError | |
| from fastapi.middleware.cors import CORSMiddleware | ||
| from fastapi.responses import Response, JSONResponse | ||
| from pydantic import BaseModel | ||
| @@ -278,6 +279,47 @@ app.add_middleware( | ||
| ) | ||
| + | def _registry_error_code(status_code: int, message: str) -> str: | |
| + | text = message.lower() | |
| + | if status_code == 401: | |
| + | return "auth_required" | |
| + | if status_code == 404: | |
| + | return "not_found" | |
| + | if status_code == 409: | |
| + | return "issuer_mismatch" | |
| + | if status_code == 429: | |
| + | return "rate_limited" | |
| + | if status_code >= 500: | |
| + | return "server_error" | |
| + | if "signature" in text: | |
| + | return "signature_invalid" | |
| + | if "beacons do not match" in text or "watermarks do not match" in text: | |
| + | return "sidecar_mismatch" | |
| + | return "missing_field" | |
| + | ||
| + | ||
| + | def _error_envelope(code: str, message: str) -> dict: | |
| + | return {"error": {"code": code, "message": message}} | |
| + | ||
| + | ||
| + | @app.exception_handler(HTTPException) | |
| + | async def _http_exception_handler(_request: Request, exc: HTTPException): | |
| + | message = str(exc.detail) | |
| + | return JSONResponse( | |
| + | status_code=exc.status_code, | |
| + | content=_error_envelope(_registry_error_code(exc.status_code, message), message), | |
| + | headers=exc.headers, | |
| + | ) | |
| + | ||
| + | ||
| + | @app.exception_handler(RequestValidationError) | |
| + | async def _validation_exception_handler(_request: Request, exc: RequestValidationError): | |
| + | return JSONResponse( | |
| + | status_code=400, | |
| + | content=_error_envelope("missing_field", f"request validation failed: {exc}"), | |
| + | ) | |
| + | ||
| + | ||
| class RegistrationRequest(BaseModel): | ||
| manifest: dict | ||
| beacons: list[dict] |
| @@ -57,6 +57,22 @@ def check(name: str, condition: bool, detail: str = "") -> None: | ||
| print(f" {FAIL} {name} ({detail})") | ||
| + | def check_error_envelope(name: str, response, expected_status: int, expected_code: str) -> None: | |
| + | try: | |
| + | body = response.json() | |
| + | except Exception: | |
| + | body = {} | |
| + | error = body.get("error") if isinstance(body, dict) else None | |
| + | ok = ( | |
| + | response.status_code == expected_status | |
| + | and isinstance(error, dict) | |
| + | and error.get("code") == expected_code | |
| + | and isinstance(error.get("message"), str) | |
| + | and bool(error.get("message")) | |
| + | ) | |
| + | check(name, ok, f"status={response.status_code} body={body}") | |
| + | ||
| + | ||
| # ---- Client abstraction ----------------------------------------------------- | ||
| @@ -228,12 +244,14 @@ def check_register_rejects_unsigned(cli: Client, manifest: dict, beacons: list, | ||
| tampered["file_id"] = str(uuid.uuid4()) | ||
| r = cli.post("/register", json={"manifest": tampered, "beacons": beacons, "watermarks": watermarks}) | ||
| check("register-rejects-bad-sig", r.status_code == 400, f"status={r.status_code}") | ||
| + | check_error_envelope("register-bad-sig-error-envelope", r, 400, "signature_invalid") | |
| def check_register_rejects_sidecar_mismatch(cli: Client, manifest: dict, beacons: list, watermarks: list) -> None: | ||
| bad = list(beacons) + [{"token_id": "sneaky", "kind": "dns"}] | ||
| r = cli.post("/register", json={"manifest": manifest, "beacons": bad, "watermarks": watermarks}) | ||
| check("register-rejects-sidecar-mismatch", r.status_code == 400, f"status={r.status_code}") | ||
| + | check_error_envelope("register-sidecar-error-envelope", r, 400, "sidecar_mismatch") | |
| def check_attribute_by_token(cli: Client, beacons: list) -> None: | ||
| @@ -249,6 +267,11 @@ def check_attribute_miss(cli: Client) -> None: | ||
| check("attribute-miss-found-false", r.json().get("found") is False) | ||
| + | def check_attribute_missing_field_error(cli: Client) -> None: | |
| + | r = cli.post("/attribute", json={}) | |
| + | check_error_envelope("attribute-missing-field-error-envelope", r, 400, "missing_field") | |
| + | ||
| + | ||
| def check_evidence(cli: Client, file_id: str) -> None: | ||
| r = cli.get(f"/evidence/{file_id}") | ||
| check("evidence-200", r.status_code == 200, f"status={r.status_code}") | ||
| @@ -267,6 +290,11 @@ def check_evidence(cli: Client, file_id: str) -> None: | ||
| isinstance(body.get("bundle_signature_ed25519"), str)) | ||
| + | def check_evidence_missing_error(cli: Client) -> None: | |
| + | r = cli.get("/evidence/missing-file-id") | |
| + | check_error_envelope("evidence-missing-error-envelope", r, 404, "not_found") | |
| + | ||
| + | ||
| def check_tlog_head(cli: Client) -> None: | ||
| r = cli.get("/tlog/head") | ||
| check("tlog-head-200", r.status_code == 200, f"status={r.status_code}") | ||
| @@ -361,7 +389,9 @@ def run(cli: Client) -> None: | ||
| print("\n[*] Attribution and evidence") | ||
| check_attribute_by_token(cli, beacons) | ||
| check_attribute_miss(cli) | ||
| + | check_attribute_missing_field_error(cli) | |
| check_evidence(cli, file_id) | ||
| + | check_evidence_missing_error(cli) | |
| print("\n[*] Transparency log") | ||
| check_tlog_head(cli) |