diff --git a/crates/buzz-push-gateway/src/http.rs b/crates/buzz-push-gateway/src/http.rs index 0564972c07..9005a0f600 100644 --- a/crates/buzz-push-gateway/src/http.rs +++ b/crates/buzz-push-gateway/src/http.rs @@ -774,3 +774,146 @@ pub fn router_with_metrics( } (public, health) } + +/// Known-answer vectors for the exact App Attest transcript bytes defined by +/// NIP-PL ("Exact App Attest transcript construction"). The fixture file is +/// shared ground truth with client-side canonical encoders (the Swift NIP-PL +/// iOS client): a client encoder that fails to reproduce these bytes exactly +/// fails every enroll/delegate/rotate/revoke call with `invalid_attestation`. +#[cfg(test)] +mod transcript_vector_tests { + use super::*; + use sha2::{Digest, Sha256}; + + const VECTORS_JSON: &str = include_str!("../tests/vectors/app_attest_transcripts.json"); + + // Deterministic fixture inputs mirrored in the vector file's `inputs`. + const CHALLENGE_ID: uuid::Uuid = + uuid::Uuid::from_u128(0x1111_1111_1111_4111_8111_1111_1111_1111); + const INSTALLATION: uuid::Uuid = + uuid::Uuid::from_u128(0x2222_2222_2222_4222_8222_2222_2222_2222); + // base64url-no-pad of bytes 0x00..=0x1f. + const CHALLENGE: &str = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"; + // Standard base64 (padded) of 32 bytes of 0xAA. + const KEY_ID: &str = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo="; + // 32-byte APNs token, lowercase hex. + const ENDPOINT: &str = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + const RELAY_PUBKEY: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn assert_vector(name: &str, actual: &str) { + let file: serde_json::Value = serde_json::from_str(VECTORS_JSON).unwrap(); + let vector = file["vectors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == name) + .unwrap_or_else(|| panic!("vector {name} missing from fixture")); + assert_eq!( + actual, + vector["transcript"].as_str().unwrap(), + "{name} bytes" + ); + assert_eq!( + hex::encode(Sha256::digest(actual.as_bytes())), + vector["sha256"].as_str().unwrap(), + "{name} sha256" + ); + } + + #[test] + fn fixture_encodings_match_their_raw_bytes() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let challenge_bytes: Vec = (0u8..32).collect(); + assert_eq!(URL_SAFE_NO_PAD.encode(&challenge_bytes), CHALLENGE); + assert_eq!(STANDARD.encode([0xAAu8; 32]), KEY_ID); + assert_eq!(hex::decode(ENDPOINT).unwrap().len(), 32); + } + + #[test] + fn enroll_transcript_vector() { + let t = EnrollTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + key_id: KEY_ID, + app_profile: AppProfile::BuzzIosProduction, + endpoint: ENDPOINT, + endpoint_epoch: 1, + expires_at: 1_752_624_000, + }; + assert_vector("enroll", &transcript("buzz.push.enroll.v1", &t).unwrap()); + } + + #[test] + fn delegate_transcript_vector() { + let t = DelegateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + generation: 1, + relay_pubkey: RELAY_PUBKEY, + not_before: 1_752_620_000, + expires_at: 1_752_624_000, + }; + assert_vector( + "delegate", + &transcript("buzz.push.delegate.v1", &t).unwrap(), + ); + } + + #[test] + fn rotate_endpoint_transcript_vector() { + let t = RotateTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/endpoint", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + endpoint: ENDPOINT, + }; + assert_vector( + "rotate_endpoint", + &transcript("buzz.push.rotate-endpoint.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_delegation_transcript_vector() { + let t = RevokeDelegationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/delegations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + relay_pubkey: RELAY_PUBKEY, + generation: 2, + }; + assert_vector( + "revoke_delegation", + &transcript("buzz.push.revoke-delegation.v1", &t).unwrap(), + ); + } + + #[test] + fn revoke_installation_transcript_vector() { + let t = RevokeInstallationTranscript { + v: 1, + audience: "https://push.buzz.xyz/v1/installations/revoke", + challenge_id: CHALLENGE_ID, + challenge: CHALLENGE, + installation_handle: INSTALLATION, + endpoint_epoch: 1, + new_endpoint_epoch: 2, + }; + assert_vector( + "revoke_installation", + &transcript("buzz.push.revoke-installation.v1", &t).unwrap(), + ); + } +} diff --git a/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json new file mode 100644 index 0000000000..7c2cedf6eb --- /dev/null +++ b/crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json @@ -0,0 +1,48 @@ +{ + "description": "Known-answer vectors for the exact App Attest transcript bytes defined by NIP-PL ('Exact App Attest transcript construction'). Generated by the gateway's own transcript encoder (crates/buzz-push-gateway/src/http.rs transcript()). Client canonical encoders (Swift NIP-PL iOS client) MUST reproduce `transcript` byte-for-byte; `sha256` is the hex digest of those UTF-8 bytes (the App Attest clientDataHash input for assertion routes, and the exact clientData for enrollment).", + "inputs": { + "challenge_id": "11111111-1111-4111-8111-111111111111", + "installation_handle": "22222222-2222-4222-8222-222222222222", + "challenge": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8", + "challenge_note": "base64url-no-pad of bytes 0x00..0x1f", + "key_id": "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=", + "key_id_note": "standard base64 (padded) of 32 bytes of 0xAA", + "app_profile": "buzz-ios-production", + "endpoint": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", + "relay_pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "not_before": 1752620000, + "expires_at": 1752624000 + }, + "vectors": [ + { + "name": "enroll", + "domain": "buzz.push.enroll.v1", + "transcript": "buzz.push.enroll.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"key_id\":\"qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=\",\"app_profile\":\"buzz-ios-production\",\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\",\"endpoint_epoch\":1,\"expires_at\":1752624000}", + "sha256": "f8f41fd671918aa0b7dc8eca704b26a5c2748270c2efc7db7348e36a4cccd189" + }, + { + "name": "delegate", + "domain": "buzz.push.delegate.v1", + "transcript": "buzz.push.delegate.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"generation\":1,\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"not_before\":1752620000,\"expires_at\":1752624000}", + "sha256": "7466177cc2dc2a4f9a075fdbb461531692fc858778a171a5862b855cccfaa059" + }, + { + "name": "rotate_endpoint", + "domain": "buzz.push.rotate-endpoint.v1", + "transcript": "buzz.push.rotate-endpoint.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/endpoint\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2,\"endpoint\":\"0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20\"}", + "sha256": "601aba0c8d4021ddf97ce1e434b9c7ad1e051bf02a44929aaebd8c6bd724e7b3" + }, + { + "name": "revoke_delegation", + "domain": "buzz.push.revoke-delegation.v1", + "transcript": "buzz.push.revoke-delegation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/delegations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"relay_pubkey\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"generation\":2}", + "sha256": "d6bcd4b25235adcb519ef189820b08dd0386fc752fd4e4c77bc3ffb7a519a84a" + }, + { + "name": "revoke_installation", + "domain": "buzz.push.revoke-installation.v1", + "transcript": "buzz.push.revoke-installation.v1\n{\"v\":1,\"audience\":\"https://push.buzz.xyz/v1/installations/revoke\",\"challenge_id\":\"11111111-1111-4111-8111-111111111111\",\"challenge\":\"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8\",\"installation_handle\":\"22222222-2222-4222-8222-222222222222\",\"endpoint_epoch\":1,\"new_endpoint_epoch\":2}", + "sha256": "0ba51827af6586a5e1230e9b770b99544fb342efb55db3ab1ce499cf24a893c8" + } + ] +} diff --git a/mobile/ios/BuzzPushKit/Package.swift b/mobile/ios/BuzzPushKit/Package.swift new file mode 100644 index 0000000000..e3d4fe5b3c --- /dev/null +++ b/mobile/ios/BuzzPushKit/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "BuzzPushKit", + platforms: [.iOS(.v15), .macOS(.v12)], + 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", + dependencies: [.product(name: "P256K", package: "swift-secp256k1")] + ), + .testTarget(name: "BuzzPushKitTests", dependencies: ["BuzzPushKit"]), + ] +) diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift new file mode 100644 index 0000000000..174fc90bb2 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/APNsRegistrationBuffer.swift @@ -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) + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift new file mode 100644 index 0000000000..fd0bc34d69 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzPushTranscript.swift @@ -0,0 +1,227 @@ +import Foundation + +/// Errors thrown by the canonical transcript encoder. +public enum BuzzPushTranscriptError: Error, Equatable { + /// A string field contained non-ASCII scalars. NIP-PL admits only ASCII + /// authority-bearing strings; rather than guess at UTF-8-vs-escaping + /// behavior we fail closed. + case nonASCIIInput(field: String) +} + +/// Canonical NIP-PL App Attest transcript encoder. +/// +/// NIP-PL ("Exact App Attest transcript construction") pins the exact bytes +/// every App Attest operation signs: +/// +/// + "\n" + +/// +/// The JSON object has no insignificant whitespace, members appear in a fixed +/// per-route order, integers use shortest decimal notation, and strings use +/// minimal JSON escaping (quotation mark, reverse solidus, U+0000..U+001F). +/// The gateway builds the same bytes with serde_json and compares hashes, so +/// any byte difference is a silent `401 invalid_attestation`. This encoder is +/// hand-rolled for that reason: `JSONSerialization` escapes `/` as `\/` and +/// does not guarantee member order, so it must never be used for transcripts. +/// +/// Ground truth: `crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`, +/// generated and asserted by the gateway's own encoder. The tests in this +/// package replay those vectors byte-for-byte. +/// +/// The `audience` member of each transcript is a **fixed protocol constant** +/// defined by NIP-PL (`https://push.buzz.xyz/v1/...`). It is a cross-route +/// domain-separation string, not a deployment URL: the gateway hardcodes it +/// regardless of where it is hosted, so clients must never derive it from a +/// discovered gateway base URL or relay host. +public enum BuzzPushTranscript { + // MARK: Domains + + public static let enrollDomain = "buzz.push.enroll.v1" + public static let delegateDomain = "buzz.push.delegate.v1" + public static let rotateEndpointDomain = "buzz.push.rotate-endpoint.v1" + public static let revokeDelegationDomain = "buzz.push.revoke-delegation.v1" + public static let revokeInstallationDomain = "buzz.push.revoke-installation.v1" + + // MARK: Fixed audiences (protocol constants, see type docs) + + public static let enrollAudience = "https://push.buzz.xyz/v1/installations" + public static let delegateAudience = "https://push.buzz.xyz/v1/delegations" + public static let rotateEndpointAudience = "https://push.buzz.xyz/v1/installations/endpoint" + public static let revokeDelegationAudience = "https://push.buzz.xyz/v1/delegations/revoke" + public static let revokeInstallationAudience = "https://push.buzz.xyz/v1/installations/revoke" + + /// Wire version pinned by NIP-PL. Every transcript carries `"v":1`. + public static let wireVersion: Int64 = 1 + + // MARK: Transcripts + + /// `buzz.push.enroll.v1` — these exact bytes are the App Attest + /// `clientData` supplied to attestation verification. + public static func enroll( + challengeId: UUID, + challenge: String, + keyId: String, + appProfile: String, + endpoint: String, + endpointEpoch: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.enrollAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + try o.string("key_id", keyId, field: "key_id") + try o.string("app_profile", appProfile, field: "app_profile") + try o.string("endpoint", endpoint, field: "endpoint") + o.int("endpoint_epoch", endpointEpoch) + o.int("expires_at", expiresAt) + return encode(domain: enrollDomain, object: o) + } + + /// `buzz.push.delegate.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func delegate( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + generation: Int64, + relayPubkey: String, + notBefore: Int64, + expiresAt: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.delegateAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("generation", generation) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("not_before", notBefore) + o.int("expires_at", expiresAt) + return encode(domain: delegateDomain, object: o) + } + + /// `buzz.push.rotate-endpoint.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func rotateEndpoint( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64, + endpoint: String + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.rotateEndpointAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + try o.string("endpoint", endpoint, field: "endpoint") + return encode(domain: rotateEndpointDomain, object: o) + } + + /// `buzz.push.revoke-delegation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeDelegation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + relayPubkey: String, + generation: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeDelegationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + try o.string("relay_pubkey", relayPubkey, field: "relay_pubkey") + o.int("generation", generation) + return encode(domain: revokeDelegationDomain, object: o) + } + + /// `buzz.push.revoke-installation.v1` — `SHA-256(bytes)` is the assertion + /// `clientDataHash`. + public static func revokeInstallation( + challengeId: UUID, + challenge: String, + installationHandle: UUID, + endpointEpoch: Int64, + newEndpointEpoch: Int64 + ) throws -> Data { + var o = CanonicalObject() + o.int("v", wireVersion) + try o.string("audience", Self.revokeInstallationAudience) + o.uuid("challenge_id", challengeId) + try o.string("challenge", challenge, field: "challenge") + o.uuid("installation_handle", installationHandle) + o.int("endpoint_epoch", endpointEpoch) + o.int("new_endpoint_epoch", newEndpointEpoch) + return encode(domain: revokeInstallationDomain, object: o) + } + + // MARK: Internals + + private static func encode(domain: String, object: CanonicalObject) -> Data { + Data((domain + "\n" + object.encoded()).utf8) + } + + /// Ordered compact JSON object writer. Emission order == call order; + /// there is deliberately no sorting, no whitespace, and no `Encodable` + /// round-trip anywhere near these bytes. + struct CanonicalObject { + private var members: [String] = [] + + mutating func int(_ key: String, _ value: Int64) { + // Swift's Int64 description is shortest decimal notation, which + // is what the spec pins and what serde_json emits. + members.append("\"\(key)\":\(value)") + } + + mutating func uuid(_ key: String, _ value: UUID) { + // Canonical lowercase-hyphenated form, matching uuid::Uuid's + // serde serialization. Foundation's uuidString is uppercase. + members.append("\"\(key)\":\"\(value.uuidString.lowercased())\"") + } + + mutating func string(_ key: String, _ value: String, field: String? = nil) throws { + members.append("\"\(key)\":\"\(try Self.escape(value, field: field ?? key))\"") + } + + func encoded() -> String { + "{" + members.joined(separator: ",") + "}" + } + + /// Minimal JSON string escaping, byte-identical to serde_json: + /// `"` and `\` get two-character escapes; U+0008, U+0009, U+000A, + /// U+000C, U+000D get their short forms; the remaining C0 controls + /// get lowercase `\u00xx`. Nothing else is escaped (in particular + /// `/` is NOT escaped — the JSONSerialization behavior that makes it + /// unusable here). Non-ASCII input is rejected outright. + static func escape(_ s: String, field: String) throws -> String { + var out = String() + out.reserveCapacity(s.count) + for scalar in s.unicodeScalars { + switch scalar.value { + case 0x22: out += "\\\"" + case 0x5C: out += "\\\\" + case 0x08: out += "\\b" + case 0x09: out += "\\t" + case 0x0A: out += "\\n" + case 0x0C: out += "\\f" + case 0x0D: out += "\\r" + case 0x00...0x1F: out += String(format: "\\u%04x", scalar.value) + case 0x20...0x7E: out.unicodeScalars.append(scalar) + default: throw BuzzPushTranscriptError.nonASCIIInput(field: field) + } + } + return out + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift new file mode 100644 index 0000000000..3912370456 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/NostrHTTPAuth.swift @@ -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..) -> 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 + } + } +} diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushWatermark.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushWatermark.swift new file mode 100644 index 0000000000..34367c8a95 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/PushWatermark.swift @@ -0,0 +1,51 @@ +import Foundation + +public enum PushWatermark { + /// Nostr permits modest clock drift, but an authenticated author must not + /// be able to pin notification queries arbitrarily far into the future. + public static let allowedFutureSkewSeconds = 300 + public static let keyPrefix = "buzz.push.watermark." + + public static func key(communityID: String) -> String { + keyPrefix + communityID + } + + public static func persistedTimestamp( + eventTimestamp: Int, + now: Int = Int(Date().timeIntervalSince1970), + allowedFutureSkew: Int = allowedFutureSkewSeconds + ) -> Int { + min(eventTimestamp, now + allowedFutureSkew) + } + + public static func isAcceptable( + eventTimestamp: Int, + now: Int = Int(Date().timeIntervalSince1970), + allowedFutureSkew: Int = allowedFutureSkewSeconds + ) -> Bool { + eventTimestamp <= now + allowedFutureSkew + } + + public static func queryTimestamp( + storedWatermark: Int, + now: Int = Int(Date().timeIntervalSince1970) + ) -> Int { + min(storedWatermark, now) + } + + /// Nostr `since` is inclusive. Keeping the watermark itself allows a later + /// event created in the same second to remain queryable. + public static func querySince(watermark: Int) -> Int? { + watermark > 0 ? watermark : nil + } + + public static func staleKeys( + in storedKeys: [String], + activeCommunityIDs: Set + ) -> [String] { + storedKeys.filter { key in + guard key.hasPrefix(keyPrefix) else { return false } + return !activeCommunityIDs.contains(String(key.dropFirst(keyPrefix.count))) + } + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift new file mode 100644 index 0000000000..657c925e17 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/APNsRegistrationBufferTests.swift @@ -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"]), + ]) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift new file mode 100644 index 0000000000..d4b0fd357a --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzPushTranscriptTests.swift @@ -0,0 +1,158 @@ +import CryptoKit +import Foundation +import XCTest + +@testable import BuzzPushKit + +/// Replays the gateway-generated known-answer vectors +/// (`crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json`) +/// against the Swift canonical encoder. Byte-for-byte transcript equality and +/// SHA-256 equality are both asserted, so a drift on either side breaks a test +/// instead of silently stranding iOS clients with `401 invalid_attestation`. +final class BuzzPushTranscriptTests: XCTestCase { + // MARK: Fixture + + struct Fixture: Decodable { + struct Vector: Decodable { + let name: String + let domain: String + let transcript: String + let sha256: String + } + + let vectors: [Vector] + } + + static let fixture: Fixture = { + // Tests/BuzzPushKitTests/… → repo root is five levels up from this file's dir. + let here = URL(fileURLWithPath: #filePath) + let repoRoot = here + .deletingLastPathComponent() // BuzzPushKitTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // BuzzPushKit + .deletingLastPathComponent() // ios + .deletingLastPathComponent() // mobile + .deletingLastPathComponent() // repo root + let path = repoRoot + .appendingPathComponent("crates/buzz-push-gateway/tests/vectors/app_attest_transcripts.json") + let data = try! Data(contentsOf: path) + return try! JSONDecoder().decode(Fixture.self, from: data) + }() + + // Deterministic inputs mirroring the fixture's `inputs` block. + static let challengeId = UUID(uuidString: "11111111-1111-4111-8111-111111111111")! + static let installationHandle = UUID(uuidString: "22222222-2222-4222-8222-222222222222")! + static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" + static let keyId = "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqo=" + static let appProfile = "buzz-ios-production" + static let endpoint = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20" + static let relayPubkey = String(repeating: "a", count: 64) + static let notBefore: Int64 = 1_752_620_000 + static let expiresAt: Int64 = 1_752_624_000 + + private func assertMatchesVector(_ name: String, _ bytes: Data, + file: StaticString = #filePath, line: UInt = #line) throws { + guard let vector = Self.fixture.vectors.first(where: { $0.name == name }) else { + XCTFail("missing fixture vector \(name)", file: file, line: line) + return + } + XCTAssertEqual(String(decoding: bytes, as: UTF8.self), vector.transcript, + "\(name) transcript bytes drifted from gateway ground truth", + file: file, line: line) + let digest = SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() + XCTAssertEqual(digest, vector.sha256, + "\(name) sha256 drifted from gateway ground truth", + file: file, line: line) + } + + // MARK: Known-answer vectors + + func testEnrollVector() throws { + try assertMatchesVector("enroll", BuzzPushTranscript.enroll( + challengeId: Self.challengeId, + challenge: Self.challenge, + keyId: Self.keyId, + appProfile: Self.appProfile, + endpoint: Self.endpoint, + endpointEpoch: 1, + expiresAt: Self.expiresAt + )) + } + + func testDelegateVector() throws { + try assertMatchesVector("delegate", BuzzPushTranscript.delegate( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + generation: 1, + relayPubkey: Self.relayPubkey, + notBefore: Self.notBefore, + expiresAt: Self.expiresAt + )) + } + + func testRotateEndpointVector() throws { + try assertMatchesVector("rotate_endpoint", BuzzPushTranscript.rotateEndpoint( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2, + endpoint: Self.endpoint + )) + } + + func testRevokeDelegationVector() throws { + try assertMatchesVector("revoke_delegation", BuzzPushTranscript.revokeDelegation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + relayPubkey: Self.relayPubkey, + generation: 2 + )) + } + + func testRevokeInstallationVector() throws { + try assertMatchesVector("revoke_installation", BuzzPushTranscript.revokeInstallation( + challengeId: Self.challengeId, + challenge: Self.challenge, + installationHandle: Self.installationHandle, + endpointEpoch: 1, + newEndpointEpoch: 2 + )) + } + + func testAllFixtureVectorsCovered() { + XCTAssertEqual( + Set(Self.fixture.vectors.map(\.name)), + ["enroll", "delegate", "rotate_endpoint", "revoke_delegation", "revoke_installation"], + "fixture gained or lost a vector; add/remove the matching known-answer test" + ) + } + + // MARK: Escaping edges (the exact JSONSerialization failure modes) + + func testSolidusIsNotEscaped() throws { + // The whole reason this encoder exists: '/' must pass through raw. + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("https://push.buzz.xyz/v1", field: "audience"), + "https://push.buzz.xyz/v1") + } + + func testMinimalEscaping() throws { + XCTAssertEqual(try BuzzPushTranscript.CanonicalObject.escape("a\"b\\c\u{08}\u{09}\u{0A}\u{0C}\u{0D}\u{01}", field: "x"), + "a\\\"b\\\\c\\b\\t\\n\\f\\r\\u0001") + } + + func testNonASCIIRejected() { + XCTAssertThrowsError(try BuzzPushTranscript.CanonicalObject.escape("caf\u{00E9}", field: "app_profile")) { + XCTAssertEqual($0 as? BuzzPushTranscriptError, .nonASCIIInput(field: "app_profile")) + } + } + + func testUUIDLowercased() throws { + var o = BuzzPushTranscript.CanonicalObject() + o.uuid("k", UUID(uuidString: "ABCDEF12-3456-4789-8ABC-DEF123456789")!) + XCTAssertEqual(o.encoded(), "{\"k\":\"abcdef12-3456-4789-8abc-def123456789\"}") + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift new file mode 100644 index 0000000000..9cffaa5cd2 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/NostrHTTPAuthTests.swift @@ -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 + ) + } +} diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift new file mode 100644 index 0000000000..af2558e567 --- /dev/null +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/PushWatermarkTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import BuzzPushKit + +final class PushWatermarkTests: XCTestCase { + func testClampsFutureTimestampToAllowedClockSkew() { + XCTAssertEqual( + PushWatermark.persistedTimestamp( + eventTimestamp: 2_000, + now: 1_000, + allowedFutureSkew: 300 + ), + 1_300 + ) + } + + func testPreservesTimestampWithinAllowedClockSkew() { + XCTAssertEqual( + PushWatermark.persistedTimestamp( + eventTimestamp: 1_200, + now: 1_000, + allowedFutureSkew: 300 + ), + 1_200 + ) + } + + func testRejectsEventsBeyondAllowedClockSkew() { + XCTAssertFalse( + PushWatermark.isAcceptable( + eventTimestamp: 1_301, + now: 1_000, + allowedFutureSkew: 300 + ) + ) + XCTAssertTrue( + PushWatermark.isAcceptable( + eventTimestamp: 1_300, + now: 1_000, + allowedFutureSkew: 300 + ) + ) + } + + func testRepairsPoisonedStoredWatermarkToCurrentTime() { + XCTAssertEqual( + PushWatermark.queryTimestamp(storedWatermark: 2_000, now: 1_000), + 1_000 + ) + XCTAssertEqual( + PushWatermark.queryTimestamp(storedWatermark: 900, now: 1_000), + 900 + ) + } + + func testQuerySinceIsInclusiveForSameSecondEvents() { + XCTAssertEqual(PushWatermark.querySince(watermark: 1_000), 1_000) + XCTAssertNil(PushWatermark.querySince(watermark: 0)) + } + + func testFindsOnlyRemovedCommunityWatermarks() { + XCTAssertEqual( + PushWatermark.staleKeys( + in: [ + PushWatermark.key(communityID: "kept"), + PushWatermark.key(communityID: "removed"), + "unrelated", + ], + activeCommunityIDs: ["kept"] + ), + [PushWatermark.key(communityID: "removed")] + ) + } +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 70d2be7ce4..1487c89feb 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -6,4 +6,8 @@ // `mobile/ios/Flutter/AppOverrides.xcconfig` containing // `BUNDLE_IDENTIFIER = your.app.id` (gitignored). BUNDLE_IDENTIFIER = com.buzz.buzzMobile +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = development +BUZZ_APP_ATTEST_ENVIRONMENT = development #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 616df87624..a6fe5aeecd 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -7,4 +7,8 @@ BUNDLE_IDENTIFIER = com.buzz.buzzMobile CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer +BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) +BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) +BUZZ_IOS_PUSH_ENVIRONMENT = production +BUZZ_APP_ATTEST_ENVIRONMENT = production #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/NotificationService/Info.plist b/mobile/ios/NotificationService/Info.plist new file mode 100644 index 0000000000..e66f3a9505 --- /dev/null +++ b/mobile/ios/NotificationService/Info.plist @@ -0,0 +1,35 @@ + + + + + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + NotificationService + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + NSExtension + + NSExtensionPointIdentifier + com.apple.usernotifications.service + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).NotificationService + + + diff --git a/mobile/ios/NotificationService/NotificationService.entitlements b/mobile/ios/NotificationService/NotificationService.entitlements new file mode 100644 index 0000000000..2187d2c03b --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/ios/NotificationService/NotificationService.swift b/mobile/ios/NotificationService/NotificationService.swift new file mode 100644 index 0000000000..b85017ffb4 --- /dev/null +++ b/mobile/ios/NotificationService/NotificationService.swift @@ -0,0 +1,228 @@ +import BuzzPushKit +import Foundation +import Security +import UserNotifications + +final class NotificationService: UNNotificationServiceExtension { + private var contentHandler: ((UNNotificationContent) -> Void)? + private var bestAttemptContent: UNMutableNotificationContent? + private var resolver: BuzzPushNotificationResolving = BuzzPushNotificationResolver() + + override func didReceive( + _ request: UNNotificationRequest, + withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void + ) { + self.contentHandler = contentHandler + guard let content = request.content.mutableCopy() as? UNMutableNotificationContent else { + contentHandler(request.content) + return + } + bestAttemptContent = content + + resolver.resolve { [weak self] resolution in + guard let self else { return } + if let resolution { + content.title = resolution.title + content.body = resolution.body + if let subtitle = resolution.subtitle { + content.subtitle = subtitle + } + if let threadIdentifier = resolution.threadIdentifier { + content.threadIdentifier = threadIdentifier + } + } + self.finish(content) + } + } + + override func serviceExtensionTimeWillExpire() { + if let bestAttemptContent { + finish(bestAttemptContent) + } + } + + private func finish(_ content: UNNotificationContent) { + guard let contentHandler else { return } + self.contentHandler = nil + contentHandler(content) + } +} + +struct BuzzPushResolution: Decodable { + let title: String + let body: String + let subtitle: String? + let threadIdentifier: String? +} + +protocol BuzzPushNotificationResolving { + func resolve(completion: @escaping (BuzzPushResolution?) -> Void) +} + +final class BuzzPushNotificationResolver: BuzzPushNotificationResolving { + 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, + 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) { + let loadedCommunities = loadCommunities() + removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id))) + let communities = loadedCommunities.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( + PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt), + forKey: PushWatermark.key(communityID: candidate.2.id) + ) + } + completion(newest?.0) + } + } + + 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 watermarkKey = PushWatermark.key(communityID: community.id) + let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0 + let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark) + if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) } + if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since } + 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.httpBody = body; request.timeoutInterval = 8 + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + 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 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() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt) + }, + community: community + )) + }.resume() + } + + 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 } + 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) + 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) + return result.count > 180 ? String(result.prefix(177)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" : result + } + + private static func shortPubkey(_ pubkey: String) -> String { + pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey + } + + private func removeStaleWatermarks(activeCommunityIDs: Set) { + guard let defaults else { return } + for key in PushWatermark.staleKeys( + in: Array(defaults.dictionaryRepresentation().keys), + activeCommunityIDs: activeCommunityIDs + ) { + defaults.removeObject(forKey: key) + } + } + + 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), + 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 BuzzPushSnapshot: Decodable { + let communities: [BuzzPushCommunity] +} + +struct BuzzPushCommunity: Decodable { + let id: String + let name: String + let relayUrl: String + let pubkey: String? + + var relayURL: URL { + URL(string: relayUrl) ?? URL(string: "http://127.0.0.1")! + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 46c61ce2fc..ba2b7feb68 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,10 @@ objects = { /* 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 */; }; 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; }; @@ -16,6 +20,7 @@ 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 */; }; @@ -23,6 +28,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + BZZ00000000000000000014 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = BZZ0000000000000000000E; + remoteInfo = NotificationService; + }; 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 97C146E61CF9000F007C117D /* Project object */; @@ -33,6 +45,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + BZZ00000000000000000004 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + BZZ00000000000000000002 /* NotificationService.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -46,6 +69,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + BZZ00000000000000000006 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + BZZ00000000000000000007 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + BZZ00000000000000000008 /* NotificationService.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = NotificationService.entitlements; sourceTree = ""; }; + BZZ00000000000000000009 /* NotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BZZ0000000000000000000A /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Runner.entitlements; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; @@ -60,6 +88,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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + BZZ00000000000000000024 /* PushNativeState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNativeState.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 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 = ""; }; @@ -76,6 +105,14 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + BZZ0000000000000000000C /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000020 /* BuzzPushKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 3C28C6B702C81085E6F96F2A /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -88,6 +125,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BZZ00000000000000000025 /* BuzzPushKit in Frameworks */, 33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -141,6 +179,7 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + BZZ0000000000000000000B /* NotificationService */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, @@ -155,6 +194,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + BZZ00000000000000000009 /* NotificationService.appex */, ); name = Products; sourceTree = ""; @@ -166,9 +206,11 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + BZZ0000000000000000000A /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + BZZ00000000000000000024 /* PushNativeState.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, @@ -185,6 +227,16 @@ name = Frameworks; sourceTree = ""; }; + BZZ0000000000000000000B /* NotificationService */ = { + isa = PBXGroup; + children = ( + BZZ00000000000000000006 /* NotificationService.swift */, + BZZ00000000000000000007 /* Info.plist */, + BZZ00000000000000000008 /* NotificationService.entitlements */, + ); + path = NotificationService; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -217,18 +269,43 @@ 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + BZZ00000000000000000004 /* Embed App Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( + BZZ00000000000000000013 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; }; + BZZ0000000000000000000E /* NotificationService */ = { + isa = PBXNativeTarget; + buildConfigurationList = BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */; + buildPhases = ( + BZZ0000000000000000000D /* Sources */, + BZZ0000000000000000000C /* Frameworks */, + BZZ00000000000000000010 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = NotificationService; + packageProductDependencies = ( + BZZ00000000000000000022 /* BuzzPushKit */, + ); + productName = NotificationService; + productReference = BZZ00000000000000000009 /* NotificationService.appex */; + productType = "com.apple.product-type.app-extension"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -239,6 +316,9 @@ LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + BZZ0000000000000000000E = { + CreatedOnToolsVersion = 15.0; + }; 331C8080294A63A400263BE5 = { CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; @@ -258,17 +338,28 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + BZZ00000000000000000021 /* XCLocalSwiftPackageReference "BuzzPushKit" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + BZZ0000000000000000000E /* NotificationService */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + BZZ00000000000000000010 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807F294A63A400263BE5 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -387,6 +478,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + BZZ0000000000000000000D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + BZZ00000000000000000001 /* NotificationService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 331C807D294A63A400263BE5 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -400,6 +499,7 @@ buildActionMask = 2147483647; files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + BZZ00000000000000000023 /* PushNativeState.swift in Sources */, 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, @@ -409,6 +509,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + BZZ00000000000000000013 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BZZ0000000000000000000E /* NotificationService */; + targetProxy = BZZ00000000000000000014 /* PBXContainerItemProxy */; + }; 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 97C146ED1CF9000F007C117D /* Runner */; @@ -416,6 +521,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; @@ -497,6 +617,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -685,6 +806,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -708,6 +830,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; DEVELOPMENT_TEAM = ""; ENABLE_BITCODE = NO; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -721,9 +844,91 @@ }; name = Release; }; + BZZ00000000000000000011 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + BZZ00000000000000000012 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + BZZ00000000000000000015 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + CODE_SIGN_ENTITLEMENTS = NotificationService/NotificationService.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = NotificationService/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + BZZ0000000000000000000F /* Build configuration list for PBXNativeTarget "NotificationService" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + BZZ00000000000000000011 /* Debug */, + BZZ00000000000000000012 /* Release */, + BZZ00000000000000000015 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c6ae676c6e..3485c5e7e9 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -1,4 +1,5 @@ import AVFoundation +import BuzzPushKit import Flutter import UIKit import UserNotifications @@ -6,12 +7,24 @@ import UserNotifications @main @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 + } override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in } + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { + granted, _ in + if granted { + DispatchQueue.main.async { + application.registerForRemoteNotifications() + } + } + } return super.application(application, didFinishLaunchingWithOptions: launchOptions) } @@ -24,6 +37,87 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + + pushChannel = FlutterMethodChannel( + name: "buzz/push", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + 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( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + super.application(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) + apnsRegistrationBuffer.recordToken(deviceToken) + } + + override func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + super.application(application, didFailToRegisterForRemoteNotificationsWithError: error) + apnsRegistrationBuffer.recordError(error.localizedDescription) + } + + private func handlePushMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "saveCommunitySnapshot": + guard let arguments = call.arguments as? [String: Any], + let communities = arguments["communities"] as? [[String: Any]], + let signingKeys = arguments["signingKeys"] as? [String: String] + else { + result( + FlutterError( + code: "invalid_arguments", message: "Expected communities array.", details: nil)) + return + } + 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 credentials.", + details: error.localizedDescription)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { + guard let appGroupIdentifier else { + throw NSError( + domain: "BuzzPush", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Missing BuzzAppGroupIdentifier"]) + } + guard + let container = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupIdentifier) + else { + throw NSError( + domain: "BuzzPush", code: 2, + userInfo: [NSLocalizedDescriptionKey: "Missing App Group container"]) + } + let data = try JSONSerialization.data( + withJSONObject: ["communities": communities], options: [.sortedKeys]) + let destination = container.appendingPathComponent("push-communities.json") + try data.write(to: destination, options: [.atomic]) } private func handleMediaUploadMethodCall( @@ -173,10 +267,12 @@ import UserNotifications let sourceURL = URL(fileURLWithPath: sourcePath) let asset = AVURLAsset(url: sourceURL) - guard let exportSession = AVAssetExportSession( - asset: asset, - presetName: AVAssetExportPresetPassthrough - ) else { + guard + let exportSession = AVAssetExportSession( + asset: asset, + presetName: AVAssetExportPresetPassthrough + ) + else { result( FlutterError( code: "transcode_failed", @@ -201,7 +297,8 @@ import UserNotifications case .completed: result(outputURL.path) default: - let errorMessage = exportSession.error?.localizedDescription + let errorMessage = + exportSession.error?.localizedDescription ?? "Video transcoding failed with status \(exportSession.status.rawValue)." result( FlutterError( diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 9e4f42cb89..f99b4f1dc4 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -2,6 +2,10 @@ + BuzzAppGroupIdentifier + $(BUZZ_APP_GROUP_IDENTIFIER) + BuzzKeychainAccessGroup + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) CADisableMinimumFrameDurationOnPhone CFBundleDevelopmentRegion diff --git a/mobile/ios/Runner/PushNativeState.swift b/mobile/ios/Runner/PushNativeState.swift new file mode 100644 index 0000000000..845fd8eda1 --- /dev/null +++ b/mobile/ios/Runner/PushNativeState.swift @@ -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 + } +} diff --git a/mobile/ios/Runner/Runner.entitlements b/mobile/ios/Runner/Runner.entitlements new file mode 100644 index 0000000000..5725dc08cb --- /dev/null +++ b/mobile/ios/Runner/Runner.entitlements @@ -0,0 +1,18 @@ + + + + + aps-environment + $(BUZZ_IOS_PUSH_ENVIRONMENT) + com.apple.developer.app-attest.environment + $(BUZZ_APP_ATTEST_ENVIRONMENT) + com.apple.security.application-groups + + $(BUZZ_APP_GROUP_IDENTIFIER) + + keychain-access-groups + + $(AppIdentifierPrefix)$(BUZZ_KEYCHAIN_ACCESS_GROUP) + + + diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 833d48b207..e2423b010c 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -3,10 +3,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'app.dart'; +import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); + installBuzzPushMethodHandler(); // Pre-load preferences so the first frame uses the saved theme/accent. final prefs = await SharedPreferences.getInstance(); diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index ade2220264..606a0680b6 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -24,6 +24,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); final communities = await storage.loadAll(); if (communities.isEmpty) { + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -36,6 +37,7 @@ class AuthNotifier extends AsyncNotifier { await storage.saveActiveId(active.id); if (_hasValidNsec(active.nsec)) { + await syncCommunitySnapshot(ref, communities); return AuthState(status: AuthStatus.authenticated, community: active); } @@ -47,6 +49,7 @@ class AuthNotifier extends AsyncNotifier { } await storage.clearActiveId(); + await syncCommunitySnapshot(ref, communities); return const AuthState(status: AuthStatus.unauthenticated); } @@ -57,6 +60,7 @@ class AuthNotifier extends AsyncNotifier { final storage = ref.read(communityStorageProvider); await storage.save(community); await storage.saveActiveId(community.id); + await syncStoredCommunitySnapshot(ref); // Invalidate community providers so other consumers pick up the new data. ref.invalidate(communityListProvider); @@ -76,8 +80,10 @@ class AuthNotifier extends AsyncNotifier { } // Check if other communities remain — switch to the next one instead of - // forcing the user back to the pairing screen. + // forcing the user back to the pairing screen. Refreshing the complete + // snapshot also removes revoked keys from the extension Keychain. final remaining = await storage.loadAll(); + await syncCommunitySnapshot(ref, remaining); // Invalidate community providers so other consumers pick up the change. ref.invalidate(communityListProvider); diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart index d014c23b50..842877c9cd 100644 --- a/mobile/lib/shared/community/community_provider.dart +++ b/mobile/lib/shared/community/community_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../auth/auth_provider.dart'; +import '../push/push_bridge.dart'; import 'community.dart'; import 'community_storage.dart'; @@ -8,11 +9,68 @@ final communityStorageProvider = Provider((ref) { return CommunityStorage(); }); +typedef CommunitySnapshotWriter = + Future Function(List communities); + +/// Writes the complete persisted community set to storage shared with the iOS +/// notification service extension. Tests override this provider to verify that +/// every persistence path refreshes (or clears) the native snapshot. +final communitySnapshotWriterProvider = Provider(( + ref, +) { + return registerBuzzPushCommunitySnapshot; +}); + +final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) { + return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider)); +}); + +class _CommunitySnapshotSync { + _CommunitySnapshotSync(this._writer); + + final CommunitySnapshotWriter _writer; + String? _lastSuccessfulSnapshot; + + Future write(List communities) async { + final fingerprint = communities + .map( + (community) => [ + community.id, + community.name, + community.relayUrl, + community.pubkey, + community.nsec, + ].join('\u0000'), + ) + .join('\u0001'); + if (fingerprint == _lastSuccessfulSnapshot) return; + + await _writer(communities); + _lastSuccessfulSnapshot = fingerprint; + } +} + +Future syncCommunitySnapshot(Ref ref, List communities) async { + try { + await ref.read(_communitySnapshotSyncProvider).write(communities); + pushCommunitySnapshotError.value = null; + } catch (error, stackTrace) { + reportPushCommunitySnapshotError(error, stackTrace); + } +} + +Future syncStoredCommunitySnapshot(Ref ref) async { + final communities = await ref.read(communityStorageProvider).loadAll(); + await syncCommunitySnapshot(ref, communities); +} + class CommunityListNotifier extends AsyncNotifier> { @override Future> build() async { final storage = ref.read(communityStorageProvider); - return storage.loadAll(); + final communities = await storage.loadAll(); + await syncCommunitySnapshot(ref, communities); + return communities; } /// Add a community. If one with the same relay URL already exists, update @@ -36,11 +94,14 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[existingIndex] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return existing.id; } await storage.save(community); - state = AsyncData([...current, community]); + final updatedList = [...current, community]; + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); return community.id; } @@ -49,7 +110,9 @@ class CommunityListNotifier extends AsyncNotifier> { await storage.remove(id); final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); + final updatedList = current.where((w) => w.id != id).toList(); + state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); // If we removed the active community, switch to another or sign out. final activeId = await storage.loadActiveId(); @@ -90,6 +153,7 @@ class CommunityListNotifier extends AsyncNotifier> { final updatedList = [...current]; updatedList[index] = updated; state = AsyncData(updatedList); + await syncCommunitySnapshot(ref, updatedList); } } diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart new file mode 100644 index 0000000000..24021af194 --- /dev/null +++ b/mobile/lib/shared/push/push_bridge.dart @@ -0,0 +1,140 @@ +import 'package:nostr/nostr.dart' as nostr; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import '../community/community.dart'; +import '../relay/nostr_models.dart'; +import '../relay/relay_provider.dart'; +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(null); +final apnsRegistrationError = ValueNotifier(null); + +/// Latest failure to export the community snapshot used by the iOS +/// notification service extension. Snapshot export is push enrichment and must +/// never gate authentication or community persistence. +final pushCommunitySnapshotError = ValueNotifier(null); + +void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) { + pushCommunitySnapshotError.value = error.toString(); + debugPrint('Push community snapshot export failed: $error'); + debugPrintStack(stackTrace: stackTrace); +} + +Future registerBuzzPushCommunitySnapshot( + List communities, +) async { + if (defaultTargetPlatform != TargetPlatform.iOS) return; + try { + final snapshots = [ + for (final community in communities) + BuzzPushCommunitySnapshot( + id: community.id, + name: community.name, + relayUrl: community.relayUrl, + pubkey: community.pubkey ?? pubkeyFromNsec(community.nsec), + ), + ]; + final signingKeys = {}; + 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('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. + } +} + +Future resolveBuzzPushPayload( + Map arguments, +) async { + final myPubkey = arguments['pubkey'] as String?; + final communityName = arguments['communityName'] as String? ?? 'Buzz'; + if (myPubkey == null || myPubkey.isEmpty) return null; + + final eventPayloads = arguments['events']; + if (eventPayloads is! List) return null; + final events = []; + for (final payload in eventPayloads) { + if (payload is Map) { + try { + events.add(NostrEvent.fromJson(Map.from(payload))); + } catch (_) { + // Ignore malformed relay rows and preserve the fallback notification. + } + } + } + + final profiles = {}; + final profilePayloads = arguments['profiles']; + if (profilePayloads is List) { + for (final payload in profilePayloads) { + if (payload is Map) { + try { + final event = NostrEvent.fromJson(Map.from(payload)); + final profile = ProfileData.fromEvent(event); + profiles[profile.pubkey.toLowerCase()] = profile; + } catch (_) {} + } + } + } + + return resolveBuzzPushNotification( + events: events, + myPubkey: myPubkey, + communityName: communityName, + channelName: arguments['channelName'] as String?, + profilesByPubkey: profiles, + ); +} + +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; + return (await resolveBuzzPushPayload( + Map.from(args), + ))?.toJson(); + default: + throw MissingPluginException('Unknown buzz/push method ${call.method}'); + } + }); +} diff --git a/mobile/lib/shared/push/push_models.dart b/mobile/lib/shared/push/push_models.dart new file mode 100644 index 0000000000..f741e5b9ec --- /dev/null +++ b/mobile/lib/shared/push/push_models.dart @@ -0,0 +1,133 @@ +import '../relay/nostr_models.dart'; + +const buzzPushFallbackBody = 'Reconnect to your relay now'; + +class BuzzPushCommunitySnapshot { + final String id; + final String name; + final String relayUrl; + final String? pubkey; + + const BuzzPushCommunitySnapshot({ + required this.id, + required this.name, + required this.relayUrl, + this.pubkey, + }); + + Map toJson() => { + 'id': id, + 'name': name, + 'relayUrl': relayUrl, + if (pubkey != null) 'pubkey': pubkey, + }; + + factory BuzzPushCommunitySnapshot.fromJson(Map json) { + return BuzzPushCommunitySnapshot( + id: json['id'] as String, + name: json['name'] as String, + relayUrl: json['relayUrl'] as String, + pubkey: json['pubkey'] as String?, + ); + } +} + +class BuzzPushResolution { + final String title; + final String body; + final String? subtitle; + final String? threadIdentifier; + + const BuzzPushResolution({ + required this.title, + required this.body, + this.subtitle, + this.threadIdentifier, + }); + + Map toJson() => { + 'title': title, + 'body': body, + if (subtitle != null) 'subtitle': subtitle, + if (threadIdentifier != null) 'threadIdentifier': threadIdentifier, + }; +} + +BuzzPushResolution? resolveBuzzPushNotification({ + required List events, + required String myPubkey, + required String communityName, + String? channelName, + Map profilesByPubkey = const {}, +}) { + final normalizedPubkey = myPubkey.toLowerCase(); + final candidates = [ + for (final event in events) + if (_isUserVisiblePushEvent(event, normalizedPubkey)) event, + ]; + if (candidates.isEmpty) return null; + candidates.sort((a, b) { + final created = b.createdAt.compareTo(a.createdAt); + return created != 0 ? created : a.id.compareTo(b.id); + }); + final event = candidates.first; + final author = profilesByPubkey[event.pubkey.toLowerCase()]; + final title = _firstNonEmpty([ + author?.displayName, + author?.nip05, + _shortPubkey(event.pubkey), + ]); + final body = _previewBody(event.content); + if (body.isEmpty) return null; + return BuzzPushResolution( + title: title, + subtitle: channelName ?? communityName, + body: body, + threadIdentifier: event.channelId ?? communityName, + ); +} + +bool _isUserVisiblePushEvent(NostrEvent event, String normalizedPubkey) { + if (!EventKind.channelMessageEventKinds.contains(event.kind)) return false; + if (event.pubkey.toLowerCase() == normalizedPubkey) return false; + return true; +} + +String _previewBody(String content) { + String stripMarkdownLinks(String input) { + return input.replaceAllMapped( + RegExp(r'!?\[([^\]]*)\]\([^)]*\)'), + (match) => match.group(1) ?? '', + ); + } + + final stripped = + stripMarkdownLinks( + stripMarkdownLinks( + content + .replaceAll(RegExp(r'```[\s\S]*?```'), '[code]') + .replaceAllMapped( + RegExp(r'`([^`]*)`'), + (match) => match.group(1) ?? '', + ), + ), + ) + .replaceAll(RegExp(r'https?://\S+'), '[link]') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + if (stripped.length <= 180) return stripped; + return '${stripped.substring(0, 177).trimRight()}…'; +} + +String _shortPubkey(String pubkey) { + if (pubkey.length <= 8) return pubkey; + return '${pubkey.substring(0, 8)}…'; +} + +String _firstNonEmpty(Iterable values) { + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) return trimmed; + } + return 'Buzz'; +} diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index 7a1e6a6608..bc442ce0e8 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; @@ -5,6 +6,7 @@ import 'package:buzz/shared/auth/auth_provider.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; import '../community/community_storage_test.dart'; @@ -20,8 +22,16 @@ void main() { ); await storage.save(invalid); await storage.saveActiveId(invalid.id); + final snapshots = >[]; final container = ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(storage)], + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + ], ); addTearDown(container.dispose); @@ -30,9 +40,152 @@ void main() { expect(auth.status, AuthStatus.unauthenticated); expect(await storage.loadAll(), isEmpty); expect(await storage.loadActiveId(), isNull); + expect(snapshots.last, isEmpty); }, ); + test('authenticate exports the complete stored community snapshot', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final existing = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + final added = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(existing); + final snapshots = >[]; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(added); + + expect(snapshots.last.map((community) => community.id), { + existing.id, + added.id, + }); + }); + + test( + 'sign out removes the active community from the shared snapshot', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final first = Community.create( + name: 'First', + relayUrl: 'https://first.example', + nsec: nostr.Keys.generate().nsec, + ); + final second = Community.create( + name: 'Second', + relayUrl: 'https://second.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(first); + await storage.save(second); + await storage.saveActiveId(first.id); + final snapshots = >[]; + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue(( + communities, + ) async { + snapshots.add(List.of(communities)); + }), + ], + ); + addTearDown(container.dispose); + await container.read(authProvider.future); + + await container.read(authProvider.notifier).signOut(); + + expect( + snapshots.any((snapshot) { + return snapshot.length == 1 && snapshot.single.id == second.id; + }), + isTrue, + ); + expect( + snapshots.last.map((community) => community.id), + isNot(contains(first.id)), + ); + }, + ); + + test( + 'snapshot export failure does not gate startup authentication', + () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Existing', + relayUrl: 'https://existing.example', + nsec: nostr.Keys.generate().nsec, + ); + await storage.save(community); + await storage.saveActiveId(community.id); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + final auth = await container.read(authProvider.future); + + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect(pushCommunitySnapshotError.value, contains('save_failed')); + }, + ); + + test('snapshot export failure does not gate direct authentication', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final community = Community.create( + name: 'Added', + relayUrl: 'https://added.example', + nsec: nostr.Keys.generate().nsec, + ); + final container = ProviderContainer( + overrides: [ + communityStorageProvider.overrideWithValue(storage), + communitySnapshotWriterProvider.overrideWithValue((_) async { + throw PlatformException( + code: 'save_failed', + message: 'Keychain unavailable', + ); + }), + ], + ); + addTearDown(container.dispose); + + await container + .read(authProvider.notifier) + .authenticateWithCommunity(community); + + final auth = await container.read(authProvider.future); + expect(auth.status, AuthStatus.authenticated); + expect(auth.community?.id, community.id); + expect((await storage.loadAll()).single.id, community.id); + }); + test('falls through to the next valid saved community', () async { final storage = CommunityStorage(secure: FakeSecureStorage()); final invalid = Community.create( diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart index c58e03c56a..bd828c083a 100644 --- a/mobile/test/shared/community/community_provider_test.dart +++ b/mobile/test/shared/community/community_provider_test.dart @@ -1,8 +1,11 @@ +import 'dart:convert'; + import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:buzz/shared/community/community.dart'; import 'package:buzz/shared/community/community_provider.dart'; import 'package:buzz/shared/community/community_storage.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'community_storage_test.dart'; @@ -10,17 +13,24 @@ void main() { late FakeSecureStorage fakeSecure; late CommunityStorage communityStorage; late ProviderContainer container; + late List> snapshots; setUp(() { fakeSecure = FakeSecureStorage(); communityStorage = CommunityStorage(secure: fakeSecure); + snapshots = []; }); tearDown(() => container.dispose()); ProviderContainer createContainer() { return ProviderContainer( - overrides: [communityStorageProvider.overrideWithValue(communityStorage)], + overrides: [ + communityStorageProvider.overrideWithValue(communityStorage), + communitySnapshotWriterProvider.overrideWithValue((communities) async { + snapshots.add(List.of(communities)); + }), + ], ); } @@ -29,6 +39,40 @@ void main() { container = createContainer(); final communities = await container.read(communityListProvider.future); expect(communities, isEmpty); + expect(snapshots, [isEmpty]); + }); + + test('exports migrated communities on startup', () async { + final community = Community.create( + name: 'Migrated', + relayUrl: 'https://migrated.example.com', + nsec: nostr.Keys.generate().nsec, + ); + // Seed legacy storage to exercise the same migration path as an app + // upgrade. + fakeSecure['buzz_workspaces'] = jsonEncode([community.toJson()]); + + container = createContainer(); + await container.read(communityListProvider.future); + + expect(snapshots.single.single.id, community.id); + expect(fakeSecure['buzz_workspaces'], isNull); + }); + + test('skips an unchanged snapshot after provider invalidation', () async { + final community = Community.create( + name: 'Stored', + relayUrl: 'https://stored.example.com', + nsec: nostr.Keys.generate().nsec, + ); + await communityStorage.save(community); + container = createContainer(); + + await container.read(communityListProvider.future); + container.invalidate(communityListProvider); + await container.read(communityListProvider.future); + + expect(snapshots, hasLength(1)); }); test('addCommunity adds to list', () async { diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart new file mode 100644 index 0000000000..2d2b03a6c3 --- /dev/null +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -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'); + }); +} diff --git a/mobile/test/shared/push/push_models_test.dart b/mobile/test/shared/push/push_models_test.dart new file mode 100644 index 0000000000..ed0ee46d66 --- /dev/null +++ b/mobile/test/shared/push/push_models_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/push/push_models.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; + +void main() { + test('resolves newest user-visible event into notification content', () { + final mine = 'a' * 64; + final alice = 'b' * 64; + final older = _event( + id: '1' * 64, + pubkey: alice, + createdAt: 10, + content: 'older', + ); + final newest = _event( + id: '2' * 64, + pubkey: alice, + createdAt: 20, + content: 'hello [there](https://example.com)', + tags: [ + ['h', 'chan-1'], + ], + ); + final profile = _event( + id: '3' * 64, + pubkey: alice, + kind: EventKind.contactList, + content: '{"display_name":"Alice"}', + ); + + final resolved = resolveBuzzPushNotification( + events: [older, newest], + myPubkey: mine, + communityName: 'Team', + channelName: 'mobile', + profilesByPubkey: {alice: ProfileData.fromEvent(profile)}, + ); + + expect(resolved, isNotNull); + expect(resolved!.title, 'Alice'); + expect(resolved.subtitle, 'mobile'); + expect(resolved.body, 'hello there'); + expect(resolved.threadIdentifier, 'chan-1'); + }); + + test( + 'keeps fallback when only self-authored or non-message events exist', + () { + final mine = 'a' * 64; + final resolved = resolveBuzzPushNotification( + events: [ + _event(id: '1' * 64, pubkey: mine, content: 'self'), + _event( + id: '2' * 64, + pubkey: 'b' * 64, + kind: EventKind.reaction, + content: '+', + ), + ], + myPubkey: mine, + communityName: 'Team', + ); + + expect(resolved, isNull); + }, + ); + + test('trims long previews', () { + final resolved = resolveBuzzPushNotification( + events: [_event(id: '1' * 64, pubkey: 'b' * 64, content: 'x' * 240)], + myPubkey: 'a' * 64, + communityName: 'Team', + ); + + expect(resolved!.body.length, lessThanOrEqualTo(180)); + expect(resolved.body.endsWith('…'), isTrue); + }); +} + +NostrEvent _event({ + required String id, + required String pubkey, + required String content, + int kind = EventKind.streamMessage, + int createdAt = 1, + List> tags = const [], +}) { + return NostrEvent( + id: id, + pubkey: pubkey, + createdAt: createdAt, + kind: kind, + tags: tags, + content: content, + sig: '0' * 128, + ); +}