mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
Follow-up to [#9069 media auth](https://github.com/block/buzz/pull/9069), which merged at its approved head. This PR carries the fixes for the last review round on that PR. ### Changes - **Native media reads renew an expired assertion** (Carl's [recommended blocker](https://github.com/block/buzz/pull/9069#discussion_r4485183400)). Voice notes and posterless video previews hand headers to native code. They now use `freshNativeMediaHeadersProvider`, which awaits `HostedRelayAuth.headersForUrl` and so asks for a new assertion once the cached one has expired. Before, while the socket stayed up they sent none and the relay refused the read. If the renewal fails, the read falls back to cached headers and the relay decides. The voice-note player's `headers` callback is now `FutureOr`. Both the iOS authenticated download and the Android native load await it, then recheck their generation, so a cancel during a slow renewal never starts the stale load. - **Notification avatars drop a retired organization session** ([review thread](https://github.com/block/buzz/pull/9069#discussion_r4485125609)). When an avatar download finishes after the organization session that issued its assertion was replaced, BuzzPushKit removes the cached response and refuses the avatar. - **Test:** cached reads never present a signed-out or replaced account's assertion ([optional note](https://github.com/block/buzz/pull/9069#discussion_r4485183412)). Not changed: native reaction-detail avatars still use the cached assertion. After it expires they fall back to initials. ### Validation - Each new test was checked to fail with the code it guards reverted: - `replacedOrganizationSessionRefusesAvatar` (BuzzPushKit): a session replaced mid-download must be refused. - `fresh native media headers renew an expired assertion`: covers renewal and the fallback when renewal fails. - `remote voice notes renew an expired relay assertion`: a journey through `VoiceNoteAttachment`; it fails when the attachment is switched back to the synchronous provider. - `Android defers remote auth until playback starts`: now cancels during a slow renewal and expects no native load. - `an issued assertion serves cached reads for the relay`: now signs out and restores a replacement account. It fails without the account check. - `swift test` (69 tests) passes. The full mobile suite (`just mobile-install mobile-check mobile-test`) passes with these commits on main as of [#9069 media auth](https://github.com/block/buzz/pull/9069). - The automated full-app simulator run from [#9069 media auth](https://github.com/block/buzz/pull/9069) covers the surrounding flows. This change is limited to the header source for voice notes and video previews, and none of the test communities enforce the hosted assertion, so a simulator run would not show the difference. No end-to-end run against a relay that enforces the assertion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow <tomb@squareup.com> Co-authored-by: Tom Brow <tomb@squareup.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
266 lines
11 KiB
Swift
266 lines
11 KiB
Swift
import Foundation
|
|
import ImageIO
|
|
import Testing
|
|
|
|
@testable import BuzzPushKit
|
|
|
|
@Suite("Native notification avatar decoding")
|
|
struct BuzzNotificationAvatarLoaderTests {
|
|
@Test("Avatar proofs preserve IPv6 brackets and explicit ports")
|
|
func signedServerAuthority() throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
for authority in ["[::1]:3000", "[2001:db8::1]", "relay.example:443", "relay.example"] {
|
|
let url = try #require(URL(string: "https://\(authority)/media/avatar.png"))
|
|
let header = try BuzzNotificationAvatarLoader.authorization(url: url, key: key, account: account)
|
|
let data = try #require(Data(base64Encoded: String(header.dropFirst(6))))
|
|
let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: data)
|
|
#expect(event.tags.contains(["server", authority]))
|
|
#expect(event.kind == 24242)
|
|
#expect(event.hasValidIDAndSignature())
|
|
}
|
|
}
|
|
|
|
@Test("Relay media uses canonical hosts and default ports without trusting other origins")
|
|
func mediaOrigins() throws {
|
|
let hash = String(repeating: "a", count: 64)
|
|
for (relay, media, expected) in [
|
|
("wss://relay.example:443", "https://relay.example", true),
|
|
("wss://relay.example", "https://RELAY.EXAMPLE:443", true),
|
|
("ws://relay.example:80", "http://RELAY.EXAMPLE", true),
|
|
("wss://relay.example:8443", "https://relay.example:8443", true),
|
|
("wss://relay.example", "https://relay.example:8443", false),
|
|
("wss://relay.example", "http://relay.example", false),
|
|
("wss://relay.example", "https://other.example", false),
|
|
] {
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: relay, pubkey: "account", policies: [],
|
|
notificationSessionID: "session")))
|
|
let url = try #require(URL(string: "\(media)/media/\(hash).png"))
|
|
#expect((BuzzNotificationAvatarLoader.thumbnailURL(url, scope: scope) != nil) == expected)
|
|
}
|
|
}
|
|
|
|
@Test("Animated avatar selects the poster without fetching the animation")
|
|
func animatedPoster() throws {
|
|
let source =
|
|
"https://images.example/poster.png#buzz-anim=https%3A%2F%2Fimages.example%2Fanimation.apng"
|
|
#expect(
|
|
BuzzNotificationAvatarLoader.posterURL(source)?.absoluteString
|
|
== "https://images.example/poster.png")
|
|
#expect(BuzzNotificationAvatarLoader.posterURL("file:///private/avatar.png") == nil)
|
|
}
|
|
|
|
@Test("Raster decoder produces a bounded PNG and rejects non-image data")
|
|
func raster() throws {
|
|
let bytes = try #require(
|
|
Data(
|
|
base64Encoded:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
|
|
))
|
|
let png = try BuzzNotificationAvatarLoader.decode(bytes)
|
|
let source = try #require(CGImageSourceCreateWithData(png as CFData, nil))
|
|
let properties = try #require(
|
|
CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any])
|
|
#expect(properties[kCGImagePropertyPixelWidth] as? Int == 1)
|
|
#expect(properties[kCGImagePropertyPixelHeight] as? Int == 1)
|
|
#expect(png.count <= BuzzNotificationCache.maximumAvatarBytes)
|
|
#expect(throws: BuzzNotificationCacheError.self) {
|
|
try BuzzNotificationAvatarLoader.decode(Data("not an image".utf8))
|
|
}
|
|
}
|
|
|
|
@Test("A scope revoked while the assertion is issued never starts the signed download")
|
|
func scopeRevokedDuringAssertionIssuance() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let state = AvatarScopeState()
|
|
AssertionIssuanceStub.onRequest = { state.revoke() }
|
|
defer { AssertionIssuanceStub.onRequest = nil }
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration),
|
|
loadSession: {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: "session-credential",
|
|
relayHosts: ["relay.org.example"])
|
|
})
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in state.isCurrent },
|
|
assertionIssuer: issuer)
|
|
loader.startDownload = { _, _, _, completion in
|
|
state.recordDownload()
|
|
completion(.failure(URLError(.cancelled)))
|
|
}
|
|
|
|
let result = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
|
|
Issue.record("Expected a revoked failure, got \(result)")
|
|
return
|
|
}
|
|
#expect(state.downloads == 0)
|
|
}
|
|
|
|
@Test("An organization relay avatar is fetched with the issued assertion")
|
|
func relayAvatarCarriesAssertion() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration),
|
|
loadSession: {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: "session-credential",
|
|
relayHosts: ["relay.org.example"])
|
|
})
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
|
|
assertionIssuer: issuer)
|
|
let requests = AvatarRequests()
|
|
loader.startDownload = { request, _, _, completion in
|
|
requests.record(request)
|
|
completion(.failure(URLError(.cancelled)))
|
|
}
|
|
|
|
_ = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
let request = try #require(requests.first)
|
|
#expect(
|
|
request.value(forHTTPHeaderField: "Nostr-Federated-Identity")
|
|
== "Bearer header.payload.signature")
|
|
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
|
|
}
|
|
|
|
@Test("An avatar fetched with an assertion from a replaced organization session is refused")
|
|
func replacedOrganizationSessionRefusesAvatar() async throws {
|
|
let key = String(repeating: "0", count: 63) + "1"
|
|
let account = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
|
|
let scope = try #require(
|
|
BuzzNotificationCacheScope(
|
|
community: PushLeaseCommunity(
|
|
id: "community", name: "Test", relayUrl: "wss://relay.org.example", pubkey: account,
|
|
policies: [], notificationSessionID: "session")))
|
|
let configuration = URLSessionConfiguration.ephemeral
|
|
configuration.protocolClasses = [AssertionIssuanceStub.self]
|
|
let sessions = OrganizationSessions()
|
|
let issuer = BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: configuration), loadSession: { sessions.current })
|
|
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(
|
|
UUID().uuidString)
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
let loader = BuzzNotificationAvatarLoader(
|
|
directory: directory, privateKey: { _ in key }, isCurrent: { _ in true },
|
|
assertionIssuer: issuer)
|
|
let png = try #require(
|
|
Data(
|
|
base64Encoded:
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII="
|
|
))
|
|
loader.startDownload = { _, _, _, completion in
|
|
// The member signs in again while the avatar is in flight; the community is unchanged.
|
|
sessions.replace()
|
|
completion(.success(png))
|
|
}
|
|
|
|
let result = await withCheckedContinuation { continuation in
|
|
loader.load(
|
|
source: "https://relay.org.example/media/\(String(repeating: "a", count: 64)).png",
|
|
scope: scope
|
|
) { continuation.resume(returning: $0) }
|
|
}
|
|
|
|
guard case .failure(BuzzNotificationCacheError.revoked) = result else {
|
|
Issue.record("Expected a revoked failure, got \(result)")
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private final class AvatarRequests: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var requests: [URLRequest] = []
|
|
|
|
var first: URLRequest? { lock.withLock { requests.first } }
|
|
func record(_ request: URLRequest) { lock.withLock { requests.append(request) } }
|
|
}
|
|
|
|
private final class OrganizationSessions: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var credential = "session-credential"
|
|
|
|
var current: BuzzOrganizationSession {
|
|
lock.withLock {
|
|
BuzzOrganizationSession(
|
|
origin: "https://identity.org.example", credential: credential,
|
|
relayHosts: ["relay.org.example"])
|
|
}
|
|
}
|
|
func replace() { lock.withLock { credential = "replacement-credential" } }
|
|
}
|
|
|
|
private final class AvatarScopeState: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var current = true
|
|
private var started = 0
|
|
|
|
var isCurrent: Bool { lock.withLock { current } }
|
|
var downloads: Int { lock.withLock { started } }
|
|
func revoke() { lock.withLock { current = false } }
|
|
func recordDownload() { lock.withLock { started += 1 } }
|
|
}
|
|
|
|
/// Answers the organization's assertion issuer, running `onRequest` while issuance is in flight.
|
|
private final class AssertionIssuanceStub: URLProtocol, @unchecked Sendable {
|
|
nonisolated(unsafe) static var onRequest: (() -> Void)?
|
|
|
|
override class func canInit(with request: URLRequest) -> Bool { true }
|
|
override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
|
|
|
|
override func startLoading() {
|
|
Self.onRequest?()
|
|
let body = try! JSONSerialization.data(withJSONObject: [
|
|
"assertion": "header.payload.signature",
|
|
"nostr_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
|
|
"expires_at": Int(Date().timeIntervalSince1970) + 300,
|
|
])
|
|
let response = HTTPURLResponse(
|
|
url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
|
|
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
|
|
client?.urlProtocol(self, didLoad: body)
|
|
client?.urlProtocolDidFinishLoading(self)
|
|
}
|
|
|
|
override func stopLoading() {}
|
|
}
|