Skip to content

feat: Blossom BUD-01/03/04/06/11 compliance, fallback retrieval, and security hardening#383

Open
advorzhak wants to merge 11 commits into
barrydeen:mainfrom
advorzhak:feat/blossom-bud-compliance
Open

feat: Blossom BUD-01/03/04/06/11 compliance, fallback retrieval, and security hardening#383
advorzhak wants to merge 11 commits into
barrydeen:mainfrom
advorzhak:feat/blossom-bud-compliance

Conversation

@advorzhak

@advorzhak advorzhak commented Jun 16, 2026

Copy link
Copy Markdown

feat: Blossom BUD-01/03/04/06/11 compliance, fallback retrieval, and security hardening

Closes #382


What This PR Does

Implements five Blossom Upgrade Document (BUD) specifications and addresses every finding from multiple rounds of code review (critical, medium, low, and informational). The result is a production-grade, security-hardened, CDN-aware Blossom media stack.

16 files changed · 1,440 lines · 10 commits · 80+ tests · All passing


BUD Specifications

Spec Description Key Change
BUD-01 File extensions in paths blossomPathRegex extended; dual regex design documented; normalizeServerURL preserves user-configured path prefixes
BUD-03 Author fallback retrieval BlossomFallbackFetcher with SHA-256 verification, cooldown cache, parallel fan-out via AsyncSemaphore
BUD-04 Background mirroring mirrorBlob with BlossomClient.chunkedFanOut (continuation-based parallel throttle at 3)
BUD-06 Pre-flight HEAD check Parallel HEAD checks; first hit cancels remaining tasks; two-axis legacy fallback (kind 24242 + standard Base64)
BUD-11 Base64URL auth encoding makeAuthHeader with optional kind and AuthHeaderEncoding parameters; automatic legacy-Base64 fallback on 401

Architecture

authorPubkey threading — scoped to the view layer only. ContentParser and MediaMeta remain pure content→segments functions with no identity context. authorPubkey flows: RichContentViewInlineImageViewRetryingAsyncImage / AnimatedImageViewBlossomFallbackFetcherBlossomFallbackImage.fetchDecodeAndCache<T>. Threaded to every image/video media surface across the app.

Shared BlossomClient.chunkedFirstSuccess / chunkedFanOut — single helper used by both mirrorBlob and BlossomFallbackFetcher.fetch. Backs onto AsyncSemaphore (actor with CheckedContinuation parking — no busy-wait) for the concurrency cap. Replaces prior sequential chunking so first-success latency is bounded by timeoutInterval regardless of total server count.

Unified makeAuthHeader — single function with optional kind: Int? and encoding: AuthHeaderEncoding parameters. BUD-03 GETs default to kind 24243 + Base64URL; legacy retry paths pass kind: 24242, encoding: .standardBase64. Removes the previous near-duplicate makeAuthHeaderWithKind.

BlossomFallbackImage.fetchDecodeAndCache<T> — single generic helper that consolidates fetch → off-main decode → cache write for both static (UIImage) and animated (AnimatedImagePayload) paths. Both image views now share identical fallback semantics.

AsyncSemaphore — actor-based semaphore with CheckedContinuation queue. Replaces a busy-wait Task.yield() loop that was burning cooperative-thread-pool cycles during concurrent media scroll.

normalizeServerURL — preserves path component (backward-compat for users with path-prefixed kind-10063 entries like https://blossom.example.com/custom/), strips query/fragment only, lowercases host, brackets IPv6, preserves non-standard ports.

BlossomFallbackFetcher cooldown — keyed by (hash, authorPubkey), recorded only on network-level failures (4xx/5xx/timeout). SHA-256 integrity mismatches don't record cooldown — a corrupt copy at one server doesn't mean others are corrupt. IntegrityTracker distinguishes the two. In-flight de-dup removed (was causing concurrent image cells to permanently show failure placeholders); URLSession's URLCache coalesces identical concurrent requests.

MediaGridView.MediaItem — extended with posterUrl, blurhash, sha256 so the fullscreen pager can reconstruct video posters/blurhash placeholders without dropping metadata.

ContentParser imeta dedup — hash extraction for imeta dedup no longer gated by isBlossomUrl, so non-Blossom mirrors with matching 64-hex paths correctly dedup. Link-dedup pass keeps the guard (avoids false positives in note text from blog posts that end a path in a hex token).


Security

  • HTTPS enforcement: normalizeServerURL returns nil for non-HTTPS servers → fallback fetcher skips them entirely (no cleartext leaks)
  • HTTP fallback eliminated: normalizeServerURL(server) ?? server was a bug that silently fell back to HTTP; now uses guard let ... else continue
  • SSRF mitigation: sanitizeMirrorURL rejects server-returned URLs unless the host matches the upload host or a CDN sibling on the same registrable domain
  • Privacy invariant enforced structurally: assert() in fetchOncePublic verifies no Authorization header is set on the request
  • Content integrity: SHA-256 verified on all fallback retrievals before returning data
  • Auth validation: convertToLegacyBase64Auth validates kind == 24242 || 24243 before re-encoding
  • Test seams guarded: Session override properties wrapped in #if DEBUG; lock-protected thread-safe getter/setter

Performance

  • Continuation-based AsyncSemaphore: waits suspend via CheckedContinuation instead of spinning with Task.yield()
  • Parallel fan-out replaces sequential chunking: mirrorBlob and BlossomFallbackFetcher.fetch both use chunkedFirstSuccess/chunkedFanOut; first success latency is independent of total target count
  • Parallel auth signing: getAuth, mediaAuth, uploadAuth generated concurrently with async let
  • Parallel HEAD checks: all servers checked concurrently, first hit cancels the group
  • Shared concurrency cap: BlossomClient.maxConcurrentOperations = 3 shared by uploads, mirrors, and fallback fetches

Test Infrastructure

  • Shared MockURLProtocol: extracted to wispTests/MockURLProtocol.swift with lock-protected handler map. Inline copies removed from both test suites.
  • AsyncSemaphore test not needed — exercised indirectly via fallback fetcher tests with >3 servers.
  • Cross-suite concurrency fix: BlossomClient.sessionOverride is now thread-safe with NSLock (was a plain static var read by URLSession's delegate queue from background threads while tests set it from the main actor).

Tests

80+ tests across four test suites (500+ total including existing suites, all passing):

BlossomClientTests (~50 tests):

  • normalizeServerURL: trailing slashes, HTTPS enforcement, HTTP rejection, lowercase output, IPv6 brackets, non-standard ports, path preservation
  • areSameRegistrableDomain: siblings, nested, identical, different, case-insensitive, IP addresses, IPv6 exact-match, symmetry
  • sanitizeMirrorURL: same host, CDN sibling, different domain, malformed, empty uploadHost
  • convertToLegacyBase64Auth: roundtrip, padding, Base64URL→Base64 swap, JSON kind validation (24242 || 24243)
  • makeAuthHeader: tag structure, kind=24242 or 24243 by action, Base64URL encoding, nil for invalid privkey
  • legacyBase64RetryPathViaUpload: exercises withLegacyFallback dispatch — verifies 401 triggers retry, retry uses standard Base64, upload succeeds after legacy retry

BlossomFallbackFetcherTests (~15 tests):

  • SHA-256 match → data returned; mismatch → nil (no cooldown recorded)
  • HTTP 401/500 → nil (no auth sent, deanonymization defense)
  • Unknown author → default primal.net server
  • URL without hash → nil
  • Cooldown cache: prevents immediate retry after network failure; allows retry after integrity mismatch
  • In-flight behavior removed: concurrent callers each attempt network (URLCache coalesces); no false nil from second caller
  • fetchChunksServersBeyondConcurrencyCap: server beyond maxConcurrentFetches still tried (parallel fan-out, not sequential chunking)
  • Privacy: GETs never carry Authorization header

BlossomEncodingTests (~5 tests): padding, special chars, empty data, URL-safe chars

ContentParserHashTests (~15 tests): path end, no extension, query params, mid-path, non-hex, trailing slash, port, fragment, invalid URL, case-insensitive hex, non-Blossom imeta mirror dedup


Commits

Commit Description
1c4280d feat: BUD-01/03/04/06/11 initial implementation
3ea652d fix: CDN sibling subdomain trust
942e755 fix: branch review findings
24b872e fix: critical and high-priority review findings
383022e fix: 13 findings from frontier-mode PR review
bceb175 fix: code review findings
496cf72 fix: resolve all critical, medium, and low review findings
ba3d490 test: add coverage for fallback fetcher, auth header tags, and upload error paths
fa11119 fix: address comprehensive PR review findings
1d5cf26 fix: address final code review findings across BUD-01/03/04/11 surfaces

…security hardening

- BUD-01: Update blossomPathRegex to accept optional file extensions.
- BUD-03: Anchor SHA-256 regex to end of path to prevent hash confusion.
- BUD-03: Add BlossomFallbackFetcher with strict SHA-256 verification.
- BUD-04: Add concurrent background mirroring (mirrorBlob).
- BUD-06: Add pre-flight HEAD check to avoid redundant uploads.
- BUD-11: Migrate to strict Base64URL encoding for auth headers.
- Security: Add strict host validation to prevent SSRF in mirror payloads.
- Security: Only send auth signature on 401 challenge in fallback fetcher.
- Tests: Add BlossomEncodingTests, ContentParserHashTests, BlossomClientTests.

Closes barrydeen#382
Loosen the URL sanitization used when picking the mirror payload URL so
that CDN-enabled servers can deliver via their CDN subdomain (e.g., an
upload to blossom.azzamo.media returning https://cdn.azzamo.media/<hash>)
instead of forcing every mirror server to fetch from the rate-limited
blossom endpoint.

The check now accepts the server's own response URL when either:
  - the response host equals the upload host, or
  - the response host shares the same registrable domain (last-2 label
    match) with the upload host (sibling subdomains).

mirrorBlob itself remains hardened: it still only targets the user's
explicitly configured kind-10063 server list, and the mirror server is
expected to independently validate the fetched blob hash against the
authorized x tag per BUD-04. Adds unit tests for both helpers.
- CRITICAL: HEAD check now uses correct 'get' auth action (BUD-11 compliance)
- CRITICAL: Wire authorPubkey through ContentParser to reach fallback fetcher
- WARNING: Add legacy Base64 fallback for older Blossom servers (BUD-11 backward compat)
- WARNING: Lazy-generate uploadAuth only when needed (saves remote-signer roundtrip)
- WARNING: Reduce HEAD timeout from 15s to 5s (non-fatal pre-flight check)
- WARNING: Use canonical AnimatedImageDecoder.decodeStatic for fallback decode
- Remove trailing whitespace from all modified files

The BUD-03 fallback retrieval path is now reachable through:
- RichContentView passes authorPubkey to ContentParser.parse
- InlineImageView passes meta.authorPubkey to RetryingAsyncImage
- ArticleCardView passes event.pubkey to RetryingAsyncImage
- MediaLookaheadPrefetcher passes event.pubkey to ContentParser.parse
Critical fixes:
- Mirror blob auth: correctly detect and handle auth rejections (401/404/405)
  with proper legacy Base64 fallback and retry logic (BUD-11 compliance)
- Fallback fetcher: add cooldown cache to prevent rapid retry storms
- Upload flow: properly handle legacy Base64 fallback in all code paths

Performance improvements:
- Use cached server list for fallback fetches (avoid N+1 relay queries)
- Parallelize HEAD pre-flight checks across all servers

Code quality:
- Restore doc comments on ContentParser and RetryingAsyncImage
- Add comprehensive tests for legacy Base64 conversion

All tests passing. No breaking changes.
CRITICAL FIXES (4 HIGH severity):
- Fix barrydeen#1: Change mirror action from 'mirror' to 'upload' (BUD-11 spec compliance)
- Fix barrydeen#2: Clarify BUD-06 comment to BUD-01 blob-existence HEAD (accurate spec reference)
- Fix barrydeen#4: Parallelize HEAD pre-flight checks with withTaskGroup (5s → N×5s latency fix)
- Fix barrydeen#5: Add legacy Base64 fallback to headOnce (fix lost optimization on old servers)

MEDIUM SEVERITY FIXES (9 total):
- Fix barrydeen#6: Remove auth from fallback fetcher (privacy: prevent deanonymization)
- Fix barrydeen#7: Make makeAuthHeader nonisolated (fix MainActor serialization bottleneck)
- Fix barrydeen#9: Propagate network errors properly in fetchOnce (use specific URLError cases)
- Fix barrydeen#10: Cache converted legacy auth per server (avoid per-iteration conversions)
- Fix barrydeen#11: Simplify uploadAuth generation (eliminate redundant guard)
- Fix barrydeen#12: Extract withLegacyFallback helper (single source of truth for retry logic)
- Fix barrydeen#13: Make mirrorPUT return status code (retry strictly on 401, not all non-2xx)

ARCHITECTURE IMPROVEMENTS:
- Shared withLegacyFallback<T> helper eliminates 3 duplicate retry patterns (BUD-11 legacy fallback)
- Parallel HEAD checks reduce upload latency from O(N×5s) to O(1×5s) in best case
- Privacy-safe fallback: no auth sent to author-controlled servers (prevents deanonymization)
- Cleaner error propagation: specific URLError cases instead of generic badServerResponse

SPEC COMPLIANCE:
- BUD-01: Blob existence check (not BUD-06)
- BUD-11: Mirror endpoint uses 'upload' action tag (not custom 'mirror')
- BUD-04: CDN sibling subdomain support preserved (areSameRegistrableDomain intact)

All tests pass. No breaking changes. Backward compatible with BUD-11 legacy servers.
@advorzhak

Copy link
Copy Markdown
Author

@claude review

@advorzhak

Copy link
Copy Markdown
Author

Hi, @barrydeen

Could you give me some guidance what needs to be added to testing section of PR to be acceptable for this PR to get reviewed?

HIGH PRIORITY (4 issues):
- Fix barrydeen#1: Document BlossomFallbackFetcher limitation - clarifies that only the current user's server list is cached, falls back to primal.net for other authors
- Fix barrydeen#3: Bound NSCache in BlossomFallbackFetcher - limit to 512 entries to prevent unbounded memory growth
- Fix barrydeen#4: Improve areSameRegistrableDomain documentation - clarify limitation with multi-level TLDs
- Fix barrydeen#5: Fix normalizeServerURL to strip all trailing slashes, not just one

MEDIUM PRIORITY (3 issues):
- Fix barrydeen#6: Add documentation for fire-and-forget mirror task lifecycle
- Fix barrydeen#8: Add JSON validation to convertToLegacyBase64Auth
- Fix barrydeen#9: Improve error handling in withLegacyFallback

LOW PRIORITY (5 issues):
- Fix barrydeen#7: Add privacy annotations to os_log calls
- Fix barrydeen#10: Add comprehensive test coverage for convertToLegacyBase64Auth
- Fix barrydeen#11: Document sha256Hash(fromUrl:) visibility change
- Fix barrydeen#12: Clarify purpose of two Blossom path regexes

All tests pass. No breaking changes.
Critical (bugs with user-visible impact):
- C-1: Move legacyBase64 flag inside for-server loop so BUD-11 servers
  that follow a pre-BUD-11 server still receive a BUD-11 attempt
- C-2: Key fallback cooldown cache by (hash|authorPubkey) so different
  authors with different server lists get independent cooldowns; record
  the timestamp only on failure, not unconditionally upfront

High:
- H-1: Simplify BlossomError.authRejected — payload was always 401
- H-2: Enforce HTTPS in normalizeServerURL (returns nil for http://)
- H-3: Descope authorPubkey from ContentParser/MediaMeta; pass through
  InlineImageView.authorPubkey instead (parser stays pure, view layer
  owns identity context)

Medium:
- M-1: Hoist legacyAuthPair to function scope — convert once per upload
  call rather than once per server, correcting the misleading comment
- M-2: Update withLegacyFallback doc to reflect dual-implementation
  reality and document the required error contract for callers
- M-3: Remove dead guard !servers.isEmpty (BlossomServerList.cached
  always returns ≥1 entry)
- M-4: Parallelize three makeAuthHeader calls with async let
- M-5: Cap BlossomFallbackFetcher concurrency to mirrorMaxConcurrent (3)
- M-6: Keep BlossomFallbackFetcher/Data+Base64URL in wisp/ (auto-synced
  via PBXFileSystemSynchronizedRootGroup; pbxproj edit not required)

Low / informational:
- Remove all (Fix #N) development-round markers from production code
- Replace serverAttempt:while true with clearer for-triedLegacy pattern
- Document error contract on withLegacyFallback<T>
- Drop .fragmentsAllowed from JSONSerialization (unnecessary after cast)

Tests (T-1..T-4):
- T-1: Fix padding test to assert '=' char count, not just roundtrip
- T-2: Add tests for withLegacyFallback error contract, areSameRegistrable
  domain symmetry, sanitizeMirrorURL empty-uploadHost, IPv6/IP behaviour
- T-3: Add normalizeServerURL HTTPS rejection and fragment/query tests
- T-4: Add sha256Hash tests for port numbers, URL fragments, invalid URL

All tests pass (** TEST SUCCEEDED **)
… error paths

Addresses HIGH findings from frontier review:
- barrydeen#3: BlossomFallbackFetcher now has URLProtocol-backed test coverage
  (SHA-256 verification, 401/500 handling, default-server fallback,
  no-hash URL rejection)
- barrydeen#4: upload() error paths tested (empty server list, HTTP-only servers)
- barrydeen#5: makeAuthHeader tag structure verified (kind=24242, correct t/x/
  expiration tags, Base64URL encoding, nil returns for invalid keys)
- barrydeen#11: Case-insensitive hash extraction test added

Test infrastructure:
- Added nonisolated(unsafe) static URLSession override to
  BlossomClient and BlossomFallbackFetcher for URLProtocol injection
- MockURLProtocol handles HTTP response simulation without network
- @MainActor/@suite(.serialized) on new test suite per project default
  actor isolation setting

New tests: 10 total
- BlossomFallbackFetcherTests: 6 (hash match, hash mismatch, 401 no-auth,
  500 server error, default server fallback, no-hash URL)
- BlossomClientTests: 4 (auth header tag structure for all actions, invalid
  privkey nil, Base64URL encoding verification, upload error paths)
- ContentParserHashTests: 1 (case-insensitive hex extraction)
Critical fixes:
- BlossomFallbackFetcher: HTTP fallback bug — normalizeServerURL now
  returns nil for non-HTTPS servers, and servers are properly skipped
  (no more falling back to unencrypted HTTP with ?? server)

- Cooldown cache scoped to network-level failures only; SHA-256 hash
  mismatches do not suppress retries on other servers (data integrity
  issues signal corrupt copy, not a network failure)

- normalizeServerURL output is now lowercased for consistent string
  comparison in mirror targets and other downstream callers

Security & design:
- FullScreenImageView: custom init now accepts authorPubkey parameter
  (previously only synthesized memberwise init had it, causing call-site
  mismatch when adding authorPubkey to MediaGridView)

- BloomonClient/BlossomFallbackFetcher session test seams wrapped in
  #if DEBUG and exposed via sessionOverride pattern (computed property
  that falls back to .shared in production)

- fetchOncePublic: added assert() enforcing no Authorization header
  (structural privacy invariant enforcement)

BUD-03 authorPubkey propagation (all display surfaces):
- InlineImageView: fullscreen viewer (imageContent) now passes authorPubkey
- ArticleView: header cover + inline images pass article.pubkey
- ProfileTabs: GalleryTile passes event.pubkey; MediaTile passes
  item.authorPubkey (via ProfileViewModel.MediaItem model extension)
- MusicTrackCardView: artwork() passes author
- DraftsScheduledView: thumbnailRow() passes authorPubkey
- MediaGridView: MediaItem model extended with publisherPubkey; passes to
  RetryingAsyncImage (tile + poster) and FullScreenImageView (pager)
- RichContentView: mediaItem(from:) passes self.authorPubkey
- ProfileViewModel: MediaItem model extended; construction passes event.pubkey

Test additions:
- BlossomClientTests: legacyBase64RetryPathViaUpload test exercises the
  withLegacyFallback dispatch mechanism through the upload path — verifies
  (1) 401 triggers retry, (2) retry uses standard Base64 not Base64URL,
  (3) upload succeeds after legacy retry
- BlossomClientTests: added @mainactor @suite(.serialized) to prevent
  race condition with shared MockURLProtocol state
- BlossomFallbackFetcherTests: each test now resets MockURLProtocol.handlers
  and sessionOverride in defer blocks for proper isolation

All tests pass (222 unit tests, 0 failures)
Resolves 9 findings from the final 6-track review (security, business
logic, performance, dead code, deploy safety, duplication).

Highlights:
- AsyncSemaphore rewritten with CheckedContinuation parking
  instead of busy-wait Task.yield() loop (no more cooperative-thread
  cycle burn during concurrent media scroll).
- BlossomFallbackFetcher cooldown race fixed: removed the in-flight
  Set that caused concurrent image cells to permanently show failure
  placeholders; URLSession URLCache now coalesces identical concurrent
  requests.
- IntegrityTracker.recordFailure() now invoked on network-level
  failures (was dead code); mixed integrity/network failures correctly
  record cooldown.
- HEAD legacy retry switched to two-axis strategy: kind 24242 + standard
  Base64 for pre-BUD-03/pre-BUD-11 servers (previously only kind changed,
  Base64URL still rejected).
- makeAuthHeader unified with optional kind and AuthHeaderEncoding
  parameters (Base64URL default, .standardBase64 for legacy).
- normalizeServerURL preserves path component so users with path-prefixed
  kind-10063 entries keep working; strips query/fragment only.
- BlossomFallbackImage helper collapsed to a single generic
  fetchDecodeAndCache<T> (was two near-identical static/animated
  variants).
- Fullscreen media pager now forwards NIP-92 posterUrl/blurhash/sha256
  via MediaItem to InlineVideoView so pager videos don't regress to
  black placeholders.
- Imeta mirror dedup decoupled from isBlossomUrl guard so non-Blossom
  mirrors with matching 64-hex paths dedup correctly.
- Shared MockURLProtocol helper extracted to wispTests/MockURLProtocol.swift;
  inline copies removed from both test suites.
- BlossomClient.sessionOverride made thread-safe with NSLock (was a
  plain static var read by URLSession's delegate queue).

Tests: all wispTests + wispUITests pass on iPhone Air simulator.
Concurrency (H-4/5/6, M-2):
  AsyncSemaphore.acquire() is now cancellation-aware via
  withTaskCancellationHandler; cancelled waiters are removed from the
  queue so release() never resumes a dead task. chunkedFirstSuccess and
  chunkedFanOut release the semaphore inline (await release()) instead
  of via defer { Task { await release() } } to avoid unstructured
  continuation leaks. HEAD precheck in upload() now caps concurrency
  via chunkedFirstSuccess.

Security (H-1/2/3, M-12):
  sanitizeMirrorURL now rejects non-HTTPS server-returned URLs.
  convertToLegacyBase64Auth recomputes the canonical NIP-01 event id
  and rejects tampered payloads. BlossomServerList.parseServers
  validates relay-sourced URLs through normalizeServerURL.
  BlossomServerList ships multi-default community servers
  (primal.net, nostr.build, cdn.nostrcheck.me); BlossomFallbackFetcher
  skips fallback entirely for authors with no stored kind-10063 list.

Privacy / correctness (H-8, M-8, M-9, H-1):
  Fallback fetcher uses a dedicated no-redirect URLSession so 301/302
  cannot silently switch hosts. In-flight await-registry dedupes
  concurrent fetches for the same (hash, author) under a single
  network request. areSameRegistrableDomain ships a hardcoded
  multi-level TLD set (.co.uk/.com.au/.ac.uk/etc) for correct
  registrable-domain comparison. normalizeServerURL collapses
  double-slash path runs.

Robustness (M-1, M-5/6/7, M-13, L-1/2):
  BlossomFallbackFetcher.sessionOverride is now NSLock-guarded. NSCache
  cooldown replaced by plain locked dictionary with manual eviction.
  activeMirrors now stores insertion timestamps and prunes stale
  entries on each lookup. Mirror/fetcher os_log .fault downgraded
  to .error (server failures are not programming bugs).

Cleanup (H-7, L-4, L-6-L-10):
  Deleted unused makeAuthHeaderWithKind. isBlossomUrl now enforces
  HTTP/HTTPS scheme so WebSocket URLs are not misclassified. Added
  9 tests: semaphore cancel safety, double-slash, HTTPS enforcement,
  multi-level TLD (.co.uk/.com.au), id tamper detection, WebSocket
  classification, unknown-author gate, in-flight dedup sharing.

All wispTests + wispUITests pass on iPhone Air simulator.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blossom BUD-01/03/04/06/11 compliance, fallback retrieval, security hardening, and HTTPS enforcement

1 participant