mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
parent
e4b1e81312
commit
4c99a83075
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user