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>
This commit is contained in:
Tom Brow
2026-11-03 11:01:42 -08:00
committed by GitHub
parent a9aa1f73b0
commit bf13f7c83b
10 changed files with 778 additions and 61 deletions

View File

@@ -52,6 +52,8 @@ public protocol BuzzPushNotificationResolving {
func resolve(completion: @escaping (BuzzPushResolution?) -> Void)
/// Cancels any retry in flight and prevents new ones. Call when the notification is delivered.
func cancel()
/// Whether the organization session behind the resolved message, if any, still holds.
func isStillAuthorized() -> Bool
}
/// Reads configured Buzz communities and resolves their newest message not yet shown.
@@ -70,9 +72,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private var retrySessions: [URLSession] = []
private let loadCommunitiesData: () -> Data?
private let loadPrivateKey: (String) -> String?
private let assertionLock = NSLock()
private var resolvedAssertion: BuzzRelayAssertion?
private let now: () -> Date
private let notificationCache: BuzzNotificationCache?
private let avatarLoader: BuzzNotificationAvatarLoader?
private let assertionIssuer: BuzzRelayAssertionIssuer?
/// Creates a resolver around the notification extension's App Group and Keychain I/O.
public init(
@@ -81,12 +86,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
loadPrivateKey: @escaping (String) -> String?,
notificationCache: BuzzNotificationCache? = nil,
avatarLoader: BuzzNotificationAvatarLoader? = nil,
assertionIssuer: BuzzRelayAssertionIssuer? = nil,
now: @escaping () -> Date = Date.init,
makeRetrySession: (() -> URLSession)? = nil
) {
self.makeRetrySession = makeRetrySession ?? { URLSession(configuration: session.configuration) }
self.notificationCache = notificationCache
self.avatarLoader = avatarLoader
self.assertionIssuer = assertionIssuer
self.session = session
self.loadCommunitiesData = loadCommunitiesData
self.loadPrivateKey = loadPrivateKey
@@ -105,12 +112,12 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}
let group = DispatchGroup()
let lock = NSLock()
var candidates: [(VerifiedNostrEvent, PushLeaseCommunity)] = []
var candidates: [(VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)] = []
for community in communities {
group.enter()
query(community) { messages in
query(community) { messages, assertion in
lock.lock()
candidates += messages.map { ($0, community) }
candidates += messages.map { ($0, community, assertion) }
lock.unlock()
group.leave()
}
@@ -123,6 +130,9 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
completion(nil)
return
}
self.assertionLock.lock()
self.resolvedAssertion = newest.2
self.assertionLock.unlock()
self.resolvePresentation(event: newest.0, community: newest.1, completion: completion)
}
}
@@ -133,12 +143,14 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
/// push shows the generic text and the message isn't offered again, which is better
/// than repeating a message in later notifications.
private func firstUnpresented(
_ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity)],
_ newestFirst: [(VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)],
communities: [PushLeaseCommunity]
) -> (VerifiedNostrEvent, PushLeaseCommunity)? {
) -> (VerifiedNostrEvent, PushLeaseCommunity, BuzzRelayAssertion?)? {
guard let notificationCache else { return newestFirst.first }
let candidates = newestFirst.compactMap { event, community in
BuzzNotificationCacheScope(community: community).map { (candidate: (event, community), scope: $0) }
let candidates = newestFirst.compactMap { event, community, assertion in
BuzzNotificationCacheScope(community: community).map {
(candidate: (event, community, assertion), scope: $0)
}
}
do {
// Every community's history bounds the others, even one whose query failed, so a
@@ -172,10 +184,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private func query(
_ community: PushLeaseCommunity,
completion: @escaping ([VerifiedNostrEvent]) -> Void
completion: @escaping ([VerifiedNostrEvent], BuzzRelayAssertion?) -> Void
) {
guard let privateKey = accountPrivateKey(for: community), community.pubkey?.isEmpty == false else {
completion([])
completion([], nil)
return
}
guard
@@ -186,33 +198,64 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
withJSONObject: community.policies.map { $0.filter.queryFilter(since: nil, limit: 10) }
)
else {
completion([])
completion([], nil)
return
}
send(
url: url, body: body, privateKey: privateKey, session: session,
canRetry: { self.matchingPrivateKey(for: community) == privateKey }
) { data, response in
guard self.matchingPrivateKey(for: community) != nil,
let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else {
completion([])
return
federatedIdentity(relayURL: relayURL, privateKey: privateKey) { assertion in
self.send(
url: url, body: body, privateKey: privateKey, federatedIdentity: assertion?.header,
session: self.session,
canRetry: {
self.matchingPrivateKey(for: community) == privateKey && self.isCurrent(assertion)
}
) { data, response in
guard self.matchingPrivateKey(for: community) != nil, self.isCurrent(assertion),
let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else {
completion([], assertion)
return
}
completion(
Self.messages(
events: events.filter { event in
event.hasValidIDAndSignature()
&& community.policies.contains { policy in
PushLeaseMatcher.matches(event: event, policy: policy)
}
},
community: community
), assertion)
}
completion(
Self.messages(
events: events.filter { event in
event.hasValidIDAndSignature()
&& community.policies.contains { policy in
PushLeaseMatcher.matches(event: event, policy: policy)
}
},
community: community
))
}
}
/// The NIP-FI assertion an organization relay requires, or nil for any other relay.
private func federatedIdentity(
relayURL: URL, privateKey: String, completion: @escaping (BuzzRelayAssertion?) -> Void
) {
guard let assertionIssuer else {
completion(nil)
return
}
assertionIssuer.header(relayURL: relayURL, privateKeyHex: privateKey, completion: completion)
}
/// Whether the organization session behind the resolved message, if any, is still the shared
/// one. The extension checks this again before delivering, as it does the account scope.
public func isStillAuthorized() -> Bool {
assertionLock.lock()
let assertion = resolvedAssertion
assertionLock.unlock()
return isCurrent(assertion)
}
/// Responses fetched with an assertion are accepted only while its organization session lasts.
private func isCurrent(_ assertion: BuzzRelayAssertion?) -> Bool {
guard let assertion else { return true }
return assertionIssuer?.isCurrent(assertion) ?? false
}
/// Sends one signed `/query`. A timeout or dropped connection is retried once on a new
/// session, because the failed attempt may have used a pooled connection that went stale
/// when the network path changed. Each attempt is signed afresh so the relay's NIP-98
@@ -227,7 +270,8 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}
private func send(
url: URL, body: Data, privateKey: String, session: URLSession, canRetry: (() -> Bool)?,
url: URL, body: Data, privateKey: String, federatedIdentity: String?, session: URLSession,
canRetry: (() -> Bool)?,
completion: @escaping (Data?, URLResponse?) -> Void
) {
var request = URLRequest(url: url)
@@ -244,7 +288,10 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
return
}
request.setValue(auth, forHTTPHeaderField: "Authorization")
session.dataTask(with: request) { data, response, error in
if let federatedIdentity {
request.setValue(federatedIdentity, forHTTPHeaderField: "Nostr-Federated-Identity")
}
let task = session.dataTask(with: request) { data, response, error in
guard let error = error as? URLError,
[.timedOut, .networkConnectionLost].contains(error.code), canRetry?() == true
else {
@@ -262,12 +309,16 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
let retrySession = self.makeRetrySession()
self.retrySessions.append(retrySession)
self.send(
url: url, body: body, privateKey: privateKey, session: retrySession, canRetry: nil
url: url, body: body, privateKey: privateKey, federatedIdentity: federatedIdentity,
session: retrySession, canRetry: nil
) { data, response in
retrySession.finishTasksAndInvalidate()
completion(data, response)
}
}.resume()
}
// A redirect would forward the federated identity header to its target, so it is refused.
task.delegate = BuzzRedirectRefusal.shared
task.resume()
}
private func resolvePresentation(
@@ -288,7 +339,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
event: event, community: community, scope: scope, cache: notificationCache,
avatarLoader: avatarLoader, now: now,
isCurrent: { [self] in
matchingPrivateKey(for: community) != nil
matchingPrivateKey(for: community) != nil && isStillAuthorized()
},
refresh: { [self] profile, channel, done in
refreshPresentation(event: event, community: community,
@@ -361,29 +412,34 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
completion([])
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url, method: "POST", body: body, privateKeyHex: privateKey)
else {
completion([])
return
}
request.setValue(auth, forHTTPHeaderField: "Authorization")
BuzzNotificationDownload.start(
request: request, configuration: session.configuration,
maximumBytes: Self.maximumPresentationResponseBytes
) { result in
guard self.matchingPrivateKey(for: community) != nil,
case .success(let data) = result,
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
federatedIdentity(relayURL: relayURL, privateKey: privateKey) { assertion in
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url, method: "POST", body: body, privateKeyHex: privateKey)
else {
completion([])
return
}
completion(events.filter { $0.hasValidIDAndSignature() })
request.setValue(auth, forHTTPHeaderField: "Authorization")
if let assertion {
request.setValue(assertion.header, forHTTPHeaderField: "Nostr-Federated-Identity")
}
BuzzNotificationDownload.start(
request: request, configuration: self.session.configuration,
maximumBytes: Self.maximumPresentationResponseBytes
) { result in
guard self.matchingPrivateKey(for: community) != nil, self.isCurrent(assertion),
case .success(let data) = result,
let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else {
completion([])
return
}
completion(events.filter { $0.hasValidIDAndSignature() })
}
}
}
@@ -621,3 +677,16 @@ extension PushLeaseCommunity {
return components.url
}
}
/// Refuses every redirect, so a signed relay request never forwards its proof or assertion.
final class BuzzRedirectRefusal: NSObject, URLSessionTaskDelegate, Sendable {
static let shared = BuzzRedirectRefusal()
func urlSession(
_ session: URLSession, task: URLSessionTask,
willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void
) {
completionHandler(nil)
}
}

View File

@@ -0,0 +1,158 @@
import Foundation
/// The organization account session the app shares with the notification extension, so the
/// extension can mint the NIP-FI assertion an enforcing organization relay requires on `/query`.
public struct BuzzOrganizationSession: Codable, Equatable, Sendable {
/// Keychain item, in the access group the app shares with the extension.
public static let keychainService = "buzz.push.nse.organization-session"
public static let keychainAccount = "organization"
/// The organization's Builderlab HTTPS origin, for example `https://block.builderlab.xyz`.
public let origin: String
/// The account session credential, sent only to [origin].
public let credential: String
/// Exact relay hosts that trust this organization. Subdomains and other ports never match.
public let relayHosts: [String]
public init(origin: String, credential: String, relayHosts: [String]) {
self.origin = origin
self.credential = credential
self.relayHosts = relayHosts
}
/// The issuer route, when [origin] is a bare HTTPS origin.
var assertionURL: URL? {
guard let components = URLComponents(string: origin),
components.scheme == "https", components.host?.isEmpty == false,
components.user == nil, components.password == nil, components.port == nil,
components.query == nil, components.fragment == nil,
components.path.isEmpty || components.path == "/"
else { return nil }
var route = components
route.path = "/api/goose/v1/buzz/identity/assertions"
return route.url
}
/// Matches the app's `OrganizationEnvironment.protects` for an HTTPS relay URL.
func protects(_ relayURL: URL) -> Bool {
guard relayURL.scheme == "https", relayURL.user == nil, relayURL.password == nil,
relayURL.query == nil, relayURL.fragment == nil,
relayURL.path.isEmpty || relayURL.path == "/",
relayURL.port == nil || relayURL.port == 443,
let host = relayURL.host?.lowercased()
else { return false }
return relayHosts.contains(host)
}
}
/// A minted `Nostr-Federated-Identity` header and the organization session it was minted from.
public struct BuzzRelayAssertion {
public let header: String
let organization: BuzzOrganizationSession
}
/// Mints NIP-FI assertions for organization relays from the shared organization session.
/// Relays outside the organization get no header, exactly as before.
public final class BuzzRelayAssertionIssuer: @unchecked Sendable {
static let maximumResponseBytes = 32 * 1_024
static let maximumAssertionBytes = 16_384
/// A reused assertion must outlive the requests that carry it.
static let reuseMargin: TimeInterval = 30
private let loadSession: () -> BuzzOrganizationSession?
private let session: URLSession
private let now: () -> Date
private let lock = NSLock()
private var cached: [String: (token: String, expiry: Date)] = [:]
public init(
session: URLSession,
loadSession: @escaping () -> BuzzOrganizationSession?,
now: @escaping () -> Date = Date.init
) {
self.session = session
self.loadSession = loadSession
self.now = now
}
/// Whether the organization session that issued `assertion` is still the shared one. A
/// response fetched with an assertion from a signed-out or replaced session must be dropped.
public func isCurrent(_ assertion: BuzzRelayAssertion) -> Bool {
loadSession() == assertion.organization
}
/// Completes with the `Nostr-Federated-Identity` value for an organization relay, or nil when
/// the relay is not an organization relay or no assertion could be issued. A nil value for an
/// enforcing relay leaves the relay to refuse the request.
public func header(
relayURL: URL, privateKeyHex: String, completion: @escaping (BuzzRelayAssertion?) -> Void
) {
guard let organization = loadSession(), organization.protects(relayURL),
let issuerURL = organization.assertionURL,
let host = relayURL.host?.lowercased(),
let secret = VerifiedNostrEvent.hexBytes(privateKeyHex), secret.count == 32,
let pubkeyBytes = try? NostrSchnorr.publicKey(secret: secret)
else {
completion(nil)
return
}
let pubkey = VerifiedNostrEvent.hex(pubkeyBytes)
let relay = "wss://\(host)"
let cacheKey = "\(organization.origin)|\(relay)|\(pubkey)|\(organization.credential)"
lock.lock()
if let entry = cached[cacheKey], entry.expiry.timeIntervalSince(now()) > Self.reuseMargin {
lock.unlock()
completion(BuzzRelayAssertion(header: "Bearer \(entry.token)", organization: organization))
return
}
lock.unlock()
guard
let body = try? JSONSerialization.data(
withJSONObject: ["relay_url": relay, "nostr_pubkey": pubkey], options: [.sortedKeys]),
let auth = try? NostrHTTPAuth.authorizationHeader(
url: issuerURL, method: "POST", body: body, privateKeyHex: privateKeyHex)
else {
completion(nil)
return
}
var request = URLRequest(url: issuerURL)
request.httpMethod = "POST"
request.httpBody = body
request.timeoutInterval = 8
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue(organization.origin, forHTTPHeaderField: "Origin")
request.setValue(organization.credential, forHTTPHeaderField: "X-BB-Session-Credential")
request.setValue(auth, forHTTPHeaderField: "Authorization")
BuzzNotificationDownload.start(
request: request, configuration: session.configuration,
maximumBytes: Self.maximumResponseBytes
) { [self] result in
guard case .success(let data) = result,
let value = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = value["assertion"] as? String, !token.isEmpty,
token.utf8.count <= Self.maximumAssertionBytes,
!token.contains(where: { $0.isNewline || $0 == "\0" }),
(value["nostr_pubkey"] as? String)?.lowercased() == pubkey,
let expiresAt = value["expires_at"] as? Int
else {
if case .failure(let error) = result {
NSLog("Notification assertion issuance failed: %@", String(describing: error))
}
completion(nil)
return
}
let expiry = Date(timeIntervalSince1970: TimeInterval(expiresAt))
// A sign-out or account change during issuance revokes this result.
guard expiry > now(), loadSession() == organization else {
completion(nil)
return
}
lock.lock()
cached[cacheKey] = (token, expiry)
lock.unlock()
completion(BuzzRelayAssertion(header: "Bearer \(token)", organization: organization))
}
}
}

View File

@@ -1055,6 +1055,193 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
wait(for: [cancelled], timeout: 1)
}
/// An enforcing organization relay refuses `/query` without a NIP-FI assertion. The extension
/// mints one from the shared organization session, reuses it across requests and
/// notifications, and never sends the session or an assertion to any other relay.
func testOrganizationRelayQueriesCarryMintedAssertion() throws {
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Enforced hello")
let profile = try Self.signedEvent(privateKey: Self.profilePrivateKey,
createdAt: Self.now, kind: 0, content: #"{"display_name":"Fizz"}"#)
let organization = BuzzOrganizationSession(
origin: "https://identity.org.example", credential: "session-credential",
relayHosts: ["relay.org.example"])
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
var sharedSession: BuzzOrganizationSession? = organization
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { sharedSession },
now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) })
let issued = "header.payload.signature"
var issuerBodies: [[String: Any]] = []
var signOutDuringQuery = false
var signOutDuringProfile = false
var redirectedHeaders: [String?] = []
URLProtocolStub.handler = { request in
let body = Self.body(of: request)
if request.url?.host == "identity.org.example" {
XCTAssertEqual(request.url?.path, "/api/goose/v1/buzz/identity/assertions")
XCTAssertEqual(request.value(forHTTPHeaderField: "X-BB-Session-Credential"), "session-credential")
XCTAssertEqual(request.value(forHTTPHeaderField: "Origin"), "https://identity.org.example")
XCTAssertTrue(request.value(forHTTPHeaderField: "Authorization")?.hasPrefix("Nostr ") == true)
issuerBodies.append(try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]))
return Self.response(request, status: 200, data: try JSONSerialization.data(withJSONObject: [
"assertion": issued, "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now + 300,
]))
}
XCTAssertNil(request.value(forHTTPHeaderField: "X-BB-Session-Credential"))
if request.url?.host == "elsewhere.example" {
redirectedHeaders.append(request.value(forHTTPHeaderField: "Nostr-Federated-Identity"))
return Self.response(request, status: 200, data: try JSONEncoder().encode([message]))
}
let enforced = request.url?.host == "relay.org.example"
let assertion = request.value(forHTTPHeaderField: "Nostr-Federated-Identity")
if enforced, assertion != "Bearer \(issued)" {
return Self.response(request, status: 401, data: Data())
}
if !enforced { XCTAssertNil(assertion) }
if enforced, signOutDuringQuery { sharedSession = nil }
let filters = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [[String: Any]])
let kinds = filters.flatMap { $0["kinds"] as? [Int] ?? [] }
if enforced, signOutDuringProfile, kinds.contains(0) { sharedSession = nil }
return Self.response(request, status: 200,
data: try JSONEncoder().encode(kinds.contains(0) ? [profile] : kinds.contains(9) ? [message] : []))
}
for relayUrl in ["wss://relay.org.example", "wss://relay.org.example", "https://relay.example"] {
let configured = community(relayUrl: relayUrl)
let result = try XCTUnwrap(resolve(makeResolver(
communitiesData: try snapshotData([configured]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer)))
XCTAssertEqual(result.body, "Enforced hello", relayUrl)
XCTAssertEqual(result.title, "Fizz", relayUrl)
}
// One issuance served the message and profile queries of both organization notifications.
XCTAssertEqual(issuerBodies.count, 1)
XCTAssertEqual(issuerBodies.first?["relay_url"] as? String, "wss://relay.org.example")
XCTAssertEqual(issuerBodies.first?["nostr_pubkey"] as? String, Self.ownPubkey)
// A redirecting relay is refused, so its target never sees the assertion.
URLProtocolStub.redirect = { request in
request.url?.host == "relay.org.example" ? URL(string: "https://elsewhere.example/query") : nil
}
XCTAssertNil(resolve(makeResolver(
communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer)))
XCTAssertEqual(redirectedHeaders, [])
URLProtocolStub.redirect = nil
// Signing out while the relay query is in flight drops what that assertion fetched.
signOutDuringQuery = true
XCTAssertNil(resolve(makeResolver(
communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer)))
signOutDuringQuery = false
// Signing out after the message arrived revokes delivery, which rechecks the session.
sharedSession = organization
let enriching = makeResolver(
communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer)
_ = resolve(enriching)
XCTAssertTrue(enriching.isStillAuthorized())
signOutDuringProfile = true
let revoked = makeResolver(
communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]),
now: Date(timeIntervalSince1970: TimeInterval(Self.now)), assertionIssuer: issuer)
_ = resolve(revoked)
XCTAssertFalse(revoked.isStillAuthorized())
signOutDuringProfile = false
// Signing out removes the shared session: the relay refuses, and the notification falls back.
sharedSession = nil
let signedOut = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { sharedSession })
XCTAssertNil(resolve(makeResolver(
communitiesData: try snapshotData([community(relayUrl: "wss://relay.org.example")]),
assertionIssuer: signedOut)))
XCTAssertEqual(issuerBodies.count, 1)
}
/// A refused or malformed issuance never produces a header.
func testOrganizationAssertionIssuanceFailuresSendNoHeader() throws {
let configuration = URLSessionConfiguration.ephemeral
configuration.protocolClasses = [URLProtocolStub.self]
let organization = BuzzOrganizationSession(
origin: "https://identity.org.example", credential: "session-credential",
relayHosts: ["relay.org.example"])
let cases: [(String, Int, [String: Any])] = [
("refused", 401, [:]),
("other key", 200, ["assertion": "a.b.c", "nostr_pubkey": String(repeating: "f", count: 64),
"expires_at": Self.now + 300]),
("expired", 200, ["assertion": "a.b.c", "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now - 1]),
("header injection", 200, ["assertion": "a.b\r\nX: y", "nostr_pubkey": Self.ownPubkey,
"expires_at": Self.now + 300]),
]
for (name, status, body) in cases {
URLProtocolStub.handler = { request in
Self.response(request, status: status, data: try JSONSerialization.data(withJSONObject: body))
}
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { organization })
let done = expectation(description: name)
issuer.header(
relayURL: URL(string: "https://relay.org.example")!, privateKeyHex: Self.privateKey
) { header in
XCTAssertNil(header, name)
done.fulfill()
}
wait(for: [done], timeout: 2)
}
// Signing out while issuance is in flight discards the minted assertion.
var current: BuzzOrganizationSession? = organization
URLProtocolStub.handler = { request in
current = nil
return Self.response(request, status: 200, data: try JSONSerialization.data(withJSONObject: [
"assertion": "a.b.c", "nostr_pubkey": Self.ownPubkey, "expires_at": Self.now + 300,
]))
}
let racing = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { current })
let raced = expectation(description: "sign-out during issuance")
racing.header(
relayURL: URL(string: "https://relay.org.example")!, privateKeyHex: Self.privateKey
) { header in
XCTAssertNil(header)
raced.fulfill()
}
wait(for: [raced], timeout: 2)
// Hosts are exact: a subdomain or another port is not the organization relay.
for relay in ["https://evil.relay.org.example", "https://relay.org.example:8443"] {
URLProtocolStub.reset()
let issuer = BuzzRelayAssertionIssuer(
session: URLSession(configuration: configuration), loadSession: { organization })
let done = expectation(description: relay)
issuer.header(relayURL: URL(string: relay)!, privateKeyHex: Self.privateKey) { header in
XCTAssertNil(header, relay)
done.fulfill()
}
wait(for: [done], timeout: 2)
XCTAssertTrue(URLProtocolStub.requests.isEmpty, relay)
}
}
static func body(of request: URLRequest) -> Data {
var body = request.httpBody ?? Data()
if let stream = request.httpBodyStream {
stream.open()
defer { stream.close() }
var buffer = [UInt8](repeating: 0, count: 4096)
while stream.hasBytesAvailable {
let count = stream.read(&buffer, maxLength: buffer.count)
if count <= 0 { break }
body.append(contentsOf: buffer.prefix(count))
}
}
return body
}
func testLargeRosterCannotDiscardSenderProfileOrChannelMetadata() throws {
let message = try Self.signedEvent(privateKey: Self.profilePrivateKey,
createdAt: Self.now, kind: 9, tags: [["h", Self.channelID]], content: "Large channel")
@@ -1192,6 +1379,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
presentationCacheData: Data? = nil,
cacheOverride: BuzzNotificationCache? = nil,
now: Date = Date(),
assertionIssuer: BuzzRelayAssertionIssuer? = nil,
makeRetrySession: (() -> URLSession)? = nil
) -> BuzzPushNotificationResolver {
let configuration = URLSessionConfiguration.ephemeral
@@ -1201,6 +1389,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
loadCommunitiesData: { communitiesData },
loadPrivateKey: { privateKeys[$0] },
notificationCache: cacheOverride ?? makeNotificationCache(communitiesData: communitiesData, fixture: presentationCacheData, now: now),
assertionIssuer: assertionIssuer,
now: { now },
makeRetrySession: makeRetrySession
)
@@ -1385,6 +1574,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))?
static var finishLoading: ((URLRequest) -> Bool)?
static var onStopLoading: ((URLRequest) -> Void)?
static var redirect: ((URLRequest) -> URL?)?
static var requests: [URLRequest] = []
override class func canInit(with request: URLRequest) -> Bool { true }
@@ -1394,7 +1584,18 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
Self.lock.lock()
Self.requests.append(request)
let handler = Self.handler
let redirect = Self.redirect
Self.lock.unlock()
if let url = request.url, let target = redirect?(request) {
var next = request
next.url = target
let response = HTTPURLResponse(
url: url, statusCode: 307, httpVersion: nil, headerFields: ["Location": target.absoluteString])!
client?.urlProtocol(self, wasRedirectedTo: next, redirectResponse: response)
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocolDidFinishLoading(self)
return
}
do {
let (response, data) = try handler?(request) ?? { throw URLError(.unsupportedURL) }()
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
@@ -1414,6 +1615,7 @@ final class BuzzPushNotificationResolverTests: XCTestCase {
handler = nil
finishLoading = nil
onStopLoading = nil
redirect = nil
requests = []
lock.unlock()
}

View File

@@ -56,10 +56,42 @@ final class NotificationService: UNNotificationServiceExtension {
)
},
notificationCache: notificationCache,
avatarLoader: notificationAvatarLoader
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)
@@ -128,7 +160,9 @@ final class NotificationService: UNNotificationServiceExtension {
resolver.resolve { [weak self] resolution in
guard let self else { return }
if let resolution {
guard let scope = resolution.notificationScope, self.isCurrent(scope) else {
guard let scope = resolution.notificationScope, self.isCurrent(scope),
self.resolver?.isStillAuthorized() == true
else {
self.finish(Self.restrictedFallback(from: content))
return
}
@@ -152,7 +186,8 @@ final class NotificationService: UNNotificationServiceExtension {
resolution: resolution,
isStillAllowed: { [weak self] in
guard let self else { return false }
return self.isCurrent(scope) && !self.isAgeRestricted()
return self.isCurrent(scope) && self.resolver?.isStillAuthorized() == true
&& !self.isAgeRestricted()
},
onDeletionFailure: { error in
NSLog("Notification cleanup retry failed: %@", String(describing: error))
@@ -178,7 +213,7 @@ final class NotificationService: UNNotificationServiceExtension {
session?.invalidateAndCancel()
session = nil
handoff?.finish(
resolvedScope.map { isCurrent($0) } == false
resolvedScope.map { isCurrent($0) && resolver?.isStillAuthorized() != false } == false
? Self.restrictedFallback(from: content) : content,
restrictedFallback: restrictedFallbackContent ?? Self.restrictedFallback(from: content),
handoffIfAllowed: { deliver in

View File

@@ -76,6 +76,38 @@ enum BuzzPushKeychain {
}
}
/// 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,

View File

@@ -78,6 +78,8 @@ final class BuzzPushSnapshotBridge {
switch section {
case "communities":
syncCommunities(arguments, result: result)
case "organizationSession":
syncOrganizationSession(arguments, result: result)
default: return false
}
return true
@@ -128,6 +130,50 @@ final class BuzzPushSnapshotBridge {
}
}
/// 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

View File

@@ -61,6 +61,7 @@ class BuilderlabAccountClient {
http.Client? httpClient,
FlutterSecureStorage? storage,
Uri? accountOrigin,
this.onSessionChanged,
}) : _http = httpClient ?? http.Client(),
_storage = storage ?? defaultStorage,
accountOrigin = accountOrigin ?? origin;
@@ -71,6 +72,14 @@ class BuilderlabAccountClient {
static final origin = HostedEnvironment.origin;
final Uri accountOrigin;
/// Receives the session credential each time one is adopted, and null when
/// it is cleared or a restore finds no session. Only a trusted owner of
/// [accountOrigin]'s session may set it, for example to share the session
/// with the notification extension. A failed clear fails the operation that
/// cleared, so a signed-out session is never left shared; a failed share of
/// an adopted session is retried at the next adopt.
final Future<void> Function(String? credential)? onSessionChanged;
static const _prefix = '/api/goose';
/// Remote-agent registry routes, shared with desktop.
@@ -98,6 +107,9 @@ class BuilderlabAccountClient {
final http.Client _http;
final FlutterSecureStorage _storage;
String? _credential;
// The adopted credential while the notification extension lacks its copy.
String? _unshared;
bool _sharing = false;
BuilderlabAccount? _account;
final _queue = SerialQueue();
bool _disposed = false;
@@ -125,7 +137,11 @@ class BuilderlabAccountClient {
Future<BuilderlabAccount?>? _restoring;
Future<BuilderlabAccount?> _restore() => _serial(() async {
final record = await _readRecord();
if (record == null) return null;
if (record == null) {
// Retries a removal that failed after an earlier sign-out.
await _unshare();
return null;
}
try {
final verified = await _verify(record.credential);
if (verified.subject != record.subject) {
@@ -228,9 +244,36 @@ class BuilderlabAccountClient {
// Assigned before notifying, so subject listeners see the new account.
_account = verified;
_subject.value = verified.subject;
_unshared = credential;
_share();
return verified;
}
/// Shares the adopted credential with the notification extension. Sharing is
/// push enrichment and must never gate sign-in, so a failure is retried by
/// the next account request instead of being thrown.
void _share() {
final share = onSessionChanged;
final credential = _unshared;
if (share == null || credential == null || _sharing) return;
_sharing = true;
unawaited(
share(credential)
.then((_) {
if (_unshared == credential) _unshared = null;
})
.catchError(
(Object error) => debugPrint('Session share failed: $error'),
)
.whenComplete(() {
_sharing = false;
// A newer credential adopted while this write was in flight still needs sharing.
final pending = _unshared;
if (pending != null && pending != credential) _share();
}),
);
}
Future<BuilderlabAccount> _verify(String credential) async {
final result = await _request('/v1/auth/me', credential: credential);
final subject = result['subject'];
@@ -293,6 +336,7 @@ class BuilderlabAccountClient {
return _serial(() async {
final credential = _credential;
if (credential == null) throw AccountFailure.signedOut;
_share();
if (expectedSubject != null && _account?.subject != expectedSubject) {
throw AccountFailure.denied;
}
@@ -340,16 +384,40 @@ class BuilderlabAccountClient {
Future<void> clearLocalSession() => _serial(_clear);
Future<void> _clear() async {
// Attempt both removals so one failure never leaves the other copy live.
var failed = false;
try {
await _storage.delete(key: _storageKey);
} catch (_) {
failed = true;
}
try {
await _unshare();
} on AccountFailure {
failed = true;
}
if (failed) {
// The account stays signed in, so the extension gets its copy back.
_unshared = _credential;
_share();
throw AccountFailure.storage;
}
_credential = null;
_unshared = null;
_account = null;
if (!_disposed) _subject.value = null;
}
Future<void> _unshare() async {
final share = onSessionChanged;
if (share == null) return;
try {
await share(null);
} catch (_) {
throw AccountFailure.storage;
}
}
Future<Map<String, dynamic>> _request(
String path, {
String? credential,

View File

@@ -9,6 +9,7 @@ import 'builderlab_account.dart';
import 'account_identity_store.dart';
import 'hosted_environment.dart';
import 'organization_environment.dart';
import '../push/push_bridge.dart';
final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
final account = BuilderlabAccountClient();
@@ -21,7 +22,15 @@ final builderlabAccountProvider = Provider<BuilderlabAccountClient>((ref) {
final organizationAccountProvider = Provider<BuilderlabAccountClient?>((ref) {
final origin = OrganizationEnvironment.origin;
if (origin == null) return null;
final account = BuilderlabAccountClient(accountOrigin: origin);
final account = BuilderlabAccountClient(
accountOrigin: origin,
// The notification extension mints its own assertions for these relays.
onSessionChanged: (credential) => shareOrganizationSessionWithNotifications(
origin: origin,
credential: credential,
relayHosts: OrganizationEnvironment.relayHosts,
),
);
ref.onDispose(account.dispose);
return account;
});

View File

@@ -238,6 +238,29 @@ Future<void> registerBuzzPushCommunitySnapshot(
}
}
/// Shares the organization account session with the iOS notification
/// extension, or removes it when [credential] is null. Failures propagate to
/// the account client, which owns retrying them. Organization relays
/// enforce NIP-FI, so the extension needs the session to mint the assertion
/// that lets it read the message a push refers to.
Future<void> shareOrganizationSessionWithNotifications({
required Uri origin,
required String? credential,
required List<String> relayHosts,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return;
try {
await _channel.invokeMethod<void>('syncPushSnapshot', {
'section': 'organizationSession',
'origin': origin.toString(),
'credential': credential,
'relayHosts': relayHosts,
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
}
}
void installBuzzPushMethodHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {

View File

@@ -52,36 +52,111 @@ void main() {
200,
);
});
// The notification extension's copy follows every adopt and clear.
final shared = <String?>[];
var unshareFails = false;
var shareFails = true;
Future<void> share(String? credential) async {
if (credential == null && unshareFails) throw StateError('keychain');
if (credential != null && shareFails) throw StateError('keychain');
shared.add(credential);
}
final storage = _SessionStorage();
final client = BuilderlabAccountClient(
httpClient: transport,
storage: storage,
onSessionChanged: share,
);
expect(
(await client.exchange(code: 'one-use', verifier: verifier)).subject,
'account-1',
);
// A failed share never blocks sign-in, and the next request retries it.
await Future<void>.delayed(Duration.zero);
expect(shared, isEmpty);
shareFails = false;
await client.post('/v1/buzz/known-communities/list', {});
await Future<void>.delayed(Duration.zero);
expect(shared, ['secret']);
await client.post('/v1/buzz/known-communities/list', {});
await Future<void>.delayed(Duration.zero);
expect(shared, ['secret']);
final restored = BuilderlabAccountClient(
httpClient: transport,
storage: storage,
onSessionChanged: share,
);
final restoredAccount = (await restored.restore())!;
expect(restoredAccount.subject, 'account-1');
expect(restoredAccount.email, 'owner@example.com');
expect(requests.length, 3);
expect(requests.length, 5);
await Future<void>.delayed(Duration.zero);
expect(shared, ['secret', 'secret']);
// A failed local deletion fails the sign-out and keeps the account
// signed in, so the extension gets its copy back after the removal.
storage.failDelete = true;
await expectLater(
restored.clearLocalSession(),
throwsA(AccountFailure.storage),
);
await Future<void>.delayed(Duration.zero);
expect(restored.account?.subject, 'account-1');
expect(shared, ['secret', 'secret', null, 'secret']);
expect(await client.restore(), isNotNull);
storage.failDelete = false;
// A failed removal fails the sign-out instead of leaving it shared, and
// the next restore retries it.
unshareFails = true;
await expectLater(
restored.clearLocalSession(),
throwsA(AccountFailure.storage),
);
unshareFails = false;
expect(
await BuilderlabAccountClient(
httpClient: transport,
onSessionChanged: share,
).restore(),
isNull,
);
expect(shared.last, isNull);
await restored.clearLocalSession();
expect(shared.last, isNull);
expect(restored.account, isNull);
expect(
await BuilderlabAccountClient(httpClient: transport).restore(),
isNull,
);
// A credential adopted while an earlier share is in flight is shared
// once that write completes.
final gate = Completer<void>();
final delayed = <String?>[];
var issued = 0;
final rotating = BuilderlabAccountClient(
httpClient: MockClient(
(request) async => http.Response(
jsonEncode(
request.url.path.endsWith('/exchange')
? {
'session_credential': 'next-${++issued}',
'expires_at': expiry,
}
: {'subject': 'account-1', 'expires_at': expiry},
),
200,
),
),
onSessionChanged: (credential) async {
await gate.future;
delayed.add(credential);
},
);
await rotating.exchange(code: 'one-use', verifier: verifier);
await rotating.exchange(code: 'two-use', verifier: verifier);
gate.complete();
await pumpEventQueue();
expect(delayed, ['next-1', 'next-2']);
},
);