mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user