Files
buzz/crates/buzz-media/src/auth.rs
Will Pfleger f7ff4294fa feat(nip-fi): harden Blossom kind-24242 verifier to NIP-FI spec (#7288)
Brings `buzz-media`, `buzz-relay`, all first-party kind-24242 minters,
and desktop token minting into compliance with [NIP-FI
§kind-24242](https://github.com/block/buzz/blob/main/docs/nips/NIP-FI.md).

## What changed

**`buzz-media/src/auth.rs`**
- Add `BlossomStrictness` enum (`Strict` | `Permissive`). `Strict`
applies full NIP-FI rules (active modes); `Permissive` preserves
pre-NIP-FI Off-mode behavior byte-identical [FI-INV-15].
- Rewrite `verify_blossom_auth_event_for_verb` with count-based
cardinality: exactly one `t`/`expiration`/`server` in `Strict`;
duplicate `x` also rejected. `Permissive` keeps main's boolean semantics
exactly: any valued `t` other than the verb (including `""`) rejects,
valueless `t` is ignored.
- `Strict`: mandatory `server` tag on all proofs (upload + read); absent
or mismatched → `evidence_rejected` (`ServerMismatch`).
- `Strict`: 60-second proof window (`now - created_at <= 60s`,
`expiration <= created_at + 60s`). `Permissive`: 3600s / optional
server.
- **[F1]** Permissive server validation uses any-match semantics: a
proof with multiple `server` tags is accepted if any tag matches the
bound host. This restores origin/main base behavior. Strict
duplicate-rejection unchanged.
- **[F1 compat]** Permissive server gate uses `server_values.is_empty()`
(valued tags only) rather than `server_count > 0` (includes valueless
tags). A lone valueless `["server"]` tag is treated as absent in
Permissive/Off mode, matching pre-NIP-FI base behavior [FI-INV-15].
- **[F4]** Valueless `x` tags in Strict get reads are tracked before
`filter_map` so they cannot become absent scope (host-wide
authorization). Any `x` tag present in Strict must contain the exact
requested sha256.
- **[F3 unit]** Cases A–D in `auth.rs` are helper-level unit tests: they
call `verify_blossom_upload_auth` and `verify_upload_hash_only`
directly. They do not enter the upload pipeline
(`process_upload`/`process_video_upload`). Retained as helper evidence.

**`buzz-media/src/error.rs`**
- Add `DuplicateTag(&'static str)` variant.
- Add `blossom_denial_kind()` method: classifies each auth error as
`MissingEvidence`, `EvidenceRejected`, or `AuthorizationDenied`.
Non-auth errors return `None`.
- `IntoResponse` retains the legacy JSON 401 shape [FI-INV-15].

**`buzz-relay/src/api/media.rs`**
- Add `MediaDenial(MediaError, BlossomStrictness)` wrapper mapping to
byte-exact NIP-FI fixed responses in `Strict`; falls through to legacy
JSON in `Permissive`.
- `blossom_strictness_from_state()` selects `Strict` when
`state.config.nip_fi.is_enforce()`, otherwise `Permissive`. Strictness
is threaded through the `admit_nip_fi_http_on_state` NIP-98 closures on
upload and read, and through the post-admission X-SHA-256, hash-binding,
and membership denials.
- `extract_blossom_auth` keeps first-value `Authorization` extraction;
Enforce rejects repeated fields at the `admit_nip_fi_http` cardinality
gate, and Off keeps legacy behavior [FI-INV-15].
- Both membership-denial call sites use
`media_denial(RelayMembershipRequired, strictness)` so Strict mode
produces the correct fixed-text response.
- `upload_blob` serving-write fence conversion (`:406`) uses
`map_err(|e| media_denial(e, strictness))` — a community fence committed
between auth admission and lease acquisition no longer resets Strict to
Permissive.
- Mode-driven regressions in `mod postgres_tests` (selected by the
nextest `postgres-ci` profile via `test(/postgres_tests::/)`, `#[ignore
= "requires Postgres"]`), run through the full relay router with a real
Enforce verifier and assertion: Enforce rejects Permissive-only proofs
(300s lifetime, missing `server` tag) on both upload routes and reads
with `evidence rejected`; Off accepts the same proofs with pre-NIP-FI
responses; DenyProtected answers 503 before any proof check; membership
denials are `authorization denied` text/plain in Enforce and exact
legacy JSON 403 in Off, with a member positive control. GET and HEAD are
each discriminated (HEAD with a suppressed body). Both upload routes pin
the handler's `X-SHA-256` missing/malformed/unsigned and post-body
hash-mismatch denials in both modes. Off rejects an empty-valued `t`
beside a valid one with legacy 401 JSON in either order, and ignores a
valueless one. Exact status, Content-Type, challenge, and body bytes are
pinned.

**`buzz-media/src/upload.rs`** (`mod minio_tests`)
- **[F3 pipeline]** Four tests exercising the actual post-body call
sites (`process_upload` `:85`, `process_video_upload` `:413`) with real
MinIO I/O: buffered/streaming × expired-proof-accept +
mismatched-hash-reject. The positive cases use
`fresh_strict_upload_auth` (`created_at = now-58`, `expiration = now+2`,
lifetime = 60s) — Strict-admissible at sign time — followed by a real 3s
sleep so the proof is expired when the upload completes.
`nostr::Timestamp::now()` reads the OS wall clock directly; paused Tokio
time does not advance it. The negative cases use a pre-expired proof
with a mismatched `x` tag. All four are `#[ignore = "requires MinIO"]`;
no CI lane runs them automatically.

**`buzz-media/src/validation.rs`**
- `minimal_valid_mp4()` promoted to `#[cfg(test)] pub(crate)` — a
618-byte pre-computed H.264/avc1 fast-start MP4 available to all
`buzz-media` test modules; smoke-tested by
`test_minimal_valid_mp4_passes_validate_video_file`.

**`desktop/src-tauri/src/commands/media.rs`**
- `sign_blossom_upload_auth`: `server` tag mandatory; upload expiry 60s
(was 3600s/300s).
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s).

**`desktop/src-tauri/src/media_proxy.rs`**
- Single re-mint+retry on 401/403 for range requests; non-auth failures
do not retry.

**`crates/buzz-cli/src/client.rs`**
- Read expiry: 60s (was 600s). Upload expiry: 60s. `server` tag
mandatory on upload.

**`crates/buzz-dev-mcp/src/view_image.rs`**
- `MEDIA_GET_AUTH_EXPIRY_SECS`: 60s (was 600s). Test updated.

**`mobile/lib/shared/relay/media_auth.dart`**
- `_mediaGetAuthLifetimeSeconds`: 60 (was 600). Refresh margin updated.

**`mobile/lib/shared/relay/media_upload.dart`**
- `_uploadAuthLifetimeSeconds`: 60 (was 300). `server` tag mandatory.

**`mobile/lib/features/channels/media_viewer_page/video_viewer.dart`**
- **[F2]** Remove Android-specific `VideoPlayerController.networkUrl`
streaming path. `video_player_android` 2.9.5 freezes headers into static
`DefaultHttpDataSource` request properties — a 60s proof minted at
controller creation time becomes stale on seeks. All platforms use the
authenticated local-file download path.
- **[F2 transport]** `unawaited(request.sink.close())` before
`client.send()`. `AbortableStreamedRequest` carries no request body;
without closing the sink, `IOClient.send()` awaits
`stream.pipe(ioRequest)` which blocks until the sink ends — every video
download hung in loading indefinitely on every platform.
- **[F2 pending-ctrl]** `pendingController` ref set before the first
`await (initialize())`, cleared in all exit paths. Effect cleanup
disposes `pendingController.value` so a close-during-init releases the
native player even if the initialized event never arrives.
- **[F2r(a)]** On native init failure (post-create,
pre-initialized-event), the inner catch starts disposal detached via
`unawaited(localController.dispose().catchError(...))` — the
`.catchError` handler absorbs any disposal exception so it does not
reach the test error zone — then rethrows the original load error.
`video_player` 2.11.1 completes the init future with an error without
disposing the native player.
- **[F2r(b)]** Non-2xx error body cancelled via `_cancelVideoResponse()`
(subscribe+cancel) instead of `drain()`. `drain()` waits for the
upstream to close; a stalled server body blocked `initializeVideo`
indefinitely.
- **[F2r(d)]** `unawaited(localController.dispose().catchError(...))` in
the catch — not `await`. Two failure mechanisms exist: (1) when
`createWithOptions()` throws, `_creatingCompleter` is never completed
and `await dispose()` deadlocks at `:682-683`, blocking the outer catch
from setting error state. (2) when `createWithOptions()` succeeds but
initialization fails, `dispose()` runs but its thrown exception becomes
an unhandled async error reaching the test zone. Both are fixed:
`unawaited` lets the outer catch run immediately and set `error.value`;
`.catchError(...)` on the detached disposal future absorbs any disposal
exception.


**`mobile/test/features/channels/media_viewer_page/video_viewer_test.dart`**
- `_FinalizingFakeClient`: calls `request.finalize().drain()` — a test
without the sink fix times out because the drain never completes,
validating the transport contract.
- F2r(a): `forceInitError=true` fake emits `PlatformException` on
subscribe; `disposeCallCount >= 1` verifies disposal even on init
failure.
- F2r(b): `stalledBody` `StreamController` with `onCancel` Completer
(bounded completion signal, not a sleep). Asserts: (1)
`bodyStreamCancelled=true` while body stream is still open (impossible
with `drain()`); (2) error UI visible while body remains open. Restoring
`drain()` breaks both.
- F2r(c): `neverInitialize=true` fake never sends initialized event;
unmount asserts `disposeCallCount >= 1` via `pendingController`.
Distinct from the event-error case.
- F2r(d): `forceCreateError=true` fake throws `PlatformException` from
`createWithOptions()` itself (before any player ID is returned). Asserts
error UI visible within a bounded deadline. With the old `await
dispose()` the outer catch is blocked — `error.value` is never set and
the error UI assertion fails at the deadline; with
`unawaited(dispose())` the error text appears immediately.


**`mobile/test/features/channels/media_viewer_page/video_viewer_transport_test.dart`**
(separate file)
- Real-IO loopback probes using `flutter_test` (not `package:test`) to
avoid `depend_on_referenced_packages` lint. No
`TestWidgetsFlutterBinding` — no HttpClient override interfering with
`dart:io`.
- Probe 1: `HttpServer.bind(loopbackIPv4, 0)` reads the full request
body before replying. Without `sink.close()`, `req.drain()` hangs and
the test times out.
- Probe 2: server signals request arrival via a Completer (not a sleep);
abort fires after confirmed arrival; `client.send()` must throw
`RequestAbortedException` (typed assertion) within a bounded 5s
deadline.

**`scripts/test-video-upload.sh`**
- Proof minted with `+60` expiry and mandatory `server` tag.

**`buzz-test-client/tests/e2e_media*.rs`** +
**`buzz-relay/src/api/media.rs` test fixtures**
- All fixtures updated to 55s expiry + `server` tag.

**`RELEASING.md`**
- New "NIP-FI upload proof freshness (relay floor)" section documenting
that 60s proofs require the first relay release carrying this PR's
post-body hash-only check (the first tag > `v0.2.1` — update once cut)
for reliable large-file uploads. Operators on older self-hosted relays
(≤ `v0.2.1`) must upgrade before receiving these clients.

## Finding 3: 60s upload proofs vs old-relay compatibility

Desktop/CLI/mobile upload proofs are now 60s. Older relays re-run full
expiry verification after the body is transferred. A valid upload whose
body transfer takes >60s will fail on an old relay.

**Resolution (relay floor prerequisite):** `RELEASING.md` requires that
verified supported deployments (relay releases confirmed to carry the
post-body hash-only check, commit `75e9bef748`) be in place before
distributing 60s-proof client builds. Unknown or unverified self-hosted
relay deployments are excluded from distribution until they upgrade.
This makes the verified-deployment status a prerequisite for release
distribution, not a post-distribution advisory. The concrete tag (`>
v0.2.1`) is to be recorded once cut.

## Sequencing note

Builds on #7264 (merged): Blossom strictness is derived from its
`config.nip_fi` mode in this PR, so enabling `BUZZ_NIP_FI_MODE=enforce`
activates the strict verifier with no further wiring.

## GIF compatibility exception

An unauthenticated Off-mode request on a configured tenant with no GIF
provider changes from 404 to 401 (base `gifs.rs:269-275` checked an
unconfigured provider first; the current code authenticates first). All
other Off-mode Blossom responses are byte-identical to pre-NIP-FI
behavior.

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2026-09-28 14:46:56 -04:00

1873 lines
71 KiB
Rust

//! Blossom kind:24242 auth verification (BUD-11 + NIP-FI compliant).
use crate::error::MediaError;
/// Blossom kind:24242 verbs Buzz currently accepts.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomVerb {
Upload,
Get,
}
impl BlossomVerb {
fn as_str(self) -> &'static str {
match self {
Self::Upload => "upload",
Self::Get => "get",
}
}
}
/// Verification strictness derived from the NIP-FI mode.
///
/// `Strict` applies the full NIP-FI kind-24242 rules: 60-second proof window,
/// `expiration <= created_at + 60s`, mandatory `server` tag on all proofs, and
/// exact cardinality (exactly one each of `t`, `expiration`, `server`; at most
/// one `x`).
///
/// `Permissive` preserves the pre-NIP-FI behavior for Off-mode deployments:
/// 3600-second proof window, `server` tag optional, and duplicate tags accepted.
/// This keeps a deployed desktop that mints old-shape tokens working against an
/// Off-mode relay [FI-INV-15].
///
/// The relay call sites derive strictness from `config.nip_fi.mode`; library
/// callers without mode access may pass `Strict` directly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlossomStrictness {
/// Full NIP-FI compliance: 60s window, mandatory `server`, exact cardinality.
Strict,
/// Pre-NIP-FI compatibility: 3600s window, optional `server`, tolerant cardinality.
Permissive,
}
/// Verify common kind:24242 Blossom auth event validity.
///
/// Checks in order:
/// 1. Schnorr signature
/// 2. kind == 24242 and non-empty content
/// 3. `t` tag matches `verb`:
/// - `Strict`: count every tag named `t` by field name; require exactly
/// one occurrence; require its content to be non-empty and equal to
/// `verb` (NIP-FI.md:658-666 — malformed/empty/duplicate instances MUST
/// be rejected as `evidence_rejected`).
/// - `Permissive`: at least one `t` tag equal to `verb`; any other valued
/// `t` (including `""`) rejects and valueless tags are ignored
/// (origin/main `found_t` semantics).
/// 4. `expiration` tag present, strictly future, and within the freshness window
/// 5. `created_at` bounded: not more than 5s in the future; not older than the
/// mode-selected window (60s `Strict`, 3600s `Permissive`)
/// 6. `server` tag enforcement: in `Strict` mode, exactly one `server` tag is
/// required and MUST match the bound tenant host; in `Permissive`, optional
/// when-present behavior is preserved
///
/// Does NOT check verb-specific scope tags (`x` for upload, `x` OR `server`
/// for get). Call this BEFORE trusting the event's pubkey for scope resolution.
pub fn verify_blossom_auth_event_for_verb(
auth_event: &nostr::Event,
verb: BlossomVerb,
server_domain: Option<&str>,
strictness: BlossomStrictness,
) -> Result<(), MediaError> {
let strict = strictness == BlossomStrictness::Strict;
// 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);
}
// Tag accumulation with strict cardinality tracking.
// In Strict mode: each of t/expiration/server must appear exactly once;
// x is counted separately for upload scope checking (at most one).
// In Permissive mode: boolean-style "found at least one" semantics,
// matching the pre-NIP-FI behavior.
let mut t_count: u8 = 0;
let mut exp_count: u8 = 0;
let mut server_count: u8 = 0;
let mut x_count: u8 = 0;
let mut exp_value: u64 = 0;
// All server tag values, collected for Permissive any-match semantics.
// Strict mode returns early on duplicate-server before reaching the
// validation block, so server_values[0] is always safe to use there.
let mut server_values: Vec<String> = Vec::new();
for tag in auth_event.tags.iter() {
let kind = tag.kind().to_string();
match kind.as_str() {
"t" => {
if strict {
// Strict (NIP-FI): count EVERY tag named "t" by field name,
// regardless of its content. NIP-FI.md:658-666 requires
// that malformed, empty, duplicate, or conflicting instances
// be rejected as `evidence_rejected`. We count first, gate
// on cardinality, then validate content.
t_count = t_count.saturating_add(1);
if t_count > 1 {
return Err(MediaError::DuplicateTag("t"));
}
// Exactly one t tag: its content must be non-empty and
// equal to the requested verb.
match tag.content() {
Some(v) if !v.is_empty() && v == verb.as_str() => {}
_ => return Err(MediaError::InvalidAuthVerb),
}
} else {
// Permissive: origin/main's `found_t` semantics exactly.
// Any valued tag (including empty) that is not the verb
// rejects; valueless tags are ignored [FI-INV-15].
if let Some(v) = tag.content() {
if v != verb.as_str() {
return Err(MediaError::InvalidAuthVerb);
}
t_count = t_count.saturating_add(1);
}
// No content: tag is ignored (not counted, not rejected).
}
}
"expiration" => {
exp_count = exp_count.saturating_add(1);
if strict && exp_count > 1 {
return Err(MediaError::DuplicateTag("expiration"));
}
if let Some(v) = tag.content() {
if strict {
// Strict: first-wins (duplicate already rejected above).
if exp_count == 1 {
exp_value = v.parse().unwrap_or(0);
}
} else {
// Permissive: last-wins, matching pre-NIP-FI base
// behavior (base auth.rs processed each valued tag
// unconditionally, so a later tag overwrote earlier
// ones). A valueless tag followed by a future-valued
// tag must not be treated as expired [FI-INV-15].
exp_value = v.parse().unwrap_or(0);
}
}
}
"server" => {
server_count = server_count.saturating_add(1);
if strict && server_count > 1 {
return Err(MediaError::DuplicateTag("server"));
}
// Collect all valued server tags for Permissive any-match; Strict
// will only ever have at most one here (duplicate check above).
if let Some(v) = tag.content() {
server_values.push(v.to_owned());
}
}
"x" => {
x_count = x_count.saturating_add(1);
if strict && x_count > 1 {
return Err(MediaError::DuplicateTag("x"));
}
}
_ => {}
}
}
// 3. t tag required (exactly one in Strict, at least one in Permissive)
if t_count == 0 {
return Err(MediaError::MissingTag("t"));
}
// 4a. Expiration must exist
if exp_count == 0 {
return Err(MediaError::MissingTag("expiration"));
}
let now = nostr::Timestamp::now().as_secs();
// 4b. Expiration must be strictly in the future
if exp_value <= now {
return Err(MediaError::TokenExpired);
}
// 5. created_at freshness bounds.
//
// Strict (NIP-FI active modes):
// created_at <= now + 5s — bounded future skew
// now - created_at <= 60s — 60-second replay window
// expiration <= created_at + 60s — token cannot outlive its window
//
// Permissive (Off mode):
// created_at <= now + 5s — future skew only
// now - created_at <= 3600s — 1-hour replay window (pre-NIP-FI)
// expiration window not enforced
let created = auth_event.created_at.as_secs();
if created > now + 5 {
return Err(MediaError::TimestampOutOfWindow);
}
let max_age = if strict { 60u64 } else { 3600u64 };
if now > created + max_age {
return Err(MediaError::TimestampOutOfWindow);
}
if strict && exp_value > created + 60 {
// expiration tag must satisfy expiration <= created_at + 60s
return Err(MediaError::TimestampOutOfWindow);
}
// 6. Server tag enforcement.
//
// `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.
//
// Strict (NIP-FI active): exactly one server tag MUST be present and MUST
// match the bound tenant host. Absent or mismatched → evidence_rejected.
//
// Permissive (Off mode): if ANY server tag is present, at least one MUST
// match our host (any-match semantics, preserving origin/main behavior);
// absent server tags are accepted [FI-INV-15].
if strict {
// Strict: server_values has at most one entry (duplicate rejected above).
match (
server_count,
server_values.first().map(String::as_str),
server_domain,
) {
(0, _, _) => {
// Strict: server tag mandatory
return Err(MediaError::ServerMismatch);
}
(_, Some(tag_host), Some(domain)) => {
if normalize_server_host(tag_host) != normalize_server_host(domain) {
return Err(MediaError::ServerMismatch);
}
}
(_, _, None) => {
// Strict: bound host unknown — fail closed
return Err(MediaError::ServerMismatch);
}
(_, None, _) => {
// server tag present but valueless — treat as mismatch
return Err(MediaError::ServerMismatch);
}
}
} else {
// Permissive: validate only when VALUED server tags are present.
// A valueless `["server"]` tag does not constitute a server-tag binding
// and must not change the admission decision (FI-INV-15: Permissive/Off
// must preserve pre-NIP-FI base behavior, which gated on the collected
// valued-tag list being nonempty). Using `server_values.is_empty()`
// (not `server_count > 0`) ensures a lone valueless server tag is
// treated the same as no server tag — the proof is admitted without a
// server check, matching origin/main behavior.
//
// Any-match semantics: a proof with multiple VALUED server tags is
// accepted if at least one matches our host.
if !server_values.is_empty() {
let Some(domain) = server_domain else {
// Server tags present but our host is unknown — fail closed.
return Err(MediaError::ServerMismatch);
};
let want = normalize_server_host(domain);
let any_match = server_values
.iter()
.any(|tag_host| normalize_server_host(tag_host) == want);
if !any_match {
return Err(MediaError::ServerMismatch);
}
}
}
Ok(())
}
/// Verify only the `x` tag hash match on an already-admitted upload auth event.
///
/// This is the post-body hash check: the full auth event verification
/// (signature, kind, freshness, cardinality, server) was already performed at
/// the pre-body admission gate. Re-running the full verifier after streaming
/// a potentially large body would fail for any upload that takes longer than
/// the minted token's `expiration` window (typically 60 s in Strict mode).
///
/// The ONLY thing that is unknown before the body is transferred is the
/// content hash (`x` tag). This function confirms that the body's SHA-256
/// matches what was declared in the signed proof.
pub fn verify_upload_hash_only(auth_event: &nostr::Event, sha256: &str) -> Result<(), MediaError> {
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(())
}
/// Verify common upload auth event validity.
///
/// Kept as the upload-shaped public wrapper for existing callers; new verb-aware
/// code should prefer [`verify_blossom_auth_event_for_verb`].
pub fn verify_blossom_auth_event(
auth_event: &nostr::Event,
server_domain: Option<&str>,
strictness: BlossomStrictness,
) -> Result<(), MediaError> {
verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness)
}
/// 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.
///
/// In `Strict` mode (NIP-FI active): exactly one `x` tag MUST be present and
/// MUST match `sha256`. In `Permissive` mode: at least one `x` tag must match.
pub fn verify_blossom_upload_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
strictness: BlossomStrictness,
) -> Result<(), MediaError> {
verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Upload, server_domain, strictness)?;
// Upload: x tag must match the body sha256.
// Strict: exactly one x tag and it must match (duplicate x caught above).
// Permissive: at least one x tag must match.
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(())
}
/// Verify a kind:24242 Blossom get auth event for one requested blob.
///
/// BUD-01 permits either blob-scoped authorization (`x` tag matches `sha256`)
/// or server-scoped authorization (`server` tag matches this relay host). The
/// latter intentionally grants reads for all blobs on the host until expiration;
/// callers must still apply relay membership after this verifier returns.
///
/// In `Strict` mode the `server` tag is mandatory (enforced by the base verifier);
/// `x` is optional on reads. An `x` tag, when present, must be exactly one and
/// must match `sha256`.
pub fn verify_blossom_get_auth(
auth_event: &nostr::Event,
sha256: &str,
server_domain: Option<&str>,
strictness: BlossomStrictness,
) -> Result<(), MediaError> {
verify_blossom_auth_event_for_verb(auth_event, BlossomVerb::Get, server_domain, strictness)?;
// x tag scope check for get: if an x tag is present it must match sha256.
// In Strict mode duplicate x is already rejected above; here we check the value.
// In Permissive mode we use the original "any matching x OR matching server" logic.
// Count ALL x tags (including valueless) — presence of a valueless x in Strict
// mode is a malformed proof and must not become absent scope.
let x_tag_count: usize = auth_event
.tags
.iter()
.filter(|tag| tag.kind().to_string() == "x")
.count();
// Collect only valued x tags for hash comparison.
let x_tags: Vec<&str> = auth_event
.tags
.iter()
.filter(|tag| tag.kind().to_string() == "x")
.filter_map(|tag| tag.content())
.collect();
let has_matching_x = x_tags.contains(&sha256);
let has_matching_server = match server_domain {
Some(domain) => {
let want = normalize_server_host(domain);
auth_event.tags.iter().any(|tag| {
tag.kind().to_string() == "server"
&& tag
.content()
.map(|value| normalize_server_host(value) == want)
.unwrap_or(false)
})
}
None => false,
};
if strictness == BlossomStrictness::Strict {
// Strict: server is already validated as present+matching by the base verifier.
// If any x tag is present (including a valueless one), it MUST contain the
// exact requested sha256 — a valueless or mismatched x is malformed evidence
// (evidence_rejected). No x tags → server-scoped read, admitted here.
if x_tag_count > 0 && !has_matching_x {
return Err(MediaError::ServerMismatch);
}
// Server-scoped read (no x) is always admitted here — server was validated above.
} else {
// Permissive: original BUD-01 semantics — matching x OR matching server.
if !has_matching_x && !has_matching_server {
return Err(MediaError::InsufficientScope);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use sha2::Digest as _;
fn build_valid_auth(keys: &Keys, sha256: &str) -> nostr::Event {
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
fn build_permissive_auth(keys: &Keys, sha256: &str) -> nostr::Event {
// Old-shape token: no server tag, 300s expiry — valid in Permissive, rejected in Strict.
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()
}
// ── Strict mode ──────────────────────────────────────────────────────────
#[test]
fn test_verify_valid_strict() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_ok());
}
#[test]
fn test_verify_auth_event_valid_strict() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(verify_blossom_auth_event(
&event,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_ok());
}
// ── Permissive mode (Off-mode regression guard [FI-INV-15]) ──────────────
#[test]
fn test_verify_permissive_old_shape_token_admitted() {
// Old-shape token (no server tag, 300s expiry) MUST be admitted in Permissive mode.
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_permissive_auth(&keys, &sha256);
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Off-mode must admit old-shape tokens without server tag [FI-INV-15]"
);
}
#[test]
fn test_verify_permissive_long_expiry_admitted() {
// 600s expiry token (pre-NIP-FI desktop default) MUST be admitted in Permissive mode.
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 600).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(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Off-mode must admit 600s expiry tokens [FI-INV-15]"
);
}
// ── Cardinality: Strict rejects duplicates ────────────────────────────────
#[test]
fn test_strict_rejects_duplicate_t_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["t", "upload"]).unwrap(), // duplicate
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::DuplicateTag("t"))
));
}
#[test]
fn test_strict_rejects_duplicate_expiration_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(), // duplicate
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::DuplicateTag("expiration"))
));
}
#[test]
fn test_strict_rejects_duplicate_server_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(), // duplicate
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::DuplicateTag("server"))
));
}
#[test]
fn test_strict_rejects_duplicate_x_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(), // duplicate
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::DuplicateTag("x"))
));
}
// ── Permissive mode: duplicate tags still admitted ────────────────────────
#[test]
fn test_permissive_admits_duplicate_x_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(), // duplicate — permitted in Permissive
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok());
}
// ── Strict: server tag mandatory ─────────────────────────────────────────
#[test]
fn test_strict_rejects_absent_server_on_upload() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
// no server tag
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::ServerMismatch)
),
"Strict mode must reject upload proof without server tag"
);
}
#[test]
fn test_strict_rejects_absent_server_on_read() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
// no server tag
];
let event = EventBuilder::new(Kind::from(24242), "Get buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::ServerMismatch)
),
"Strict mode must reject read proof without server tag"
);
}
// ── Strict: freshness window ──────────────────────────────────────────────
#[test]
fn test_strict_rejects_expiration_exceeding_60s_window() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 61).to_string(); // 61s > 60s limit
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::TimestampOutOfWindow)
),
"Strict mode must reject expiration > created_at + 60s"
);
}
#[test]
fn test_strict_admits_60s_expiration() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 60).to_string(); // exactly 60s — allowed
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_ok(),
"Strict mode must admit expiration == created_at + 60s"
);
}
// ── Get auth ─────────────────────────────────────────────────────────────
fn build_get_auth(keys: &Keys, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::from(24242), "Get buzz-media")
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
#[test]
fn test_verify_get_accepts_matching_server_strict() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["server", "https://Relay.Example./media/ignored"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_ok());
}
#[test]
fn test_verify_get_accepts_matching_x_and_server_strict() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_ok());
}
#[test]
fn test_verify_get_strict_rejects_mismatched_x_with_valid_server() {
// x present but wrong hash → evidence_rejected even if server matches
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let other = "b".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", &other]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(matches!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::ServerMismatch)
));
}
#[test]
fn test_verify_get_accepts_matching_x_without_server_permissive() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok());
}
#[test]
fn test_verify_get_accepts_matching_server_without_x_permissive() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["server", "https://Relay.Example./media/ignored"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok());
}
#[test]
fn test_verify_get_rejects_upload_verb() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_valid_auth(&keys, &sha256);
assert!(matches!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
),
Err(MediaError::InvalidAuthVerb)
));
}
#[test]
fn test_verify_get_requires_x_or_server_scope_permissive() {
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 event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x", &other_hash]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(matches!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
),
Err(MediaError::InsufficientScope)
));
}
#[test]
fn test_verify_get_rejects_wrong_server_scope() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
let event = build_get_auth(
&keys,
vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["server", "other.example"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
],
);
assert!(matches!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
),
Err(MediaError::ServerMismatch)
));
}
#[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,
Some("relay.example"),
BlossomStrictness::Strict
),
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 + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).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,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::InvalidAuthKind)
));
}
#[test]
fn test_server_tag_enforcement_permissive() {
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"),
BlossomStrictness::Permissive
),
Err(MediaError::ServerMismatch)
));
// Should pass when our domain matches
assert!(verify_blossom_upload_auth(
&event,
&sha256,
Some("other.example.com"),
BlossomStrictness::Permissive
)
.is_ok());
// Should fail when server_domain is None — fail closed
assert!(matches!(
verify_blossom_upload_auth(&event, &sha256, None, BlossomStrictness::Permissive),
Err(MediaError::ServerMismatch)
));
}
#[test]
fn test_permissive_no_server_tags_always_passes() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let event = build_permissive_auth(&keys, &sha256);
// No server tags → passes regardless of our domain in Permissive mode
assert!(verify_blossom_upload_auth(
&event,
&sha256,
Some("any.domain.com"),
BlossomStrictness::Permissive
)
.is_ok());
}
/// A `server` tag is matched against the *bound tenant host* under the
/// shared `normalize_host` rule, so equivalent host spellings agree.
#[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 + 55).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.
assert!(verify_blossom_upload_auth(
&build("127.0.0.1:3100"),
&sha256,
Some("127.0.0.1:3100"),
BlossomStrictness::Strict
)
.is_ok());
// Equivalence under normalize_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"),
BlossomStrictness::Strict
)
.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"),
BlossomStrictness::Strict
),
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 + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).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, Some("relay.example"), BlossomStrictness::Strict),
Err(MediaError::InvalidAuthEvent)
));
}
// ── Finding 3: valueless / empty-string t tag must not satisfy verb binding ──
/// A `t` tag with no content (`["t"]`) in Strict mode: counted as one occurrence,
/// content check fires → `InvalidAuthVerb` (malformed instance, NIP-FI §cardinality).
#[test]
fn test_strict_rejects_valueless_t_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
// ["t"] with no second element — no content, must be rejected
let tags = vec![
Tag::parse(["t"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::InvalidAuthVerb)
),
"valueless t tag must be rejected in Strict mode (counted once, content invalid)"
);
}
#[test]
fn test_valueless_t_tag_is_not_counted_permissive() {
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"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
),
Err(MediaError::MissingTag("t"))
),
"valueless t tag must not satisfy t requirement in Permissive mode"
);
}
/// A `t` tag with an empty-string value (`["t", ""]`) in Strict mode: counted
/// as one occurrence, content check fires → `InvalidAuthVerb`.
#[test]
fn test_strict_rejects_empty_string_t_tag() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", ""]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::InvalidAuthVerb)
),
"empty-string t tag must be rejected in Strict mode (counted once, content invalid)"
);
}
/// A valueless `x` tag on upload (`["x"]`) does not match any sha256.
#[test]
fn test_valueless_x_tag_does_not_match_hash() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x"]).unwrap(), // no value
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::HashMismatch)
),
"valueless x tag must not satisfy hash requirement"
);
}
// ── Finding 3 R2: mixed malformed+valid t combos must reject in Strict ────
/// `["t"]` (valueless) + `["t","upload"]` in Strict: the malformed tag is counted
/// and content-validated on first encounter; the exact rejection error depends on
/// tag ordering but any error is correct — no combination may be admitted.
/// (If malformed comes first: `InvalidAuthVerb`; if valid first: `DuplicateTag("t")`.)
#[test]
fn test_strict_rejects_valueless_plus_valid_t_combo() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t"]).unwrap(), // valueless — counted in Strict
Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let result = verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict,
);
assert!(
result.is_err(),
"valueless+valid t combo must be rejected in Strict mode, got Ok"
);
// The first tag is valueless → InvalidAuthVerb fires before the second tag
// is seen; if ordering were reversed it would be DuplicateTag("t"). Both are
// valid evidence_rejected-class outcomes — what matters is admission is denied.
assert!(
matches!(
result,
Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t"))
),
"expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant"
);
}
/// `["t",""]` (empty-string) + `["t","upload"]` in Strict: same reasoning —
/// any error is correct; admission must be denied.
#[test]
fn test_strict_rejects_empty_plus_valid_t_combo() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", ""]).unwrap(), // empty-string — counted in Strict
Tag::parse(["t", "upload"]).unwrap(), // valid — but two t tags total
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
let result = verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict,
);
assert!(
result.is_err(),
"empty-string+valid t combo must be rejected in Strict mode, got Ok"
);
assert!(
matches!(
result,
Err(MediaError::InvalidAuthVerb) | Err(MediaError::DuplicateTag("t"))
),
"expected InvalidAuthVerb or DuplicateTag(t), got unexpected error variant"
);
}
/// Permissive must match origin/main's predicate exactly [FI-INV-15]: any
/// valued `t` unequal to the verb — including `""` — rejects, in either
/// order; only a valueless `["t"]` is ignored.
fn permissive_get_with_t_tags(t_tags: &[&[&str]]) -> Result<(), MediaError> {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let exp_str = (Timestamp::now().as_secs() + 300).to_string();
let mut tags: Vec<Tag> = t_tags
.iter()
.map(|t| Tag::parse(t.iter().copied()).unwrap())
.collect();
tags.push(Tag::parse(["x", &sha256]).unwrap());
tags.push(Tag::parse(["expiration", &exp_str]).unwrap());
let event = build_get_auth(&keys, tags);
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive,
)
}
#[test]
fn test_permissive_rejects_empty_plus_valid_t_either_order() {
for order in [
&[&["t", ""][..], &["t", "get"][..]][..],
&[&["t", "get"][..], &["t", ""][..]][..],
] {
assert!(
matches!(
permissive_get_with_t_tags(order),
Err(MediaError::InvalidAuthVerb)
),
"Permissive must reject empty-valued t like main: {order:?}"
);
}
}
#[test]
fn test_permissive_admits_valueless_plus_valid_t_either_order() {
for order in [
&[&["t"][..], &["t", "get"][..]][..],
&[&["t", "get"][..], &["t"][..]][..],
] {
assert!(
permissive_get_with_t_tags(order).is_ok(),
"Permissive must ignore valueless t like main: {order:?}"
);
}
}
/// `["x"]` (valueless) + `["x", sha256]` (valid) in Strict: `x_count` increments
/// unconditionally for both occurrences → `DuplicateTag("x")`. This confirms
/// the x arm already handles mixed malformed+valid correctly.
#[test]
fn test_strict_rejects_valueless_plus_valid_x_combo() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x"]).unwrap(), // valueless — counted unconditionally
Tag::parse(["x", &sha256]).unwrap(), // valid — but makes x_count = 2
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload bypass attempt")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
),
Err(MediaError::DuplicateTag("x"))
),
"valueless+valid x combo must be rejected as DuplicateTag in Strict mode"
);
}
// ── Finding 1 (F1): Permissive multi-server any-match regression ─────────
/// Permissive: `[[\"server\",\"other.example\"],[\"server\",\"relay.example\"]]` —
/// relay.example is second; must be admitted (any-match). Restores origin/main semantics.
#[test]
fn test_permissive_multi_server_second_matches() {
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"]).unwrap(),
Tag::parse(["server", "relay.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload multi-server")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Permissive must accept multi-server proof when any server matches (relay.example second)"
);
}
/// Permissive: `[[\"server\",\"relay.example\"],[\"server\",\"other.example\"]]` —
/// relay.example is first; must also be admitted.
#[test]
fn test_permissive_multi_server_first_matches() {
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", "relay.example"]).unwrap(),
Tag::parse(["server", "other.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload multi-server")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Permissive must accept multi-server proof when any server matches (relay.example first)"
);
}
/// Permissive multi-server: neither server matches our host → still rejected.
#[test]
fn test_permissive_multi_server_none_match() {
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"]).unwrap(),
Tag::parse(["server", "another.example"]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload multi-server")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
),
Err(MediaError::ServerMismatch)
),
"Permissive must reject multi-server proof when no server matches"
);
}
// ── Finding 4 (F4): valueless x tag on Strict get must not become host-wide scope ─
/// Strict get: proof with exactly one `[\"x\"]` (valueless) and a valid server —
/// the valueless x tag must not become absent scope (host-wide authorization).
/// Before the fix, `filter_map(tag.content())` dropped it silently and the read passed.
#[test]
fn test_strict_get_valueless_x_tag_does_not_grant_host_wide_scope() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 55).to_string();
let tags = vec![
Tag::parse(["t", "get"]).unwrap(),
Tag::parse(["x"]).unwrap(), // valueless — must not become absent scope
Tag::parse(["server", "relay.example"]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Get buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Must be rejected: valueless x is present but matches no sha256.
assert!(
verify_blossom_get_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Strict
)
.is_err(),
"valueless x tag in Strict get must not grant host-wide read scope"
);
}
// ── Finding 1 compatibility edge (F1): valueless server tag must not change Permissive/Off behavior ─
/// Permissive: a proof with a valid matching `x`, a valid expiration, and a
/// lone VALUELESS `["server"]` tag (no content) was admitted by the pre-NIP-FI
/// base verifier, which gated server validation on the collected valued-tag list
/// being nonempty. The fix gates on `!server_values.is_empty()` (not
/// `server_count > 0`) so a valueless server tag is treated as absent.
///
/// Before the fix, `server_count > 0` was true (count includes valueless tags),
/// entering the branch, and `server_values.iter().any(...)` over an empty vec
/// always returned false → `ServerMismatch` — a regression vs. base behavior.
#[test]
fn test_permissive_valueless_server_tag_does_not_change_admission() {
let keys = Keys::generate();
let sha256 = "a".repeat(64);
let now = Timestamp::now().as_secs();
let exp_str = (now + 300).to_string();
// Valid proof: matching x, valid expiration, lone valueless server tag.
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server"]).unwrap(), // valueless — must be ignored in Permissive
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Permissive: valueless server tag must not trigger ServerMismatch.
// The proof has a matching x tag so it must be admitted on the x-scope path.
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Permissive must admit a proof whose only server tag is valueless — \
valueless server tag must not change admission (FI-INV-15)"
);
}
/// Permissive (used for both Permissive and Off NIP-FI modes on the Blossom path):
/// same invariant as above, tested on its own to confirm the fix is not
/// order-dependent. Permissive is the only non-Strict variant; Off mode
/// selects Permissive strictness at the API layer.
#[test]
fn test_off_valueless_server_tag_does_not_change_admission() {
let keys = Keys::generate();
let sha256 = "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", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server"]).unwrap(), // valueless
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// BlossomStrictness::Off is represented as Permissive on the Blossom path.
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
Some("relay.example"),
BlossomStrictness::Permissive
)
.is_ok(),
"Off must admit a proof whose only server tag is valueless (FI-INV-15)"
);
}
// ── Upload expiry-boundary regression (commit 75e9bef748d2149ce459b14da842e706a51a5f78) ──
//
// Demonstrates the split between pre-body admission (freshness + full auth)
// and post-body verification (hash only via verify_upload_hash_only).
//
// Scenario: a 60-second Strict upload proof is valid at admit time. After a
// slow transfer, the proof's expiry has passed. The OLD full-verifier
// (verify_blossom_upload_auth called post-body) would reject the expired
// token, failing a legitimate large upload. The NEW post-body gate
// (verify_upload_hash_only) ignores freshness and accepts only the hash.
//
// Three cases exercise the production upload path contract:
// A. Admitted when fresh → pre-body gate (verify_blossom_auth_event_for_verb) accepts.
// B. Old full-verifier post-body → rejects even a valid hash if the token expired.
// C. New hash-only post-body → accepts the matching hash regardless of expiry.
// D. Hash-mismatch control → verify_upload_hash_only rejects a wrong hash.
#[test]
fn test_upload_expiry_boundary_admission_before_expiry() {
// Case A: the pre-body gate must accept a fresh 60-second proof.
let keys = Keys::generate();
let body = b"hello world";
let sha256 = hex::encode(sha2::Sha256::digest(body));
let now = Timestamp::now().as_secs();
let exp_str = (now + 60).to_string(); // exactly at the Strict ceiling
let server = "relay.example";
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", server]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_auth_event_for_verb(
&event,
BlossomVerb::Upload,
Some(server),
BlossomStrictness::Strict,
)
.is_ok(),
"pre-body gate must admit a fresh 60-second Strict upload proof"
);
}
#[test]
fn test_upload_expiry_boundary_old_verifier_rejects_expired_post_body() {
// Case B: the OLD full-verifier rejects an expired token post-body, even
// when the hash matches. This is the failure the hash-only split fixes.
let keys = Keys::generate();
let body = b"hello world";
let sha256 = hex::encode(sha2::Sha256::digest(body));
let server = "relay.example";
// Token expired in the past — simulates a proof that was valid at admit
// time but whose expiry passed during a slow body transfer.
let past = Timestamp::now().as_secs().saturating_sub(10);
let exp_str = past.to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", server]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Full verifier (old post-body path) rejects the expired token.
assert!(
matches!(
verify_blossom_upload_auth(
&event,
&sha256,
Some(server),
BlossomStrictness::Strict
),
Err(MediaError::TokenExpired)
),
"old full-verifier must reject an expired token even when the hash matches"
);
}
#[test]
fn test_upload_expiry_boundary_hash_only_accepts_expired_after_transfer() {
// Case C: the NEW hash-only post-body gate accepts the matching hash
// regardless of expiry. A slow-transfer large upload survives.
let keys = Keys::generate();
let body = b"hello world";
let sha256 = hex::encode(sha2::Sha256::digest(body));
let server = "relay.example";
let past = Timestamp::now().as_secs().saturating_sub(10);
let exp_str = past.to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", server]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Hash-only post-body gate (new path): ignores freshness, accepts matching hash.
assert!(
verify_upload_hash_only(&event, &sha256).is_ok(),
"hash-only post-body gate must accept a matching hash regardless of expiry"
);
}
#[test]
fn test_upload_expiry_boundary_hash_mismatch_rejected() {
// Case D: verify_upload_hash_only rejects a wrong hash (security control).
let keys = Keys::generate();
let body = b"hello world";
let correct_sha256 = hex::encode(sha2::Sha256::digest(body));
let wrong_sha256 = "a".repeat(64);
let server = "relay.example";
let now = Timestamp::now().as_secs();
let exp_str = (now + 60).to_string();
let tags = vec![
Tag::parse(["t", "upload"]).unwrap(),
Tag::parse(["x", &correct_sha256]).unwrap(),
Tag::parse(["expiration", &exp_str]).unwrap(),
Tag::parse(["server", server]).unwrap(),
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
// Presenting the WRONG body hash — the proof is for a different blob.
assert!(
matches!(
verify_upload_hash_only(&event, &wrong_sha256),
Err(MediaError::HashMismatch)
),
"hash-only gate must reject a body whose SHA-256 does not match the proof's x tag"
);
}
#[test]
fn test_permissive_valueless_expiration_then_valued_uses_last_wins() {
// FI-INV-15: Permissive mode must use last-wins expiration semantics
// (matching pre-NIP-FI base behavior). A valueless expiration tag
// followed by a future-valued expiration tag must admit, not expire.
let keys = Keys::generate();
let sha256 = "c".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"]).unwrap(), // valueless first
Tag::parse(["expiration", &exp_str]).unwrap(), // valued second
];
let event = EventBuilder::new(Kind::from(24242), "Upload buzz-media")
.tags(tags)
.sign_with_keys(&keys)
.unwrap();
assert!(
verify_blossom_upload_auth(
&event,
&sha256,
None,
BlossomStrictness::Permissive,
)
.is_ok(),
"Permissive must use last-wins expiration: valueless-first must not cause expiry rejection"
);
}
}