Zion Boggan zionboggan.com ↗

Harden Rust registry and format adapters

Co-authored-by: Codex (GPT-5.4) <noreply@openai.com>
8f0075c   Zion Boggan committed on Apr 20, 2026 (2 months ago)
CHANGELOG.md +23 -0
@@ -33,6 +33,22 @@ confuse the hardened tree with the vulnerable `v0.4.3` baseline.
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.
+- `oversight-rust/oversight-registry`: `/dns_event` now requires
+ `OVERSIGHT_DNS_EVENT_SECRET` for non-loopback callbacks, signed
+ beacon/watermark artifacts fail registration when malformed instead of being
+ silently dropped, and Rekor attestation skips watermarkless registrations
+ rather than logging `mark:<file_id>`.
+- `oversight-rust/oversight-container` and `oversight-rust/oversight-policy`:
+ Rust opens can now enforce `max_opens` after successful recipient decrypt,
+ `REGISTRY` / `HYBRID` modes fail closed instead of falling back to local
+ counters, and Rust `seal_multi()` fails closed until recipient-honest
+ manifests exist.
+- `oversight-rust/oversight-rekor`: offline verification now mirrors Python by
+ rejecting DSSE envelopes whose subject digest does not match the expected
+ content hash.
+- `oversight-rust/oversight-formats`: DOCX metadata insertion no longer reports
+ success when `<cp:keywords>` is missing, and PDF processing rejects indirect
+ Launch / JavaScript / unsafe URI actions before rewriting files.
- 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`.
@@ -51,6 +67,13 @@ Patch sequence on top of `v0.4.3`:
empty tlog roots fixed.
7. `0.4.4` / `0a7a2da`: package, core, and CLI version metadata
aligned to the hardened `0.4.4` line.
+8. `0.4.4` / `69e50aa`: public changelog patch chronology documented.
+9. `0.4.4` / `26db8d3`: DNS evidence hardening, Rust RNG dependency
+ cleanup, and evidence-bundle inclusion proofs.
+10. `0.5.0+` / `b9bee41`: Claude-added Rust format adapters, Axum registry,
+ and USENIX benchmark scaffolding.
+11. `0.5.0+` / current hardening commit: Codex audit fixes for the new Rust
+ registry/container/policy/Rekor/format-adapter security regressions.
## v0.5.0 - 2026-04-19
README.md +4 -0
@@ -131,6 +131,10 @@ These items are included in v0.4.4 and current `main`:
- Registry registration now refuses unsigned beacon/watermark sidecars that do not match the issuer-signed manifest.
- Multi-recipient sealing is disabled until a recipient-honest manifest format lands.
- Local transparency-log empty-tree roots now match RFC 6962 exactly.
+- Rust registry and format-adapter paths now mirror the Python hardening:
+ authenticated DNS beacon callbacks, no silent signed-artifact drops,
+ digest-checked Rekor offline verification, fail-closed Rust `max_opens`,
+ DOCX keyword insertion, and PDF action screening.
## Repository layout
oversight-rust/oversight-cli/Cargo.toml +1 -0
@@ -17,6 +17,7 @@ oversight-container = { path = "../oversight-container" }
oversight-manifest = { path = "../oversight-manifest" }
oversight-watermark = { path = "../oversight-watermark" }
oversight-formats = { path = "../oversight-formats" }
+oversight-policy = { path = "../oversight-policy" }
clap.workspace = true
serde.workspace = true
serde_json.workspace = true
oversight-rust/oversight-cli/src/main.rs +8 -1
@@ -10,6 +10,7 @@ use oversight_container::{open_sealed, seal, SealedFile};
use oversight_crypto::{self as crypto, ClassicIdentity};
use oversight_formats::{FormatAdapter, FormatRegistry};
use oversight_manifest::{Manifest, Recipient};
+use oversight_policy::PolicyContext;
#[derive(Parser)]
#[command(name = "oversight")]
@@ -69,6 +70,10 @@ enum Commands {
/// Recipient identity JSON
#[arg(short = 'R', long)]
recipient: PathBuf,
+
+ /// Local directory for max_opens counters
+ #[arg(long, default_value = ".oversight/policy-state")]
+ policy_state_dir: PathBuf,
},
/// Print the signed manifest + structural metadata of a sealed file
@@ -216,11 +221,13 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
input,
output,
recipient,
+ policy_state_dir,
} => {
let recipient_id = load_identity(&recipient)?;
let blob = std::fs::read(&input)?;
+ let policy_ctx = PolicyContext::local_only(policy_state_dir)?;
let (plaintext, manifest) =
- open_sealed(&blob, recipient_id.x25519_priv.as_ref(), None)?;
+ open_sealed(&blob, recipient_id.x25519_priv.as_ref(), None, Some(&policy_ctx))?;
if output.as_os_str() == "-" {
use std::io::Write;
std::io::stdout().write_all(&plaintext)?;
oversight-rust/oversight-container/Cargo.toml +1 -0
@@ -9,6 +9,7 @@ description = "Binary .sealed container format for Oversight"
[dependencies]
oversight-crypto = { path = "../oversight-crypto" }
oversight-manifest = { path = "../oversight-manifest" }
+oversight-policy = { path = "../oversight-policy" }
serde.workspace = true
serde_json.workspace = true
hex.workspace = true
oversight-rust/oversight-container/src/lib.rs +44 -58
@@ -21,6 +21,7 @@
use oversight_crypto::{self as crypto, CryptoError, WrappedDek};
use oversight_manifest::{Manifest, ManifestError};
+use oversight_policy::{self, PolicyContext};
use thiserror::Error;
pub const MAGIC: [u8; 6] = *b"OSGT\x01\x00";
@@ -54,6 +55,8 @@ pub enum ContainerError {
Manifest(#[from] ManifestError),
#[error(transparent)]
Crypto(#[from] CryptoError),
+ #[error(transparent)]
+ Policy(#[from] oversight_policy::PolicyError),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("invalid utf-8: {0}")]
@@ -232,6 +235,7 @@ pub fn open_sealed(
blob: &[u8],
recipient_x25519_priv: &[u8],
trusted_issuer_pubs: Option<&[String]>,
+ policy_ctx: Option<&PolicyContext>,
) -> Result<(Vec<u8>, Manifest), ContainerError> {
if recipient_x25519_priv.len() != 32 {
return Err(ContainerError::Precondition("recipient priv key must be 32 bytes"));
@@ -287,58 +291,24 @@ pub fn open_sealed(
return Err(ContainerError::HashMismatch);
}
+ // Count only successful recipient decryptions. Failed key guesses cannot
+ // burn max_opens, but a policy failure still prevents plaintext release.
+ oversight_policy::record_open(&sf.manifest, policy_ctx)?;
+
Ok((plaintext, sf.manifest))
}
-/// Seal for multiple recipients (compact storage: one ciphertext, N key wraps).
+/// Fail closed until the manifest schema can explicitly bind every recipient.
pub fn seal_multi(
plaintext: &[u8],
manifest: &mut Manifest,
issuer_ed25519_priv: &[u8],
recipient_x25519_pubs: &[&[u8]],
) -> Result<Vec<u8>, ContainerError> {
- if manifest.content_hash != crypto::content_hash(plaintext) {
- return Err(ContainerError::Precondition(
- "manifest.content_hash != sha256(plaintext)",
- ));
- }
- if manifest.size_bytes != plaintext.len() as u64 {
- return Err(ContainerError::Precondition(
- "manifest.size_bytes != len(plaintext)",
- ));
- }
- if recipient_x25519_pubs.is_empty() {
- return Err(ContainerError::Precondition("need at least one recipient"));
- }
- for (i, pub_key) in recipient_x25519_pubs.iter().enumerate() {
- if pub_key.len() != 32 {
- return Err(ContainerError::Precondition(
- "recipient pubkey must be 32 bytes",
- ));
- }
- let _ = i;
- }
-
- manifest.sign(issuer_ed25519_priv)?;
- let dek = crypto::random_dek();
- let slots: Result<Vec<_>, _> = recipient_x25519_pubs
- .iter()
- .map(|p| crypto::wrap_dek_for_recipient(dek.as_ref(), p))
- .collect();
- let slots = slots?;
- let slots_json: Vec<_> = slots.iter().map(|s| s.to_json_hex()).collect();
-
- let aad = manifest.content_hash.as_bytes();
- let (nonce, ct) = crypto::aead_encrypt(dek.as_ref(), plaintext, aad)?;
-
- let sf = SealedFile {
- manifest: manifest.clone(),
- wrapped_dek: serde_json::json!({ "slots": slots_json }),
- aead_nonce: nonce,
- ciphertext: ct,
- suite_id: SUITE_CLASSIC_V1_ID,
- };
- sf.to_bytes()
+ let _ = (plaintext, manifest, issuer_ed25519_priv, recipient_x25519_pubs);
+ Err(ContainerError::Precondition(
+ "seal_multi disabled until manifests can bind all recipients",
+ ))
}
#[cfg(test)]
@@ -374,7 +344,7 @@ mod tests {
let plaintext = b"This is my secret document.";
let mut m = make_manifest(&issuer, &recipient, plaintext);
let blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &recipient.x25519_pub).unwrap();
- let (pt, manifest) = open_sealed(&blob, recipient.x25519_priv.as_ref(), None).unwrap();
+ let (pt, manifest) = open_sealed(&blob, recipient.x25519_priv.as_ref(), None, None).unwrap();
assert_eq!(pt, plaintext);
assert_eq!(manifest.file_id, m.file_id);
}
@@ -388,7 +358,7 @@ mod tests {
let mut m = make_manifest(&issuer, &alice, plaintext);
let blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
// Bob tries to open - should fail at AEAD stage
- assert!(open_sealed(&blob, bob.x25519_priv.as_ref(), None).is_err());
+ assert!(open_sealed(&blob, bob.x25519_priv.as_ref(), None, None).is_err());
}
#[test]
@@ -400,7 +370,7 @@ mod tests {
let mut blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
let len = blob.len();
blob[len - 1] ^= 0x01;
- assert!(open_sealed(&blob, alice.x25519_priv.as_ref(), None).is_err());
+ assert!(open_sealed(&blob, alice.x25519_priv.as_ref(), None, None).is_err());
}
#[test]
@@ -440,22 +410,43 @@ mod tests {
let mut m = make_manifest(&issuer, &alice, plaintext);
m.policy["not_after"] = serde_json::json!(1000); // long ago
let blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
- match open_sealed(&blob, alice.x25519_priv.as_ref(), None) {
+ match open_sealed(&blob, alice.x25519_priv.as_ref(), None, None) {
Err(ContainerError::Precondition("file expired (not_after)")) => (),
other => panic!("expected expiry error, got {:?}", other.is_ok()),
}
}
#[test]
- fn seal_multi_three_recipients() {
+ fn max_opens_counts_only_successful_decrypts() {
+ let issuer = ClassicIdentity::generate();
+ let alice = ClassicIdentity::generate();
+ let bob = ClassicIdentity::generate();
+ let plaintext = b"limited";
+ let mut m = make_manifest(&issuer, &alice, plaintext);
+ m.policy["max_opens"] = serde_json::json!(1);
+ let blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
+
+ let dir = std::env::temp_dir().join(format!(
+ "oversight-container-policy-{}",
+ std::process::id()
+ ));
+ let _ = std::fs::remove_dir_all(&dir);
+ let ctx = oversight_policy::PolicyContext::local_only(&dir).unwrap();
+
+ assert!(open_sealed(&blob, bob.x25519_priv.as_ref(), None, Some(&ctx)).is_err());
+ assert!(open_sealed(&blob, alice.x25519_priv.as_ref(), None, Some(&ctx)).is_ok());
+ assert!(open_sealed(&blob, alice.x25519_priv.as_ref(), None, Some(&ctx)).is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn seal_multi_fails_closed_until_manifest_schema_exists() {
let issuer = ClassicIdentity::generate();
let alice = ClassicIdentity::generate();
let bob = ClassicIdentity::generate();
let carol = ClassicIdentity::generate();
- let stranger = ClassicIdentity::generate();
let plaintext = b"shared document for cohort";
- // For seal_multi, we use a placeholder recipient in the manifest
let mut m = Manifest::new(
"cohort.txt",
crypto::content_hash(plaintext),
@@ -474,14 +465,9 @@ mod tests {
"GLOBAL",
);
let recipients: Vec<&[u8]> = vec![&alice.x25519_pub, &bob.x25519_pub, &carol.x25519_pub];
- let blob = seal_multi(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &recipients).unwrap();
-
- // All three should decrypt
- for r in [&alice, &bob, &carol] {
- let (pt, _) = open_sealed(&blob, r.x25519_priv.as_ref(), None).unwrap();
- assert_eq!(pt, plaintext);
+ match seal_multi(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &recipients) {
+ Err(ContainerError::Precondition(msg)) => assert!(msg.contains("seal_multi disabled")),
+ other => panic!("expected seal_multi to fail closed, got {:?}", other.is_ok()),
}
- // Stranger should fail
- assert!(open_sealed(&blob, stranger.x25519_priv.as_ref(), None).is_err());
}
}
oversight-rust/oversight-formats/src/docx.rs +36 -7
@@ -284,19 +284,36 @@ fn inject_keywords_into_core_xml(xml_bytes: &[u8], tag: &str) -> Result<Vec<u8>,
}
}
- // If no <cp:keywords> element was found, we would need to insert one.
- // For simplicity in this scaffold, we just return the output as-is.
- // A full implementation would insert the element before </cp:coreProperties>.
if !found_keywords {
- // Fall back: rewrite the whole XML with the keywords added.
- // For now, return original with a note that keywords weren't found.
- // TODO: Insert <cp:keywords> element if missing.
- return Ok(xml_bytes.to_vec());
+ return insert_keywords_into_core_xml(xml_bytes, tag);
}
Ok(output)
}
+fn insert_keywords_into_core_xml(xml_bytes: &[u8], tag: &str) -> Result<Vec<u8>, FormatError> {
+ let xml = std::str::from_utf8(xml_bytes)
+ .map_err(|e| FormatError::Malformed(format!("core.xml is not UTF-8: {}", e)))?;
+ let keywords = format!(
+ "<cp:keywords>{}</cp:keywords>",
+ quick_xml::escape::escape(tag)
+ );
+
+ for closing in ["</cp:coreProperties>", "</coreProperties>"] {
+ if let Some(idx) = xml.rfind(closing) {
+ let mut out = String::with_capacity(xml.len() + keywords.len());
+ out.push_str(&xml[..idx]);
+ out.push_str(&keywords);
+ out.push_str(&xml[idx..]);
+ return Ok(out.into_bytes());
+ }
+ }
+
+ Err(FormatError::Malformed(
+ "docProps/core.xml missing coreProperties closing tag".into(),
+ ))
+}
+
/// Extract the text content of `<cp:keywords>` from core.xml.
fn extract_keywords_from_core_xml(xml_bytes: &[u8]) -> Result<Option<String>, FormatError> {
let mut reader = Reader::from_reader(xml_bytes);
@@ -509,4 +526,16 @@ mod tests {
assert!(xml.contains("oversight:abcdef"));
assert!(xml.contains("<?xml"));
}
+
+ #[test]
+ fn inject_keywords_adds_missing_element() {
+ let xml = br#"<?xml version="1.0" encoding="UTF-8"?>
+<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties">
+ <dc:title xmlns:dc="http://purl.org/dc/elements/1.1/">Report</dc:title>
+</cp:coreProperties>"#;
+ let out = inject_keywords_into_core_xml(xml, "oversight:abcdef").unwrap();
+ let s = String::from_utf8(out).unwrap();
+ assert!(s.contains("<cp:keywords>oversight:abcdef</cp:keywords>"));
+ assert!(s.contains("</cp:coreProperties>"));
+ }
}
oversight-rust/oversight-formats/src/pdf.rs +56 -3
@@ -213,15 +213,38 @@ pub fn extract_text_for_fingerprint(pdf_bytes: &[u8]) -> Result<String, FormatEr
fn security_check(doc: &Document) -> Result<(), FormatError> {
for (_id, obj) in doc.objects.iter() {
if let Ok(dict) = obj.as_dict() {
- // Check for JavaScript
if dict.has(b"JS") || dict.has(b"JavaScript") {
return Err(FormatError::Malformed(
"PDF contains JavaScript -- refusing to process for security".into(),
));
}
- // Check for auto-open actions
+ if let Ok(s_type) = dict.get(b"S") {
+ if let Ok(name) = s_type.as_name_str() {
+ match name {
+ "Launch" | "JavaScript" => {
+ return Err(FormatError::Malformed(
+ "PDF contains Launch/JavaScript action -- refusing to process"
+ .into(),
+ ));
+ }
+ "URI" => {
+ if let Ok(uri_obj) = dict.get(b"URI") {
+ if let Some(uri) = pdf_object_string(uri_obj) {
+ let lower = uri.to_ascii_lowercase();
+ if !lower.starts_with("https://") {
+ return Err(FormatError::Malformed(
+ "PDF contains unsafe URI action -- refusing to process"
+ .into(),
+ ));
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
if dict.has(b"OpenAction") || dict.has(b"AA") {
- // Check if the action is JavaScript-based
if let Ok(action) = dict.get(b"OpenAction").or(dict.get(b"AA")) {
if let Ok(action_dict) = action.as_dict() {
if action_dict.has(b"JS") || action_dict.has(b"JavaScript") {
@@ -247,6 +270,14 @@ fn security_check(doc: &Document) -> Result<(), FormatError> {
Ok(())
}
+fn pdf_object_string(obj: &Object) -> Option<String> {
+ match obj {
+ Object::String(bytes, _) => Some(String::from_utf8_lossy(bytes).to_string()),
+ Object::Name(bytes) => Some(String::from_utf8_lossy(bytes).to_string()),
+ _ => None,
+ }
+}
+
/// Sanitize a string for safe inclusion in PDF metadata.
/// Strips control characters and PDF-special delimiters that could cause injection.
fn sanitize_pdf_string(s: &str) -> String {
@@ -336,6 +367,28 @@ mod tests {
assert_eq!(sanitize_pdf_string("normal text 123"), "normal text 123");
}
+ #[test]
+ fn security_check_rejects_indirect_launch_action_objects() {
+ let mut doc = Document::with_version("1.7");
+ let mut action = lopdf::Dictionary::new();
+ action.set("S", Object::Name(b"Launch".to_vec()));
+ doc.objects.insert((1, 0), Object::Dictionary(action));
+ assert!(security_check(&doc).is_err());
+ }
+
+ #[test]
+ fn security_check_rejects_unsafe_uri_actions() {
+ let mut doc = Document::with_version("1.7");
+ let mut action = lopdf::Dictionary::new();
+ action.set("S", Object::Name(b"URI".to_vec()));
+ action.set(
+ "URI",
+ Object::String(b"file:///C:/secret".to_vec(), StringFormat::Literal),
+ );
+ doc.objects.insert((1, 0), Object::Dictionary(action));
+ assert!(security_check(&doc).is_err());
+ }
+
// Note: Full embed/extract round-trip tests require a valid PDF file.
// These are integration tests that should be run with test fixtures.
// The unit tests above verify the adapter's detection and sanitization logic.
oversight-rust/oversight-policy/src/lib.rs +29 -5
@@ -237,7 +237,8 @@ pub fn check_policy(manifest: &Manifest, ctx: Option<&PolicyContext>) -> Result<
}
/// Atomic check-and-bump the open counter (if policy has max_opens).
-/// Call BEFORE decryption so plaintext is never computed when limit is exceeded.
+/// Call after a successful recipient decrypt, before releasing plaintext, so
+/// failed key guesses cannot consume the recipient's open budget.
/// Returns new count (0 if no max_opens policy).
pub fn record_open(manifest: &Manifest, ctx: Option<&PolicyContext>) -> Result<u64> {
let ctx = match ctx {
@@ -249,10 +250,13 @@ pub fn record_open(manifest: &Manifest, ctx: Option<&PolicyContext>) -> Result<u
None => return Ok(0),
};
match ctx.mode {
- Mode::LocalOnly | Mode::Hybrid | Mode::Registry => {
- // Registry/Hybrid fallback to local; real registry handling would POST /policy/open.
- local_check_and_bump(ctx, &manifest.file_id, mx)
- }
+ Mode::LocalOnly => local_check_and_bump(ctx, &manifest.file_id, mx),
+ Mode::Registry => Err(PolicyError::Violation(
+ "registry max_opens enforcement is not implemented; refusing local fallback".into(),
+ )),
+ Mode::Hybrid => Err(PolicyError::Violation(
+ "hybrid max_opens enforcement is not implemented; refusing silent local fallback".into(),
+ )),
}
}
@@ -348,4 +352,24 @@ mod tests {
m.file_id = "../../../etc/passwd".into();
assert!(record_open(&m, Some(&ctx)).is_err());
}
+
+ #[test]
+ fn registry_modes_refuse_silent_local_fallback() {
+ let m = make_manifest_with(serde_json::json!({
+ "max_opens": 1,
+ }));
+ let ctx = PolicyContext {
+ mode: Mode::Registry,
+ registry_url: Some("https://registry.test".into()),
+ ..Default::default()
+ };
+ assert!(record_open(&m, Some(&ctx)).is_err());
+
+ let ctx = PolicyContext {
+ mode: Mode::Hybrid,
+ registry_url: Some("https://registry.test".into()),
+ ..Default::default()
+ };
+ assert!(record_open(&m, Some(&ctx)).is_err());
+ }
}
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 dns_event_secret: Option<String>,
pub rekor_enabled: bool,
pub rekor_url: String,
}
@@ -326,6 +327,11 @@ async fn main() -> anyhow::Result<()> {
.trim()
== "1";
+ let dns_event_secret = std::env::var("OVERSIGHT_DNS_EVENT_SECRET")
+ .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());
@@ -359,6 +365,7 @@ async fn main() -> anyhow::Result<()> {
identity,
rate_limiter: RateLimiter::new(10.0, 30.0, 100_000),
trusted_proxy,
+ dns_event_secret,
rekor_enabled,
rekor_url,
});
oversight-rust/oversight-registry/src/routes/dns_event.rs +52 -2
@@ -1,7 +1,9 @@
-//! POST /dns_event - beacon callback logging from the DNS server.
+//! POST /dns_event - authenticated beacon callback logging from the DNS server.
-use axum::extract::State;
+use axum::extract::{ConnectInfo, State};
+use axum::http::HeaderMap;
use axum::Json;
+use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -12,12 +14,22 @@ use crate::AppState;
pub async fn dns_event(
State(state): State<Arc<AppState>>,
+ ConnectInfo(addr): ConnectInfo<SocketAddr>,
+ headers: HeaderMap,
Json(evt): Json<DnsEventRequest>,
) -> Result<Json<DnsEventResponse>> {
+ verify_dns_event_auth(&state, &headers, &addr)?;
+
// Validate input sizes
if evt.token_id.is_empty() || evt.token_id.len() > MAX_ID_LEN {
return Err(RegistryError::BadRequest("invalid token_id".into()));
}
+ if evt.client_ip.as_deref().is_some_and(|v| v.len() > MAX_ID_LEN)
+ || evt.qtype.as_deref().is_some_and(|v| v.len() > MAX_ID_LEN)
+ || evt.qname.as_deref().is_some_and(|v| v.len() > MAX_ID_LEN)
+ {
+ return Err(RegistryError::BadRequest("dns event field too long".into()));
+ }
// Look up beacon ownership
let beacon = db::get_beacon(&state.db, &evt.token_id).await?;
@@ -84,3 +96,41 @@ pub async fn dns_event(
tlog_index: tlog_idx,
}))
}
+
+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(());
+ }
+ return Err(RegistryError::BadRequest(
+ "invalid dns event authentication".into(),
+ ));
+ }
+
+ if addr.ip().is_loopback() {
+ return Ok(());
+ }
+
+ Err(RegistryError::BadRequest(
+ "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/register.rs +17 -7
@@ -118,13 +118,16 @@ pub async fn register(
let token_id = beacon
.get("token_id")
.and_then(|v| v.as_str())
- .unwrap_or("");
+ .ok_or_else(|| RegistryError::BadRequest("signed beacon missing token_id".into()))?;
let kind = beacon
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if token_id.is_empty() || token_id.len() > MAX_ID_LEN {
- continue;
+ return Err(RegistryError::BadRequest("signed beacon has invalid token_id".into()));
+ }
+ if kind.is_empty() || kind.len() > MAX_ID_LEN {
+ return Err(RegistryError::BadRequest("signed beacon has invalid kind".into()));
}
db::upsert_beacon(&state.db, token_id, file_id, recipient_id, issuer_id, kind, now)
.await?;
@@ -134,13 +137,16 @@ pub async fn register(
let mark_id = watermark
.get("mark_id")
.and_then(|v| v.as_str())
- .unwrap_or("");
+ .ok_or_else(|| RegistryError::BadRequest("signed watermark missing mark_id".into()))?;
let layer = watermark
.get("layer")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
if mark_id.is_empty() || mark_id.len() > MAX_ID_LEN {
- continue;
+ return Err(RegistryError::BadRequest("signed watermark has invalid mark_id".into()));
+ }
+ if layer.is_empty() || layer.len() > MAX_ID_LEN {
+ return Err(RegistryError::BadRequest("signed watermark has invalid layer".into()));
}
db::upsert_watermark(&state.db, mark_id, layer, file_id, recipient_id, issuer_id, now)
.await?;
@@ -236,10 +242,14 @@ fn attest_to_rekor(
None => "0".repeat(64),
};
- let mark_id_hex = signed_watermarks
+ let Some(mark_id_hex) = signed_watermarks
.iter()
- .find_map(|w| w.get("mark_id").and_then(|v| v.as_str()))
- .unwrap_or(file_id);
+ .find_map(|w| w.get("mark_id").and_then(|v| v.as_str())) else {
+ return Some(serde_json::json!({
+ "skipped": "no signed watermark mark_id to attest",
+ "tlog_kind": oversight_rekor::TLOG_KIND,
+ }));
+ };
let mut wm_map = std::collections::BTreeMap::new();
for (i, w) in signed_watermarks.iter().enumerate() {
oversight-rust/oversight-rekor/src/lib.rs +61 -2
@@ -304,10 +304,28 @@ pub fn verify_inclusion_offline(
bundle_rekor_field: &Value,
envelope: &DsseEnvelope,
issuer_ed25519_pub: &[u8],
+ expected_content_hash_sha256_hex: &str,
) -> (bool, &'static str) {
if !verify_dsse(envelope, issuer_ed25519_pub) {
return (false, "dsse signature did not verify under issuer pubkey");
}
+ let statement = match envelope_payload_statement(envelope) {
+ Ok(v) => v,
+ Err(_) => return (false, "dsse payload missing subject digest"),
+ };
+ let subject_digest = statement
+ .get("subject")
+ .and_then(|v| v.as_array())
+ .and_then(|items| items.first())
+ .and_then(|subject| subject.get("digest"))
+ .and_then(|digest| digest.get("sha256"))
+ .and_then(|v| v.as_str());
+ if subject_digest.is_none() {
+ return (false, "dsse payload missing subject digest");
+ }
+ if subject_digest != Some(expected_content_hash_sha256_hex) {
+ return (false, "dsse subject digest does not match expected content hash");
+ }
let tle = match bundle_rekor_field.get("transparency_log_entry") {
Some(v) if v.is_object() => v,
_ => return (false, "bundle missing transparency_log_entry payload"),
@@ -525,11 +543,52 @@ mod tests {
let mut csprng = OsRng;
let sk = SigningKey::generate(&mut csprng);
let pk = sk.verifying_key();
- let stmt = serde_json::json!({"x": 1});
+ let pred = OversightRegistrationPredicate {
+ file_id: "f".into(),
+ issuer_pubkey_ed25519: "1".repeat(64),
+ recipient_id: "r".into(),
+ recipient_pubkey_sha256: "0".repeat(64),
+ suite: "classic".into(),
+ registered_at: "2026-04-19T00:00:00Z".into(),
+ rfc3161_tsa: None,
+ rfc3161_token_b64: None,
+ rfc3161_chain_b64: None,
+ policy: Default::default(),
+ watermarks: Default::default(),
+ };
+ let stmt = build_statement("a", &"b".repeat(64), &pred);
let env = sign_dsse(&stmt, &sk.to_bytes(), "").unwrap();
let bundle_rekor = serde_json::json!({});
- let (ok, reason) = verify_inclusion_offline(&bundle_rekor, &env, pk.as_bytes());
+ let (ok, reason) = verify_inclusion_offline(&bundle_rekor, &env, pk.as_bytes(), &"b".repeat(64));
assert!(!ok);
assert!(reason.contains("transparency_log_entry"));
}
+
+ #[test]
+ fn offline_verify_rejects_subject_digest_mismatch() {
+ let mut csprng = OsRng;
+ let sk = SigningKey::generate(&mut csprng);
+ let pk = sk.verifying_key();
+ let pred = OversightRegistrationPredicate {
+ file_id: "f".into(),
+ issuer_pubkey_ed25519: "1".repeat(64),
+ recipient_id: "r".into(),
+ recipient_pubkey_sha256: "0".repeat(64),
+ suite: "classic".into(),
+ registered_at: "2026-04-19T00:00:00Z".into(),
+ rfc3161_tsa: None,
+ rfc3161_token_b64: None,
+ rfc3161_chain_b64: None,
+ policy: Default::default(),
+ watermarks: Default::default(),
+ };
+ let stmt = build_statement("a", &"b".repeat(64), &pred);
+ let env = sign_dsse(&stmt, &sk.to_bytes(), "").unwrap();
+ let bundle_rekor = serde_json::json!({
+ "transparency_log_entry": {"inclusionProof": {}}
+ });
+ let (ok, reason) = verify_inclusion_offline(&bundle_rekor, &env, pk.as_bytes(), &"c".repeat(64));
+ assert!(!ok);
+ assert!(reason.contains("subject digest"));
+ }
}