commit d6e3b284de9d6cd131ae5791041ed230fb846b43
parent e3badfb7b9891fbd249c789a38b68f5f42a0fce2
Author: triesap <tyson@radroots.org>
Date: Wed, 5 Aug 2026 19:48:24 +0000
test(secrets): qualify context-bound envelope security
Diffstat:
8 files changed, 68 insertions(+), 26 deletions(-)
diff --git a/contracts/releases/fuzz_matrix.toml b/contracts/releases/fuzz_matrix.toml
@@ -12,4 +12,5 @@ targets = [
"nip46_messages_and_uris",
"protocol_identifiers",
"restore_plan",
+ "secrets_envelope_and_artifact",
]
diff --git a/crates/secrets/src/envelope.rs b/crates/secrets/src/envelope.rs
@@ -14,7 +14,7 @@ use crate::{SecretId, SecretRef};
use alloc::string::String;
use alloc::vec::Vec;
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
-use chacha20poly1305::{Key, XChaCha20Poly1305, XNonce};
+use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use core::fmt;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
@@ -254,10 +254,12 @@ impl EncryptedEnvelope {
)?;
let ciphertext = material.data_key.expose_secret(|data_key| {
plaintext.expose_secret(|plaintext| {
- let cipher = XChaCha20Poly1305::new(Key::from_slice(data_key));
+ let cipher = XChaCha20Poly1305::new_from_slice(data_key)
+ .map_err(|_| Error::EncryptFailed)?;
+ let nonce = XNonce::from(*material.nonce.as_bytes());
cipher
.encrypt(
- XNonce::from_slice(material.nonce.as_bytes()),
+ &nonce,
Payload {
msg: plaintext,
aad: aad.as_slice(),
@@ -304,10 +306,12 @@ impl EncryptedEnvelope {
validate_data_key(&data_key)?;
let aad = self.encoded_header()?;
let plaintext = data_key.expose_secret(|data_key| {
- let cipher = XChaCha20Poly1305::new(Key::from_slice(data_key));
+ let cipher =
+ XChaCha20Poly1305::new_from_slice(data_key).map_err(|_| Error::DecryptFailed)?;
+ let nonce = XNonce::from(*self.nonce.as_bytes());
cipher
.decrypt(
- XNonce::from_slice(self.nonce.as_bytes()),
+ &nonce,
Payload {
msg: self.ciphertext.as_slice(),
aad: aad.as_slice(),
@@ -533,10 +537,12 @@ impl EncryptedEnvelope {
validate_data_key(&data_key)?;
let aad = self.encoded_header()?;
let plaintext = data_key.expose_secret(|data_key| {
- let cipher = XChaCha20Poly1305::new(Key::from_slice(data_key));
+ let cipher =
+ XChaCha20Poly1305::new_from_slice(data_key).map_err(|_| Error::DecryptFailed)?;
+ let nonce = XNonce::from(*self.nonce.as_bytes());
cipher
.decrypt(
- XNonce::from_slice(self.nonce.as_bytes()),
+ &nonce,
Payload {
msg: self.ciphertext.as_slice(),
aad: aad.as_slice(),
diff --git a/crates/secrets/tests/security_contract.rs b/crates/secrets/tests/security_contract.rs
@@ -146,19 +146,23 @@ fn diagnostics_snapshot_is_redacted_and_plaintext_free() {
}
#[test]
-fn production_sources_have_no_plaintext_logging_surface() {
- let source_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+fn envelope_and_private_artifact_sources_have_no_plaintext_logging_surface() {
+ let crates_root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .expect("secrets crate has a crates directory parent");
let mut paths = Vec::new();
- collect_rust_sources(&source_root, &mut paths);
- assert!(!paths.is_empty());
+ for crate_name in ["secrets", "storage", "storage_sqlite"] {
+ collect_rust_sources(&crates_root.join(crate_name).join("src"), &mut paths);
+ }
+ assert!(!paths.is_empty(), "audited production sources must exist");
for path in paths {
- let source = fs::read_to_string(&path).expect("read secret source");
+ let source = fs::read_to_string(&path).expect("read audited source");
let production = source.split("\n#[cfg(test)]").next().unwrap_or(&source);
for forbidden in ["tracing::", "log::", "println!(", "eprintln!(", "dbg!("] {
assert!(
!production.contains(forbidden),
- "secret production source contains logging surface `{forbidden}`: {}",
+ "envelope or private-artifact source contains logging surface `{forbidden}`: {}",
path.display()
);
}
diff --git a/crates/storage_sqlite/src/private_artifact/mod.rs b/crates/storage_sqlite/src/private_artifact/mod.rs
@@ -1415,14 +1415,17 @@ mod tests {
200,
)
.expect("reseal request");
- let committed = store
- .commit_private_artifact_reseal(request.clone(), resealed.envelope())
- .await
- .expect("atomic commit");
- assert_eq!(
- committed.disposition(),
- PrivateArtifactResealDisposition::Committed
+ let contender = store.clone();
+ let (left, right) = tokio::join!(
+ store.commit_private_artifact_reseal(request.clone(), resealed.envelope()),
+ contender.commit_private_artifact_reseal(request.clone(), resealed.envelope()),
);
+ let dispositions = [
+ left.expect("first concurrent outcome").disposition(),
+ right.expect("second concurrent outcome").disposition(),
+ ];
+ assert!(dispositions.contains(&PrivateArtifactResealDisposition::Committed));
+ assert!(dispositions.contains(&PrivateArtifactResealDisposition::Replayed));
let replayed = store
.commit_private_artifact_reseal(request.clone(), resealed.envelope())
.await
diff --git a/fuzz/event_codec/Cargo.lock b/fuzz/event_codec/Cargo.lock
@@ -845,6 +845,7 @@ dependencies = [
"radroots_event_codec",
"radroots_nostr_connect",
"radroots_protocol",
+ "radroots_secrets",
"radroots_storage",
"serde_json",
]
@@ -857,6 +858,16 @@ dependencies = [
]
[[package]]
+name = "radroots_secrets"
+version = "0.1.0-alpha"
+dependencies = [
+ "chacha20poly1305",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "radroots_storage"
version = "0.1.0-alpha"
dependencies = [
@@ -1436,10 +1447,6 @@ name = "radroots_geonames"
version = "0.1.0-alpha"
[[patch.unused]]
-name = "radroots_secrets"
-version = "0.1.0-alpha"
-
-[[patch.unused]]
name = "radroots_signing"
version = "0.1.0-alpha"
diff --git a/fuzz/event_codec/Cargo.toml b/fuzz/event_codec/Cargo.toml
@@ -14,6 +14,7 @@ radroots_blossom = { path = "../../crates/blossom", default-features = false, fe
radroots_event_codec = { path = "../../crates/event_codec", default-features = false, features = ["manifests", "std"] }
radroots_nostr_connect = { path = "../../crates/nostr_connect" }
radroots_protocol = { path = "../../crates/protocol", default-features = false, features = ["std"] }
+radroots_secrets = { path = "../../crates/secrets", default-features = false }
radroots_storage = { path = "../../crates/storage", default-features = false, features = ["serde"] }
serde_json = "1"
@@ -66,5 +67,12 @@ test = false
doc = false
bench = false
+[[bin]]
+name = "secrets_envelope_and_artifact"
+path = "fuzz_targets/secrets_envelope_and_artifact.rs"
+test = false
+doc = false
+bench = false
+
[workspace]
members = ["."]
diff --git a/fuzz/event_codec/fuzz_targets/secrets_envelope_and_artifact.rs b/fuzz/event_codec/fuzz_targets/secrets_envelope_and_artifact.rs
@@ -0,0 +1,13 @@
+#![no_main]
+
+use libfuzzer_sys::fuzz_target;
+use radroots_secrets::EncryptedEnvelope;
+use radroots_storage::private_artifact::PrivateArtifactMetadata;
+
+fuzz_target!(|data: &[u8]| {
+ if let Ok(envelope) = EncryptedEnvelope::decode(data) {
+ let encoded = envelope.encode().expect("decoded envelope must re-encode");
+ assert_eq!(encoded, data);
+ }
+ let _ = serde_json::from_slice::<PrivateArtifactMetadata>(data);
+});
diff --git a/tools/xtask/src/fuzz_qualification.rs b/tools/xtask/src/fuzz_qualification.rs
@@ -60,8 +60,8 @@ fn validate(root: &Path, contract: &Contract) -> Result<(), String> {
return Err("invalid fuzz qualification contract".to_owned());
}
let expected = contract.targets.iter().collect::<BTreeSet<_>>();
- if expected.len() != 7 || expected.len() != contract.targets.len() {
- return Err("fuzz matrix requires exactly seven unique parser targets".to_owned());
+ if expected.len() != 8 || expected.len() != contract.targets.len() {
+ return Err("fuzz matrix requires exactly eight unique parser targets".to_owned());
}
let path = root.join(&contract.harness).join("Cargo.toml");
let raw = fs::read_to_string(&path)