Zion Boggan zionboggan.com ↗

Align Rust registry operator auth

Co-authored-by: Codex (GPT-5.4) <noreply@openai.com>
fe202c6   Zion Boggan committed on May 15, 2026 (1 month ago)
CHANGELOG.md +5 -0
@@ -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.
README.md +2 -1
@@ -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
docs/REGISTRY_DEPLOYMENT.md +4 -2
@@ -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
docs/ROADMAP.md +7 -4
@@ -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.
---
docs/spec/registry-v1.md +12 -3
@@ -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.
oversight-rust/oversight-registry/src/auth.rs +109 -0
@@ -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(_))
+ ));
+ }
}
oversight-rust/oversight-registry/src/main.rs +7 -0
@@ -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,
oversight-rust/oversight-registry/src/routes/attribute.rs +10 -0
@@ -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 {
oversight-rust/oversight-registry/src/routes/dns_event.rs +5 -17
@@ -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
-}
oversight-rust/oversight-registry/src/routes/evidence.rs +1 -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(),
oversight-rust/oversight-registry/src/routes/register.rs +10 -1
@@ -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;