| @@ -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 | ||
| @@ -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 | ||
| @@ -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 |
| @@ -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)?; |
| @@ -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 |
| @@ -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()); | |
| } | ||
| } |
| @@ -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>")); | |
| + | } | |
| } |
| @@ -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. |
| @@ -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()); | |
| + | } | |
| } |
| @@ -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, | ||
| }); |
| @@ -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 | |
| + | } |
| @@ -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() { |
| @@ -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")); | |
| + | } | |
| } |