commit 64985502122dcff4022eeb6681622d9aad23e2e2
parent bad7c1874598a6f8f4f2234779f0d5391eb08ca1
Author: triesap <tyson@radroots.org>
Date: Wed, 20 May 2026 02:02:52 +0000
trade: harden validation receipt proof material
- add canonical base64 validation for inline SP1 proof bytes
- require sha256 proof-reference identifiers for referenced SP1 material
- wire base64 as an alloc-only workspace dependency for trade receipts
- cover invalid proof material and reference grammar in receipt tests
Diffstat:
4 files changed, 76 insertions(+), 31 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -4453,6 +4453,7 @@ version = "0.1.0-alpha.2"
name = "radroots_trade"
version = "0.1.0-alpha.2"
dependencies = [
+ "base64 0.22.1",
"hex",
"radroots_core",
"radroots_events",
diff --git a/Cargo.toml b/Cargo.toml
@@ -101,7 +101,7 @@ radroots_sp1_guest_trade = { path = "crates/sp1_guest_trade", version = "0.1.0-a
radroots_sp1_host_trade = { path = "crates/sp1_host_trade", version = "0.1.0-alpha.2", default-features = false }
anyhow = { version = "1" }
-base64 = { version = "0.22" }
+base64 = { version = "0.22", default-features = false, features = ["alloc"] }
bincode = { version = "1.3.3" }
chacha20poly1305 = { version = "0.10.1", default-features = false, features = [
"alloc",
diff --git a/crates/trade/Cargo.toml b/crates/trade/Cargo.toml
@@ -24,6 +24,7 @@ serde = [
]
serde_json = [
"serde",
+ "dep:base64",
"dep:hex",
"dep:serde_json",
"dep:sha2",
@@ -35,6 +36,7 @@ ts-rs = ["dep:ts-rs", "radroots_events/ts-rs", "radroots_events/std"]
radroots_core = { workspace = true, default-features = false }
radroots_events = { workspace = true, default-features = false }
radroots_events_codec = { workspace = true, default-features = false }
+base64 = { workspace = true, optional = true }
hex = { workspace = true, optional = true }
serde = { workspace = true, default-features = false, features = [
"alloc",
diff --git a/crates/trade/src/validation_receipt.rs b/crates/trade/src/validation_receipt.rs
@@ -7,6 +7,7 @@ use alloc::{
vec::Vec,
};
+use base64::Engine as _;
use radroots_events::{
RadrootsNostrEvent,
kinds::{KIND_TRADE_RECEIPT, KIND_TRADE_VALIDATION_RECEIPT},
@@ -21,6 +22,7 @@ pub const VALIDATION_RECEIPT_DOMAIN: &str = "radroots.receipt";
pub const VALIDATION_RECEIPT_VERSION: u32 = 1;
pub const VALIDATION_RECEIPT_PUBLIC_VALUES_HASH_DOMAIN: &[u8] = b"radroots:sp1-public-values:v1";
pub const VALIDATION_RECEIPT_PROOF_REFERENCE_SCHEME: &str = "radroots-proof://";
+pub const VALIDATION_RECEIPT_PROOF_REFERENCE_SHA256_PREFIX: &str = "radroots-proof://sha256/";
pub const TAG_VALIDATION_RECEIPT_EVENT_SET_ROOT: &str = "event_set_root";
pub const TAG_VALIDATION_RECEIPT_PROOF_SYSTEM: &str = "proof_system";
pub const TAG_VALIDATION_RECEIPT_PUBLIC_VALUES_HASH: &str = "public_values_hash";
@@ -587,47 +589,33 @@ fn validate_required_str(
fn validate_inline_proof_base64(value: &str) -> Result<(), RadrootsValidationReceiptError> {
validate_required_str(value, "proof.inline_proof_base64")?;
- if value.len() % 4 != 0 {
+ let decoded = base64::engine::general_purpose::STANDARD
+ .decode(value)
+ .map_err(|_| {
+ RadrootsValidationReceiptError::InvalidProofMetadata("proof.inline_proof_base64")
+ })?;
+ if decoded.is_empty() || base64::engine::general_purpose::STANDARD.encode(&decoded) != value {
return Err(RadrootsValidationReceiptError::InvalidProofMetadata(
"proof.inline_proof_base64",
));
}
- let bytes = value.as_bytes();
- let mut padding_started = false;
- let mut padding_count = 0usize;
- for (index, byte) in bytes.iter().copied().enumerate() {
- match byte {
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' if !padding_started => {}
- b'=' => {
- padding_started = true;
- padding_count += 1;
- if padding_count > 2 || index < bytes.len().saturating_sub(2) {
- return Err(RadrootsValidationReceiptError::InvalidProofMetadata(
- "proof.inline_proof_base64",
- ));
- }
- }
- _ => {
- return Err(RadrootsValidationReceiptError::InvalidProofMetadata(
- "proof.inline_proof_base64",
- ));
- }
- }
- }
-
Ok(())
}
fn validate_proof_reference(value: &str) -> Result<(), RadrootsValidationReceiptError> {
validate_required_str(value, "proof.proof_reference")?;
- let body = value
- .strip_prefix(VALIDATION_RECEIPT_PROOF_REFERENCE_SCHEME)
+ let digest = value
+ .strip_prefix(VALIDATION_RECEIPT_PROOF_REFERENCE_SHA256_PREFIX)
.ok_or(RadrootsValidationReceiptError::InvalidProofMetadata(
"proof.proof_reference",
))?;
- validate_required_str(body, "proof.proof_reference")
- .map_err(|_| RadrootsValidationReceiptError::InvalidProofMetadata("proof.proof_reference"))
+ if digest.len() != 64 || !is_lower_hex(digest) {
+ return Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.proof_reference",
+ ));
+ }
+ Ok(())
}
fn validate_result_error_bitmap(
@@ -741,7 +729,7 @@ mod tests {
inline_proof_base64: None,
mode: Some("core".to_string()),
program_hash: Some(hash32('a')),
- proof_reference: Some("radroots-proof://proof-1".to_string()),
+ proof_reference: Some(format!("radroots-proof://sha256/{}", "1".repeat(64))),
system: RadrootsValidationReceiptProofSystem::Sp1Core,
verifying_key_hash: Some(hash32('b')),
};
@@ -902,7 +890,7 @@ mod tests {
))
);
- receipt.proof.proof_reference = Some("radroots-proof://proof-1".to_string());
+ receipt.proof.proof_reference = Some(format!("radroots-proof://sha256/{}", "1".repeat(64)));
let parts = validation_receipt_event_build("order-1", &receipt).expect("sp1 event parts");
let mut event = sample_validation_receipt_event();
event.content = parts.content;
@@ -966,6 +954,27 @@ mod tests {
invalid_inline.proof.inline_proof_base64 = Some("cHJvb2Y=".to_string());
invalid_inline.validate().expect("valid inline proof shape");
+ invalid_inline.proof.inline_proof_base64 = Some("AA==".to_string());
+ invalid_inline
+ .validate()
+ .expect("canonical zero byte inline proof shape");
+
+ invalid_inline.proof.inline_proof_base64 = Some("AB==".to_string());
+ assert_eq!(
+ invalid_inline.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.inline_proof_base64"
+ ))
+ );
+
+ invalid_inline.proof.inline_proof_base64 = Some(String::new());
+ assert_eq!(
+ invalid_inline.validate(),
+ Err(RadrootsValidationReceiptError::EmptyField(
+ "proof.inline_proof_base64"
+ ))
+ );
+
let mut invalid_reference = sample_sp1_reference_receipt();
invalid_reference.proof.proof_reference = Some("https://example.test/proof".to_string());
assert_eq!(
@@ -982,6 +991,39 @@ mod tests {
"proof.proof_reference"
))
);
+
+ invalid_reference.proof.proof_reference =
+ Some(format!("radroots-proof://sha256/{}", "A".repeat(64)));
+ assert_eq!(
+ invalid_reference.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.proof_reference"
+ ))
+ );
+
+ invalid_reference.proof.proof_reference =
+ Some(format!("radroots-proof://sha256/{}", "1".repeat(63)));
+ assert_eq!(
+ invalid_reference.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.proof_reference"
+ ))
+ );
+
+ invalid_reference.proof.proof_reference =
+ Some(format!("radroots-proof://sha256/{}/proof", "1".repeat(64)));
+ assert_eq!(
+ invalid_reference.validate(),
+ Err(RadrootsValidationReceiptError::InvalidProofMetadata(
+ "proof.proof_reference"
+ ))
+ );
+
+ invalid_reference.proof.proof_reference =
+ Some(format!("radroots-proof://sha256/{}", "1".repeat(64)));
+ invalid_reference
+ .validate()
+ .expect("valid sha256 proof reference");
}
#[test]