| @@ -26,6 +26,13 @@ confuse the hardened tree with the vulnerable `v0.4.3` baseline. | ||
| hash (`SHA-256("")`) instead of an all-zero placeholder. | ||
| - `oversight_core/__init__.py`, `pyproject.toml`, and the Rich CLI banner: | ||
| version metadata is now `0.4.4`, marking this post-`0.4.3` hardening train. | ||
| + | - `oversight_dns/server.py` and `registry/server.py`: DNS beacon callbacks now | |
| + | support a shared `OVERSIGHT_DNS_EVENT_SECRET`, and non-loopback callbacks | |
| + | fail closed when no secret is configured. | |
| + | - `registry/server.py`: evidence bundles now include local transparency-log | |
| + | inclusion proofs for recorded events, not just the signed tree head. | |
| + | - `oversight-rust`: removed the direct `rand` dependency in favor of | |
| + | `rand_core::OsRng`, clearing the low-severity `rand` advisory path. | |
| - Added focused regression coverage in `tests/test_policy_unit.py`, | ||
| `tests/test_registry_unit.py`, `tests/test_rekor_unit.py`, | ||
| `tests/test_text_format_unit.py`, and `tests/test_tlog_unit.py`. |
| @@ -696,7 +696,6 @@ dependencies = [ | ||
| "ed25519-dalek", | ||
| "hex", | ||
| "hkdf", | ||
| - | "rand", | |
| "rand_core", | ||
| "serde_json", | ||
| "sha2", | ||
| @@ -774,7 +773,7 @@ dependencies = [ | ||
| name = "oversight-watermark" | ||
| version = "0.5.0" | ||
| dependencies = [ | ||
| - | "rand", | |
| + | "rand_core", | |
| ] | ||
| [[package]] | ||
| @@ -813,15 +812,6 @@ dependencies = [ | ||
| "zerovec", | ||
| ] | ||
| - | [[package]] | |
| - | name = "ppv-lite86" | |
| - | version = "0.2.21" | |
| - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| - | checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" | |
| - | dependencies = [ | |
| - | "zerocopy", | |
| - | ] | |
| - | ||
| [[package]] | ||
| name = "prettyplease" | ||
| version = "0.2.37" | ||
| @@ -856,27 +846,6 @@ version = "6.0.0" | ||
| source = "registry+https://github.com/rust-lang/crates.io-index" | ||
| checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | ||
| - | [[package]] | |
| - | name = "rand" | |
| - | version = "0.8.6" | |
| - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| - | checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" | |
| - | dependencies = [ | |
| - | "libc", | |
| - | "rand_chacha", | |
| - | "rand_core", | |
| - | ] | |
| - | ||
| - | [[package]] | |
| - | name = "rand_chacha" | |
| - | version = "0.3.1" | |
| - | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| - | checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" | |
| - | dependencies = [ | |
| - | "ppv-lite86", | |
| - | "rand_core", | |
| - | ] | |
| - | ||
| [[package]] | ||
| name = "rand_core" | ||
| version = "0.6.4" |
| @@ -30,7 +30,6 @@ ed25519-dalek = { version = "2", features = ["rand_core"] } | ||
| chacha20poly1305 = { version = "0.10", features = ["alloc"] } | ||
| hkdf = "0.12" | ||
| sha2 = "0.10" | ||
| - | rand = "0.8" | |
| rand_core = "0.6" | ||
| # Serialization / encoding |
| @@ -12,8 +12,7 @@ ed25519-dalek.workspace = true | ||
| chacha20poly1305.workspace = true | ||
| hkdf.workspace = true | ||
| sha2.workspace = true | ||
| - | rand.workspace = true | |
| - | rand_core.workspace = true | |
| + | rand_core = { workspace = true, features = ["getrandom"] } | |
| hex.workspace = true | ||
| zeroize.workspace = true | ||
| thiserror.workspace = true |
| @@ -33,8 +33,7 @@ use ed25519_dalek::{ | ||
| VerifyingKey as EdVerifyingKey, | ||
| }; | ||
| use hkdf::Hkdf; | ||
| - | use rand::rngs::OsRng; | |
| - | use rand_core::RngCore; | |
| + | use rand_core::{OsRng, RngCore}; | |
| use sha2::{Digest, Sha256}; | ||
| use thiserror::Error; | ||
| use x25519_dalek::{PublicKey as X25519PublicKey, StaticSecret as X25519StaticSecret}; |
| @@ -7,4 +7,4 @@ license.workspace = true | ||
| description = "Per-recipient text watermarking for Oversight (L1 zero-width, L2 whitespace)" | ||
| [dependencies] | ||
| - | rand.workspace = true | |
| + | rand_core = { workspace = true, features = ["getrandom"] } |
| @@ -11,7 +11,7 @@ | ||
| //! Higher-fidelity layers (semantic synonym rotation, DCT image watermarks, | ||
| //! PDF/DOCX metadata) live in separate crates so each can evolve independently. | ||
| - | use rand::{rngs::OsRng, RngCore}; | |
| + | use rand_core::{OsRng, RngCore}; | |
| pub const ZW_SPACE: char = '\u{200b}'; // bit 0 | ||
| pub const ZW_NONJOIN: char = '\u{200c}'; // bit 1 |
| @@ -32,6 +32,7 @@ from __future__ import annotations | ||
| import argparse | ||
| import logging | ||
| + | import os | |
| import sys | ||
| import time | ||
| from pathlib import Path | ||
| @@ -52,10 +53,17 @@ log = logging.getLogger("oversight_dns") | ||
| class OversightResolver(BaseResolver): | ||
| """Resolves queries matching <token_id>.t.<beacon_domain> and logs them.""" | ||
| - | def __init__(self, beacon_domain: str, registry_url: str, answer_ip: str): | |
| + | def __init__( | |
| + | self, | |
| + | beacon_domain: str, | |
| + | registry_url: str, | |
| + | answer_ip: str, | |
| + | registry_secret: str = "", | |
| + | ): | |
| self.beacon_domain = beacon_domain.rstrip(".").lower() | ||
| self.registry_url = registry_url.rstrip("/") | ||
| self.answer_ip = answer_ip | ||
| + | self.registry_secret = registry_secret | |
| self.token_suffix = f".t.{self.beacon_domain}" | ||
| def resolve(self, request, handler): | ||
| @@ -77,6 +85,9 @@ class OversightResolver(BaseResolver): | ||
| log.info(f"DNS beacon fired: token={token_id[:16]}... client={client_ip} qtype={qtype}") | ||
| # Report to registry asynchronously (best-effort - we still answer the query) | ||
| try: | ||
| + | headers = {} | |
| + | if self.registry_secret: | |
| + | headers["X-Oversight-DNS-Secret"] = self.registry_secret | |
| httpx.post( | ||
| f"{self.registry_url}/dns_event", | ||
| json={ | ||
| @@ -85,6 +96,7 @@ class OversightResolver(BaseResolver): | ||
| "qtype": qtype, | ||
| "qname": qname, | ||
| }, | ||
| + | headers=headers, | |
| timeout=2.0, | ||
| ) | ||
| except Exception as e: | ||
| @@ -106,6 +118,8 @@ def main(): | ||
| help="URL of the OVERSIGHT registry, e.g. http://localhost:8765") | ||
| p.add_argument("--answer-ip", required=True, | ||
| help="A-record answer IP (usually this server's public IP)") | ||
| + | p.add_argument("--registry-secret", default=os.environ.get("OVERSIGHT_DNS_EVENT_SECRET", ""), | |
| + | help="shared secret sent to registry /dns_event") | |
| p.add_argument("--port", type=int, default=53) | ||
| p.add_argument("--address", default="0.0.0.0") | ||
| p.add_argument("--log-level", default="INFO") | ||
| @@ -114,7 +128,15 @@ def main(): | ||
| logging.basicConfig(level=args.log_level, | ||
| format="%(asctime)s %(levelname)s %(name)s %(message)s") | ||
| - | resolver = OversightResolver(args.beacon_domain, args.registry_url, args.answer_ip) | |
| + | if not args.registry_secret and "localhost" not in args.registry_url and "127.0.0.1" not in args.registry_url: | |
| + | log.warning("no registry secret configured; public registry callbacks may be rejected") | |
| + | ||
| + | resolver = OversightResolver( | |
| + | args.beacon_domain, | |
| + | args.registry_url, | |
| + | args.answer_ip, | |
| + | registry_secret=args.registry_secret, | |
| + | ) | |
| server = DNSServer(resolver, port=args.port, address=args.address, | ||
| tcp=False) | ||
| tcp_server = DNSServer(resolver, port=args.port, address=args.address, |
| @@ -20,6 +20,8 @@ import sqlite3 | ||
| import sys | ||
| import threading | ||
| import time | ||
| + | import hmac | |
| + | import ipaddress | |
| from collections import OrderedDict | ||
| from contextlib import asynccontextmanager, contextmanager | ||
| from pathlib import Path | ||
| @@ -45,6 +47,7 @@ TLOG_DIR = DATA_DIR / "tlog" | ||
| IDENTITY_PATH = DATA_DIR / "registry-identity.json" | ||
| TRUSTED_PROXY = bool(int(os.environ.get("TRUSTED_PROXY", "0"))) | ||
| # When TRUSTED_PROXY=1, honor X-Forwarded-For for rate limiting. | ||
| + | DNS_EVENT_SECRET = os.environ.get("OVERSIGHT_DNS_EVENT_SECRET", "") | |
| # Rekor v2 wiring (v0.5 Session B). Off by default so existing tests do not | ||
| # generate live network traffic. Set OVERSIGHT_REKOR_ENABLED=1 to opt in. | ||
| @@ -262,6 +265,31 @@ def _append_tlog(event: dict) -> int: | ||
| return TLOG.append(event) if TLOG else -1 | ||
| + | def _tlog_proofs_for_events(events: list[dict]) -> list[dict]: | |
| + | """Attach inclusion proofs for event rows that have local tlog indexes.""" | |
| + | if not TLOG: | |
| + | return [] | |
| + | proofs = [] | |
| + | for i, event in enumerate(events): | |
| + | idx = event.get("tlog_index") | |
| + | if idx is None: | |
| + | continue | |
| + | try: | |
| + | idx = int(idx) | |
| + | except (TypeError, ValueError): | |
| + | continue | |
| + | if idx < 0: | |
| + | continue | |
| + | proof = TLOG.inclusion_proof(idx) | |
| + | if proof is not None: | |
| + | proofs.append({ | |
| + | "event_row": i, | |
| + | "tlog_index": idx, | |
| + | "proof": proof, | |
| + | }) | |
| + | return proofs | |
| + | ||
| + | ||
| def _attest_to_rekor( | ||
| file_id: str, | ||
| issuer_pub_hex: str, | ||
| @@ -341,6 +369,38 @@ def _rate_limit(request: Request): | ||
| raise HTTPException(429, "rate limit exceeded") | ||
| + | def _is_loopback_host(host: Optional[str]) -> bool: | |
| + | if not host: | |
| + | return False | |
| + | try: | |
| + | return ipaddress.ip_address(host).is_loopback | |
| + | except ValueError: | |
| + | return host in {"localhost", "testclient"} | |
| + | ||
| + | ||
| + | def _verify_dns_event_auth(request: Request): | |
| + | """Authenticate DNS bridge callbacks before trusting client_ip in the body.""" | |
| + | if DNS_EVENT_SECRET: | |
| + | supplied = request.headers.get("x-oversight-dns-secret", "") | |
| + | if not supplied: | |
| + | auth = request.headers.get("authorization", "") | |
| + | if auth.lower().startswith("bearer "): | |
| + | supplied = auth[7:].strip() | |
| + | if hmac.compare_digest(supplied, DNS_EVENT_SECRET): | |
| + | return | |
| + | raise HTTPException(401, "invalid DNS event secret") | |
| + | ||
| + | # Local same-host deployments are acceptable without a shared secret; public | |
| + | # deployments must set OVERSIGHT_DNS_EVENT_SECRET to prevent spoofed events. | |
| + | host = request.client.host if request.client else None | |
| + | if _is_loopback_host(host): | |
| + | return | |
| + | raise HTTPException( | |
| + | 503, | |
| + | "OVERSIGHT_DNS_EVENT_SECRET is required for non-loopback DNS event callbacks", | |
| + | ) | |
| + | ||
| + | ||
| def _verify_manifest_signature(manifest_dict: dict) -> tuple[bool, str]: | ||
| """ | ||
| Parse and verify the manifest's embedded Ed25519 signature. | ||
| @@ -615,6 +675,7 @@ def evidence_bundle(file_id: str): | ||
| "SELECT * FROM watermarks WHERE file_id=?", (file_id,) | ||
| ).fetchall() | ||
| + | event_dicts = [dict(e) for e in events] | |
| bundle = { | ||
| "file_id": file_id, | ||
| "bundle_generated_at": timestamp_stub(), | ||
| @@ -622,8 +683,9 @@ def evidence_bundle(file_id: str): | ||
| "manifest": json.loads(m["manifest_json"]), | ||
| "beacons": [dict(b) for b in beacons], | ||
| "watermarks": [dict(w) for w in watermarks], | ||
| - | "events": [dict(e) for e in events], | |
| + | "events": event_dicts, | |
| "tlog_head": TLOG.signed_head() if TLOG else None, | ||
| + | "tlog_proofs": _tlog_proofs_for_events(event_dicts), | |
| "disclaimer": ( | ||
| "This bundle is a provenance record, not a legal finding. For court use, " | ||
| "supplement with RFC 3161 qualified timestamps and ISO/IEC 27037 chain-of-custody." | ||
| @@ -686,6 +748,7 @@ class DnsEvent(BaseModel): | ||
| def dns_event(evt: DnsEvent, request: Request): | ||
| """Called by the oversight_dns server when a beacon DNS query arrives.""" | ||
| _rate_limit(request) | ||
| + | _verify_dns_event_auth(request) | |
| with db() as con: | ||
| row = con.execute( | ||
| "SELECT file_id, recipient_id, issuer_id FROM beacons WHERE token_id=?", |
| @@ -9,7 +9,10 @@ from __future__ import annotations | ||
| import base64 | ||
| import json | ||
| import os | ||
| + | import shutil | |
| import sys | ||
| + | import uuid | |
| + | from types import SimpleNamespace | |
| ROOT = os.path.join(os.path.dirname(__file__), "..") | ||
| sys.path.insert(0, ROOT) | ||
| @@ -19,6 +22,7 @@ from cryptography.hazmat.primitives import serialization | ||
| import registry.server as registry_server | ||
| from fastapi import HTTPException | ||
| + | from oversight_core.tlog import TransparencyLog | |
| def _new_identity() -> dict: | ||
| @@ -123,14 +127,76 @@ def t2_register_rejects_unsigned_sidecar_mismatch(): | ||
| print(" [PASS] register rejects unsigned beacon/watermark sidecars") | ||
| + | def _fake_request(host: str, headers: dict[str, str] | None = None): | |
| + | return SimpleNamespace( | |
| + | client=SimpleNamespace(host=host), | |
| + | headers=headers or {}, | |
| + | ) | |
| + | ||
| + | ||
| + | def t3_dns_event_requires_secret_for_non_loopback(): | |
| + | original_secret = registry_server.DNS_EVENT_SECRET | |
| + | try: | |
| + | registry_server.DNS_EVENT_SECRET = "" | |
| + | registry_server._verify_dns_event_auth(_fake_request("127.0.0.1")) | |
| + | try: | |
| + | registry_server._verify_dns_event_auth(_fake_request("203.0.113.10")) | |
| + | except HTTPException as exc: | |
| + | assert exc.status_code == 503 | |
| + | assert "OVERSIGHT_DNS_EVENT_SECRET" in exc.detail | |
| + | else: | |
| + | raise AssertionError("public DNS callbacks should fail closed without a secret") | |
| + | ||
| + | registry_server.DNS_EVENT_SECRET = "shared-secret" | |
| + | registry_server._verify_dns_event_auth( | |
| + | _fake_request("203.0.113.10", {"x-oversight-dns-secret": "shared-secret"}) | |
| + | ) | |
| + | try: | |
| + | registry_server._verify_dns_event_auth( | |
| + | _fake_request("203.0.113.10", {"x-oversight-dns-secret": "wrong"}) | |
| + | ) | |
| + | except HTTPException as exc: | |
| + | assert exc.status_code == 401 | |
| + | else: | |
| + | raise AssertionError("wrong DNS callback secret should be rejected") | |
| + | finally: | |
| + | registry_server.DNS_EVENT_SECRET = original_secret | |
| + | print(" [PASS] dns_event rejects unauthenticated non-loopback callbacks") | |
| + | ||
| + | ||
| + | def t4_evidence_bundle_can_attach_tlog_proofs(): | |
| + | original_tlog = registry_server.TLOG | |
| + | td = os.path.join(ROOT, ".tmp-tests", f"registry-tlog-{uuid.uuid4().hex}") | |
| + | os.makedirs(td, exist_ok=False) | |
| + | try: | |
| + | registry_server.TLOG = TransparencyLog(td) | |
| + | first = registry_server.TLOG.append({"event": "register", "file_id": "f"}) | |
| + | second = registry_server.TLOG.append({"event": "beacon", "file_id": "f"}) | |
| + | proofs = registry_server._tlog_proofs_for_events([ | |
| + | {"kind": "register", "tlog_index": first}, | |
| + | {"kind": "beacon", "tlog_index": second}, | |
| + | {"kind": "offline", "tlog_index": -1}, | |
| + | ]) | |
| + | finally: | |
| + | registry_server.TLOG = original_tlog | |
| + | shutil.rmtree(td, ignore_errors=True) | |
| + | ||
| + | assert [p["event_row"] for p in proofs] == [0, 1] | |
| + | assert [p["tlog_index"] for p in proofs] == [first, second] | |
| + | assert all(p["proof"]["root"] for p in proofs) | |
| + | print(" [PASS] evidence bundles attach tlog inclusion proofs for events") | |
| + | ||
| + | ||
| def main(): | ||
| print("=" * 60) | ||
| print(" registry.server - focused unit tests") | ||
| print("=" * 60) | ||
| t1_rekor_attestation_uses_real_mark_id_and_digest() | ||
| t2_register_rejects_unsigned_sidecar_mismatch() | ||
| + | t3_dns_event_requires_secret_for_non_loopback() | |
| + | t4_evidence_bundle_can_attach_tlog_proofs() | |
| print() | ||
| - | print(" ALL TESTS PASSED - 2/2") | |
| + | print(" ALL TESTS PASSED - 4/4") | |
| if __name__ == "__main__": |