Files
buzz/mobile/ios/Runner/PushSnapshotBridge.swift
Tom Brow bf13f7c83b fix(ios): mint NIP-FI assertions in the notification extension (#8982)
Organization relays that enforce NIP-FI (federated identity) require a
`Nostr-Federated-Identity` assertion on `/query`. The iOS notification
service extension signed its `/query` with NIP-98 only, so the relay
answered `401` and every push from an enforcing organization relay
showed the generic "You have a new message." body.

The app already mints these assertions in Dart from its organization
account session, but that session lives in the app's own keychain, and
assertions last minutes, so a cached one is useless by the time a push
arrives. This change lets the extension mint its own:

- **App:** whenever the organization `BuilderlabAccountClient` adopts or
clears its session, the app writes or deletes a device-only keychain
item in the group it already shares with the extension. The item holds
the organization origin, the session credential and the organization's
exact relay hosts (from `BUZZ_ORGANIZATION_RELAYS`). Builds without an
organization never write it.
- **Extension:** for a community whose relay host is one of those exact
hosts, `BuzzRelayAssertionIssuer` requests an assertion from the
organization issuer (`/api/goose/v1/buzz/identity/assertions`, session
credential plus NIP-98 with the community's account key, the same
request the app makes), checks it was issued for that key, and caches it
in-process until 30 seconds before expiry. Both `/query` paths (message
lookup and sender/channel enrichment) send it. Other relays get no
header and never see the session.
- Signing out awaits removal of the shared copy and fails if removal
fails; each restore that finds no session retries the removal.
- If issuance fails or the user is signed out, the extension sends the
query without the header and the relay refuses it, which keeps today's
generic fallback.

Not covered here: relay-hosted avatars on enforcing relays still load
without the assertion (the notification text and title are unaffected),
hosted Builderlab communities, and Android.

### Validation

Added tests:
- `testOrganizationRelayQueriesCarryMintedAssertion`: against a stub
relay that answers `401` without the assertion, two organization pushes
resolve with message body and sender name from a single issuance, a
non-organization relay gets neither the header nor the session, and
after sign-out the notification falls back. Removing the header from the
query path makes this test fail.
- `testOrganizationAssertionIssuanceFailuresSendNoHeader`: refused,
wrong-key, expired and header-injection responses produce no header.
Subdomains and other ports of the organization relay host are never sent
the session.
- `builderlab_account_test.dart`: the shared copy follows exchange,
restore and clear; a failed removal fails sign-out, and the next restore
retries it.

Focused existing validation: the full BuzzPushKit suite, the hosted Dart
tests, `flutter analyze`, and an iOS device build of the app and
extension.

Not yet validated: a live push from the Block relay on a device.

## Before merge

Live device push test: waived by Tom (2026-11-03).

🤖 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>
2026-11-03 19:01:42 +00:00

260 lines
8.7 KiB
Swift

import BuzzPushKit
import Flutter
import Foundation
import Intents
import UserNotifications
final class BuzzPushSnapshotBridge {
private let containerURL: () -> URL?
private let endpointGrantStore: BuzzPushEndpointGrantKeychainStore
private let interactionDeletionDeadline: BuzzInteractionDeletionDeadline
private let keychainAccessGroup: String?
private let queue = DispatchQueue(
label: "xyz.block.buzz.push-snapshot",
qos: .utility
)
private lazy var store: BuzzPushPresentationCacheStore? = {
guard let container = containerURL() else { return nil }
return BuzzPushPresentationCacheStore(containerURL: container)
}()
private let ageRestrictionSession = BuzzAgeRestrictionSession()
init(
appGroupIdentifier: String?,
endpointGrantStore: BuzzPushEndpointGrantKeychainStore,
keychainAccessGroup: String?,
containerURL: (() -> URL?)? = nil,
interactionDeletionDeadline: BuzzInteractionDeletionDeadline =
BuzzInteractionDeletionDeadline(
timeout: 5,
deleteAllInteractions: { completion in
INInteraction.deleteAll(completion: completion)
},
scheduleTimeout: { delay, action in
DispatchQueue.global(qos: .utility).asyncAfter(
deadline: .now() + delay,
execute: action
)
}
)
) {
self.containerURL = containerURL ?? {
guard let appGroupIdentifier else { return nil }
return FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
}
self.endpointGrantStore = endpointGrantStore
self.keychainAccessGroup = keychainAccessGroup
self.interactionDeletionDeadline = interactionDeletionDeadline
}
@discardableResult
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) -> Bool {
if call.method == "restoreAgeRestrictedNotifications" {
queue.async { [self] in
ageRestrictionSession.release()
BuzzInteractionCleanupRetry(
containerURL: containerURL(), deletion: interactionDeletionDeadline
).retryPending { error in
Self.complete(result, value: error.map {
FlutterError(code: "interaction_cleanup_retry_failed",
message: "Unable to finish pending interaction cleanup.",
details: $0.localizedDescription)
})
}
}
return true
}
if call.method == "purgeAgeRestrictedNotifications" {
purgeAgeRestrictedNotifications(result: result)
return true
}
guard call.method == "syncPushSnapshot",
let arguments = call.arguments as? [String: Any],
let section = arguments["section"] as? String
else {
return false
}
switch section {
case "communities":
syncCommunities(arguments, result: result)
case "organizationSession":
syncOrganizationSession(arguments, result: result)
default: return false
}
return true
}
private func purgeAgeRestrictedNotifications(result: @escaping FlutterResult) {
queue.async { [weak self] in
do {
guard let self, let container = containerURL() else {
throw NSError(
domain: "BuzzPushSnapshotBridge",
code: 1,
userInfo: [
NSLocalizedDescriptionKey: "The age-restriction session container is unavailable."
]
)
}
try ageRestrictionSession.restrict(containerURL: container)
// Preserve snapshots and credentials. Restriction authority expires
// with this process even when cleanup or a later storage read fails.
let center = UNUserNotificationCenter.current()
center.removeAllDeliveredNotifications()
center.removeAllPendingNotificationRequests()
BuzzInteractionCleanupRetry(
containerURL: container, deletion: interactionDeletionDeadline
).request { error in
Self.complete(
result,
value: error.map {
FlutterError(
code: "age_restriction_purge_failed",
message: "Unable to suppress and purge restricted notifications.",
details: $0.localizedDescription
)
}
)
}
} catch {
Self.complete(
result,
value: FlutterError(
code: "age_restriction_purge_failed",
message: "Unable to suppress and purge restricted notifications.",
details: error.localizedDescription
)
)
}
}
}
/// Shares the organization account session with the notification extension, or removes it
/// when the app has none, so the extension can mint NIP-FI assertions for organization relays.
private func syncOrganizationSession(
_ arguments: [String: Any],
result: @escaping FlutterResult
) {
let session: BuzzOrganizationSession?
if arguments["credential"] is NSNull || arguments["credential"] == nil {
session = nil
} else if let origin = arguments["origin"] as? String,
let credential = arguments["credential"] as? String, !credential.isEmpty,
credential.utf8.count <= 512,
let relayHosts = arguments["relayHosts"] as? [String], !relayHosts.isEmpty
{
session = BuzzOrganizationSession(
origin: origin, credential: credential, relayHosts: relayHosts)
} else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected an organization origin, credential and relay hosts.",
details: nil
)
)
return
}
queue.async { [weak self] in
do {
try BuzzPushKeychain.replace(
organizationSession: session, accessGroup: self?.keychainAccessGroup)
Self.complete(result, value: nil)
} catch {
Self.complete(
result,
value: FlutterError(
code: "snapshot_sync_failed",
message: "Unable to share the organization session.",
details: error.localizedDescription
)
)
}
}
}
private func syncCommunities(
_ arguments: [String: Any],
result: @escaping FlutterResult
) {
guard let communities = arguments["communities"] as? [[String: Any]],
let signingKeys = arguments["signingKeys"] as? [String: String],
communities.count <= BuzzPushPresentationCacheStore.maximumCommunities
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected bounded communities and signing keys.",
details: nil
)
)
return
}
queue.async { [weak self] in
do {
guard let self else {
Self.complete(result, value: nil)
return
}
guard let store else {
Self.complete(result, value: nil)
return
}
// Relay-metadata enrichment is optional presentation state. A damaged
// grant cache must not block the core NSE snapshot and key update.
let grants = (try? endpointGrantStore.records()) ?? []
let enriched = communities.map { community -> [String: Any] in
var community = community
guard let relayURL = community["relayUrl"] as? String,
let relayMetadataPubkey = Self.relayMetadataPubkey(
relayURL: relayURL,
grants: grants
)
else { return community }
community["relayMetadataPubkey"] = relayMetadataPubkey
return community
}
let data = try JSONSerialization.data(withJSONObject: enriched, options: [.sortedKeys])
let decoded = try JSONDecoder().decode([PushLeaseCommunity].self, from: data)
// Ordinary snapshot maintenance never changes age access.
try store.replaceCommunities(decoded)
try BuzzPushKeychain.replace(
signingKeys: signingKeys,
accessGroup: self.keychainAccessGroup
)
Self.complete(result, value: nil)
} catch {
Self.complete(
result,
value: FlutterError(
code: "snapshot_sync_failed",
message: "Unable to sync push community state.",
details: error.localizedDescription
)
)
}
}
}
static func relayMetadataPubkey(
relayURL: String,
grants: [BuzzPushEndpointGrantRecord]
) -> String? {
guard let origin = BuzzPushPresentationCacheStore.canonicalRelayOrigin(relayURL) else {
return nil
}
return grants.filter {
BuzzPushPresentationCacheStore.canonicalRelayOrigin($0.relayOrigin) == origin
}.max {
$0.generation < $1.generation
}?.relayMetadataPubkey
}
private static func complete(_ result: @escaping FlutterResult, value: Any?) {
DispatchQueue.main.async {
result(value)
}
}
}