fix(mobile/ios): harden push lifecycle failures

Co-authored-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er <a71807c648dd158bd8dda1bc9571aa39ae5654ea3f9344ca898738f7003bd467@buzz.block.builderlab.xyz>
Signed-off-by: npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er <a71807c648dd158bd8dda1bc9571aa39ae5654ea3f9344ca898738f7003bd467@buzz.block.builderlab.xyz>
This commit is contained in:
npub15uvq03jgm52chkxa5x7f2ud28xh9v48287f5fj5fsuu0wqpm63ns8hn2er
2026-07-24 11:30:41 -07:00
parent e067a394dc
commit 8912fdc5ac
8 changed files with 291 additions and 17 deletions

View File

@@ -0,0 +1,51 @@
import Foundation
public enum PushWatermark {
/// Nostr permits modest clock drift, but an authenticated author must not
/// be able to pin notification queries arbitrarily far into the future.
public static let allowedFutureSkewSeconds = 300
public static let keyPrefix = "buzz.push.watermark."
public static func key(communityID: String) -> String {
keyPrefix + communityID
}
public static func persistedTimestamp(
eventTimestamp: Int,
now: Int = Int(Date().timeIntervalSince1970),
allowedFutureSkew: Int = allowedFutureSkewSeconds
) -> Int {
min(eventTimestamp, now + allowedFutureSkew)
}
public static func isAcceptable(
eventTimestamp: Int,
now: Int = Int(Date().timeIntervalSince1970),
allowedFutureSkew: Int = allowedFutureSkewSeconds
) -> Bool {
eventTimestamp <= now + allowedFutureSkew
}
public static func queryTimestamp(
storedWatermark: Int,
now: Int = Int(Date().timeIntervalSince1970)
) -> Int {
min(storedWatermark, now)
}
/// Nostr `since` is inclusive. Keeping the watermark itself allows a later
/// event created in the same second to remain queryable.
public static func querySince(watermark: Int) -> Int? {
watermark > 0 ? watermark : nil
}
public static func staleKeys(
in storedKeys: [String],
activeCommunityIDs: Set<String>
) -> [String] {
storedKeys.filter { key in
guard key.hasPrefix(keyPrefix) else { return false }
return !activeCommunityIDs.contains(String(key.dropFirst(keyPrefix.count)))
}
}
}

View File

@@ -0,0 +1,73 @@
import XCTest
@testable import BuzzPushKit
final class PushWatermarkTests: XCTestCase {
func testClampsFutureTimestampToAllowedClockSkew() {
XCTAssertEqual(
PushWatermark.persistedTimestamp(
eventTimestamp: 2_000,
now: 1_000,
allowedFutureSkew: 300
),
1_300
)
}
func testPreservesTimestampWithinAllowedClockSkew() {
XCTAssertEqual(
PushWatermark.persistedTimestamp(
eventTimestamp: 1_200,
now: 1_000,
allowedFutureSkew: 300
),
1_200
)
}
func testRejectsEventsBeyondAllowedClockSkew() {
XCTAssertFalse(
PushWatermark.isAcceptable(
eventTimestamp: 1_301,
now: 1_000,
allowedFutureSkew: 300
)
)
XCTAssertTrue(
PushWatermark.isAcceptable(
eventTimestamp: 1_300,
now: 1_000,
allowedFutureSkew: 300
)
)
}
func testRepairsPoisonedStoredWatermarkToCurrentTime() {
XCTAssertEqual(
PushWatermark.queryTimestamp(storedWatermark: 2_000, now: 1_000),
1_000
)
XCTAssertEqual(
PushWatermark.queryTimestamp(storedWatermark: 900, now: 1_000),
900
)
}
func testQuerySinceIsInclusiveForSameSecondEvents() {
XCTAssertEqual(PushWatermark.querySince(watermark: 1_000), 1_000)
XCTAssertNil(PushWatermark.querySince(watermark: 0))
}
func testFindsOnlyRemovedCommunityWatermarks() {
XCTAssertEqual(
PushWatermark.staleKeys(
in: [
PushWatermark.key(communityID: "kept"),
PushWatermark.key(communityID: "removed"),
"unrelated",
],
activeCommunityIDs: ["kept"]
),
[PushWatermark.key(communityID: "removed")]
)
}
}

View File

@@ -77,7 +77,9 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
}
func resolve(completion: @escaping (BuzzPushResolution?) -> Void) {
let communities = loadCommunities().filter {
let loadedCommunities = loadCommunities()
removeStaleWatermarks(activeCommunityIDs: Set(loadedCommunities.map(\.id)))
let communities = loadedCommunities.filter {
$0.pubkey?.isEmpty == false && loadPrivateKey(communityID: $0.id) != nil
}
guard !communities.isEmpty else { completion(nil); return }
@@ -96,10 +98,13 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
group.notify(queue: .global(qos: .userInitiated)) { [weak self] in
guard let self else { return }
let newest = candidates.max {
$0.1.createdAt == $1.1.createdAt ? $0.1.id < $1.1.id : $0.1.createdAt < $1.1.createdAt
$0.1.createdAt == $1.1.createdAt ? $0.1.id > $1.1.id : $0.1.createdAt < $1.1.createdAt
}
for candidate in candidates {
self.defaults?.set(candidate.1.createdAt, forKey: self.watermarkKey(candidate.2.id))
self.defaults?.set(
PushWatermark.persistedTimestamp(eventTimestamp: candidate.1.createdAt),
forKey: PushWatermark.key(communityID: candidate.2.id)
)
}
completion(newest?.0)
}
@@ -113,8 +118,11 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
completion(nil); return
}
var filter: [String: Any] = ["kinds": [9, 40002, 45001, 45003], "#p": [pubkey], "limit": 10]
let watermark = defaults?.integer(forKey: watermarkKey(community.id)) ?? 0
if watermark > 0 { filter["since"] = watermark + 1 }
let watermarkKey = PushWatermark.key(communityID: community.id)
let storedWatermark = defaults?.integer(forKey: watermarkKey) ?? 0
let watermark = PushWatermark.queryTimestamp(storedWatermark: storedWatermark)
if watermark != storedWatermark { defaults?.set(watermark, forKey: watermarkKey) }
if let since = PushWatermark.querySince(watermark: watermark) { filter["since"] = since }
guard let body = try? JSONSerialization.data(withJSONObject: [filter]) else { completion(nil); return }
let url = URL(string: "/query", relativeTo: community.relayURL)!
var request = URLRequest(url: url)
@@ -128,7 +136,12 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
guard let response = response as? HTTPURLResponse, (200..<300).contains(response.statusCode),
let data, let events = try? JSONDecoder().decode([VerifiedNostrEvent].self, from: data)
else { completion(nil); return }
completion(Self.decodeResolution(events: events.filter { $0.hasValidIDAndSignature() }, community: community))
completion(Self.decodeResolution(
events: events.filter {
$0.hasValidIDAndSignature() && PushWatermark.isAcceptable(eventTimestamp: $0.createdAt)
},
community: community
))
}.resume()
}
@@ -164,7 +177,15 @@ final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
pubkey.count > 8 ? String(pubkey.prefix(8)) + "…" : pubkey
}
private func watermarkKey(_ id: String) -> String { "buzz.push.watermark.\(id)" }
private func removeStaleWatermarks(activeCommunityIDs: Set<String>) {
guard let defaults else { return }
for key in PushWatermark.staleKeys(
in: Array(defaults.dictionaryRepresentation().keys),
activeCommunityIDs: activeCommunityIDs
) {
defaults.removeObject(forKey: key)
}
}
private func loadPrivateKey(communityID: String) -> String? {
var query: [String: Any] = [

View File

@@ -24,7 +24,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll();
if (communities.isEmpty) {
await ref.read(communitySnapshotWriterProvider)(communities);
await syncCommunitySnapshot(ref, communities);
return const AuthState(status: AuthStatus.unauthenticated);
}
@@ -37,7 +37,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
await storage.saveActiveId(active.id);
if (_hasValidNsec(active.nsec)) {
await ref.read(communitySnapshotWriterProvider)(communities);
await syncCommunitySnapshot(ref, communities);
return AuthState(status: AuthStatus.authenticated, community: active);
}
@@ -49,7 +49,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
}
await storage.clearActiveId();
await ref.read(communitySnapshotWriterProvider)(communities);
await syncCommunitySnapshot(ref, communities);
return const AuthState(status: AuthStatus.unauthenticated);
}
@@ -83,7 +83,7 @@ class AuthNotifier extends AsyncNotifier<AuthState> {
// forcing the user back to the pairing screen. Refreshing the complete
// snapshot also removes revoked keys from the extension Keychain.
final remaining = await storage.loadAll();
await ref.read(communitySnapshotWriterProvider)(remaining);
await syncCommunitySnapshot(ref, remaining);
// Invalidate community providers so other consumers pick up the change.
ref.invalidate(communityListProvider);

View File

@@ -21,9 +21,47 @@ final communitySnapshotWriterProvider = Provider<CommunitySnapshotWriter>((
return registerBuzzPushCommunitySnapshot;
});
final _communitySnapshotSyncProvider = Provider<_CommunitySnapshotSync>((ref) {
return _CommunitySnapshotSync(ref.read(communitySnapshotWriterProvider));
});
class _CommunitySnapshotSync {
_CommunitySnapshotSync(this._writer);
final CommunitySnapshotWriter _writer;
String? _lastSuccessfulSnapshot;
Future<void> write(List<Community> communities) async {
final fingerprint = communities
.map(
(community) => [
community.id,
community.name,
community.relayUrl,
community.pubkey,
community.nsec,
].join('\u0000'),
)
.join('\u0001');
if (fingerprint == _lastSuccessfulSnapshot) return;
await _writer(communities);
_lastSuccessfulSnapshot = fingerprint;
}
}
Future<void> syncCommunitySnapshot(Ref ref, List<Community> communities) async {
try {
await ref.read(_communitySnapshotSyncProvider).write(communities);
pushCommunitySnapshotError.value = null;
} catch (error, stackTrace) {
reportPushCommunitySnapshotError(error, stackTrace);
}
}
Future<void> syncStoredCommunitySnapshot(Ref ref) async {
final communities = await ref.read(communityStorageProvider).loadAll();
await ref.read(communitySnapshotWriterProvider)(communities);
await syncCommunitySnapshot(ref, communities);
}
class CommunityListNotifier extends AsyncNotifier<List<Community>> {
@@ -31,7 +69,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
Future<List<Community>> build() async {
final storage = ref.read(communityStorageProvider);
final communities = await storage.loadAll();
await ref.read(communitySnapshotWriterProvider)(communities);
await syncCommunitySnapshot(ref, communities);
return communities;
}
@@ -56,14 +94,14 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
final updatedList = [...current];
updatedList[existingIndex] = updated;
state = AsyncData(updatedList);
await registerBuzzPushCommunitySnapshot(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return existing.id;
}
await storage.save(community);
final updatedList = [...current, community];
state = AsyncData(updatedList);
await registerBuzzPushCommunitySnapshot(updatedList);
await syncCommunitySnapshot(ref, updatedList);
return community.id;
}
@@ -74,7 +112,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
final current = state.value ?? [];
final updatedList = current.where((w) => w.id != id).toList();
state = AsyncData(updatedList);
await registerBuzzPushCommunitySnapshot(updatedList);
await syncCommunitySnapshot(ref, updatedList);
// If we removed the active community, switch to another or sign out.
final activeId = await storage.loadActiveId();
@@ -115,7 +153,7 @@ class CommunityListNotifier extends AsyncNotifier<List<Community>> {
final updatedList = [...current];
updatedList[index] = updated;
state = AsyncData(updatedList);
await registerBuzzPushCommunitySnapshot(updatedList);
await syncCommunitySnapshot(ref, updatedList);
}
}

View File

@@ -14,6 +14,17 @@ const _channel = MethodChannel('buzz/push');
final apnsDeviceToken = ValueNotifier<String?>(null);
final apnsRegistrationError = ValueNotifier<String?>(null);
/// Latest failure to export the community snapshot used by the iOS
/// notification service extension. Snapshot export is push enrichment and must
/// never gate authentication or community persistence.
final pushCommunitySnapshotError = ValueNotifier<String?>(null);
void reportPushCommunitySnapshotError(Object error, StackTrace stackTrace) {
pushCommunitySnapshotError.value = error.toString();
debugPrint('Push community snapshot export failed: $error');
debugPrintStack(stackTrace: stackTrace);
}
Future<void> registerBuzzPushCommunitySnapshot(
List<Community> communities,
) async {

View File

@@ -1,3 +1,4 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
@@ -5,6 +6,7 @@ import 'package:buzz/shared/auth/auth_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/community/community_storage.dart';
import 'package:buzz/shared/push/push_bridge.dart';
import '../community/community_storage_test.dart';
@@ -122,6 +124,68 @@ void main() {
},
);
test(
'snapshot export failure does not gate startup authentication',
() async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final community = Community.create(
name: 'Existing',
relayUrl: 'https://existing.example',
nsec: nostr.Keys.generate().nsec,
);
await storage.save(community);
await storage.saveActiveId(community.id);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((_) async {
throw PlatformException(
code: 'save_failed',
message: 'Keychain unavailable',
);
}),
],
);
addTearDown(container.dispose);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.authenticated);
expect(auth.community?.id, community.id);
expect(pushCommunitySnapshotError.value, contains('save_failed'));
},
);
test('snapshot export failure does not gate direct authentication', () async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final community = Community.create(
name: 'Added',
relayUrl: 'https://added.example',
nsec: nostr.Keys.generate().nsec,
);
final container = ProviderContainer(
overrides: [
communityStorageProvider.overrideWithValue(storage),
communitySnapshotWriterProvider.overrideWithValue((_) async {
throw PlatformException(
code: 'save_failed',
message: 'Keychain unavailable',
);
}),
],
);
addTearDown(container.dispose);
await container
.read(authProvider.notifier)
.authenticateWithCommunity(community);
final auth = await container.read(authProvider.future);
expect(auth.status, AuthStatus.authenticated);
expect(auth.community?.id, community.id);
expect((await storage.loadAll()).single.id, community.id);
});
test('falls through to the next valid saved community', () async {
final storage = CommunityStorage(secure: FakeSecureStorage());
final invalid = Community.create(

View File

@@ -59,6 +59,22 @@ void main() {
expect(fakeSecure['buzz_workspaces'], isNull);
});
test('skips an unchanged snapshot after provider invalidation', () async {
final community = Community.create(
name: 'Stored',
relayUrl: 'https://stored.example.com',
nsec: nostr.Keys.generate().nsec,
);
await communityStorage.save(community);
container = createContainer();
await container.read(communityListProvider.future);
container.invalidate(communityListProvider);
await container.read(communityListProvider.future);
expect(snapshots, hasLength(1));
});
test('addCommunity adds to list', () async {
container = createContainer();
await container.read(communityListProvider.future);