Zion Boggan zionboggan.com ↗

Harden Rust sealed parser follow-up

Co-authored-by: Codex (GPT-5.4) <noreply@openai.com>
b894f4c   Zion Boggan committed on Apr 24, 2026 (1 month ago)
CHANGELOG.md +3 -0
@@ -74,6 +74,9 @@ Review-driven hardening from `P:/Oversight/oversight-protocol-review.md`.
restrictive permissions/ACL hardening where supported.
- `.sealed` parsing now rejects tampered suite IDs, malformed manifest/wrapped-DEK
JSON, unknown manifest fields, and trailing bytes after ciphertext.
+- `oversight-rust/oversight-container`: Rust now mirrors the Python parser's
+ strictness by rejecting suite-byte tamper and trailing bytes after the
+ authenticated ciphertext region.
- `docs/security.md`: documented L3 collusion/canonicalization limits, layer
survival properties, passive beacon limits, jurisdiction-by-IP limits, and
RFC 3161 timestamp semantics.
docs/SPEC.md +4 -0
@@ -122,6 +122,10 @@ offset length field notes
... C ciphertext AEAD output, includes 16-byte tag
```
+Implementations MUST reject any `.sealed` file whose unsigned `suite_id`
+header does not match the signed `manifest.suite` value, and MUST reject
+trailing bytes after the declared ciphertext region.
+
### 5.2 Manifest
The manifest is canonical JSON (sorted keys, no whitespace, UTF-8). Required fields:
oversight-rust/oversight-container/src/lib.rs +56 -0
@@ -63,12 +63,26 @@ pub enum ContainerError {
Utf8(#[from] std::string::FromUtf8Error),
#[error("precondition failed: {0}")]
Precondition(&'static str),
+ #[error("suite_id header {header} does not match signed manifest suite {manifest_suite}")]
+ SuiteMismatch { header: u8, manifest_suite: String },
+ #[error("unsupported manifest suite: {0}")]
+ UnsupportedManifestSuite(String),
+ #[error("trailing bytes after ciphertext: {0}")]
+ TrailingBytes(usize),
#[error("no decryptable slot found (tried {slots} slots)")]
NoDecryptableSlot { slots: usize },
#[error("plaintext hash mismatch - manifest and plaintext disagree")]
HashMismatch,
}
+fn suite_id_for_manifest(suite: &str) -> Option<u8> {
+ match suite {
+ crypto::SUITE_CLASSIC_V1 => Some(SUITE_CLASSIC_V1_ID),
+ crypto::SUITE_HYBRID_V1 => Some(SUITE_HYBRID_V1_ID),
+ _ => None,
+ }
+}
+
#[derive(Debug)]
pub struct SealedFile {
pub manifest: Manifest,
@@ -141,6 +155,14 @@ impl SealedFile {
}
let manifest_bytes = read_exact(data, &mut at, mlen, "manifest")?;
let manifest = Manifest::from_json(manifest_bytes)?;
+ let expected_suite_id = suite_id_for_manifest(&manifest.suite)
+ .ok_or_else(|| ContainerError::UnsupportedManifestSuite(manifest.suite.clone()))?;
+ if suite_id != expected_suite_id {
+ return Err(ContainerError::SuiteMismatch {
+ header: suite_id,
+ manifest_suite: manifest.suite.clone(),
+ });
+ }
let wlen = read_u32_be(data, &mut at, "wrapped_dek_len")? as usize;
if wlen > MAX_WRAPPED_DEK_BYTES {
@@ -166,6 +188,9 @@ impl SealedFile {
});
}
let ciphertext = read_exact(data, &mut at, clen, "ciphertext")?.to_vec();
+ if at != data.len() {
+ return Err(ContainerError::TrailingBytes(data.len() - at));
+ }
Ok(SealedFile {
manifest,
@@ -402,6 +427,37 @@ mod tests {
assert!(SealedFile::from_bytes(&blob).is_err());
}
+ #[test]
+ fn suite_id_tamper_rejected() {
+ let issuer = ClassicIdentity::generate();
+ let alice = ClassicIdentity::generate();
+ let plaintext = b"secret";
+ let mut m = make_manifest(&issuer, &alice, plaintext);
+ let mut blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
+ blob[7] ^= 0x01;
+ match SealedFile::from_bytes(&blob) {
+ Err(ContainerError::SuiteMismatch { header, manifest_suite }) => {
+ assert_eq!(header, 0);
+ assert_eq!(manifest_suite, crypto::SUITE_CLASSIC_V1);
+ }
+ other => panic!("expected SuiteMismatch, got {:?}", other),
+ }
+ }
+
+ #[test]
+ fn trailing_bytes_rejected() {
+ let issuer = ClassicIdentity::generate();
+ let alice = ClassicIdentity::generate();
+ let plaintext = b"secret";
+ let mut m = make_manifest(&issuer, &alice, plaintext);
+ let mut blob = seal(plaintext, &mut m, issuer.ed25519_priv.as_ref(), &alice.x25519_pub).unwrap();
+ blob.extend_from_slice(b"junk");
+ match SealedFile::from_bytes(&blob) {
+ Err(ContainerError::TrailingBytes(4)) => (),
+ other => panic!("expected TrailingBytes, got {:?}", other),
+ }
+ }
+
#[test]
fn expired_file_rejected() {
let issuer = ClassicIdentity::generate();