security_contract.rs (6939B)
1 use radroots_secrets::context::{ 2 EnvelopeContext, EnvelopePurpose, EnvelopeSubject, PayloadSchemaId, 3 }; 4 use radroots_secrets::envelope::{Nonce, SealMaterial, SealRequest}; 5 use radroots_secrets::error::{Operation, SecretIdError}; 6 use radroots_secrets::id::{BackendKind, KeyVersion}; 7 use radroots_secrets::wrapping::{SecretMaterial, WrappedSecret}; 8 use radroots_secrets::{Error, SecretId, SecretRef}; 9 use std::fs; 10 use std::path::{Path, PathBuf}; 11 12 const PUBLIC_API: &str = include_str!("../../../contracts/api_baselines/radroots_secrets.txt"); 13 14 #[test] 15 fn reviewed_api_forbids_secret_bearing_clone_serialize_and_byte_access() { 16 for required in [ 17 "pub mod radroots_secrets::context", 18 "pub mod radroots_secrets::envelope", 19 "pub mod radroots_secrets::error", 20 "pub mod radroots_secrets::id", 21 "pub mod radroots_secrets::provider", 22 "pub mod radroots_secrets::wrapping", 23 "pub struct radroots_secrets::wrapping::SecretMaterial(_)", 24 "pub struct radroots_secrets::id::SecretRef", 25 "pub trait radroots_secrets::provider::SecretProvider", 26 "pub trait radroots_secrets::wrapping::KeyWrapping", 27 ] { 28 assert!( 29 PUBLIC_API.contains(required), 30 "reviewed public API is missing `{required}`" 31 ); 32 } 33 34 for secret_bearing_type in [ 35 "radroots_secrets::wrapping::SecretMaterial", 36 "radroots_secrets::id::SecretRef", 37 "radroots_secrets::envelope::SealMaterial", 38 "radroots_secrets::envelope::SealRequest", 39 "radroots_secrets::envelope::LegacyV1ResealAuthority", 40 "radroots_secrets::wrapping::LegacyV1UnwrapRequest", 41 ] { 42 for forbidden_trait in ["core::clone::Clone", "serde_core::ser::Serialize"] { 43 let forbidden = format!("impl {forbidden_trait} for {secret_bearing_type}"); 44 assert!( 45 !PUBLIC_API.contains(&forbidden), 46 "secret-bearing public type exposes forbidden trait: {forbidden}" 47 ); 48 } 49 } 50 51 for forbidden in [ 52 "SecretMaterial::as_bytes", 53 "SecretMaterial::as_slice", 54 "SecretMaterial::into_bytes", 55 "SecretMaterial::to_vec", 56 "SecretRef::clone", 57 "EncryptedEnvelope::open(&self, &dyn radroots_secrets::wrapping::KeyWrapping) ->", 58 "SealRequest<'a>::new(radroots_secrets::id::SecretRef, &'a radroots_secrets::wrapping::SecretMaterial", 59 ] { 60 assert!( 61 !PUBLIC_API.contains(forbidden), 62 "reviewed API exposes forbidden plaintext or duplication surface `{forbidden}`" 63 ); 64 } 65 66 for forbidden_dependency in [ 67 "chacha20poly1305", 68 "futures_executor", 69 "keyring", 70 "serde_json", 71 "tempfile", 72 "zeroize", 73 ] { 74 assert!( 75 !exposes_crate_path(PUBLIC_API, forbidden_dependency), 76 "reviewed API leaks implementation dependency `{forbidden_dependency}`" 77 ); 78 } 79 } 80 81 fn exposes_crate_path(public_api: &str, crate_name: &str) -> bool { 82 public_api 83 .split(|character: char| { 84 !(character.is_ascii_alphanumeric() || matches!(character, '_' | ':')) 85 }) 86 .any(|token| { 87 token 88 .strip_prefix(crate_name) 89 .is_some_and(|remainder| remainder.starts_with("::")) 90 }) 91 } 92 93 #[test] 94 fn diagnostics_snapshot_is_redacted_and_plaintext_free() { 95 const SECRET_ID_SENTINEL: &str = "plaintext-secret-id-sentinel"; 96 const PLAINTEXT_SENTINEL: &[u8] = b"plaintext-material-sentinel"; 97 98 let id = SecretId::parse(SECRET_ID_SENTINEL).expect("valid secret id"); 99 let reference = SecretRef::new( 100 id, 101 BackendKind::External, 102 KeyVersion::new(7).expect("valid key version"), 103 ); 104 let plaintext = SecretMaterial::from_slice(PLAINTEXT_SENTINEL).expect("valid material"); 105 let wrapped = WrappedSecret::from_bytes(b"wrapped-material-sentinel".to_vec()) 106 .expect("valid wrapped material"); 107 let sealing_key = SecretMaterial::from_slice(&[0x42; 32]).expect("valid sealing key"); 108 let context = EnvelopeContext::new( 109 EnvelopePurpose::parse("radroots.security_test").expect("purpose"), 110 EnvelopeSubject::parse("security_test", "plaintext-secret-id-sentinel").expect("subject"), 111 PayloadSchemaId::parse("radroots.security_test.v1").expect("schema"), 112 ); 113 let request = SealRequest::new( 114 reference, 115 context, 116 &plaintext, 117 SealMaterial::new(sealing_key, Nonce::new([0x24; 24])), 118 ); 119 120 let diagnostics = [ 121 format!("{plaintext:?}"), 122 format!("{wrapped:?}"), 123 format!("{request:?}"), 124 format!("{:?}", Error::DecryptFailed), 125 Error::BackendFailure { 126 backend: BackendKind::External, 127 operation: Operation::Unwrap, 128 } 129 .to_string(), 130 Error::SecretNotFound { 131 backend: BackendKind::External, 132 key_version: 7, 133 } 134 .to_string(), 135 Error::InvalidSecretId(SecretIdError::InvalidCharacter { byte_offset: 9 }).to_string(), 136 ]; 137 138 assert_eq!(diagnostics[0], "SecretMaterial(<redacted>)"); 139 assert_eq!(diagnostics[1], "WrappedSecret(<redacted>)"); 140 assert_eq!(diagnostics[2], "SealRequest(<redacted>)"); 141 for diagnostic in diagnostics { 142 assert!(!diagnostic.contains(SECRET_ID_SENTINEL)); 143 assert!(!diagnostic.contains("plaintext-material-sentinel")); 144 assert!(!diagnostic.contains("wrapped-material-sentinel")); 145 } 146 } 147 148 #[test] 149 fn envelope_and_private_artifact_sources_have_no_plaintext_logging_surface() { 150 let crates_root = Path::new(env!("CARGO_MANIFEST_DIR")) 151 .parent() 152 .expect("secrets crate has a crates directory parent"); 153 let mut paths = Vec::new(); 154 for crate_name in ["secrets", "storage", "storage_sqlite"] { 155 collect_rust_sources(&crates_root.join(crate_name).join("src"), &mut paths); 156 } 157 assert!(!paths.is_empty(), "audited production sources must exist"); 158 159 for path in paths { 160 let source = fs::read_to_string(&path).expect("read audited source"); 161 let production = source.split("\n#[cfg(test)]").next().unwrap_or(&source); 162 for forbidden in ["tracing::", "log::", "println!(", "eprintln!(", "dbg!("] { 163 assert!( 164 !production.contains(forbidden), 165 "envelope or private-artifact source contains logging surface `{forbidden}`: {}", 166 path.display() 167 ); 168 } 169 } 170 } 171 172 fn collect_rust_sources(root: &Path, paths: &mut Vec<PathBuf>) { 173 for entry in fs::read_dir(root).expect("read source directory") { 174 let path = entry.expect("source entry").path(); 175 if path.is_dir() { 176 collect_rust_sources(&path, paths); 177 } else if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { 178 paths.push(path); 179 } 180 } 181 }