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:
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",