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>
This commit is contained in:
npub12wpjffj7q5qjsky5jvk4ldwlxmse5xll3d8gytk4wqd0c5y7jvwspg37n6
2026-07-28 16:59:57 -07:00
parent 7a3eae89a3
commit 0f069252a2
12 changed files with 1551 additions and 31 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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<void> 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,
),
);
}

View File

@@ -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()));

View File

@@ -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<String?>(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<void> _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);
}
}

View File

@@ -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<String, dynamic> 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 = <String>{};
final currentKeys = <Map<String, dynamic>>[];
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>{};
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<BuzzPushLeaseDescriptor> 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<String, dynamic>) {
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<NostrEvent> Function({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
});
Future<BuzzPushLeasePublication> 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 = <String, dynamic>{
'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<BuzzPushLeasePublication> 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<String, dynamic> 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<String, dynamic> _stringMap(Object? value, {required String name}) {
if (value is! Map<String, dynamic>) {
throw FormatException('$name must be an object');
}
return value;
}
List<Map<String, dynamic>> _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<String, dynamic>)
item
else
throw FormatException('$name entries must be objects'),
];
}
List<String> _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<int> _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<String, dynamic> value, {
required Set<String> required,
required Set<String> 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}');
}
}

View File

@@ -20,33 +20,47 @@ final pushEndpointGrantError = ValueNotifier<String?>(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<dynamic, dynamic> 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<BuzzPushEndpointGrant> enrollBuzzDevPush(String relayUrl) async {
return grant;
}
Future<void> markBuzzDevPushLeasePublished(BuzzPushEndpointGrant grant) async {
if (!kDebugMode) {
throw UnsupportedError(
'Development push publication state is unavailable outside debug builds.',
);
}
await _channel.invokeMethod<void>('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.

View File

@@ -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<String, dynamic>? 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 = <NostrEvent>[];
final acknowledgements = <List<dynamic>>[];
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<Exception>().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<FormatException>().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<Exception>().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<String, dynamic>)['origin'] =
'wss://tenant.example:8443/';
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(isA<FormatException>()),
);
});
test('descriptor rejects urgent kinds outside push kinds', () {
final information = _descriptorJson(relay.public);
(information['push'] as Map<String, dynamic>)['urgent_kinds'] = [40002];
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(isA<FormatException>()),
);
});
test('descriptor rejects unsupported h grammar', () {
final information = _descriptorJson(relay.public);
(information['push'] as Map<String, dynamic>)['h_grammar'] = 'opaque';
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(isA<FormatException>()),
);
});
test('descriptor rejects unknown push fields', () {
final information = _descriptorJson(relay.public);
(information['push'] as Map<String, dynamic>)['future'] = true;
expect(
() => BuzzPushLeaseDescriptor.fromRelayInformation(information),
throwsA(isA<FormatException>()),
);
});
}
class _UnauthenticatedAuthNotifier extends AuthNotifier {
@override
Future<AuthState> build() async =>
const AuthState(status: AuthStatus.unauthenticated);
}
class _MutatingRelaySocket extends RelaySocket {
final List<NostrEvent> events;
final ({bool accepted, String message}) Function(NostrEvent event) onEvent;
final void Function(List<dynamic> message) _onAcknowledgement;
_MutatingRelaySocket(
this.events, {
required this.onEvent,
required void Function(List<dynamic> 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<dynamic> payload) {
if (payload case ['EVENT', final Map<String, dynamic> eventJson]) {
final event = NostrEvent.fromJson(eventJson);
events.add(event);
final response = onEvent(event);
_onAcknowledgement(['OK', event.id, response.accepted, response.message]);
}
}
@override
Future<void> disconnect() async {}
@override
void dispose() {}
}
void _ignoreConnected() {}
void _ignoreDisconnected(Object? _) {}
void _ignoreMessage(List<dynamic> _) {}
Future<void> _deliverAcknowledgement(
List<List<dynamic>> acknowledgements,
RelaySessionNotifier session,
) async {
while (acknowledgements.isEmpty) {
await Future<void>.delayed(Duration.zero);
}
session.debugHandleMessage(acknowledgements.removeAt(0));
}
BuzzPushLeaseDescriptor _descriptor(String relayPubkey) =>
BuzzPushLeaseDescriptor.fromRelayInformation(_descriptorJson(relayPubkey));
Map<String, dynamic> _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': <int>[],
'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,
);

View File

@@ -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 = <String>[];
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<FormatException>()),
);
});
test('exposes APNs registration failure', () async {
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
@@ -100,13 +139,18 @@ void main() {
});
}
Map<String, Object> _grantMap(String endpointGrant) => {
'relayOrigin': 'wss://relay.example/',
Map<String, Object> _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,
};