| @@ -14,6 +14,11 @@ | ||
| runs keep working, but production operators can protect write-side APIs | ||
| without changing route shapes. The conformance harness sends the token as | ||
| a bearer header when `OVERSIGHT_OPERATOR_TOKEN` is set. | ||
| + | - **Rust registry operator-token parity.** The Axum + SQLx registry now reads | |
| + | `OVERSIGHT_OPERATOR_TOKEN` too and enforces it on `POST /register` and | |
| + | `POST /attribute` with the same bearer/header contract as the Python | |
| + | registry. Its DNS event route also accepts either `Authorization: Bearer` | |
| + | or `X-Oversight-DNS-Secret`, matching the live deployment guide. | |
| - **Deployment docs.** Added `docs/REGISTRY_DEPLOYMENT.md` covering the live | ||
| Compose/Caddy flow, route map, token headers, DNS bridge secret, and local | ||
| versus live conformance commands. |
| @@ -64,7 +64,8 @@ docker compose --profile live up -d | ||
| Set `OVERSIGHT_DNS_EVENT_SECRET` and `OVERSIGHT_OPERATOR_TOKEN` in `.env` | ||
| before exposing a public host. The operator token protects `POST /register` | ||
| and `POST /attribute`; the DNS secret authenticates `/dns_event` bridge | ||
| - | callbacks. Full route map and validation commands are in | |
| + | callbacks. The Python FastAPI registry and Rust Axum registry both honor | |
| + | the same bearer/header token contract. Full route map and validation commands are in | |
| [`docs/REGISTRY_DEPLOYMENT.md`](docs/REGISTRY_DEPLOYMENT.md). | ||
| ## Quick start |
| @@ -2,7 +2,8 @@ | ||
| This is the public-safe live configuration for the reference Oversight | ||
| registry. It keeps secrets in `.env`, keeps the registry process off the public | ||
| - | host interface, and exposes TLS through Caddy. | |
| + | host interface, and exposes TLS through Caddy. The Python FastAPI registry and | |
| + | the Rust Axum registry both honor the write-side operator token described here. | |
| ## Layout | ||
| @@ -72,7 +73,8 @@ X-Oversight-Operator-Token: <token> | ||
| Leaving `OVERSIGHT_OPERATOR_TOKEN` empty keeps the v1 conformance harness and | ||
| local development behavior unchanged. Do not leave it empty on a public | ||
| - | operator deployment. | |
| + | operator deployment. Both reference registry implementations use the same | |
| + | token contract, so live conformance commands work against either backend. | |
| DNS bridge callbacks are separate. Set `OVERSIGHT_DNS_EVENT_SECRET`; the DNS | ||
| bridge must send either `Authorization: Bearer <secret>` or |
| @@ -229,11 +229,14 @@ material. | ||
| ### Registry in Rust | ||
| `oversight-rust/oversight-registry` is scaffolded with all endpoints | ||
| - | implemented under `#![forbid(unsafe_code)]`. As of 2026-05-03, the Axum | |
| + | implemented under `#![forbid(unsafe_code)]`. As of 2026-05-14, the Axum | |
| server passes the existing 33-check `tests/test_registry_conformance.py` | ||
| - | harness in live-URL mode against the registry v1 surface. Remaining work: | |
| - | migration tooling from the Python registry, longer-running deployment tests, | |
| - | and a wire-format stability declaration before declaring v1.0 ready. | |
| + | 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 | |
| + | auth. Remaining work: migration tooling from the Python registry, | |
| + | longer-running deployment tests, and a wire-format stability declaration | |
| + | before declaring v1.0 ready. | |
| --- | ||
| @@ -61,6 +61,14 @@ with identical output. The cross-language conformance suite pins this. | ||
| `issuer_ed25519_pub` as the original record. A mismatch returns | ||
| HTTP 409. | ||
| + | ### Operator authentication | |
| + | ||
| + | Public operator deployments SHOULD protect write-side registry APIs with | |
| + | an operator token. If configured, `POST /register` and `POST /attribute` | |
| + | MUST require either `Authorization: Bearer <token>` or | |
| + | `X-Oversight-Operator-Token: <token>`. Leaving the token unset preserves | |
| + | local development and unauthenticated conformance-harness behavior. | |
| + | ||
| ### Error envelope | ||
| Non-2xx responses MUST carry a JSON envelope: | ||
| @@ -219,9 +227,10 @@ Authentication: | ||
| - Loopback clients are trusted without a secret so a DNS server on | ||
| the same host can call without extra configuration. | ||
| - | - Non-loopback callers MUST send `X-Oversight-DNS-Secret: <secret>` | |
| - | that matches the registry's configured secret. The comparison MUST | |
| - | be constant-time (`hmac.compare_digest` or equivalent). | |
| + | - Non-loopback callers MUST send either `Authorization: Bearer <secret>` | |
| + | or `X-Oversight-DNS-Secret: <secret>` matching the registry's configured | |
| + | secret. The comparison MUST be constant-time (`hmac.compare_digest` or | |
| + | equivalent). | |
| - A registry that has no secret configured MUST refuse non-loopback | ||
| callers. Silent acceptance of unauthenticated non-loopback events | ||
| is a conformance failure. |
| @@ -4,8 +4,70 @@ | ||
| //! in canonical JSON form. The issuer's Ed25519 public key is embedded in the | ||
| //! manifest itself - verification proves the issuer signed the exact bytes. | ||
| + | use axum::http::{header, HeaderMap}; | |
| use oversight_manifest::Manifest; | ||
| + | use crate::error::{RegistryError, Result as RegistryResult}; | |
| + | ||
| + | /// Extract a token from either `Authorization: Bearer ...` or a named header. | |
| + | pub fn bearer_or_header_token(headers: &HeaderMap, header_name: &'static str) -> Option<String> { | |
| + | if let Some(auth) = headers | |
| + | .get(header::AUTHORIZATION) | |
| + | .and_then(|v| v.to_str().ok()) | |
| + | { | |
| + | if let Some((scheme, value)) = auth.trim().split_once(' ') { | |
| + | let token = value.trim(); | |
| + | if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() { | |
| + | return Some(token.to_string()); | |
| + | } | |
| + | } | |
| + | } | |
| + | ||
| + | headers | |
| + | .get(header_name) | |
| + | .and_then(|v| v.to_str().ok()) | |
| + | .map(str::trim) | |
| + | .filter(|token| !token.is_empty()) | |
| + | .map(str::to_string) | |
| + | } | |
| + | ||
| + | /// Require an optional deployment token. Empty config preserves local/dev mode. | |
| + | pub fn require_optional_token( | |
| + | configured_token: Option<&str>, | |
| + | headers: &HeaderMap, | |
| + | header_name: &'static str, | |
| + | label: &'static str, | |
| + | ) -> RegistryResult<()> { | |
| + | let Some(expected) = configured_token else { | |
| + | return Ok(()); | |
| + | }; | |
| + | ||
| + | let Some(supplied) = bearer_or_header_token(headers, header_name) else { | |
| + | return Err(RegistryError::Unauthorized(format!( | |
| + | "{label} authentication required" | |
| + | ))); | |
| + | }; | |
| + | ||
| + | if constant_time_eq(supplied.as_bytes(), expected.as_bytes()) { | |
| + | return Ok(()); | |
| + | } | |
| + | ||
| + | Err(RegistryError::Unauthorized(format!( | |
| + | "invalid {label} authentication" | |
| + | ))) | |
| + | } | |
| + | ||
| + | pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| + | if a.len() != b.len() { | |
| + | return false; | |
| + | } | |
| + | let mut diff = 0u8; | |
| + | for (&x, &y) in a.iter().zip(b.iter()) { | |
| + | diff |= x ^ y; | |
| + | } | |
| + | diff == 0 | |
| + | } | |
| + | ||
| /// Parse a manifest JSON value, canonicalize it, and verify the embedded | ||
| /// Ed25519 signature. | ||
| /// | ||
| @@ -84,6 +146,7 @@ pub fn validate_signed_artifacts( | ||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| + | use axum::http::HeaderValue; | |
| #[test] | ||
| fn canonical_items_sorts_deterministically() { | ||
| @@ -101,4 +164,50 @@ mod tests { | ||
| let b = serde_json::json!({"token_id": "xyz", "kind": "dns"}); | ||
| assert_ne!(canonical_items(&[a]), canonical_items(&[b])); | ||
| } | ||
| + | ||
| + | #[test] | |
| + | fn bearer_or_named_header_token_are_supported() { | |
| + | let mut headers = HeaderMap::new(); | |
| + | headers.insert( | |
| + | header::AUTHORIZATION, | |
| + | HeaderValue::from_static("Bearer operator-secret"), | |
| + | ); | |
| + | assert_eq!( | |
| + | bearer_or_header_token(&headers, "x-oversight-operator-token").as_deref(), | |
| + | Some("operator-secret") | |
| + | ); | |
| + | ||
| + | let mut headers = HeaderMap::new(); | |
| + | headers.insert( | |
| + | "x-oversight-operator-token", | |
| + | HeaderValue::from_static("operator-secret"), | |
| + | ); | |
| + | assert_eq!( | |
| + | bearer_or_header_token(&headers, "x-oversight-operator-token").as_deref(), | |
| + | Some("operator-secret") | |
| + | ); | |
| + | } | |
| + | ||
| + | #[test] | |
| + | fn optional_token_fails_closed_when_configured() { | |
| + | let headers = HeaderMap::new(); | |
| + | assert!(require_optional_token(None, &headers, "x-test-token", "operator").is_ok()); | |
| + | assert!(matches!( | |
| + | require_optional_token(Some("secret"), &headers, "x-test-token", "operator"), | |
| + | Err(RegistryError::Unauthorized(_)) | |
| + | )); | |
| + | ||
| + | let mut headers = HeaderMap::new(); | |
| + | headers.insert( | |
| + | header::AUTHORIZATION, | |
| + | HeaderValue::from_static("Bearer secret"), | |
| + | ); | |
| + | assert!( | |
| + | require_optional_token(Some("secret"), &headers, "x-test-token", "operator").is_ok() | |
| + | ); | |
| + | assert!(matches!( | |
| + | require_optional_token(Some("wrong"), &headers, "x-test-token", "operator"), | |
| + | Err(RegistryError::Unauthorized(_)) | |
| + | )); | |
| + | } | |
| } |
| @@ -69,6 +69,7 @@ pub struct AppState { | ||
| pub identity: Option<RegistryIdentity>, | ||
| pub rate_limiter: RateLimiter, | ||
| pub trusted_proxy: bool, | ||
| + | pub operator_token: Option<String>, | |
| pub dns_event_secret: Option<String>, | ||
| pub rekor_enabled: bool, | ||
| pub rekor_url: String, | ||
| @@ -362,6 +363,11 @@ async fn main() -> anyhow::Result<()> { | ||
| .map(|s| s.trim().to_string()) | ||
| .filter(|s| !s.is_empty()); | ||
| + | let operator_token = std::env::var("OVERSIGHT_OPERATOR_TOKEN") | |
| + | .ok() | |
| + | .map(|s| s.trim().to_string()) | |
| + | .filter(|s| !s.is_empty()); | |
| + | ||
| let rekor_url = std::env::var("OVERSIGHT_REKOR_URL") | ||
| .unwrap_or_else(|_| oversight_rekor::DEFAULT_REKOR_URL.to_string()); | ||
| @@ -395,6 +401,7 @@ async fn main() -> anyhow::Result<()> { | ||
| identity, | ||
| rate_limiter: RateLimiter::new(10.0, 30.0, 100_000), | ||
| trusted_proxy, | ||
| + | operator_token, | |
| dns_event_secret, | ||
| rekor_enabled, | ||
| rekor_url, |
| @@ -1,9 +1,11 @@ | ||
| //! POST /attribute - attribution lookup by token_id, mark_id, or perceptual_hash. | ||
| use axum::extract::State; | ||
| + | use axum::http::HeaderMap; | |
| use axum::Json; | ||
| use std::sync::Arc; | ||
| + | use crate::auth::require_optional_token; | |
| use crate::db; | ||
| use crate::error::{RegistryError, Result}; | ||
| use crate::models::*; | ||
| @@ -11,8 +13,16 @@ use crate::AppState; | ||
| pub async fn attribute( | ||
| State(state): State<Arc<AppState>>, | ||
| + | headers: HeaderMap, | |
| Json(q): Json<AttributionQuery>, | ||
| ) -> Result<Json<AttributionResponse>> { | ||
| + | require_optional_token( | |
| + | state.operator_token.as_deref(), | |
| + | &headers, | |
| + | "x-oversight-operator-token", | |
| + | "operator", | |
| + | )?; | |
| + | ||
| // Validate input sizes | ||
| if let Some(ref id) = q.token_id { | ||
| if id.len() > MAX_ID_LEN { |
| @@ -7,6 +7,7 @@ use std::net::SocketAddr; | ||
| use std::sync::Arc; | ||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||
| + | use crate::auth::{bearer_or_header_token, constant_time_eq}; | |
| use crate::db; | ||
| use crate::error::{RegistryError, Result}; | ||
| use crate::models::*; | ||
| @@ -102,12 +103,10 @@ pub async fn dns_event( | ||
| fn verify_dns_event_auth(state: &AppState, headers: &HeaderMap, addr: &SocketAddr) -> Result<()> { | ||
| if let Some(secret) = state.dns_event_secret.as_deref() { | ||
| - | let supplied = headers | |
| - | .get("x-oversight-dns-secret") | |
| - | .and_then(|v| v.to_str().ok()) | |
| - | .unwrap_or(""); | |
| - | if constant_time_eq(supplied.as_bytes(), secret.as_bytes()) { | |
| - | return Ok(()); | |
| + | if let Some(supplied) = bearer_or_header_token(headers, "x-oversight-dns-secret") { | |
| + | if constant_time_eq(supplied.as_bytes(), secret.as_bytes()) { | |
| + | return Ok(()); | |
| + | } | |
| } | ||
| return Err(RegistryError::Unauthorized( | ||
| "invalid dns event authentication".into(), | ||
| @@ -122,14 +121,3 @@ fn verify_dns_event_auth(state: &AppState, headers: &HeaderMap, addr: &SocketAdd | ||
| "OVERSIGHT_DNS_EVENT_SECRET is required for non-loopback DNS event callbacks".into(), | ||
| )) | ||
| } | ||
| - | ||
| - | fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { | |
| - | if a.len() != b.len() { | |
| - | return false; | |
| - | } | |
| - | let mut diff = 0u8; | |
| - | for (&x, &y) in a.iter().zip(b.iter()) { | |
| - | diff |= x ^ y; | |
| - | } | |
| - | diff == 0 | |
| - | } |
| @@ -134,6 +134,7 @@ mod tests { | ||
| }), | ||
| rate_limiter: RateLimiter::new(10.0, 30.0, 100), | ||
| trusted_proxy: false, | ||
| + | operator_token: None, | |
| dns_event_secret: None, | ||
| rekor_enabled: false, | ||
| rekor_url: String::new(), |
| @@ -7,11 +7,12 @@ | ||
| //! 4. All inputs are size-validated before processing. | ||
| use axum::extract::State; | ||
| + | use axum::http::HeaderMap; | |
| use axum::Json; | ||
| use std::sync::Arc; | ||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||
| - | use crate::auth::{validate_signed_artifacts, verify_manifest_signature}; | |
| + | use crate::auth::{require_optional_token, validate_signed_artifacts, verify_manifest_signature}; | |
| use crate::db; | ||
| use crate::error::{RegistryError, Result}; | ||
| use crate::models::*; | ||
| @@ -19,8 +20,16 @@ use crate::AppState; | ||
| pub async fn register( | ||
| State(state): State<Arc<AppState>>, | ||
| + | headers: HeaderMap, | |
| Json(req): Json<RegistrationRequest>, | ||
| ) -> Result<Json<RegistrationResponse>> { | ||
| + | require_optional_token( | |
| + | state.operator_token.as_deref(), | |
| + | &headers, | |
| + | "x-oversight-operator-token", | |
| + | "operator", | |
| + | )?; | |
| + | ||
| // ---- Input validation ---- | ||
| let manifest = &req.manifest; |