mirror of
https://github.com/block/buzz.git
synced 2026-11-06 09:21:45 +00:00
Follow-up to https://github.com/block/buzz/pull/9056, from an audit for other places that miss the hosted relay assertion. Hosted and organization relays require `Nostr-Federated-Identity` on every non-exempt HTTP request and WebSocket upgrade. The Dart `HostedRelayHttpClient` adds it, but anything that downloads or connects outside that client has to add it itself. The notification service extension and the iOS emoji picker both missed it, and these places do too: - **Voice notes** (`just_audio`, mainly Android) and **video preview first frames** fetch media natively with only the Blossom `Authorization` header. Both now use `nativeMediaHeadersProvider` from https://github.com/block/buzz/pull/9056. - **Push-lease tombstones**: removing a community, and the durable revocation outbox, open a one-off relay socket with no headers. A hosted relay refuses the upgrade, so the lease is never revoked and the outbox keeps retrying. Both now ask `HostedRelayAuth` for that community's own assertion, because the community being removed may not be the active session. - **Notification avatars** (iOS notification service extension): relay-hosted sender avatars were fetched with Blossom auth only. The loader now takes the same `BuzzRelayAssertionIssuer` the resolver uses for `/query`. Signed requests already refuse redirects. The iOS Me-chat agent picker also fetched avatars with no headers, but https://github.com/block/buzz/pull/9079 removed that native screen, so this PR no longer touches it. ### Validation - Added tests, each checked to fail when the code it guards is reverted: - A queued push-lease revocation and a community-removal tombstone both send `Nostr-Federated-Identity` on the WebSocket upgrade, as background requests. Runs against a local relay socket that handles NIP-42 auth. - A notification avatar on an organization relay is downloaded with the issued assertion (BuzzPushKit). - Issuing for a second community keeps the first one's cached assertion. A background request records no failure, never backs up the key, and its failure never reaches the active community's reconnect waiting on it. - Background issuance never binds an unbound account to its key, bypasses the shared assertion cache (each attempt gets a fresh assertion, and foreground use never inherits one that skipped the key backup), and its failure is never handed to a foreground caller waiting on it. When it can't get an assertion it sends none, so a non-enforcing relay still accepts the tombstone. - A notification avatar whose organization session is replaced mid-download is refused and dropped from the cache (BuzzPushKit). - Voice notes and posterless video previews renew an expired assertion before the native fetch, and fall back to cached headers if renewal fails. A voice-note attachment journey binds the production provider. - Cached reads never present a signed-out or replaced account's assertion. - `flutter analyze` is clean. `swift test` passes for the BuzzPushKit avatar loader tests, and `flutter build ios --simulator --debug` succeeds. - Full mobile suite (`just mobile-check` and `just mobile-test`) passes at 2c84940832. - Automated full-app run on an iOS simulator (debug build of this PR before it was rebuilt on main, signed in to the internal Builderlab community with two more communities saved). Passed: launch and channel list, image attachments and avatars in a channel, New chat sheet kept open for about 80 seconds before the agent list was opened (all agent avatars loaded, so headers are requested per download), dismiss and reopen, background and resume, switching to a second community (its icon and avatars loaded), and removing a community. The native New chat sheet is unreachable in the normal flow on this branch (a new chat opens the Flutter draft page; the native sheet only opens when resuming an unfinished chat), so that check ran with a temporary local bypass that was reverted. Limits: none of these communities requires the hosted relay assertion in this build, so the run shows the Swift-to-Dart header bridge, the per-relay cache and the tombstone path cause no regression, not that the assertion is sent or accepted. No voice note, video preview or deleted message was reachable. Screenshots are omitted because they show internal channel content. - Limitations: no end-to-end run against a hosted relay. Simulator sign-in to a hosted relay needs SSO, so a hosted-relay run needs a device build. 🤖 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: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
313 lines
12 KiB
Swift
313 lines
12 KiB
Swift
import BuzzPushKit
|
|
import Foundation
|
|
import Intents
|
|
import Security
|
|
import UserNotifications
|
|
|
|
final class NotificationService: UNNotificationServiceExtension {
|
|
private var handoff: BuzzNotificationHandoff<UNNotificationContent>?
|
|
private var resolvedScope: BuzzNotificationCacheScope?
|
|
private var bestAttemptContent: UNMutableNotificationContent?
|
|
private var restrictedFallbackContent: UNMutableNotificationContent?
|
|
private lazy var interactionCleanup = BuzzInteractionCleanupRetry(
|
|
containerURL: ageRestrictionContainerURL, deletion: interactionDeletionDeadline)
|
|
private lazy var communicationPresenter = BuzzCommunicationNotificationPresenter(
|
|
donate: { interaction, completion in interaction.donate(completion: completion) },
|
|
deleteAllInteractions: { [cleanup = interactionCleanup] completion in
|
|
cleanup.request(completion: completion)
|
|
},
|
|
updateContent: { content, intent in try content.updating(from: intent) }
|
|
)
|
|
private let interactionDeletionDeadline = BuzzInteractionDeletionDeadline(
|
|
timeout: 5,
|
|
deleteAllInteractions: { completion in
|
|
INInteraction.deleteAll(completion: completion)
|
|
},
|
|
scheduleTimeout: { delay, action in
|
|
DispatchQueue.global(qos: .utility).asyncAfter(
|
|
deadline: .now() + delay,
|
|
execute: action
|
|
)
|
|
}
|
|
)
|
|
private lazy var appGroupIdentifier =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzAppGroupIdentifier"
|
|
) as? String
|
|
// A session per notification keeps a long-lived extension process from reusing
|
|
// connections that went stale when the network path changed (for example a VPN
|
|
// coming up or dropping).
|
|
private var session: URLSession?
|
|
private var resolver: BuzzPushNotificationResolving?
|
|
private func makeResolver(session: URLSession) -> BuzzPushNotificationResolving {
|
|
let keychainAccessGroup =
|
|
Bundle.main.object(
|
|
forInfoDictionaryKey: "BuzzKeychainAccessGroup"
|
|
) as? String
|
|
return BuzzPushNotificationResolver(
|
|
session: session,
|
|
loadCommunitiesData: { [self] in
|
|
Self.loadPushSnapshotData(appGroupIdentifier: self.appGroupIdentifier)
|
|
},
|
|
loadPrivateKey: { communityID in
|
|
Self.loadPrivateKey(
|
|
communityID: communityID,
|
|
keychainAccessGroup: keychainAccessGroup
|
|
)
|
|
},
|
|
notificationCache: notificationCache,
|
|
avatarLoader: notificationAvatarLoader,
|
|
assertionIssuer: Self.assertionIssuer
|
|
)
|
|
}
|
|
|
|
/// Process-wide, so consecutive notifications reuse an assertion until it nears expiry.
|
|
private static let assertionIssuer: BuzzRelayAssertionIssuer = {
|
|
let keychainAccessGroup =
|
|
Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
|
return BuzzRelayAssertionIssuer(
|
|
session: URLSession(configuration: .ephemeral),
|
|
loadSession: { loadOrganizationSession(keychainAccessGroup: keychainAccessGroup) }
|
|
)
|
|
}()
|
|
|
|
/// The organization account session the app shares for NIP-FI assertion issuance.
|
|
private static func loadOrganizationSession(
|
|
keychainAccessGroup: String?
|
|
) -> BuzzOrganizationSession? {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: BuzzOrganizationSession.keychainService,
|
|
kSecAttrAccount as String: BuzzOrganizationSession.keychainAccount,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
if let keychainAccessGroup, !keychainAccessGroup.isEmpty {
|
|
query[kSecAttrAccessGroup as String] = keychainAccessGroup
|
|
}
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data
|
|
else { return nil }
|
|
return try? JSONDecoder().decode(BuzzOrganizationSession.self, from: data)
|
|
}
|
|
|
|
private var notificationCacheRoot: URL? {
|
|
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
|
|
.appendingPathComponent("notification-enrichment-v1", isDirectory: true)
|
|
}
|
|
|
|
private lazy var notificationCache: BuzzNotificationCache? = {
|
|
guard let directory = notificationCacheRoot else { return nil }
|
|
let cache = BuzzNotificationCache(directory: directory, isCurrent: { [weak self] scope in
|
|
self?.isCurrent(scope) == true
|
|
})
|
|
let scopes = Self.communities(appGroupIdentifier: appGroupIdentifier)
|
|
.compactMap(BuzzNotificationCacheScope.init(community:))
|
|
do { try cache.removeRevokedScopes(keeping: scopes) }
|
|
catch { NSLog("Notification cache cleanup failed: %@", String(describing: error)) }
|
|
return cache
|
|
}()
|
|
|
|
private lazy var notificationAvatarLoader: BuzzNotificationAvatarLoader? = {
|
|
guard let directory = notificationCacheRoot else { return nil }
|
|
let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
|
return BuzzNotificationAvatarLoader(directory: directory,
|
|
privateKey: { Self.loadPrivateKey(communityID: $0, keychainAccessGroup: accessGroup) },
|
|
isCurrent: { [weak self] in self?.isCurrent($0) == true },
|
|
assertionIssuer: Self.assertionIssuer)
|
|
}()
|
|
|
|
private func isCurrent(_ scope: BuzzNotificationCacheScope) -> Bool {
|
|
let accessGroup = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
|
|
return Self.communities(appGroupIdentifier: appGroupIdentifier).contains {
|
|
BuzzNotificationCacheScope(community: $0) == scope
|
|
} && scope.matchesAccountKey(
|
|
Self.loadPrivateKey(communityID: scope.communityID, keychainAccessGroup: accessGroup))
|
|
}
|
|
|
|
private static func communities(appGroupIdentifier: String?) -> [PushLeaseCommunity] {
|
|
guard let data = loadPushSnapshotData(appGroupIdentifier: appGroupIdentifier),
|
|
let snapshot = try? JSONDecoder().decode(PushLeaseSnapshot.self, from: data)
|
|
else { return [] }
|
|
return snapshot.communities
|
|
}
|
|
|
|
override func didReceive(
|
|
_ request: UNNotificationRequest,
|
|
withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void
|
|
) {
|
|
resolvedScope = nil
|
|
handoff = BuzzNotificationHandoff(handler: contentHandler)
|
|
guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else {
|
|
finish(request.content)
|
|
return
|
|
}
|
|
// Workaround until https://github.com/block/buzz/pull/8431 lands.
|
|
if content.sound == nil { content.sound = .default }
|
|
// Shown if the relay can't be reached in time. The gateway's fixed body is meant
|
|
// to be replaced and reads like an instruction to the user.
|
|
content.body = "You have a new message."
|
|
restrictedFallbackContent = Self.restrictedFallback(from: content)
|
|
bestAttemptContent = content
|
|
var cleanUserInfo = content.userInfo
|
|
cleanUserInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
content.userInfo = cleanUserInfo
|
|
|
|
let session = URLSession(configuration: .ephemeral)
|
|
self.session = session
|
|
let resolver = makeResolver(session: session)
|
|
self.resolver = resolver
|
|
resolver.resolve { [weak self] resolution in
|
|
guard let self else { return }
|
|
if let resolution {
|
|
guard let scope = resolution.notificationScope, self.isCurrent(scope),
|
|
self.resolver?.isStillAuthorized() == true
|
|
else {
|
|
self.finish(Self.restrictedFallback(from: content))
|
|
return
|
|
}
|
|
self.resolvedScope = scope
|
|
content.title = resolution.title
|
|
content.body = resolution.body
|
|
if let subtitle = resolution.subtitle {
|
|
content.subtitle = subtitle
|
|
}
|
|
if let threadIdentifier = resolution.threadIdentifier {
|
|
content.threadIdentifier = threadIdentifier
|
|
}
|
|
if let navigationTarget = resolution.navigationTarget {
|
|
var userInfo = content.userInfo
|
|
userInfo[BuzzPushNavigationTarget.userInfoKey] = navigationTarget.userInfoValue
|
|
content.userInfo = userInfo
|
|
}
|
|
self.bestAttemptContent = content
|
|
self.communicationPresenter.present(
|
|
ordinaryContent: content,
|
|
resolution: resolution,
|
|
isStillAllowed: { [weak self] in
|
|
guard let self else { return false }
|
|
return self.isCurrent(scope) && self.resolver?.isStillAuthorized() == true
|
|
&& !self.isAgeRestricted()
|
|
},
|
|
onDeletionFailure: { error in
|
|
NSLog("Notification cleanup retry failed: %@", String(describing: error))
|
|
}
|
|
) { [weak self] specializedContent in
|
|
self?.finish(specializedContent)
|
|
}
|
|
return
|
|
}
|
|
self.finish(content)
|
|
}
|
|
}
|
|
|
|
override func serviceExtensionTimeWillExpire() {
|
|
if let bestAttemptContent {
|
|
finish(bestAttemptContent)
|
|
}
|
|
}
|
|
|
|
private func finish(_ content: UNNotificationContent) {
|
|
// Cancelling on expiry also stops a retry that is running or about to start.
|
|
resolver?.cancel()
|
|
session?.invalidateAndCancel()
|
|
session = nil
|
|
handoff?.finish(
|
|
resolvedScope.map { isCurrent($0) && resolver?.isStillAuthorized() != false } == false
|
|
? Self.restrictedFallback(from: content) : content,
|
|
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
|
|
handoffIfAllowed: { deliver in
|
|
BuzzAgeRestrictionSession.handoffIfAllowed(
|
|
containerURL: ageRestrictionContainerURL, deliver: deliver)
|
|
}
|
|
) { [self] in
|
|
// The service deadline cannot wait for Intents cleanup. The safe content
|
|
// has already been handed back synchronously, including on expiration.
|
|
let center = UNUserNotificationCenter.current()
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
interactionCleanup.request { error in
|
|
if error != nil { NSLog("Notification cleanup failed; age access is unchanged.") }
|
|
center.removeAllDeliveredNotifications()
|
|
center.removeAllPendingNotificationRequests()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var ageRestrictionContainerURL: URL? {
|
|
appGroupIdentifier.flatMap {
|
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: $0)
|
|
}
|
|
}
|
|
|
|
private func isAgeRestricted() -> Bool {
|
|
BuzzAgeRestrictionSession.isRestricted(containerURL: ageRestrictionContainerURL)
|
|
}
|
|
|
|
private static func restrictedFallback(
|
|
from content: UNNotificationContent
|
|
) -> UNMutableNotificationContent {
|
|
let fallback =
|
|
(content.mutableCopy() as? UNMutableNotificationContent)
|
|
?? UNMutableNotificationContent()
|
|
fallback.title = "Buzz"
|
|
fallback.subtitle = ""
|
|
fallback.body = "Open Buzz to view this message."
|
|
fallback.threadIdentifier = ""
|
|
var userInfo = fallback.userInfo
|
|
userInfo.removeValue(forKey: BuzzPushNavigationTarget.userInfoKey)
|
|
fallback.userInfo = userInfo
|
|
return fallback
|
|
}
|
|
|
|
private static func loadPrivateKey(
|
|
communityID: String,
|
|
keychainAccessGroup: String?
|
|
) -> String? {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrService as String: "buzz.push.nse.signing",
|
|
kSecAttrAccount as String: communityID,
|
|
kSecReturnData as String: true,
|
|
kSecMatchLimit as String: kSecMatchLimitOne,
|
|
]
|
|
if let keychainAccessGroup, !keychainAccessGroup.isEmpty {
|
|
query[kSecAttrAccessGroup as String] = keychainAccessGroup
|
|
}
|
|
var item: CFTypeRef?
|
|
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
|
|
let data = item as? Data
|
|
else { return nil }
|
|
return String(data: data, encoding: .utf8)
|
|
}
|
|
|
|
private static func loadPushSnapshotData(appGroupIdentifier: String?) -> Data? {
|
|
loadAppGroupData(
|
|
fileName: BuzzPushPresentationCacheStore.fileName,
|
|
appGroupIdentifier: appGroupIdentifier,
|
|
maximumBytes: BuzzPushPresentationCacheStore.maximumSnapshotBytes
|
|
)
|
|
}
|
|
|
|
private static func loadAppGroupData(
|
|
fileName: String,
|
|
appGroupIdentifier: String?,
|
|
maximumBytes: Int? = nil
|
|
) -> Data? {
|
|
guard let appGroupIdentifier,
|
|
let container = FileManager.default.containerURL(
|
|
forSecurityApplicationGroupIdentifier: appGroupIdentifier
|
|
)
|
|
else { return nil }
|
|
let fileURL = container.appendingPathComponent(fileName)
|
|
if let maximumBytes {
|
|
guard let values = try? fileURL.resourceValues(forKeys: [.fileSizeKey]),
|
|
let fileSize = values.fileSize,
|
|
fileSize <= maximumBytes
|
|
else { return nil }
|
|
}
|
|
return try? Data(contentsOf: fileURL)
|
|
}
|
|
}
|