Files
buzz/mobile/ios/Runner/PushNativeState.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

122 lines
4.2 KiB
Swift

import BuzzPushKit
import Foundation
import Security
import UserNotifications
final class BuzzOneShotCompletion {
private let lock = NSLock()
private var completion: (() -> Void)?
init(_ completion: @escaping () -> Void) {
self.completion = completion
}
func call() {
lock.lock()
let completion = completion
self.completion = nil
lock.unlock()
completion?()
}
}
enum BuzzPushNotificationResponseCoordinator {
static func handle(
actionIdentifier: String,
userInfo: [AnyHashable: Any],
onTarget: (BuzzPushNavigationTarget) -> Void,
forwardToFlutter: (@escaping () -> Void) -> Void,
completion: @escaping () -> Void
) {
let completionGate = BuzzOneShotCompletion(completion)
defer { completionGate.call() }
if actionIdentifier == UNNotificationDefaultActionIdentifier,
let target = BuzzPushNavigationTarget.decodeIfPresent(from: userInfo)
{
onTarget(target)
}
forwardToFlutter { completionGate.call() }
}
}
enum BuzzPushKeychain {
static let service = "buzz.push.nse.signing"
static func replace(signingKeys: [String: String], accessGroup: String?) throws {
var query = baseQuery(accessGroup: accessGroup)
let deletionStatus = SecItemDelete(query as CFDictionary)
guard deletionStatus == errSecSuccess || deletionStatus == errSecItemNotFound else {
throw NSError(
domain: NSOSStatusErrorDomain, code: Int(deletionStatus),
userInfo: [
NSLocalizedDescriptionKey: SecCopyErrorMessageString(deletionStatus, nil)
?? "Keychain deletion failed" as CFString
]
)
}
for (communityID, privateKeyHex) in signingKeys {
query[kSecAttrAccount as String] = communityID
query[kSecValueData as String] = Data(privateKeyHex.utf8)
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary)
throw NSError(
domain: NSOSStatusErrorDomain, code: Int(status),
userInfo: [
NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil)
?? "Keychain write failed" as CFString
]
)
}
query.removeValue(forKey: kSecValueData as String)
query.removeValue(forKey: kSecAttrAccessible as String)
query.removeValue(forKey: kSecAttrAccount as String)
}
}
/// Replaces the shared organization session. Device-only, like the app's own copy.
static func replace(organizationSession: BuzzOrganizationSession?, accessGroup: String?) throws {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: BuzzOrganizationSession.keychainService,
kSecAttrAccount as String: BuzzOrganizationSession.keychainAccount,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
let deletionStatus = SecItemDelete(query as CFDictionary)
guard deletionStatus == errSecSuccess || deletionStatus == errSecItemNotFound else {
throw keychainError(deletionStatus, fallback: "Keychain deletion failed")
}
guard let organizationSession else { return }
query[kSecValueData as String] = try JSONEncoder().encode(organizationSession)
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
throw keychainError(status, fallback: "Keychain write failed")
}
}
private static func keychainError(_ status: OSStatus, fallback: String) -> NSError {
NSError(
domain: NSOSStatusErrorDomain, code: Int(status),
userInfo: [
NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? fallback as CFString
]
)
}
private static func baseQuery(accessGroup: String?) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
}