fix(mobile/ios): secure push relay resolution

Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz>
This commit is contained in:
npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj
2026-07-23 17:41:34 -07:00
parent e4b1e81312
commit 4c99a83075
11 changed files with 554 additions and 115 deletions
+7 -1
View File
@@ -7,8 +7,14 @@ let package = Package(
products: [
.library(name: "BuzzPushKit", targets: ["BuzzPushKit"])
],
dependencies: [
.package(url: "https://github.com/21-DOT-DEV/swift-secp256k1.git", exact: "0.21.1")
],
targets: [
.target(name: "BuzzPushKit"),
.target(
name: "BuzzPushKit",
dependencies: [.product(name: "P256K", package: "swift-secp256k1")]
),
.testTarget(name: "BuzzPushKitTests", dependencies: ["BuzzPushKit"]),
]
)
@@ -0,0 +1,40 @@
import Foundation
public struct APNsRegistrationUpdate: Equatable, Sendable {
public let method: String
public let arguments: [String: String]
public init(method: String, arguments: [String: String]) {
self.method = method
self.arguments = arguments
}
}
public final class APNsRegistrationBuffer {
public private(set) var pending: APNsRegistrationUpdate?
private var deliver: ((APNsRegistrationUpdate) -> Void)?
public init() {}
public func attach(_ deliver: @escaping (APNsRegistrationUpdate) -> Void) {
self.deliver = deliver
flush()
}
public func recordToken(_ token: Data) {
record(APNsRegistrationUpdate(
method: "apnsTokenChanged",
arguments: ["token": token.map { String(format: "%02x", $0) }.joined()]
))
}
public func recordError(_ message: String) {
record(APNsRegistrationUpdate(
method: "apnsRegistrationFailed", arguments: ["message": message]
))
}
private func record(_ update: APNsRegistrationUpdate) {
pending = update
flush()
}
private func flush() {
guard let pending, let deliver else { return }
self.pending = nil
deliver(pending)
}
}
@@ -0,0 +1,143 @@
import CryptoKit
import Foundation
import P256K
public enum NostrHTTPAuthError: Error, Equatable {
case invalidHex
case signingFailed
}
public struct VerifiedNostrEvent: Codable, Equatable, Sendable {
public let id: String
public let pubkey: String
public let createdAt: Int
public let kind: Int
public let tags: [[String]]
public let content: String
public let sig: String
enum CodingKeys: String, CodingKey {
case id, pubkey, kind, tags, content, sig
case createdAt = "created_at"
}
public init(
id: String, pubkey: String, createdAt: Int, kind: Int,
tags: [[String]], content: String, sig: String
) {
self.id = id
self.pubkey = pubkey
self.createdAt = createdAt
self.kind = kind
self.tags = tags
self.content = content
self.sig = sig
}
public func hasValidIDAndSignature() -> Bool {
guard let idBytes = Self.hexBytes(id), idBytes.count == 32,
let pubkeyBytes = Self.hexBytes(pubkey), pubkeyBytes.count == 32,
let signatureBytes = Self.hexBytes(sig), signatureBytes.count == 64,
let serialized = try? Self.canonicalSerialization(
pubkey: pubkey.lowercased(), createdAt: createdAt, kind: kind,
tags: tags, content: content
)
else { return false }
let digest = Array(SHA256.hash(data: serialized))
guard digest == idBytes,
let signature = try? P256K.Schnorr.SchnorrSignature(
dataRepresentation: Data(signatureBytes)
)
else { return false }
var message = digest
let key = P256K.Schnorr.XonlyKey(dataRepresentation: pubkeyBytes)
return key.isValid(signature, for: &message)
}
static func canonicalSerialization(
pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String
) throws -> Data {
try JSONSerialization.data(
withJSONObject: [0, pubkey, createdAt, kind, tags, content],
options: [.withoutEscapingSlashes]
)
}
static func hexBytes(_ value: String) -> [UInt8]? {
guard value.count.isMultiple(of: 2) else { return nil }
var result: [UInt8] = []
result.reserveCapacity(value.count / 2)
var index = value.startIndex
while index < value.endIndex {
let end = value.index(index, offsetBy: 2)
guard let byte = UInt8(value[index..<end], radix: 16) else { return nil }
result.append(byte)
index = end
}
return result
}
static func hex(_ bytes: some Sequence<UInt8>) -> String {
bytes.map { String(format: "%02x", $0) }.joined()
}
}
public enum NostrHTTPAuth {
public static func authorizationHeader(
url: URL,
method: String,
body: Data,
privateKeyHex: String,
createdAt: Int = Int(Date().timeIntervalSince1970),
auxiliaryRandomness: [UInt8]? = nil
) throws -> String {
guard let privateKeyBytes = VerifiedNostrEvent.hexBytes(privateKeyHex),
privateKeyBytes.count == 32
else { throw NostrHTTPAuthError.invalidHex }
do {
let privateKey = try P256K.Schnorr.PrivateKey(
dataRepresentation: privateKeyBytes
)
let pubkey = VerifiedNostrEvent.hex(privateKey.xonly.bytes)
let payload = VerifiedNostrEvent.hex(SHA256.hash(data: body))
let tags = [
["u", url.absoluteString],
["method", method.uppercased()],
["payload", payload],
]
let serialized = try VerifiedNostrEvent.canonicalSerialization(
pubkey: pubkey, createdAt: createdAt, kind: 27235,
tags: tags, content: ""
)
let digest = Array(SHA256.hash(data: serialized))
var message = digest
let signature: P256K.Schnorr.SchnorrSignature
if var randomness = auxiliaryRandomness {
guard randomness.count == 32 else { throw NostrHTTPAuthError.signingFailed }
signature = try privateKey.signature(
message: &message, auxiliaryRand: &randomness
)
} else {
signature = try privateKey.signature(
message: &message, auxiliaryRand: nil
)
}
let event = VerifiedNostrEvent(
id: VerifiedNostrEvent.hex(digest),
pubkey: pubkey,
createdAt: createdAt,
kind: 27235,
tags: tags,
content: "",
sig: VerifiedNostrEvent.hex(signature.dataRepresentation)
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.withoutEscapingSlashes]
return "Nostr " + (try encoder.encode(event)).base64EncodedString()
} catch let error as NostrHTTPAuthError {
throw error
} catch {
throw NostrHTTPAuthError.signingFailed
}
}
}
@@ -0,0 +1,29 @@
import Foundation
import XCTest
@testable import BuzzPushKit
final class APNsRegistrationBufferTests: XCTestCase {
func testReplaysTokenAfterChannelAttachment() {
let buffer = APNsRegistrationBuffer()
buffer.recordToken(Data([0x01, 0xAB, 0x00]))
var delivered: [APNsRegistrationUpdate] = []
buffer.attach { delivered.append($0) }
XCTAssertEqual(delivered, [
APNsRegistrationUpdate(method: "apnsTokenChanged", arguments: ["token": "01ab00"])
])
XCTAssertNil(buffer.pending)
}
func testKeepsLatestUpdateAndDeliversLiveFailures() {
let buffer = APNsRegistrationBuffer()
buffer.recordToken(Data([0x01]))
buffer.recordError("offline")
var delivered: [APNsRegistrationUpdate] = []
buffer.attach { delivered.append($0) }
buffer.recordError("denied")
XCTAssertEqual(delivered, [
APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "offline"]),
APNsRegistrationUpdate(method: "apnsRegistrationFailed", arguments: ["message": "denied"]),
])
}
}
@@ -0,0 +1,76 @@
import CryptoKit
import Foundation
import XCTest
@testable import BuzzPushKit
final class NostrHTTPAuthTests: XCTestCase {
private let privateKey = String(repeating: "0", count: 63) + "1"
private let expectedPubkey =
"79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
func testAuthorizationHeaderConstructsValidNIP98Event() throws {
let body = Data("[{\"kinds\":[9]}]".utf8)
let url = URL(string: "https://relay.example/query")!
let header = try NostrHTTPAuth.authorizationHeader(
url: url,
method: "post",
body: body,
privateKeyHex: privateKey,
createdAt: 1_700_000_000,
auxiliaryRandomness: [UInt8](repeating: 0, count: 32)
)
XCTAssertTrue(header.hasPrefix("Nostr "))
let encoded = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6))))
let event = try JSONDecoder().decode(VerifiedNostrEvent.self, from: encoded)
XCTAssertEqual(event.pubkey, expectedPubkey)
XCTAssertEqual(event.createdAt, 1_700_000_000)
XCTAssertEqual(event.kind, 27235)
XCTAssertEqual(event.content, "")
XCTAssertEqual(event.tags, [
["u", "https://relay.example/query"],
["method", "POST"],
["payload", SHA256.hash(data: body).map { String(format: "%02x", $0) }.joined()],
])
XCTAssertTrue(event.hasValidIDAndSignature())
}
func testEventVerificationRejectsChangedIDSignatureAndContent() throws {
let event = try makeEvent()
XCTAssertTrue(event.hasValidIDAndSignature())
XCTAssertFalse(copy(event, id: String(repeating: "0", count: 64)).hasValidIDAndSignature())
XCTAssertFalse(copy(event, sig: String(repeating: "0", count: 128)).hasValidIDAndSignature())
XCTAssertFalse(copy(event, content: "tampered").hasValidIDAndSignature())
}
private func makeEvent() throws -> VerifiedNostrEvent {
let header = try NostrHTTPAuth.authorizationHeader(
url: URL(string: "https://relay.example/query")!,
method: "POST",
body: Data(),
privateKeyHex: privateKey,
createdAt: 1_700_000_000,
auxiliaryRandomness: [UInt8](repeating: 0, count: 32)
)
let data = try XCTUnwrap(Data(base64Encoded: String(header.dropFirst(6))))
return try JSONDecoder().decode(VerifiedNostrEvent.self, from: data)
}
private func copy(
_ event: VerifiedNostrEvent,
id: String? = nil,
content: String? = nil,
sig: String? = nil
) -> VerifiedNostrEvent {
VerifiedNostrEvent(
id: id ?? event.id,
pubkey: event.pubkey,
createdAt: event.createdAt,
kind: event.kind,
tags: event.tags,
content: content ?? event.content,
sig: sig ?? event.sig
)
}
}
@@ -1,4 +1,6 @@
import BuzzPushKit
import Foundation
import Security
import UserNotifications
final class NotificationService: UNNotificationServiceExtension {
@@ -58,153 +60,137 @@ protocol BuzzPushNotificationResolving {
}
final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
private let snapshotFile = "push-communities.json"
private let session: URLSession
private let appGroupIdentifier: String?
private let keychainAccessGroup: String?
private let defaults: UserDefaults?
init(
session: URLSession = .shared,
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier")
as? String
appGroupIdentifier: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String,
keychainAccessGroup: String? = Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup") as? String
) {
self.session = session
self.appGroupIdentifier = appGroupIdentifier
self.keychainAccessGroup = keychainAccessGroup
defaults = appGroupIdentifier.flatMap(UserDefaults.init(suiteName:))
}
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
guard let community = loadCommunities().first(where: { $0.pubkey?.isEmpty == false }) else {
completion(nil)
return
let communities = loadCommunities().filter {
$0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil
}
guard !communities.isEmpty else { completion(nil); return }
let group = DispatchGroup()
let lock = NSLock()
var candidates: [(BuzzPushResolution, VerifiedNostrEvent, BuzzPushCommunity)] = []
for community in communities {
group.enter()
query(community) { candidate in
if let candidate {
lock.lock(); candidates.append((candidate.0, candidate.1, community)); lock.unlock()
}
group.leave()
}
}
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
guard let self else { return }
let newest = candidates.max {
$0.1.createdAt == $1.1.createdAt ? $0.1.id < $1.1.id : $0.1.createdAt < $1.1.createdAt
}
for candidate in candidates {
self.defaults?.set(candidate.1.createdAt, forKey: self.watermarkKey(candidate.2.id))
}
completion(newest?.0)
}
}
// The NIP-PL APNs payload intentionally carries no relay or event id. The
// service extension therefore performs a bounded catch-up against locally
// configured origins and only replaces the fixed placeholder if authoritative
// relay data is available before the NSE deadline.
let filters: [[String: Any]] = [
[
"kinds": [9, 40002, 45001, 45003],
"#p": [community.pubkey!],
"limit": 10,
]
]
guard let body = try? JSONSerialization.data(withJSONObject: filters) else {
completion(nil)
return
private func query(
_ community: BuzzPushCommunity,
completion: @escaping ((BuzzPushResolution, VerifiedNostrEvent)?) -> Void
) {
guard let privateKey = loadPrivateKey(communityID: community.id), let pubkey = community.pubkey else {
completion(nil); return
}
var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10]
let watermark = defaults?.integer(forKey: watermarkKey(community.id)) ?? 0
if watermark > 0 { filter["since"] = watermark + 1 }
guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return }
let url = URL(string: "/query", relativeTo: community.relayURL)!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.httpMethod = "POST"; request.httpBody = body; request.timeoutInterval = 8
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = body
guard let auth = try? NostrHTTPAuth.authorizationHeader(
url: url, method: "POST", body: body, privateKeyHex: privateKey
) else { completion(nil); return }
request.setValue(auth, forHTTPHeaderField: "Authorization")
session.dataTask(with: request) { data, response, _ in
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode),
let data,
let events = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
!events.isEmpty
else {
completion(nil)
return
}
let resolution = Self.decodeResolution(
events: events,
community: community
)
completion(resolution)
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { completion(nil); return }
completion(Self.decodeResolution(events: events.filter { $0.hasValidIDAndSignature() }, community: community))
}.resume()
}
private static func decodeResolution(events: [[String: Any]], community: BuzzPushCommunity)
-> BuzzPushResolution?
{
guard let myPubkey = community.pubkey?.lowercased() else { return nil }
let candidates = events.compactMap(BuzzPushEvent.init(json:)).filter { event in
event.pubkey.lowercased() != myPubkey && [9, 40002, 45001, 45003].contains(event.kind)
}
guard
let event = candidates.sorted(by: { left, right in
if left.createdAt != right.createdAt { return left.createdAt > right.createdAt }
return left.id < right.id
}).first
else { return nil }
private static func decodeResolution(
events: [VerifiedNostrEvent], community: BuzzPushCommunity
) -> (BuzzPushResolution, VerifiedNostrEvent)? {
guard let mine = community.pubkey?.lowercased() else { return nil }
let event = events.filter {
$0.pubkey.lowercased() != mine && [9, 40002, 45001, 45003].contains($0.kind)
}.sorted {
$0.createdAt == $1.createdAt ? $0.id < $1.id : $0.createdAt > $1.createdAt
}.first
guard let event else { return nil }
let body = previewBody(event.content)
guard !body.isEmpty else { return nil }
return BuzzPushResolution(
title: shortPubkey(event.pubkey),
body: body,
subtitle: community.name,
threadIdentifier: event.channelId ?? community.id
)
let channel = event.tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
return (BuzzPushResolution(
title: shortPubkey(event.pubkey), body: body, subtitle: community.name,
threadIdentifier: channel ?? community.id
), event)
}
private static func previewBody(_ content: String) -> String {
var result = content.replacingOccurrences(
of: #"```[\s\S]*?```"#,
with: "[code]",
options: .regularExpression
)
var result = content.replacingOccurrences(of: #"```[\s\S]*?```"#, with: "[code]", options: .regularExpression)
result = result.replacingOccurrences(of: #"`([^`]*)`"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(
of: #"!\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(
of: #"\[([^\]]+)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(
of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard result.count > 180 else { return result }
return String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…"
result = result.replacingOccurrences(of: #"!?\[([^\]]*)\]\([^)]*\)"#, with: "$1", options: .regularExpression)
result = result.replacingOccurrences(of: #"https?://\S+"#, with: "[link]", options: .regularExpression)
result = result.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression).trimmingCharacters(in: .whitespacesAndNewlines)
return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result
}
private static func shortPubkey(_ pubkey: String) -> String {
guard pubkey.count > 8 else { return pubkey }
return String(pubkey.prefix(8)) + "…"
pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey
}
private func watermarkKey(_ id: String) -> String { "buzz.push.watermark.\(id)" }
private func loadPrivateKey(communityID: String) -> String? {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: "buzz.push.nse.signing",
kSecAttrAccount as String: communityID,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
if let keychainAccessGroup, !keychainAccessGroup.isEmpty { query[kSecAttrAccessGroup as String] = keychainAccessGroup }
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
private func loadCommunities() -> [BuzzPushCommunity] {
guard let appGroupIdentifier,
let container = FileManager.default.containerURL(
forSecurityApplicationGroupIdentifier: appGroupIdentifier)
else { return [] }
let url = container.appendingPathComponent(snapshotFile)
guard let data = try? Data(contentsOf: url),
let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier),
let data = try? Data(contentsOf: container.appendingPathComponent("push-communities.json")),
let decoded = try? JSONDecoder().decode(BuzzPushSnapshot.self, from: data)
else { return [] }
return decoded.communities
}
}
struct BuzzPushEvent {
let id: String
let pubkey: String
let createdAt: Int
let kind: Int
let tags: [[String]]
let content: String
init?(json: [String: Any]) {
guard let id = json["id"] as? String,
let pubkey = json["pubkey"] as? String,
let createdAt = json["created_at"] as? Int,
let kind = json["kind"] as? Int,
let tags = json["tags"] as? [[String]],
let content = json["content"] as? String
else { return nil }
self.id = id
self.pubkey = pubkey
self.createdAt = createdAt
self.kind = kind
self.tags = tags
self.content = content
}
var channelId: String? {
tags.first { $0.count >= 2 && $0[0] == "h" }?[1]
}
}
struct BuzzPushSnapshot: Decodable {
let communities: [BuzzPushCommunity]
}
@@ -9,12 +9,15 @@
/* Begin PBXBuildFile section */
BZZ00000000000000000001 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000006 /* NotificationService.swift */; };
BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000009 /* NotificationService.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
BZZ00000000000000000020 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
BZZ00000000000000000025 /* BuzzPushKit in Frameworks */ = {isa = PBXBuildFile; productRef = BZZ00000000000000000022 /* BuzzPushKit */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
BZZ00000000000000000023 /* PushNativeState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BZZ00000000000000000024 /* PushNativeState.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -79,6 +82,7 @@
57A155722F02B92C397E5AE2 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
7CF2415588E96D5723581BA9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
@@ -99,6 +103,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BZZ00000000000000000020 /* BuzzPushKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -114,6 +119,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BZZ00000000000000000025 /* BuzzPushKit in Frameworks */,
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -188,6 +194,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
BZZ00000000000000000024 /* PushNativeState.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -255,6 +262,9 @@
BZZ00000000000000000013 /* PBXTargetDependency */,
);
name = Runner;
packageProductDependencies = (
BZZ00000000000000000022 /* BuzzPushKit */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -272,6 +282,9 @@
dependencies = (
);
name = NotificationService;
packageProductDependencies = (
BZZ00000000000000000022 /* BuzzPushKit */,
);
productName = NotificationService;
productReference = BZZ00000000000000000009 /* NotificationService.appex */;
productType = "com.apple.product-type.app-extension";
@@ -308,6 +321,9 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -464,6 +480,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
BZZ00000000000000000023 /* PushNativeState.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
@@ -484,6 +501,21 @@
};
/* End PBXTargetDependency section */
/* Begin XCLocalSwiftPackageReference section */
BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = BuzzPushKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
BZZ00000000000000000022 /* BuzzPushKit */ = {
isa = XCSwiftPackageProductDependency;
package = BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */;
productName = BuzzPushKit;
};
/* End XCSwiftPackageProductDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
+15 -7
View File
@@ -1,4 +1,5 @@
import AVFoundation
import BuzzPushKit
import Flutter
import UIKit
import UserNotifications
@@ -7,6 +8,7 @@ import UserNotifications
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
private var pushChannel: FlutterMethodChannel?
private let apnsRegistrationBuffer = APNsRegistrationBuffer()
private var appGroupIdentifier: String? {
Bundle.main.object(forInfoDictionaryKey: "BuzzAppGroupIdentifier") as? String
}
@@ -43,6 +45,9 @@ import UserNotifications
pushChannel?.setMethodCallHandler { [weak self] call, result in
self?.handlePushMethodCall(call, result: result)
}
apnsRegistrationBuffer.attach { [weak self] update in
self?.pushChannel?.invokeMethod(update.method, arguments: update.arguments)
}
}
override func application(
@@ -50,9 +55,7 @@ import UserNotifications
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken)
pushChannel?.invokeMethod(
"apnsTokenChanged",
arguments: ["token": deviceToken.map { String(format: "%02x", $0) }.joined()])
apnsRegistrationBuffer.recordToken(deviceToken)
}
override func application(
@@ -60,8 +63,7 @@ import UserNotifications
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
pushChannel?.invokeMethod(
"apnsRegistrationFailed", arguments: ["message": error.localizedDescription])
apnsRegistrationBuffer.recordError(error.localizedDescription)
}
private func handlePushMethodCall(
@@ -71,7 +73,8 @@ import UserNotifications
switch call.method {
case "saveCommunitySnapshot":
guard let arguments = call.arguments as? [String: Any],
let communities = arguments["communities"] as? [[String: Any]]
let communities = arguments["communities"] as? [[String: Any]],
let signingKeys = arguments["signingKeys"] as? [String: String]
else {
result(
FlutterError(
@@ -80,11 +83,16 @@ import UserNotifications
}
do {
try savePushCommunitySnapshot(communities)
try BuzzPushKeychain.replace(
signingKeys: signingKeys,
accessGroup: Bundle.main.object(forInfoDictionaryKey: "BuzzKeychainAccessGroup")
as? String
)
result(nil)
} catch {
result(
FlutterError(
code: "save_failed", message: "Unable to save push community snapshot.",
code: "save_failed", message: "Unable to save push community credentials.",
details: error.localizedDescription))
}
default:
+38
View File
@@ -0,0 +1,38 @@
import Foundation
import Security
enum BuzzPushKeychain {
static let service = "buzz.push.nse.signing"
static func replace(signingKeys: [String: String], accessGroup: String?) throws {
var query = baseQuery(accessGroup: accessGroup)
SecItemDelete(query as CFDictionary)
for (communityID, privateKeyHex) in signingKeys {
query[kSecAttrAccount as String] = communityID
query[kSecValueData as String] = Data(privateKeyHex.utf8)
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
let status = SecItemAdd(query as CFDictionary, nil)
guard status == errSecSuccess else {
SecItemDelete(baseQuery(accessGroup: accessGroup) as CFDictionary)
throw NSError(
domain: NSOSStatusErrorDomain, code: Int(status),
userInfo: [NSLocalizedDescriptionKey: SecCopyErrorMessageString(status, nil) ?? "Keychain write failed" as CFString]
)
}
query.removeValue(forKey: kSecValueData as String)
query.removeValue(forKey: kSecAttrAccessible as String)
query.removeValue(forKey: kSecAttrAccount as String)
}
}
private static func baseQuery(accessGroup: String?) -> [String: Any] {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
]
if let accessGroup, !accessGroup.isEmpty {
query[kSecAttrAccessGroup as String] = accessGroup
}
return query
}
}
+40
View File
@@ -1,3 +1,4 @@
import 'package:nostr/nostr.dart' as nostr;
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
@@ -8,6 +9,11 @@ import 'push_models.dart';
const _channel = MethodChannel('buzz/push');
/// Latest APNs registration state, including callbacks replayed by iOS after
/// the Flutter method channel attaches.
final apnsDeviceToken = ValueNotifier<String?>(null);
final apnsRegistrationError = ValueNotifier<String?>(null);
Future<void> registerBuzzPushCommunitySnapshot(
List<Community> communities,
) async {
@@ -22,8 +28,24 @@ Future<void> registerBuzzPushCommunitySnapshot(
pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec),
),
];
final signingKeys = <String, String>{};
for (final community in communities) {
final nsec = community.nsec;
if (nsec == null || nsec.isEmpty) continue;
try {
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec ||
decoded.data.length != 64) {
continue;
}
signingKeys[community.id] = decoded.data;
} catch (_) {
// Native storage is fail-closed; malformed keys are never exported.
}
}
await _channel.invokeMethod<void>('saveCommunitySnapshot', {
'communities': [for (final snapshot in snapshots) snapshot.toJson()],
'signingKeys': signingKeys,
});
} on MissingPluginException {
// Flutter tests and non-Runner embeddings do not install the native bridge.
@@ -76,6 +98,24 @@ Future<BuzzPushResolution?> resolveBuzzPushPayload(
void installBuzzPushMethodHandler() {
_channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'apnsTokenChanged':
final args = call.arguments;
if (args is Map) {
final token = args['token'];
if (token is String && token.isNotEmpty) {
apnsDeviceToken.value = token;
apnsRegistrationError.value = null;
}
}
return null;
case 'apnsRegistrationFailed':
final args = call.arguments;
final message = args is Map ? args['message'] : null;
apnsRegistrationError.value = message is String && message.isNotEmpty
? message
: 'APNs registration failed';
debugPrint('APNs registration failed: ${apnsRegistrationError.value}');
return null;
case 'resolveNotification':
final args = call.arguments;
if (args is! Map) return null;
@@ -0,0 +1,41 @@
import 'package:buzz/shared/push/push_bridge.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
const _channel = MethodChannel('buzz/push');
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
apnsDeviceToken.value = null;
apnsRegistrationError.value = null;
installBuzzPushMethodHandler();
});
test('captures APNs token success and clears the previous error', () async {
apnsRegistrationError.value = 'old error';
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
const MethodCall('apnsTokenChanged', {'token': '01ab'}),
),
(_) {},
);
expect(apnsDeviceToken.value, '01ab');
expect(apnsRegistrationError.value, isNull);
});
test('exposes APNs registration failure', () async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
_channel.name,
_channel.codec.encodeMethodCall(
const MethodCall('apnsRegistrationFailed', {'message': 'denied'}),
),
(_) {},
);
expect(apnsRegistrationError.value, 'denied');
});
}