From 0f069252a28e7d803aa8f426a0d2b3c818f5ecab Mon Sep 17 00:00:00 2001 From: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Date: Tue, 28 Jul 2026 16:59:57 -0700 Subject: [PATCH] feat(mobile/ios): publish development push leases Co-authored-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> Signed-off-by: npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6 <538324a65e0501285894932d5fb5df36e19a1bff8b4e822ed5701afc509e931d@buzz.block.builderlab.xyz> --- .../BuzzDevPushEnrollmentDriver.swift | 92 ++- .../BuzzDevPushEnrollmentDriverTests.swift | 174 ++++- mobile/ios/Flutter/Debug.xcconfig | 1 + mobile/ios/Runner/AppDelegate.swift | 41 ++ .../ios/Runner/PushEndpointGrantStore.swift | 63 +- mobile/lib/main.dart | 6 +- mobile/lib/main_debug.dart | 5 + .../push/debug_push_lease_bootstrap.dart | 82 +++ mobile/lib/shared/push/dev_push_lease.dart | 669 ++++++++++++++++++ mobile/lib/shared/push/push_bridge.dart | 30 +- .../test/shared/push/dev_push_lease_test.dart | 369 ++++++++++ mobile/test/shared/push/push_bridge_test.dart | 50 +- 12 files changed, 1551 insertions(+), 31 deletions(-) create mode 100644 mobile/lib/main_debug.dart create mode 100644 mobile/lib/shared/push/debug_push_lease_bootstrap.dart create mode 100644 mobile/lib/shared/push/dev_push_lease.dart create mode 100644 mobile/test/shared/push/dev_push_lease_test.dart diff --git a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift index 7ebb530a18..c69617d340 100644 --- a/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift +++ b/mobile/ios/BuzzPushKit/Sources/BuzzPushKit/BuzzDevPushEnrollmentDriver.swift @@ -13,30 +13,42 @@ import Foundation public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public let relayOrigin: String public let relayPubkey: String + public let installationId: String public let endpointGrant: String public let endpointHash: String public let appProfile: String public let endpointEpoch: Int64 public let generation: Int64 + public let publishedGeneration: Int64? public let expiresAt: Int64 public init( relayOrigin: String, relayPubkey: String, + installationId: String, endpointGrant: String, endpointHash: String, appProfile: String, endpointEpoch: Int64, generation: Int64, + publishedGeneration: Int64?, expiresAt: Int64 ) { + precondition(generation > 0, "Endpoint grant generation must be positive") + precondition( + publishedGeneration == nil + || (publishedGeneration! > 0 && publishedGeneration! <= generation), + "Published push lease generation must be positive and no greater than the grant generation" + ) self.relayOrigin = relayOrigin self.relayPubkey = relayPubkey + self.installationId = installationId self.endpointGrant = endpointGrant self.endpointHash = endpointHash self.appProfile = appProfile self.endpointEpoch = endpointEpoch self.generation = generation + self.publishedGeneration = publishedGeneration self.expiresAt = expiresAt } } @@ -46,6 +58,7 @@ public struct BuzzPushEndpointGrantRecord: Codable, Equatable, Sendable { public protocol BuzzPushEndpointGrantStore { func records() throws -> [BuzzPushEndpointGrantRecord] func save(_ record: BuzzPushEndpointGrantRecord) throws + func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws } #if DEBUG @@ -56,6 +69,7 @@ public protocol BuzzPushEndpointGrantStore { case invalidResponse(route: String) case unexpectedStatus(route: String, expected: Int, actual: Int, body: String) case randomGenerationFailed(Int32) + case generationExhausted public var errorDescription: String? { switch self { @@ -71,6 +85,8 @@ public protocol BuzzPushEndpointGrantStore { return "The response from \(route) was HTTP \(actual), expected \(expected): \(body)" case .randomGenerationFailed(let status): return "Secure random generation failed with status \(status)." + case .generationExhausted: + return "The development push grant generation cannot advance further." } } } @@ -92,7 +108,11 @@ public protocol BuzzPushEndpointGrantStore { let randomBytes: () throws -> Data - init(randomBytes: @escaping () throws -> Data = BuzzDevAppAttestProvider.secureRandomBytes) { + init( + randomBytes: @escaping () throws -> Data = { + try BuzzDevAppAttestProvider.secureRandomBytes() + } + ) { self.randomBytes = randomBytes } @@ -117,8 +137,8 @@ public protocol BuzzPushEndpointGrantStore { return Self.assertionBytes.base64EncodedString() } - private static func secureRandomBytes() throws -> Data { - var bytes = [UInt8](repeating: 0, count: 32) + fileprivate static func secureRandomBytes(count: Int = 32) throws -> Data { + var bytes = [UInt8](repeating: 0, count: count) let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) guard status == errSecSuccess else { throw BuzzDevPushEnrollmentError.randomGenerationFailed(status) @@ -132,7 +152,6 @@ public protocol BuzzPushEndpointGrantStore { public final class BuzzDevPushEnrollmentDriver { public static let appProfile = "buzz-ios-sandbox" public static let endpointEpoch: Int64 = 1 - public static let generation: Int64 = 1 private let gatewayBaseURL: URL private let store: BuzzPushEndpointGrantStore @@ -140,6 +159,7 @@ public protocol BuzzPushEndpointGrantStore { private let appAttest: BuzzDevAppAttesting private let now: () -> Date private let lifetimeSeconds: Int64 + private let installationIdBytes: () throws -> Data public convenience init( gatewayBaseURL: URL, @@ -152,7 +172,8 @@ public protocol BuzzPushEndpointGrantStore { session: session, appAttest: BuzzDevAppAttestProvider(), now: Date.init, - lifetimeSeconds: 2_592_000 + lifetimeSeconds: 2_592_000, + installationIdBytes: { try BuzzDevAppAttestProvider.secureRandomBytes(count: 16) } ) } @@ -162,7 +183,10 @@ public protocol BuzzPushEndpointGrantStore { session: URLSession, appAttest: BuzzDevAppAttesting, now: @escaping () -> Date, - lifetimeSeconds: Int64 + lifetimeSeconds: Int64, + installationIdBytes: @escaping () throws -> Data = { + try BuzzDevAppAttestProvider.secureRandomBytes(count: 16) + } ) throws { guard Self.isHTTPOrigin(gatewayBaseURL), lifetimeSeconds > 0 else { throw BuzzDevPushEnrollmentError.invalidGatewayURL @@ -173,6 +197,7 @@ public protocol BuzzPushEndpointGrantStore { self.appAttest = appAttest self.now = now self.lifetimeSeconds = lifetimeSeconds + self.installationIdBytes = installationIdBytes } public func endpointGrants() throws -> [BuzzPushEndpointGrantRecord] { @@ -192,17 +217,28 @@ public protocol BuzzPushEndpointGrantStore { let endpointHash = Self.lowercaseHex(Data(SHA256.hash(data: deviceToken))) let nowSeconds = Int64(now().timeIntervalSince1970) - if let current = try store.records().first(where: { - $0.relayOrigin == relayOrigin.text - && $0.relayPubkey == relayPubkey - && $0.endpointHash == endpointHash - && $0.appProfile == Self.appProfile - && $0.endpointEpoch == Self.endpointEpoch - && $0.generation == Self.generation - && $0.expiresAt > nowSeconds + 300 - }) { + let storedRecords = try store.records() + let storedForOrigin = storedRecords.first { + $0.relayOrigin == relayOrigin.text && $0.appProfile == Self.appProfile + } + if let current = storedForOrigin, + current.relayPubkey == relayPubkey, + current.endpointHash == endpointHash, + current.endpointEpoch == Self.endpointEpoch, + current.expiresAt > nowSeconds + 300 + { return current } + let generation: Int64 + if let storedForOrigin { + let (next, overflow) = storedForOrigin.generation.addingReportingOverflow(1) + guard !overflow, next > 0 else { + throw BuzzDevPushEnrollmentError.generationExhausted + } + generation = next + } else { + generation = 1 + } let (expiresAt, expiresOverflow) = nowSeconds.addingReportingOverflow(lifetimeSeconds) guard !expiresOverflow else { @@ -240,7 +276,7 @@ public protocol BuzzPushEndpointGrantStore { challenge: delegationChallenge.value, installationHandle: installation, endpointEpoch: Self.endpointEpoch, - generation: Self.generation, + generation: generation, relayPubkey: relayPubkey, notBefore: nowSeconds, expiresAt: expiresAt @@ -250,19 +286,35 @@ public protocol BuzzPushEndpointGrantStore { challenge: delegationChallenge, installationHandle: installation, relayPubkey: relayPubkey, + generation: generation, notBefore: nowSeconds, expiresAt: expiresAt, assertion: assertion ) + let installationId: String + if let storedForOrigin { + installationId = storedForOrigin.installationId + } else { + let installationBytes = try installationIdBytes() + precondition( + installationBytes.count == 16, + "NIP-PL installation identity entropy must be exactly 16 bytes" + ) + // NIP-PL:76 also requires a fresh value on reinstall. Keychain survival can + // retain this value across reinstall. Reinstall detection is intentionally deferred. + installationId = Self.lowercaseHex(installationBytes) + } let record = BuzzPushEndpointGrantRecord( relayOrigin: relayOrigin.text, relayPubkey: relayPubkey, + installationId: installationId, endpointGrant: endpointGrant, endpointHash: endpointHash, appProfile: Self.appProfile, endpointEpoch: Self.endpointEpoch, - generation: Self.generation, + generation: generation, + publishedGeneration: nil, expiresAt: expiresAt ) try store.save(record) @@ -320,6 +372,7 @@ public protocol BuzzPushEndpointGrantStore { challenge: Challenge, installationHandle: UUID, relayPubkey: String, + generation: Int64, notBefore: Int64, expiresAt: Int64, assertion: String @@ -333,7 +386,7 @@ public protocol BuzzPushEndpointGrantStore { challenge: challenge.value, installationHandle: installationHandle.uuidString.lowercased(), endpointEpoch: Self.endpointEpoch, - generation: Self.generation, + generation: generation, relayPubkey: relayPubkey, notBefore: notBefore, expiresAt: expiresAt, @@ -434,7 +487,8 @@ public protocol BuzzPushEndpointGrantStore { } var relayComponents = components relayComponents.scheme = url.scheme - guard let relayText = relayComponents.url?.absoluteString else { + relayComponents.path = "" + guard let relayText = relayComponents.string else { throw BuzzDevPushEnrollmentError.invalidRelayURL } return (httpURL, relayText) diff --git a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift index 2920d6fdba..5e4699742e 100644 --- a/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift +++ b/mobile/ios/BuzzPushKit/Tests/BuzzPushKitTests/BuzzDevPushEnrollmentDriverTests.swift @@ -15,6 +15,7 @@ private static let firstChallengeId = "11111111-1111-4111-8111-111111111111" private static let secondChallengeId = "33333333-3333-4333-8333-333333333333" private static let installationHandle = "22222222-2222-4222-8222-222222222222" + private static let installationId = "000102030405060708090a0b0c0d0e0f" private static let challenge = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" private static let now: Int64 = 1_752_620_000 private static let expiresAt: Int64 = 1_752_624_000 @@ -118,6 +119,7 @@ ) XCTAssertEqual(appAttest.clientData.count, 2) + XCTAssertEqual(record.relayOrigin, "wss://relay.example") try assertMatchesVector( "enroll", actual: appAttest.clientData[0], @@ -143,19 +145,75 @@ XCTAssertEqual( record, BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example/", + relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + installationId: Self.installationId, endpointGrant: "opaque-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-sandbox", endpointEpoch: 1, generation: 1, + publishedGeneration: nil, expiresAt: Self.expiresAt ) ) XCTAssertEqual(store.saved, [record]) } + func testRelayOriginPreservesNonDefaultPortWithoutTrailingSlash() async throws { + let relayURL = URL(string: "wss://relay.example:8443/")! + let store = MemoryGrantStore() + let driver = try makeDriver(store: store, appAttest: RecordingAppAttest()) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example:8443/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "opaque-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: relayURL + ) + + XCTAssertEqual(record.relayOrigin, "wss://relay.example:8443") + } + func testDevelopmentAttestationMatchesGatewayBypassShape() throws { let entropy = Data(repeating: 0xAB, count: 32) let provider = BuzzDevAppAttestProvider(randomBytes: { entropy }) @@ -177,13 +235,15 @@ func testReusesPersistedUnexpiredGrant() async throws { let existing = BuzzPushEndpointGrantRecord( - relayOrigin: "wss://relay.example/", + relayOrigin: "wss://relay.example", relayPubkey: Self.relayPubkey, + installationId: Self.installationId, endpointGrant: "existing-grant", endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), appProfile: "buzz-ios-sandbox", endpointEpoch: 1, generation: 1, + publishedGeneration: 1, expiresAt: Self.expiresAt ) let store = MemoryGrantStore(records: [existing]) @@ -206,10 +266,88 @@ ) XCTAssertEqual(record, existing) + XCTAssertEqual(record.publishedGeneration, 1) XCTAssertEqual(store.saved, [existing]) XCTAssertEqual(URLProtocolStub.requests.count, 1) } + func testExpiredGrantRefreshReusesInstallationIdAndIncrementsGeneration() async throws { + let existing = BuzzPushEndpointGrantRecord( + relayOrigin: "wss://relay.example", + relayPubkey: Self.relayPubkey, + installationId: Self.installationId, + endpointGrant: "existing-grant", + endpointHash: Self.hex(SHA256.hash(data: Data((1...32).map(UInt8.init)))), + appProfile: "buzz-ios-sandbox", + endpointEpoch: 1, + generation: 7, + publishedGeneration: 7, + expiresAt: Self.now + 300 + ) + let store = MemoryGrantStore(records: [existing]) + let driver = try makeDriver( + store: store, + appAttest: RecordingAppAttest(), + installationIdBytes: { + XCTFail("Grant refresh must reuse the persisted installation id") + return Data(repeating: 0xFF, count: 16) + } + ) + var challengeCount = 0 + URLProtocolStub.handler = { request in + switch (request.httpMethod, request.url?.absoluteString) { + case ("GET", "https://relay.example/"): + return Self.response( + request, + status: 200, + json: ["push": ["keys": [["pubkey": Self.relayPubkey, "current": true]]]] + ) + case ("POST", "http://push.example/v1/installations/challenges"): + challengeCount += 1 + return Self.response( + request, + status: 200, + json: [ + "challenge_id": challengeCount == 1 ? Self.firstChallengeId : Self.secondChallengeId, + "challenge": Self.challenge, + "expires_at": Self.now + 300, + ] + ) + case ("POST", "http://push.example/v1/installations"): + return Self.response( + request, + status: 201, + json: [ + "installation_handle": Self.installationHandle, + "endpoint_epoch": 1, + "expires_at": Self.expiresAt, + ] + ) + case ("POST", "http://push.example/v1/delegations"): + let body = try Self.body(request) + XCTAssertEqual(body["generation"] as? Int, 8) + return Self.response( + request, + status: 201, + json: ["endpoint_grant": "refreshed-grant"] + ) + default: + XCTFail("Unexpected request \(request.url?.absoluteString ?? "nil")") + return Self.response(request, status: 500, json: [:]) + } + } + + let record = try await driver.enroll( + deviceToken: Data((1...32).map(UInt8.init)), + relayURL: Self.relayURL + ) + + XCTAssertEqual(record.installationId, Self.installationId) + XCTAssertEqual(record.generation, 8) + XCTAssertNil(record.publishedGeneration) + XCTAssertEqual(record.endpointGrant, "refreshed-grant") + } + func testRejectsMultipleCurrentRelayKeysBeforeGatewayEnrollment() async throws { let driver = try makeDriver(store: MemoryGrantStore(), appAttest: RecordingAppAttest()) URLProtocolStub.handler = { request in @@ -267,7 +405,10 @@ private func makeDriver( store: BuzzPushEndpointGrantStore, - appAttest: BuzzDevAppAttesting + appAttest: BuzzDevAppAttesting, + installationIdBytes: @escaping () throws -> Data = { + Data(0..<16) + } ) throws -> BuzzDevPushEnrollmentDriver { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [URLProtocolStub.self] @@ -277,7 +418,8 @@ session: URLSession(configuration: configuration), appAttest: appAttest, now: { Date(timeIntervalSince1970: TimeInterval(Self.now)) }, - lifetimeSeconds: Self.expiresAt - Self.now + lifetimeSeconds: Self.expiresAt - Self.now, + installationIdBytes: installationIdBytes ) } @@ -386,6 +528,30 @@ init(records: [BuzzPushEndpointGrantRecord] = []) { saved = records } func records() throws -> [BuzzPushEndpointGrantRecord] { saved } func save(_ record: BuzzPushEndpointGrantRecord) throws { saved = [record] } + func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws { + precondition(generation > 0, "Published push lease generation must be positive") + guard + let current = saved.first(where: { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + }), current.generation == generation + else { + throw NSError(domain: "MemoryGrantStore", code: 1) + } + saved = [ + BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + publishedGeneration: generation, + expiresAt: current.expiresAt + ) + ] + } } private final class RecordingAppAttest: BuzzDevAppAttesting { diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 1487c89feb..811685ae43 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -10,4 +10,5 @@ BUZZ_APP_GROUP_IDENTIFIER = group.$(BUNDLE_IDENTIFIER) BUZZ_KEYCHAIN_ACCESS_GROUP = $(BUNDLE_IDENTIFIER) BUZZ_IOS_PUSH_ENVIRONMENT = development BUZZ_APP_ATTEST_ENVIRONMENT = development +FLUTTER_TARGET = lib/main_debug.dart #include? "AppOverrides.xcconfig" diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 8687a974f4..035ac72b73 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -118,6 +118,8 @@ import UserNotifications #if DEBUG case "devEnrollPush": handleDevPushEnrollment(call, result: result) + case "devMarkPushLeasePublished": + handleDevPushLeasePublished(call, result: result) #endif default: result(FlutterMethodNotImplemented) @@ -199,6 +201,45 @@ import UserNotifications ) } } + + private func handleDevPushLeasePublished( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + guard let arguments = call.arguments as? [String: Any], + let relayOrigin = arguments["relayOrigin"] as? String, + let appProfile = arguments["appProfile"] as? String, + let generation = (arguments["generation"] as? NSNumber)?.int64Value, + !relayOrigin.isEmpty, + !appProfile.isEmpty, + generation > 0 + else { + result( + FlutterError( + code: "invalid_arguments", + message: "Published push lease state requires relayOrigin, appProfile, and generation.", + details: nil + ) + ) + return + } + do { + try endpointGrantStore.markPublished( + relayOrigin: relayOrigin, + appProfile: appProfile, + generation: generation + ) + result(nil) + } catch { + result( + FlutterError( + code: "push_lease_state_write_failed", + message: "Unable to persist the published push lease generation.", + details: error.localizedDescription + ) + ) + } + } #endif private func savePushCommunitySnapshot(_ communities: [[String: Any]]) throws { diff --git a/mobile/ios/Runner/PushEndpointGrantStore.swift b/mobile/ios/Runner/PushEndpointGrantStore.swift index 7a15b3e5b1..548b698607 100644 --- a/mobile/ios/Runner/PushEndpointGrantStore.swift +++ b/mobile/ios/Runner/PushEndpointGrantStore.swift @@ -41,7 +41,61 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { $0.relayOrigin == record.relayOrigin && $0.appProfile == record.appProfile } all.append(record) - let data = try JSONEncoder().encode(all) + try replace(all) + } + + func markPublished(relayOrigin: String, appProfile: String, generation: Int64) throws { + precondition(generation > 0, "Published push lease generation must be positive") + var all = try records() + guard + let index = all.firstIndex(where: { + $0.relayOrigin == relayOrigin && $0.appProfile == appProfile + }) + else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Cannot mark a missing endpoint grant as published."] + ) + } + let current = all[index] + guard current.publishedGeneration == nil else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 3, + userInfo: [ + NSLocalizedDescriptionKey: + "Push lease generation \(current.publishedGeneration!) is already marked as published." + ] + ) + } + guard current.generation == generation else { + throw NSError( + domain: "BuzzPushEndpointGrantStore", + code: 4, + userInfo: [ + NSLocalizedDescriptionKey: + "Published generation \(generation) does not match stored grant generation \(current.generation)." + ] + ) + } + all[index] = BuzzPushEndpointGrantRecord( + relayOrigin: current.relayOrigin, + relayPubkey: current.relayPubkey, + installationId: current.installationId, + endpointGrant: current.endpointGrant, + endpointHash: current.endpointHash, + appProfile: current.appProfile, + endpointEpoch: current.endpointEpoch, + generation: current.generation, + publishedGeneration: generation, + expiresAt: current.expiresAt + ) + try replace(all) + } + + private func replace(_ records: [BuzzPushEndpointGrantRecord]) throws { + let data = try JSONEncoder().encode(records) let updateStatus = SecItemUpdate( baseQuery() as CFDictionary, [kSecValueData as String: data] as CFDictionary @@ -86,9 +140,10 @@ final class BuzzPushEndpointGrantKeychainStore: BuzzPushEndpointGrantStore { extension BuzzPushEndpointGrantRecord { var flutterArguments: [String: Any] { - [ + var arguments: [String: Any] = [ "relayOrigin": relayOrigin, "relayPubkey": relayPubkey, + "installationId": installationId, "endpointGrant": endpointGrant, "endpointHash": endpointHash, "appProfile": appProfile, @@ -96,5 +151,9 @@ extension BuzzPushEndpointGrantRecord { "generation": generation, "expiresAt": expiresAt, ] + if let publishedGeneration { + arguments["publishedGeneration"] = publishedGeneration + } + return arguments } } diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index e2423b010c..53bb5c0d65 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -6,7 +6,9 @@ import 'app.dart'; import 'shared/push/push_bridge.dart'; import 'shared/theme/theme_provider.dart'; -void main() async { +void main() => runBuzzApp(const App()); + +Future runBuzzApp(Widget app) async { WidgetsFlutterBinding.ensureInitialized(); installBuzzPushMethodHandler(); @@ -16,7 +18,7 @@ void main() async { runApp( ProviderScope( overrides: [savedPrefsProvider.overrideWithValue(prefs)], - child: const App(), + child: app, ), ); } diff --git a/mobile/lib/main_debug.dart b/mobile/lib/main_debug.dart new file mode 100644 index 0000000000..8b8c75a3cb --- /dev/null +++ b/mobile/lib/main_debug.dart @@ -0,0 +1,5 @@ +import 'app.dart'; +import 'main.dart' as common; +import 'shared/push/debug_push_lease_bootstrap.dart'; + +void main() => common.runBuzzApp(const DebugPushLeaseBootstrap(child: App())); diff --git a/mobile/lib/shared/push/debug_push_lease_bootstrap.dart b/mobile/lib/shared/push/debug_push_lease_bootstrap.dart new file mode 100644 index 0000000000..8b7ceeb3d6 --- /dev/null +++ b/mobile/lib/shared/push/debug_push_lease_bootstrap.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../relay/relay_provider.dart'; +import '../relay/relay_session.dart'; +import '../relay/signed_event_relay.dart'; +import 'dev_push_lease.dart'; +import 'push_bridge.dart'; + +class DebugPushLeaseBootstrap extends HookConsumerWidget { + final Widget child; + + const DebugPushLeaseBootstrap({required this.child, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + useListenable(apnsDeviceToken); + final attempted = useRef(null); + final session = ref.watch(relaySessionProvider); + final config = ref.watch(relayConfigProvider); + final memberPubkey = ref.watch(myPubkeyProvider); + final nsec = config.nsec; + final deviceToken = apnsDeviceToken.value; + useEffect(() { + final attemptKey = [ + session.status.name, + config.baseUrl, + nsec, + memberPubkey, + deviceToken, + ].join('|'); + if (session.status == SessionStatus.connected && + nsec != null && + memberPubkey != null && + deviceToken != null && + attempted.value != attemptKey) { + attempted.value = attemptKey; + final relay = SignedEventRelay( + session: ref.read(relaySessionProvider.notifier), + nsec: nsec, + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_publish(config, memberPubkey, relay)); + }); + } + return null; + }, [session.status, config.baseUrl, nsec, memberPubkey, deviceToken]); + return child; + } + + Future _publish( + RelayConfig config, + String memberPubkey, + SignedEventRelay relay, + ) async { + final descriptor = await fetchBuzzPushLeaseDescriptor(config.baseUrl); + final grant = await enrollBuzzDevPush(config.wsUrl); + if (grant.relayOrigin != descriptor.origin) { + throw StateError( + 'Endpoint grant origin ${grant.relayOrigin} does not match descriptor origin ${descriptor.origin}', + ); + } + final publishedGeneration = grant.publishedGeneration; + if (publishedGeneration == grant.generation) return; + + final nsec = config.nsec; + if (nsec == null || nsec.isEmpty) { + throw StateError('Cannot publish a push lease without a signing key'); + } + await publishBuzzDevPushLeaseThroughRelay( + grant: grant, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + relay: relay, + ); + await markBuzzDevPushLeasePublished(grant); + } +} diff --git a/mobile/lib/shared/push/dev_push_lease.dart b/mobile/lib/shared/push/dev_push_lease.dart new file mode 100644 index 0000000000..fd5ca9677f --- /dev/null +++ b/mobile/lib/shared/push/dev_push_lease.dart @@ -0,0 +1,669 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip44.dart'; +import '../relay/nostr_models.dart'; +import '../relay/signed_event_relay.dart'; +import 'push_bridge.dart'; + +const buzzPushLeaseKind = 30350; +const buzzPushMessageKind = 9; +const buzzDevPushAppProfile = 'buzz-ios-sandbox'; +const buzzPushTransport = 'apns'; +const _maxSafeJsonInteger = 9007199254740991; +const _maxLeaseLifetimeSeconds = 2592000; +const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$'; +const _installationIdPattern = r'^[0-9a-f]{32}$'; + +class BuzzPushLeaseDescriptor { + final String origin; + final String executorKeyId; + final String executorPubkey; + final String transport; + final int maxLeaseTtlSeconds; + final int maxContentLength; + final int maxPlaintextLength; + final int maxEndpointLength; + final int maxStringLength; + + const BuzzPushLeaseDescriptor({ + required this.origin, + required this.executorKeyId, + required this.executorPubkey, + required this.transport, + required this.maxLeaseTtlSeconds, + required this.maxContentLength, + required this.maxPlaintextLength, + required this.maxEndpointLength, + required this.maxStringLength, + }); + + factory BuzzPushLeaseDescriptor.fromRelayInformation( + Map information, + ) { + _requireExactKeys( + information, + required: const {}, + allowed: const { + 'name', + 'description', + 'pubkey', + 'contact', + 'supported_nips', + 'supported_extensions', + 'software', + 'version', + 'limitation', + 'retention', + 'relay_countries', + 'language_tags', + 'tags', + 'posting_policy', + 'payments_url', + 'fees', + 'icon', + 'self', + 'pairing_relay_url', + 'push', + }, + name: 'NIP-11 document', + ); + final extensions = _stringList( + information['supported_extensions'], + name: 'supported_extensions', + ); + if (!extensions.contains('nip-pl')) { + throw const FormatException('NIP-11 does not advertise nip-pl'); + } + + final push = _stringMap(information['push'], name: 'push'); + _requireExactKeys( + push, + required: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + allowed: const { + 'origin', + 'keys', + 'app_profiles', + 'push_kinds', + 'urgent_kinds', + 'h_grammar', + 'class_support', + 'limitation', + }, + name: 'push descriptor', + ); + + final origin = _canonicalOrigin(push['origin']); + final keys = _mapList(push['keys'], name: 'push.keys'); + final keyIds = {}; + final currentKeys = >[]; + for (final key in keys) { + _requireExactKeys( + key, + required: const {'id', 'pubkey'}, + allowed: const {'id', 'pubkey', 'current', 'retiring'}, + name: 'push key', + ); + final id = _nonEmptyString(key['id'], name: 'push key id'); + if (!keyIds.add(id)) { + throw FormatException('Duplicate push key id: $id'); + } + _lowercaseHex64(key['pubkey'], name: 'push key pubkey'); + final current = key['current']; + final retiring = key['retiring']; + if (current != null && current is! bool) { + throw const FormatException('push key current must be a boolean'); + } + if (retiring != null && retiring is! bool) { + throw const FormatException('push key retiring must be a boolean'); + } + if (current == true) currentKeys.add(key); + } + if (currentKeys.length != 1) { + throw const FormatException( + 'push descriptor must contain exactly one current key', + ); + } + final currentKey = currentKeys.single; + + final profiles = _mapList(push['app_profiles'], name: 'app_profiles'); + final profileIds = {}; + String? transport; + for (final profile in profiles) { + _requireExactKeys( + profile, + required: const {'id', 'transport'}, + allowed: const {'id', 'transport'}, + name: 'app profile', + ); + final id = _nonEmptyString(profile['id'], name: 'app profile id'); + if (!profileIds.add(id)) { + throw FormatException('Duplicate app profile id: $id'); + } + final candidate = _nonEmptyString( + profile['transport'], + name: 'app profile transport', + ); + if (id == buzzDevPushAppProfile) transport = candidate; + } + if (transport != buzzPushTransport) { + throw const FormatException( + 'NIP-11 does not advertise the sandbox APNs profile', + ); + } + + final pushKinds = _intList(push['push_kinds'], name: 'push_kinds'); + if (!pushKinds.contains(buzzPushMessageKind)) { + throw const FormatException('NIP-11 does not advertise kind 9 for push'); + } + final urgentKinds = _intList( + push['urgent_kinds'], + name: 'urgent_kinds', + allowEmpty: true, + ); + if (urgentKinds.any((kind) => !pushKinds.contains(kind))) { + throw const FormatException( + 'urgent_kinds must be a subset of push_kinds', + ); + } + final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar'); + if (hGrammar != 'uuid-v4-lowercase') { + throw const FormatException('Unsupported push h_grammar'); + } + + final classSupport = _stringMap( + push['class_support'], + name: 'class_support', + ); + final supportedClasses = _stringList( + classSupport[buzzPushTransport], + name: 'class_support.apns', + ); + const knownClasses = {'silent', 'default', 'time_sensitive', 'urgent'}; + if (supportedClasses.any((value) => !knownClasses.contains(value))) { + throw const FormatException('class_support contains an unknown class'); + } + if (!supportedClasses.contains('default')) { + throw const FormatException('APNs does not support the default class'); + } + + final limitation = _stringMap(push['limitation'], name: 'limitation'); + _requireExactKeys( + limitation, + required: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + allowed: const { + 'max_lease_ttl', + 'max_leases_per_pubkey', + 'max_subscriptions_per_lease', + 'max_kinds', + 'max_authors', + 'max_h', + 'max_tag_values', + 'max_ignore', + 'max_content_len', + 'max_plaintext_len', + 'max_endpoint_len', + 'max_string_len', + }, + name: 'push limitation', + ); + for (final entry in limitation.entries) { + _positiveInt(entry.value, name: 'limitation.${entry.key}'); + } + final maxStringLength = limitation['max_string_len'] as int; + _checkStringLength(origin, maxStringLength, name: 'origin'); + _checkStringLength( + currentKey['id'] as String, + maxStringLength, + name: 'push key id', + ); + final maxLeaseTtl = limitation['max_lease_ttl'] as int; + if (maxLeaseTtl > _maxLeaseLifetimeSeconds) { + throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit'); + } + + return BuzzPushLeaseDescriptor( + origin: origin, + executorKeyId: currentKey['id'] as String, + executorPubkey: currentKey['pubkey'] as String, + transport: transport!, + maxLeaseTtlSeconds: maxLeaseTtl, + maxContentLength: limitation['max_content_len'] as int, + maxPlaintextLength: limitation['max_plaintext_len'] as int, + maxEndpointLength: limitation['max_endpoint_len'] as int, + maxStringLength: maxStringLength, + ); + } +} + +Future fetchBuzzPushLeaseDescriptor( + String relayBaseUrl, { + http.Client? client, + Duration timeout = const Duration(seconds: 8), +}) async { + final uri = Uri.tryParse(relayBaseUrl); + if (uri == null || + !const {'http', 'https'}.contains(uri.scheme) || + uri.host.isEmpty) { + throw FormatException('Invalid relay HTTP URL: $relayBaseUrl'); + } + final requestUri = uri.resolve('/'); + final ownedClient = client ?? http.Client(); + try { + final response = await ownedClient + .get(requestUri, headers: const {'Accept': 'application/nostr+json'}) + .timeout(timeout); + if (response.statusCode != 200) { + throw StateError( + 'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const FormatException('NIP-11 response must be a JSON object'); + } + return BuzzPushLeaseDescriptor.fromRelayInformation(decoded); + } finally { + if (client == null) ownedClient.close(); + } +} + +class BuzzPushLeasePublication { + final String eventId; + final int expiration; + final String plaintext; + + const BuzzPushLeasePublication({ + required this.eventId, + required this.expiration, + required this.plaintext, + }); +} + +typedef BuzzPushLeaseSubmit = + Future Function({ + required int kind, + required String content, + required List> tags, + int? createdAt, + }); + +Future publishBuzzDevPushLease({ + required BuzzPushEndpointGrant grant, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required BuzzPushLeaseSubmit submit, + DateTime Function() now = DateTime.now, +}) async { + _validateGrant(grant, descriptor); + final normalizedMemberPubkey = _lowercaseHex64( + memberPubkey, + name: 'member pubkey', + ); + final decoded = nostr.Nip19.decode(payload: nsec); + if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) { + throw const FormatException('Signing key must be a 32-byte nsec'); + } + final signingPubkey = nostr.Keys(decoded.data).public; + if (signingPubkey != normalizedMemberPubkey) { + throw const FormatException( + 'Authenticated signing key does not match the lease member pubkey', + ); + } + + final nowSeconds = now().millisecondsSinceEpoch ~/ 1000; + final expiration = min( + grant.expiresAt, + nowSeconds + descriptor.maxLeaseTtlSeconds, + ); + if (expiration <= nowSeconds) { + throw const FormatException('Endpoint grant is already expired'); + } + + final plaintextMap = { + 'v': 1, + 'origin': descriptor.origin, + 'app_profile': grant.appProfile, + 'transport': descriptor.transport, + 'endpoint': grant.endpointGrant, + 'generation': grant.generation, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [buzzPushMessageKind], + '#p': [normalizedMemberPubkey], + }, + 'class': 'default', + }, + ], + }; + validateBuzzPushLeasePlaintext( + plaintextMap, + maxEndpointLength: descriptor.maxEndpointLength, + maxStringLength: descriptor.maxStringLength, + ); + final plaintext = jsonEncode(plaintextMap); + if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) { + throw const FormatException('lease plaintext exceeds the advertised limit'); + } + final conversationKey = getConversationKey( + decoded.data, + descriptor.executorPubkey, + ); + final content = nip44Encrypt(conversationKey, plaintext); + if (utf8.encode(content).length > descriptor.maxContentLength) { + throw const FormatException( + 'lease ciphertext exceeds the advertised limit', + ); + } + final acknowledged = await submit( + kind: buzzPushLeaseKind, + content: content, + tags: [ + ['d', grant.installationId], + ['expiration', '$expiration'], + ['exec', descriptor.executorKeyId], + ], + createdAt: nowSeconds, + ); + if (acknowledged.id.isEmpty) { + throw StateError('Relay returned an empty event id for the push lease'); + } + return BuzzPushLeasePublication( + eventId: acknowledged.id, + expiration: expiration, + plaintext: plaintext, + ); +} + +Future publishBuzzDevPushLeaseThroughRelay({ + required BuzzPushEndpointGrant grant, + required BuzzPushLeaseDescriptor descriptor, + required String nsec, + required String memberPubkey, + required SignedEventRelay relay, + DateTime Function() now = DateTime.now, +}) => publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: nsec, + memberPubkey: memberPubkey, + submit: relay.submit, + now: now, +); + +void validateBuzzPushLeasePlaintext( + Map plaintext, { + int maxEndpointLength = 4096, + int maxStringLength = 512, +}) { + _requireExactKeys( + plaintext, + required: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + allowed: const { + 'v', + 'origin', + 'app_profile', + 'transport', + 'endpoint', + 'generation', + 'active', + 'subscriptions', + }, + name: 'lease plaintext', + ); + if (plaintext['v'] != 1) { + throw const FormatException('lease version must be 1'); + } + final origin = _canonicalOrigin(plaintext['origin']); + _checkStringLength(origin, maxStringLength, name: 'origin'); + if (plaintext['app_profile'] != buzzDevPushAppProfile) { + throw const FormatException('lease app_profile must be buzz-ios-sandbox'); + } + if (plaintext['transport'] != buzzPushTransport) { + throw const FormatException('lease transport must be apns'); + } + _checkStringLength( + buzzDevPushAppProfile, + maxStringLength, + name: 'app_profile', + ); + _checkStringLength(buzzPushTransport, maxStringLength, name: 'transport'); + final endpoint = _nonEmptyString(plaintext['endpoint'], name: 'endpoint'); + _checkStringLength(endpoint, maxEndpointLength, name: 'endpoint'); + final generation = _positiveInt(plaintext['generation'], name: 'generation'); + if (generation > _maxSafeJsonInteger) { + throw const FormatException( + 'generation exceeds the safe JSON integer range', + ); + } + if (plaintext['active'] != true) { + throw const FormatException('development lease must be active'); + } + + final subscriptions = _mapList( + plaintext['subscriptions'], + name: 'subscriptions', + ); + if (subscriptions.length != 1) { + throw const FormatException('development lease requires one subscription'); + } + final subscription = subscriptions.single; + _requireExactKeys( + subscription, + required: const {'filter', 'class'}, + allowed: const {'filter', 'class'}, + name: 'subscription', + ); + if (subscription['class'] != 'default') { + throw const FormatException('development lease class must be default'); + } + final filter = _stringMap( + subscription['filter'], + name: 'subscription filter', + ); + _requireExactKeys( + filter, + required: const {'kinds', '#p'}, + allowed: const {'kinds', '#p'}, + name: 'subscription filter', + ); + final kinds = _intList(filter['kinds'], name: 'filter kinds'); + if (kinds.length != 1 || kinds.single != buzzPushMessageKind) { + throw const FormatException( + 'development lease must subscribe to kind 9 only', + ); + } + final pubkeys = _stringList(filter['#p'], name: 'filter #p'); + if (pubkeys.length != 1) { + throw const FormatException( + 'development lease requires one #p member pubkey', + ); + } + _lowercaseHex64(pubkeys.single, name: 'filter #p member pubkey'); +} + +void _validateGrant( + BuzzPushEndpointGrant grant, + BuzzPushLeaseDescriptor descriptor, +) { + if (utf8.encode(grant.installationId).length > 64 || + !RegExp(_installationIdPattern).hasMatch(grant.installationId)) { + throw const FormatException( + 'Installation id must be 16 random bytes encoded as lowercase hex', + ); + } + if (grant.relayOrigin != descriptor.origin) { + throw FormatException( + 'Stored relay origin ${grant.relayOrigin} does not match descriptor origin ${descriptor.origin}', + ); + } + if (grant.relayPubkey != descriptor.executorPubkey) { + throw const FormatException( + 'Stored endpoint grant is delegated to a different relay key', + ); + } + if (grant.appProfile != buzzDevPushAppProfile) { + throw const FormatException('Endpoint grant is not for buzz-ios-sandbox'); + } + if (grant.endpointGrant.isEmpty || + utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) { + throw const FormatException( + 'Endpoint grant violates the advertised endpoint limit', + ); + } + if (grant.endpointEpoch <= 0) { + throw const FormatException('Endpoint grant epoch is invalid'); + } + if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) { + throw const FormatException('Endpoint grant generation is invalid'); + } +} + +String _canonicalOrigin(Object? value) { + final origin = _nonEmptyString(value, name: 'origin'); + final uri = Uri.tryParse(origin); + if (uri == null || + !const {'ws', 'wss'}.contains(uri.scheme) || + uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.path.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + '${uri.scheme}://${uri.authority}' != origin) { + throw FormatException('Invalid canonical push origin: $origin'); + } + return origin; +} + +void _checkStringLength(String value, int maximum, {required String name}) { + if (maximum <= 0 || utf8.encode(value).length > maximum) { + throw FormatException('$name exceeds its advertised byte limit'); + } +} + +String _lowercaseHex64(Object? value, {required String name}) { + final text = _nonEmptyString(value, name: name); + if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) { + throw FormatException('$name must be exactly 64 lowercase hex characters'); + } + return text; +} + +String _nonEmptyString(Object? value, {required String name}) { + if (value is! String || value.isEmpty) { + throw FormatException('$name must be a non-empty string'); + } + return value; +} + +int _positiveInt(Object? value, {required String name}) { + if (value is! int || value <= 0) { + throw FormatException('$name must be a positive integer'); + } + return value; +} + +Map _stringMap(Object? value, {required String name}) { + if (value is! Map) { + throw FormatException('$name must be an object'); + } + return value; +} + +List> _mapList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty array'); + } + return [ + for (final item in value) + if (item is Map) + item + else + throw FormatException('$name entries must be objects'), + ]; +} + +List _stringList(Object? value, {required String name}) { + if (value is! List || value.isEmpty) { + throw FormatException('$name must be a non-empty string array'); + } + return [ + for (final item in value) + if (item is String && item.isNotEmpty) + item + else + throw FormatException('$name entries must be non-empty strings'), + ]; +} + +List _intList( + Object? value, { + required String name, + bool allowEmpty = false, +}) { + if (value is! List || (!allowEmpty && value.isEmpty)) { + throw FormatException( + '$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array', + ); + } + return [ + for (final item in value) + if (item is int && item >= 0) + item + else + throw FormatException('$name entries must be non-negative integers'), + ]; +} + +void _requireExactKeys( + Map value, { + required Set required, + required Set allowed, + required String name, +}) { + final missing = required.difference(value.keys.toSet()); + if (missing.isNotEmpty) { + throw FormatException('$name is missing ${missing.join(', ')}'); + } + final unexpected = value.keys.toSet().difference(allowed); + if (unexpected.isNotEmpty) { + throw FormatException('$name contains unknown field ${unexpected.first}'); + } +} diff --git a/mobile/lib/shared/push/push_bridge.dart b/mobile/lib/shared/push/push_bridge.dart index 2e60276586..5c49f00dc3 100644 --- a/mobile/lib/shared/push/push_bridge.dart +++ b/mobile/lib/shared/push/push_bridge.dart @@ -20,33 +20,47 @@ final pushEndpointGrantError = ValueNotifier(null); class BuzzPushEndpointGrant { final String relayOrigin; final String relayPubkey; + final String installationId; final String endpointGrant; final String endpointHash; final String appProfile; final int endpointEpoch; final int generation; + final int? publishedGeneration; final int expiresAt; const BuzzPushEndpointGrant({ required this.relayOrigin, required this.relayPubkey, + required this.installationId, required this.endpointGrant, required this.endpointHash, required this.appProfile, required this.endpointEpoch, required this.generation, + required this.publishedGeneration, required this.expiresAt, }); factory BuzzPushEndpointGrant.fromMap(Map map) { + final generation = map['generation'] as int; + final publishedGeneration = map['publishedGeneration'] as int?; + if (publishedGeneration != null && + (publishedGeneration <= 0 || publishedGeneration > generation)) { + throw const FormatException( + 'Published endpoint grant generation is invalid', + ); + } return BuzzPushEndpointGrant( relayOrigin: map['relayOrigin'] as String, relayPubkey: map['relayPubkey'] as String, + installationId: map['installationId'] as String, endpointGrant: map['endpointGrant'] as String, endpointHash: map['endpointHash'] as String, appProfile: map['appProfile'] as String, endpointEpoch: map['endpointEpoch'] as int, - generation: map['generation'] as int, + generation: generation, + publishedGeneration: publishedGeneration, expiresAt: map['expiresAt'] as int, ); } @@ -87,6 +101,20 @@ Future enrollBuzzDevPush(String relayUrl) async { return grant; } +Future markBuzzDevPushLeasePublished(BuzzPushEndpointGrant grant) async { + if (!kDebugMode) { + throw UnsupportedError( + 'Development push publication state is unavailable outside debug builds.', + ); + } + await _channel.invokeMethod('devMarkPushLeasePublished', { + 'relayOrigin': grant.relayOrigin, + 'appProfile': grant.appProfile, + 'generation': grant.generation, + }); + await readBuzzPushEndpointGrants(); +} + /// 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. diff --git a/mobile/test/shared/push/dev_push_lease_test.dart b/mobile/test/shared/push/dev_push_lease_test.dart new file mode 100644 index 0000000000..ec1f376d9c --- /dev/null +++ b/mobile/test/shared/push/dev_push_lease_test.dart @@ -0,0 +1,369 @@ +import 'dart:convert'; + +import 'package:buzz/shared/auth/auth_provider.dart'; +import 'package:buzz/shared/crypto/nip44.dart'; +import 'package:buzz/shared/push/dev_push_lease.dart'; +import 'package:buzz/shared/push/push_bridge.dart'; +import 'package:buzz/shared/relay/nostr_models.dart'; +import 'package:buzz/shared/relay/relay_session.dart'; +import 'package:buzz/shared/relay/relay_socket.dart'; +import 'package:buzz/shared/relay/signed_event_relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +void main() { + final signer = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final descriptor = _descriptor(relay.public); + final grant = _grant(relay.public); + final now = DateTime.fromMillisecondsSinceEpoch(1752620000 * 1000); + + test('publishes strict kind-30350 lease and waits for accepted OK', () async { + Map? submitted; + final publication = await publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + now: () => now, + submit: + ({required kind, required content, required tags, createdAt}) async { + submitted = { + 'kind': kind, + 'content': content, + 'tags': tags, + 'createdAt': createdAt, + }; + return const NostrEvent( + id: 'accepted-id', + pubkey: '', + createdAt: 0, + kind: 0, + tags: [], + content: 'saved', + sig: '', + ); + }, + ); + + expect(publication.eventId, 'accepted-id'); + expect(grant.relayOrigin, descriptor.origin); + expect(jsonDecode(publication.plaintext)['origin'], descriptor.origin); + expect(submitted!['kind'], buzzPushLeaseKind); + expect(submitted!['createdAt'], 1752620000); + expect(submitted!['tags'], [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ]); + final plaintext = nip44Decrypt( + getConversationKey(relay.secret, signer.public), + submitted!['content'] as String, + ); + expect(jsonDecode(plaintext), { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-sandbox', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 1, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [9], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }); + }); + + test( + 'mutation control rejects relay OK false then accepts restored event', + () async { + final events = []; + final acknowledgements = >[]; + final session = RelaySessionNotifier(); + final container = ProviderContainer( + overrides: [ + relaySessionProvider.overrideWith(() => session), + authProvider.overrideWith(() => _UnauthenticatedAuthNotifier()), + ], + ); + await container.read(authProvider.future); + final subscription = container.listen(relaySessionProvider, (_, _) {}); + addTearDown(subscription.close); + addTearDown(container.dispose); + final socket = _MutatingRelaySocket( + events, + onEvent: (event) => event.kind == 40002 + ? (accepted: false, message: 'invalid: kind not push-eligible') + : (accepted: true, message: 'saved'), + onAcknowledgement: acknowledgements.add, + ); + session.debugAttachSocketForTest(socket); + final relayClient = SignedEventRelay(session: session, nsec: signer.nsec); + + final mutated = relayClient.submit( + kind: 40002, + content: 'mutated', + tags: const [], + createdAt: 1752620000, + ); + final rejection = expectLater( + mutated, + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: kind not push-eligible'), + ), + ), + ); + await _deliverAcknowledgement(acknowledgements, session); + await rejection; + final acceptedFuture = relayClient.submit( + kind: buzzPushLeaseKind, + content: 'restored', + tags: [ + ['d', 'c' * 32], + ['expiration', '1755212000'], + ['exec', 'relay-v1'], + ], + createdAt: 1752620001, + ); + await _deliverAcknowledgement(acknowledgements, session); + final accepted = await acceptedFuture; + + expect(accepted.content, 'saved'); + expect(events.map((event) => event.kind), [40002, buzzPushLeaseKind]); + expect(events.every((event) => event.pubkey == signer.public), isTrue); + for (final event in events) { + expect( + () => nostr.Event( + event.id, + event.pubkey, + event.createdAt, + event.kind, + event.tags, + event.content, + event.sig, + ), + returnsNormally, + ); + } + }, + ); + + test('strict plaintext validator rejects kind 40002 then accepts kind 9', () { + final valid = { + 'v': 1, + 'origin': 'wss://tenant.example:8443', + 'app_profile': 'buzz-ios-sandbox', + 'transport': 'apns', + 'endpoint': 'opaque-grant', + 'generation': 1, + 'active': true, + 'subscriptions': [ + { + 'filter': { + 'kinds': [40002], + '#p': [signer.public], + }, + 'class': 'default', + }, + ], + }; + + expect( + () => validateBuzzPushLeasePlaintext(valid), + throwsA( + isA().having( + (error) => error.message, + 'message', + 'development lease must subscribe to kind 9 only', + ), + ), + ); + + ((valid['subscriptions'] as List).single['filter'] as Map)['kinds'] = [9]; + expect(() => validateBuzzPushLeasePlaintext(valid), returnsNormally); + }); + + test('propagates relay rejection instead of accepting locally', () async { + await expectLater( + publishBuzzDevPushLease( + grant: grant, + descriptor: descriptor, + nsec: signer.nsec, + memberPubkey: signer.public, + now: () => now, + submit: + ({ + required kind, + required content, + required tags, + createdAt, + }) async => throw Exception('invalid: origin mismatch'), + ), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('invalid: origin mismatch'), + ), + ), + ); + }); + + test('descriptor rejects canonical origin with a trailing slash', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['origin'] = + 'wss://tenant.example:8443/'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects urgent kinds outside push kinds', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['urgent_kinds'] = [40002]; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unsupported h grammar', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['h_grammar'] = 'opaque'; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); + + test('descriptor rejects unknown push fields', () { + final information = _descriptorJson(relay.public); + (information['push'] as Map)['future'] = true; + + expect( + () => BuzzPushLeaseDescriptor.fromRelayInformation(information), + throwsA(isA()), + ); + }); +} + +class _UnauthenticatedAuthNotifier extends AuthNotifier { + @override + Future build() async => + const AuthState(status: AuthStatus.unauthenticated); +} + +class _MutatingRelaySocket extends RelaySocket { + final List events; + final ({bool accepted, String message}) Function(NostrEvent event) onEvent; + final void Function(List message) _onAcknowledgement; + + _MutatingRelaySocket( + this.events, { + required this.onEvent, + required void Function(List message) onAcknowledgement, + }) : _onAcknowledgement = onAcknowledgement, + super( + wsUrl: 'wss://tenant.example:8443', + nsec: null, + onMessage: _ignoreMessage, + onConnected: _ignoreConnected, + onDisconnected: _ignoreDisconnected, + ); + + @override + SocketState get state => SocketState.connected; + + @override + void send(List payload) { + if (payload case ['EVENT', final Map eventJson]) { + final event = NostrEvent.fromJson(eventJson); + events.add(event); + final response = onEvent(event); + _onAcknowledgement(['OK', event.id, response.accepted, response.message]); + } + } + + @override + Future disconnect() async {} + + @override + void dispose() {} +} + +void _ignoreConnected() {} +void _ignoreDisconnected(Object? _) {} +void _ignoreMessage(List _) {} + +Future _deliverAcknowledgement( + List> acknowledgements, + RelaySessionNotifier session, +) async { + while (acknowledgements.isEmpty) { + await Future.delayed(Duration.zero); + } + session.debugHandleMessage(acknowledgements.removeAt(0)); +} + +BuzzPushLeaseDescriptor _descriptor(String relayPubkey) => + BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey)); + +Map _descriptorJson(String relayPubkey) => { + 'supported_extensions': ['nip-er', 'nip-pl'], + 'push': { + 'origin': 'wss://tenant.example:8443', + 'keys': [ + {'id': 'relay-v1', 'pubkey': relayPubkey, 'current': true}, + ], + 'app_profiles': [ + {'id': 'buzz-ios-sandbox', 'transport': 'apns'}, + ], + 'push_kinds': [7, 9, 1059, 40007, 46010], + 'urgent_kinds': [], + 'h_grammar': 'uuid-v4-lowercase', + 'class_support': { + 'apns': ['silent', 'default', 'time_sensitive'], + }, + 'limitation': { + 'max_lease_ttl': 2592000, + 'max_leases_per_pubkey': 16, + 'max_subscriptions_per_lease': 16, + 'max_kinds': 16, + 'max_authors': 20, + 'max_h': 50, + 'max_tag_values': 20, + 'max_ignore': 8, + 'max_content_len': 65536, + 'max_plaintext_len': 32768, + 'max_endpoint_len': 4096, + 'max_string_len': 512, + }, + }, +}; + +BuzzPushEndpointGrant _grant(String relayPubkey) => BuzzPushEndpointGrant( + relayOrigin: 'wss://tenant.example:8443', + relayPubkey: relayPubkey, + installationId: 'c' * 32, + endpointGrant: 'opaque-grant', + endpointHash: 'd' * 64, + appProfile: 'buzz-ios-sandbox', + endpointEpoch: 1, + generation: 1, + publishedGeneration: null, + expiresAt: 1756212000, +); diff --git a/mobile/test/shared/push/push_bridge_test.dart b/mobile/test/shared/push/push_bridge_test.dart index e94047493f..de56f61d03 100644 --- a/mobile/test/shared/push/push_bridge_test.dart +++ b/mobile/test/shared/push/push_bridge_test.dart @@ -48,13 +48,15 @@ void main() { final grants = await readBuzzPushEndpointGrants(); expect(grants, hasLength(1)); - expect(grants.single.relayOrigin, 'wss://relay.example/'); + expect(grants.single.relayOrigin, 'wss://relay.example'); expect(grants.single.relayPubkey, 'a' * 64); + expect(grants.single.installationId, 'c' * 32); expect(grants.single.endpointGrant, 'opaque-grant'); expect(grants.single.endpointHash, 'b' * 64); expect(grants.single.appProfile, 'buzz-ios-sandbox'); expect(grants.single.endpointEpoch, 1); expect(grants.single.generation, 1); + expect(grants.single.publishedGeneration, isNull); expect(grants.single.expiresAt, 1752624000); expect(pushEndpointGrants.value.single.endpointGrant, 'opaque-grant'); expect(pushEndpointGrantError.value, isNull); @@ -87,6 +89,43 @@ void main() { }, ); + test('persists the acknowledged push lease generation', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final methods = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(_channel, (call) async { + methods.add(call.method); + if (call.method == 'devMarkPushLeasePublished') { + expect(call.arguments, { + 'relayOrigin': 'wss://relay.example', + 'appProfile': 'buzz-ios-sandbox', + 'generation': 1, + }); + return null; + } + if (call.method == 'endpointGrants') { + return [_grantMap('opaque-grant', publishedGeneration: 1)]; + } + fail('Unexpected method ${call.method}'); + }); + + final grant = BuzzPushEndpointGrant.fromMap(_grantMap('opaque-grant')); + await markBuzzDevPushLeasePublished(grant); + + expect(methods, ['devMarkPushLeasePublished', 'endpointGrants']); + expect(pushEndpointGrants.value.single.publishedGeneration, 1); + }); + + test('rejects published generation ahead of the endpoint grant', () { + expect( + () => BuzzPushEndpointGrant.fromMap( + _grantMap('opaque-grant', publishedGeneration: 2), + ), + throwsA(isA()), + ); + }); + test('exposes APNs registration failure', () async { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( @@ -100,13 +139,18 @@ void main() { }); } -Map _grantMap(String endpointGrant) => { - 'relayOrigin': 'wss://relay.example/', +Map _grantMap( + String endpointGrant, { + int? publishedGeneration, +}) => { + 'relayOrigin': 'wss://relay.example', 'relayPubkey': 'a' * 64, + 'installationId': 'c' * 32, 'endpointGrant': endpointGrant, 'endpointHash': 'b' * 64, 'appProfile': 'buzz-ios-sandbox', 'endpointEpoch': 1, 'generation': 1, + 'publishedGeneration': ?publishedGeneration, 'expiresAt': 1752624000, };