Files
buzz/crates/buzz-media/src/auth.rs
T
+1 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

381 lines
14 KiB
Rust

//! Blossom kind:24242 auth verification (BUD-11 compliant).
use crate::error::MediaError;
/// Verify kind:24242 event validity per BUD-11:
/// 1. Schnorr signature
/// 2. kind == 24242
/// 3. `t` tag == "upload"
/// 4. `expiration` tag in the future
/// 5. `created_at` in the past (with 5s clock-skew tolerance)
/// 6. If `server` tags present, our domain must appear in at least one
///
/// Does NOT check the `x` tag — that requires the body hash, computed later.
/// Call this BEFORE trusting the event's pubkey for scope resolution.
pub fn verify_blossom_auth_event(
auth_event: &nostr::Event,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), MediaError> {
// 1. Verify Schnorr signature
auth_event
.verify()
.map_err(|_| MediaError::InvalidSignature)?;
// 2. Kind must be 24242
if auth_event.kind.as_u16() != 24242 {
return Err(MediaError::InvalidAuthKind);
}
// 2b. Content must be non-empty (BUD-11: "human readable string")
if auth_event.content.trim().is_empty() {
return Err(MediaError::InvalidAuthEvent);
}
let mut found_t = false;
let mut found_exp = false;
let mut server_tags: Vec<&str> = Vec::new();
let mut exp_value: u64 = 0;
for tag in auth_event.tags.iter() {
let kind = tag.kind().to_string();
match kind.as_str() {
"t" => {
if let Some(v) = tag.content() {
if v != "upload" {
return Err(MediaError::InvalidAuthVerb);
}
found_t = true;
}
}
"expiration" => {
if let Some(v) = tag.content() {
exp_value = v.parse().unwrap_or(0);
found_exp = true;
}
}
"server" => {
if let Some(v) = tag.content() {
server_tags.push(v);
}
}
_ => {}
}
}
// 3. t tag required
if !found_t {
return Err(MediaError::MissingTag("t"));
}
// 4. Expiration must exist and be in the future
if !found_exp {
return Err(MediaError::MissingTag("expiration"));
}
let now = nostr::Timestamp::now().as_secs();
if exp_value <= now {
return Err(MediaError::TokenExpired);
}
// 5. created_at must be recent: not in the future (5s tolerance) and not
// older than 10 minutes. This bounds the replay window — even if the
// expiration tag allows a longer lifetime, the token must have been
// freshly minted.
let created = auth_event.created_at.as_secs();
if created > now + 5 {
return Err(MediaError::TimestampOutOfWindow);
}
if now > created + max_age_secs {
return Err(MediaError::TimestampOutOfWindow);
}
// 6. Server tag enforcement (BUD-11 §5): if server tags present, our host must appear.
//
// `server_domain` is the host this request was bound to — the per-request
// tenant host (`TenantContext::host()`), NOT a single process-global domain.
// A relay process serves many tenant hosts; validating against one global
// host would 401 every non-primary tenant's server-tagged client (the stock
// CLI always tags its configured relay host). Comparison is done under the
// shared [`normalize_host`] rule so a tag and the bound host agree by
// construction across case, trailing dot, default ports, and an optional
// URL scheme/path — exactly as every other host seam resolves tenants.
//
// Fail closed: if the bound host is unknown, reject tokens that carry server
// tags rather than silently accepting them.
if !server_tags.is_empty() {
match server_domain {
Some(domain) => {
let want = normalize_server_host(domain);
let matches = server_tags
.iter()
.any(|tag| normalize_server_host(tag) == want);
if !matches {
return Err(MediaError::ServerMismatch);
}
}
None => {
// Server tags present but we don't know our own host — reject.
return Err(MediaError::ServerMismatch);
}
}
}
Ok(())
}
/// Normalize a Blossom `server` tag value (or a bound tenant host) into the
/// canonical host form used as the community lookup key.
///
/// A `server` tag may be a bare authority (`relay.example:3100`, what the stock
/// CLI emits) or a full URL (`https://relay.example/`). We strip an optional
/// scheme and path down to the authority, then apply the one shared
/// [`buzz_core::tenant::normalize_host`] rule so the comparison agrees with how
/// the WS/HTTP/git doors resolve tenants.
fn normalize_server_host(value: &str) -> String {
let authority = match value.split_once("://") {
Some((_scheme, rest)) => rest.split('/').next().unwrap_or(rest),
None => value.split('/').next().unwrap_or(value),
};
buzz_core::tenant::normalize_host(authority)
}
/// Verify a kind:24242 Blossom upload auth event, including the x tag hash check.
///
/// Calls [`verify_blossom_auth_event`] first, then verifies that at least one
/// `x` tag matches `sha256` (BUD-11 §6: "at least one x tag matches").
pub fn verify_blossom_upload_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
max_age_secs: u64,
) -> Result<(), MediaError> {
verify_blossom_auth_event(auth_event, server_domain, max_age_secs)?;
// At least one x tag must match the body sha256 (BUD-11 §6)
let has_matching_x = auth_event
.tags
.iter()
.any(|tag| tag.kind().to_string() == "x" && (tag.content() == Some(sha256)));
if !has_matching_x {
return Err(MediaError::HashMismatch);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
#[test]
fn test_verify_valid() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok());
}
#[test]
fn test_verify_auth_event_valid() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_auth_event(&event, None, 600).is_ok());
}
#[test]
fn test_verify_hash_mismatch() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
let wrong_hash = "b".repeat(64);
assert!(matches!(
verify_blossom_upload_auth(&event, &wrong_hash, None, 600),
Err(MediaError::HashMismatch)
));
}
#[test]
fn test_verify_wrong_kind() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(27235), "wrong kind")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None, 600),
Err(MediaError::InvalidAuthKind)
));
}
#[test]
fn test_verify_multi_x_tags() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let other_hash = "b".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &other_hash]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload multi-x")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Should pass because at least one x tag matches
assert!(verify_blossom_upload_auth(&event, &sha256, None, 600).is_ok());
}
#[test]
fn test_server_tag_enforcement() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "other.example.com"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload scoped")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Should fail — server tag present but doesn't match our domain
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, Some("buzz.example.com"), 600),
Err(MediaError::ServerMismatch)
));
// Should pass when our domain matches
assert!(
verify_blossom_upload_auth(&event, &sha256, Some("other.example.com"), 600).is_ok()
);
// Should fail when server_domain is None — fail closed
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None, 600),
Err(MediaError::ServerMismatch)
));
}
#[test]
fn test_no_server_tags_always_passes() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
// No server tags → passes regardless of our domain
assert!(verify_blossom_upload_auth(&event, &sha256, Some("any.domain.com"), 600).is_ok());
}
/// A `server` tag is matched against the *bound tenant host* under the
/// shared `normalize_host` rule, so equivalent host spellings agree — the
/// stock CLI's bare `host:port`, an explicit default port, a trailing dot,
/// mixed case, and a full URL all match the same bound host. This is the
/// regression guard for the multi-tenant media blocker: a non-primary
/// tenant must accept its own server-tagged client.
#[test]
fn test_server_tag_normalized_against_bound_host() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let build = |server: &str| {
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", server]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload scoped")
.tags(tags)
.sign_with_keys(&keys)
.unwrap()
};
// Non-primary tenant host with explicit non-default port (the live
// repro: tenant B on 127.0.0.1:3100). Stock CLI tags `host:port`.
assert!(verify_blossom_upload_auth(
&build("127.0.0.1:3100"),
&sha256,
Some("127.0.0.1:3100"),
600
)
.is_ok());
// Equivalence under normalize_host: explicit default port, trailing
// dot, mixed case, and a full URL all collapse to the bound host.
for tag in [
"Relay.Example:443",
"relay.example.",
"RELAY.EXAMPLE",
"https://relay.example/",
] {
assert!(
verify_blossom_upload_auth(&build(tag), &sha256, Some("relay.example"), 600)
.is_ok(),
"server tag {tag:?} should match bound host relay.example"
);
}
// A different tenant host still fails closed.
assert!(matches!(
verify_blossom_upload_auth(
&build("127.0.0.1:3100"),
&sha256,
Some("127.0.0.1:3200"),
600
),
Err(MediaError::ServerMismatch)
));
}
#[test]
fn test_empty_content_rejected() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
// Empty content — BUD-11 requires a human-readable string
let event = EventBuilder::new(Kind::from(24242), "")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_auth_event(&event, None, 600),
Err(MediaError::InvalidAuthEvent)
));
}
}