lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit d36a3aba66fa2bb0ef58702c1f71c10c891618d2
parent 06491f0f17b9c5c090eeb340242ae527ad1c44d9
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Jul 2026 06:12:02 +0000

blossom: align BUD-11 validation

- require strictly past authorization timestamps
- bound human-readable content while preserving internal whitespace
- separate pinned BUD checks from Radroots replay policy
- refresh public vectors, docs, and conformance coverage

Diffstat:
Mcontracts/conformance/vectors/blossom/bud11_claims.v1.json | 16++++++++--------
Mcontracts/events/blossom-authorization.md | 25+++++++++++++------------
Mcrates/blossom/src/authorization.rs | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/blossom/src/error.rs | 8++++----
Mcrates/blossom/src/lib.rs | 14+++++++-------
Mcrates/blossom/tests/bud11_conformance.rs | 2+-
Mcrates/blossom/tests/fixtures/bud11_claims.v1.json | 16++++++++--------
7 files changed, 132 insertions(+), 53 deletions(-)

diff --git a/contracts/conformance/vectors/blossom/bud11_claims.v1.json b/contracts/conformance/vectors/blossom/bud11_claims.v1.json @@ -226,15 +226,15 @@ }, { "id": "content-newline", - "kind": "blossom.bud11.content.parse.invalid", + "kind": "blossom.bud11.content.parse.valid", "input": { "content": "Upload\nphoto" }, - "expected": { "error": "invalid_authorization_content" } + "expected": { "content": "Upload\nphoto" } }, { "id": "content-tab", - "kind": "blossom.bud11.content.parse.invalid", + "kind": "blossom.bud11.content.parse.valid", "input": { "content": "Upload\tphoto" }, - "expected": { "error": "invalid_authorization_content" } + "expected": { "content": "Upload\tphoto" } }, { "id": "content-control", @@ -570,7 +570,7 @@ "id": "validate-get-no-hash-scope", "kind": "blossom.bud11.claim.validate.valid", "input": { - "claim": { "content": "Get farm photo", "created_at": 2000000000, "tags": [["t", "get"], ["expiration", "2000000001"]] }, + "claim": { "content": "Get farm photo", "created_at": 1999999999, "tags": [["t", "get"], ["expiration", "2000000001"]] }, "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, "server_domain": "blossom.radroots.test", "server_scope": "optional_any_match", @@ -711,7 +711,7 @@ }, { "id": "validate-created-at-now", - "kind": "blossom.bud11.claim.validate.valid", + "kind": "blossom.bud11.claim.validate.invalid", "input": { "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000001"]] }, "target": { "type": "list" }, @@ -720,7 +720,7 @@ "now": 2000000000, "max_created_age": 0 }, - "expected": { "action": "list" } + "expected": { "error": "authorization_created_in_future" } }, { "id": "validate-horizon-300", @@ -869,7 +869,7 @@ "id": "validate-horizon-301", "kind": "blossom.bud11.claim.validate.invalid", "input": { - "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000301"]] }, + "claim": { "content": "List farm photos", "created_at": 1999999999, "tags": [["t", "list"], ["expiration", "2000000300"]] }, "target": { "type": "list" }, "server_domain": "blossom.radroots.test", "server_scope": "optional_any_match", diff --git a/contracts/events/blossom-authorization.md b/contracts/events/blossom-authorization.md @@ -56,10 +56,11 @@ does not authorize arbitrary endpoints and does not perform HTTP parsing. ## Human-Readable Content -`RadrootsBlossomAuthorizationContent` is nonempty, contains no Unicode control character, and is -already equal to its Unicode-whitespace-trimmed form. This rejects empty and whitespace-only input, -leading or trailing whitespace, line breaks, tabs, and embedded controls. Interior ordinary spaces -and non-ASCII human-readable text are retained. Parsing does not silently trim or rewrite signed +`RadrootsBlossomAuthorizationContent` is from 1 through 4,096 UTF-8 bytes and is already equal to +its Unicode-whitespace-trimmed form. It rejects empty and whitespace-only input, leading or +trailing whitespace, NUL, and non-whitespace controls. Horizontal tab, newline, and carriage return +are allowed only inside otherwise human-readable content. Interior ordinary whitespace and +non-ASCII human-readable text are retained. Parsing does not silently trim or rewrite signed content. This requirement is structural and deterministic. It cannot prove that wording accurately @@ -124,22 +125,22 @@ or target scope. Those checks belong to the appropriate outward adapter or valid ## Time Validation -Endpoint validation receives an explicit `now` and maximum creation age so it is deterministic and -does not read a wall clock. Radroots uses -`RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS = 300`. +Endpoint validation receives an explicit `now` so it is deterministic and does not read a wall +clock. `RadrootsBlossomAuthorizationValidation::bud11` applies only the pinned BUD time and optional +server semantics. The existing bounded constructor applies the Radroots replay profile with +`RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS = 300` and +`RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS = 300`. The requested maximum creation age may be from 0 through 300 seconds inclusive. A value above the public cap is rejected when the validation policy is constructed rather than weakening the replay window by accident. -- `created_at == now` is accepted to tolerate integer-second signing and validation in the same - second -- `created_at > now` is rejected as future-dated +- `created_at >= now` is rejected because BUD-11 requires creation in the past - `now - created_at <= max_created_age` is accepted - an older value is rejected as stale, using checked comparisons without unsigned wraparound - `expiration > now` is required; `expiration == now` is expired -- `expiration - created_at` must be from 1 through - `RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS = 300` seconds inclusive +- BUD-only validation imposes no additional age or lifetime ceiling +- bounded Radroots validation requires `expiration - created_at` from 1 through 300 seconds The 300-second horizon is the Radroots replay-limiting application profile layered on BUD-11. Strict authored upload construction applies the same bound: lifetime must be from 1 through 300 diff --git a/crates/blossom/src/authorization.rs b/crates/blossom/src/authorization.rs @@ -8,6 +8,7 @@ use core::{fmt, str::FromStr}; use crate::{RadrootsBlossomError, RadrootsBlossomSha256}; pub const RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND: u16 = 24_242; +pub const RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES: usize = 4_096; pub const RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS: u64 = 300; pub const RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS: u64 = 300; @@ -122,7 +123,13 @@ pub struct RadrootsBlossomAuthorizationContent(String); impl RadrootsBlossomAuthorizationContent { pub fn parse(value: &str) -> Result<Self, RadrootsBlossomError> { - if value.is_empty() || value.trim() != value || value.chars().any(char::is_control) { + if value.is_empty() + || value.len() > RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES + || value.trim() != value + || value + .chars() + .any(|character| character.is_control() && !matches!(character, '\t' | '\n' | '\r')) + { return Err(RadrootsBlossomError::InvalidAuthorizationContent); } Ok(Self(value.to_string())) @@ -199,10 +206,26 @@ pub struct RadrootsBlossomAuthorizationValidation { target_server: RadrootsBlossomServerDomain, server_scope_requirement: RadrootsBlossomServerScopeRequirement, now: u64, - max_created_age_seconds: u64, + max_created_age_seconds: Option<u64>, + max_lifetime_seconds: Option<u64>, } impl RadrootsBlossomAuthorizationValidation { + pub fn bud11( + target: RadrootsBlossomAuthorizationTarget, + target_server: RadrootsBlossomServerDomain, + now: u64, + ) -> Self { + Self { + target, + target_server, + server_scope_requirement: RadrootsBlossomServerScopeRequirement::OptionalAnyMatch, + now, + max_created_age_seconds: None, + max_lifetime_seconds: None, + } + } + pub fn new( target: RadrootsBlossomAuthorizationTarget, target_server: RadrootsBlossomServerDomain, @@ -218,7 +241,8 @@ impl RadrootsBlossomAuthorizationValidation { target_server, server_scope_requirement, now, - max_created_age_seconds, + max_created_age_seconds: Some(max_created_age_seconds), + max_lifetime_seconds: Some(RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS), }) } @@ -238,9 +262,13 @@ impl RadrootsBlossomAuthorizationValidation { self.now } - pub const fn max_created_age_seconds(&self) -> u64 { + pub const fn max_created_age_seconds(&self) -> Option<u64> { self.max_created_age_seconds } + + pub const fn max_lifetime_seconds(&self) -> Option<u64> { + self.max_lifetime_seconds + } } #[derive(Clone, Debug, PartialEq, Eq)] @@ -325,19 +353,25 @@ impl RadrootsBlossomParsedAuthorizationClaim { &self, validation: &RadrootsBlossomAuthorizationValidation, ) -> Result<RadrootsBlossomValidatedAuthorizationClaim, RadrootsBlossomError> { - if self.created_at > validation.now { + if self.created_at >= validation.now { return Err(RadrootsBlossomError::AuthorizationCreatedInFuture); } - if validation.now.saturating_sub(self.created_at) > validation.max_created_age_seconds { + if let Some(max_created_age_seconds) = validation.max_created_age_seconds + && validation.now.saturating_sub(self.created_at) > max_created_age_seconds + { return Err(RadrootsBlossomError::AuthorizationStale); } let lifetime = self .expiration .checked_sub(self.created_at) - .filter(|lifetime| (1..=RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS).contains(lifetime)) + .filter(|lifetime| *lifetime > 0) .ok_or(RadrootsBlossomError::InvalidAuthorizationLifetime)?; - debug_assert!(lifetime <= RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS); + if let Some(max_lifetime_seconds) = validation.max_lifetime_seconds + && lifetime > max_lifetime_seconds + { + return Err(RadrootsBlossomError::InvalidAuthorizationLifetime); + } if self.expiration <= validation.now { return Err(RadrootsBlossomError::AuthorizationExpired); @@ -717,13 +751,25 @@ mod tests { } #[test] - fn content_requires_trimmed_nonempty_control_free_text() { - for value in ["", " Upload Blob", "Upload Blob ", "Upload\nBlob", "\u{7f}"] { + fn content_requires_bounded_trimmed_human_readable_text() { + for value in ["", " Upload Blob", "Upload Blob ", "Upload\0Blob", "\u{7f}"] { assert_eq!( RadrootsBlossomAuthorizationContent::parse(value), Err(RadrootsBlossomError::InvalidAuthorizationContent) ); } + assert_eq!( + RadrootsBlossomAuthorizationContent::parse(&"a".repeat(4_097)), + Err(RadrootsBlossomError::InvalidAuthorizationContent) + ); + for value in ["Upload\nBlob", "Upload\tBlob", "Upload\rBlob"] { + assert_eq!( + RadrootsBlossomAuthorizationContent::parse(value) + .unwrap() + .as_str(), + value + ); + } let value = RadrootsBlossomAuthorizationContent::parse("Téléverser l'image").unwrap(); assert_eq!(value.as_str(), "Téléverser l'image"); assert_eq!(value.to_string(), "Téléverser l'image"); @@ -797,7 +843,8 @@ mod tests { RadrootsBlossomServerScopeRequirement::RequiredAnyMatch ); assert_eq!(policy.now(), NOW); - assert_eq!(policy.max_created_age_seconds(), 300); + assert_eq!(policy.max_created_age_seconds(), Some(300)); + assert_eq!(policy.max_lifetime_seconds(), Some(300)); assert!( RadrootsBlossomAuthorizationValidation::new( target, @@ -963,6 +1010,11 @@ mod tests { let time_cases = [ ( + NOW, + NOW + 60, + RadrootsBlossomError::AuthorizationCreatedInFuture, + ), + ( NOW + 1, NOW + 60, RadrootsBlossomError::AuthorizationCreatedInFuture, @@ -1003,6 +1055,29 @@ mod tests { } #[test] + fn bud11_validation_keeps_radroots_replay_limits_out_of_protocol_semantics() { + let target = RadrootsBlossomAuthorizationTarget::List; + let policy = RadrootsBlossomAuthorizationValidation::bud11(target, server(), NOW); + assert_eq!(policy.max_created_age_seconds(), None); + assert_eq!(policy.max_lifetime_seconds(), None); + assert_eq!( + policy.server_scope_requirement(), + RadrootsBlossomServerScopeRequirement::OptionalAnyMatch + ); + + let claim = RadrootsBlossomParsedAuthorizationClaim::parse( + "List archived blobs", + NOW - 1_000, + &[ + vec!["t".to_string(), "list".to_string()], + vec!["expiration".to_string(), (NOW + 1_000).to_string()], + ], + ) + .unwrap(); + assert!(claim.validate(&policy).is_ok()); + } + + #[test] fn server_scope_uses_optional_or_required_any_match() { let target = RadrootsBlossomAuthorizationTarget::Upload(hash()); let optional = validation( @@ -1191,7 +1266,7 @@ mod tests { } #[test] - fn validation_accepts_exact_time_boundaries() { + fn validation_enforces_exact_time_boundaries() { let boundary_tags = tags("upload", NOW + 1); let parsed = RadrootsBlossomParsedAuthorizationClaim::parse( "Upload Blob", @@ -1222,6 +1297,9 @@ mod tests { &tags("upload", NOW + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS), ) .unwrap(); - assert!(created_now.validate(&zero_age).is_ok()); + assert_eq!( + created_now.validate(&zero_age), + Err(RadrootsBlossomError::AuthorizationCreatedInFuture) + ); } } diff --git a/crates/blossom/src/error.rs b/crates/blossom/src/error.rs @@ -121,7 +121,7 @@ impl fmt::Display for RadrootsBlossomError { f.write_str("descriptor media type does not match the approved media type") } Self::InvalidAuthorizationContent => { - f.write_str("Blossom authorization content must be trimmed human-readable text") + f.write_str("Blossom authorization content must be bounded human-readable text") } Self::InvalidAuthorizationAction => f.write_str("invalid Blossom authorization action"), Self::InvalidAuthorizationServerDomain => { @@ -161,7 +161,7 @@ impl fmt::Display for RadrootsBlossomError { f.write_str("Blossom authorization expiration timestamp overflows u64") } Self::AuthorizationCreatedInFuture => { - f.write_str("Blossom authorization was created in the future") + f.write_str("Blossom authorization must be created in the past") } Self::AuthorizationStale => { f.write_str("Blossom authorization is outside the accepted creation-age window") @@ -250,7 +250,7 @@ mod tests { ( RadrootsBlossomError::InvalidAuthorizationContent, "invalid_authorization_content", - "Blossom authorization content must be trimmed human-readable text", + "Blossom authorization content must be bounded human-readable text", ), ( RadrootsBlossomError::InvalidAuthorizationAction, @@ -320,7 +320,7 @@ mod tests { ( RadrootsBlossomError::AuthorizationCreatedInFuture, "authorization_created_in_future", - "Blossom authorization was created in the future", + "Blossom authorization must be created in the past", ), ( RadrootsBlossomError::AuthorizationStale, diff --git a/crates/blossom/src/lib.rs b/crates/blossom/src/lib.rs @@ -11,13 +11,13 @@ pub mod hash; pub mod url; pub use authorization::{ - RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, - RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, RadrootsBlossomAuthoredUploadClaim, - RadrootsBlossomAuthorizationAction, RadrootsBlossomAuthorizationContent, - RadrootsBlossomAuthorizationTarget, RadrootsBlossomAuthorizationValidation, - RadrootsBlossomAuthorizationWireParts, RadrootsBlossomParsedAuthorizationClaim, - RadrootsBlossomServerDomain, RadrootsBlossomServerScopeRequirement, - RadrootsBlossomValidatedAuthorizationClaim, + RADROOTS_BLOSSOM_AUTH_CONTENT_MAX_BYTES, RADROOTS_BLOSSOM_AUTH_MAX_CREATED_AGE_SECONDS, + RADROOTS_BLOSSOM_AUTH_MAX_HORIZON_SECONDS, RADROOTS_BLOSSOM_AUTHORIZATION_EVENT_KIND, + RadrootsBlossomAuthoredUploadClaim, RadrootsBlossomAuthorizationAction, + RadrootsBlossomAuthorizationContent, RadrootsBlossomAuthorizationTarget, + RadrootsBlossomAuthorizationValidation, RadrootsBlossomAuthorizationWireParts, + RadrootsBlossomParsedAuthorizationClaim, RadrootsBlossomServerDomain, + RadrootsBlossomServerScopeRequirement, RadrootsBlossomValidatedAuthorizationClaim, }; pub use descriptor::{ RadrootsBlossomApprovedDescriptor, RadrootsBlossomBlobDescriptor, diff --git a/crates/blossom/tests/bud11_conformance.rs b/crates/blossom/tests/bud11_conformance.rs @@ -170,7 +170,7 @@ fn validation_new_valid(vector: &Vector) { build_validation(vector).unwrap_or_else(|error| panic!("{} failed: {error}", vector.id)); assert_eq!( validation.max_created_age_seconds(), - expected_u64(vector, "max_created_age"), + Some(expected_u64(vector, "max_created_age")), "{}", vector.id ); diff --git a/crates/blossom/tests/fixtures/bud11_claims.v1.json b/crates/blossom/tests/fixtures/bud11_claims.v1.json @@ -226,15 +226,15 @@ }, { "id": "content-newline", - "kind": "blossom.bud11.content.parse.invalid", + "kind": "blossom.bud11.content.parse.valid", "input": { "content": "Upload\nphoto" }, - "expected": { "error": "invalid_authorization_content" } + "expected": { "content": "Upload\nphoto" } }, { "id": "content-tab", - "kind": "blossom.bud11.content.parse.invalid", + "kind": "blossom.bud11.content.parse.valid", "input": { "content": "Upload\tphoto" }, - "expected": { "error": "invalid_authorization_content" } + "expected": { "content": "Upload\tphoto" } }, { "id": "content-control", @@ -570,7 +570,7 @@ "id": "validate-get-no-hash-scope", "kind": "blossom.bud11.claim.validate.valid", "input": { - "claim": { "content": "Get farm photo", "created_at": 2000000000, "tags": [["t", "get"], ["expiration", "2000000001"]] }, + "claim": { "content": "Get farm photo", "created_at": 1999999999, "tags": [["t", "get"], ["expiration", "2000000001"]] }, "target": { "type": "get_blob", "hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, "server_domain": "blossom.radroots.test", "server_scope": "optional_any_match", @@ -711,7 +711,7 @@ }, { "id": "validate-created-at-now", - "kind": "blossom.bud11.claim.validate.valid", + "kind": "blossom.bud11.claim.validate.invalid", "input": { "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000001"]] }, "target": { "type": "list" }, @@ -720,7 +720,7 @@ "now": 2000000000, "max_created_age": 0 }, - "expected": { "action": "list" } + "expected": { "error": "authorization_created_in_future" } }, { "id": "validate-horizon-300", @@ -869,7 +869,7 @@ "id": "validate-horizon-301", "kind": "blossom.bud11.claim.validate.invalid", "input": { - "claim": { "content": "List farm photos", "created_at": 2000000000, "tags": [["t", "list"], ["expiration", "2000000301"]] }, + "claim": { "content": "List farm photos", "created_at": 1999999999, "tags": [["t", "list"], ["expiration", "2000000300"]] }, "target": { "type": "list" }, "server_domain": "blossom.radroots.test", "server_scope": "optional_any_match",