From 99b0beaf1af6eb639da68857b83e2f0a89968601 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 27 Jul 2026 13:07:57 +0300 Subject: [PATCH] Keep peer AppKeys roster subscriptions live --- bitchat/Services/NdrNostrService.swift | 60 +++++- .../NdrOutOfBandTransportTests.swift | 192 +++++++++++++++++- 2 files changed, 243 insertions(+), 9 deletions(-) diff --git a/bitchat/Services/NdrNostrService.swift b/bitchat/Services/NdrNostrService.swift index 32da5471..b593c6f9 100644 --- a/bitchat/Services/NdrNostrService.swift +++ b/bitchat/Services/NdrNostrService.swift @@ -66,6 +66,11 @@ final class NdrNostrService { private var activeSubIDs = Set() private var appKeysSubscriptionIDByOwner: [String: String] = [:] private var appKeysOwnerBySubscriptionID: [String: String] = [:] + /// Owners whose live AppKeys feed must outlive any one bootstrap attempt. + /// Device authorization and revocation are replaceable kind-37368 state; + /// retaining only the snapshot that admitted an invite would keep removed + /// devices eligible for future outbound fanout. + private var durableAppKeysOwners = Set() private var cachedInviteEventJson: String? private var bufferedDecryptedMessages: [NdrDecryptedMessage] = [] @@ -139,6 +144,7 @@ final class NdrNostrService { activeSubIDs.removeAll() appKeysSubscriptionIDByOwner.removeAll() appKeysOwnerBySubscriptionID.removeAll() + durableAppKeysOwners.removeAll() pendingOutOfBandInvites.removeAll() bufferedDecryptedMessages.removeAll() sessionManager = nil @@ -163,6 +169,7 @@ final class NdrNostrService { ) try mgr.`init`() sessionManager = mgr + restoreDurableAppKeysSubscriptions(using: mgr) _ = drainAndApplyPubSubEvents() SecureLogger.info("NdrNostrService configured pub=\(pubkey.prefix(8))… device=\(deviceId)", category: .session) } catch { @@ -299,6 +306,14 @@ final class NdrNostrService { ) } + if !blockedOnOwnerRoster, + !processingFailed, + hasActiveSession(with: expectedPeer) { + ensureDurableAppKeysSubscription( + ownerPubkeyHex: expectedPeer, + using: mgr + ) + } let outOfBandPublishes = drainAndApplyPubSubEvents(collectOutOfBandPublishes: true) if blockedOnOwnerRoster, let inboundInvite { if let deferredResponseHandler { @@ -363,6 +378,7 @@ final class NdrNostrService { activeSubIDs.removeAll() appKeysSubscriptionIDByOwner.removeAll() appKeysOwnerBySubscriptionID.removeAll() + durableAppKeysOwners.removeAll() pendingOutOfBandInvites.removeAll() nextPendingOutOfBandInviteSequence = 0 bufferedDecryptedMessages.removeAll() @@ -464,6 +480,7 @@ final class NdrNostrService { guard let subid = e.subid else { return } if let owner = appKeysOwnerBySubscriptionID.removeValue(forKey: subid) { appKeysSubscriptionIDByOwner.removeValue(forKey: owner) + durableAppKeysOwners.remove(owner) } guard activeSubIDs.remove(subid) != nil else { return } relayManager.unsubscribe(id: subid) @@ -575,13 +592,49 @@ final class NdrNostrService { if filter.kinds?.contains(1059) == true { return true } - if filter.kinds?.contains(30078) == true, - filter.tagFilters?["l"]?.contains("double-ratchet/invites") == true { + // `setupUser` emits an author-scoped invite-discovery filter without + // the label selector. AppKeys setup is needed for live device + // revocation, but every kind-30078 discovery shape remains BLE-only. + if filter.kinds?.contains(30078) == true { return true } return false } + private func restoreDurableAppKeysSubscriptions( + using mgr: SessionManagerHandle + ) { + for owner in mgr.knownPeerOwnerPubkeys() { + ensureDurableAppKeysSubscription( + ownerPubkeyHex: owner, + using: mgr + ) + } + } + + private func ensureDurableAppKeysSubscription( + ownerPubkeyHex: String, + using mgr: SessionManagerHandle + ) { + guard let owner = Self.normalizedPubkeyHex(ownerPubkeyHex), + !durableAppKeysOwners.contains(owner) + else { + return + } + do { + // The FFI emits both AppKeys and invite-discovery filters. `apply` + // keeps the former and drops the latter under BitChat's BLE-only + // bootstrap policy. + try mgr.setupUser(userPubkeyHex: owner) + durableAppKeysOwners.insert(owner) + } catch { + SecureLogger.error( + "NdrNostrService: failed to retain AppKeys updates for \(owner.prefix(8))…: \(error)", + category: .session + ) + } + } + private func appKeysSubscriptionOwner(_ filter: NostrFilter) -> String? { guard filter.kinds == [37368], filter.authors?.count == 1, @@ -660,6 +713,9 @@ final class NdrNostrService { }) else { return } + guard !durableAppKeysOwners.contains(ownerPubkeyHex) else { + return + } guard let subid = appKeysSubscriptionIDByOwner.removeValue(forKey: ownerPubkeyHex) else { return } diff --git a/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift b/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift index 8668e979..c5694a20 100644 --- a/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift +++ b/bitchatTests/DoubleRatchet/NdrOutOfBandTransportTests.swift @@ -14,11 +14,17 @@ final class FakeRelayManager: NostrRelayManaging { struct Subscription { let id: String let filter: NostrFilter + let handler: (NostrEvent) -> Void } private(set) var subscriptions: [Subscription] = [] private(set) var unsubscribedIDs: [String] = [] private(set) var sentEvents: [NostrEvent] = [] + private var activeSubscriptionIDs = Set() + + var activeSubscriptions: [Subscription] { + subscriptions.filter { activeSubscriptionIDs.contains($0.id) } + } func resetSentEvents() { sentEvents.removeAll() @@ -31,16 +37,31 @@ final class FakeRelayManager: NostrRelayManaging { handler: @escaping (NostrEvent) -> Void, onEOSE: (() -> Void)? ) { - subscriptions.append(Subscription(id: id, filter: filter)) + subscriptions.append( + Subscription(id: id, filter: filter, handler: handler) + ) + activeSubscriptionIDs.insert(id) } func unsubscribe(id: String) { unsubscribedIDs.append(id) + activeSubscriptionIDs.remove(id) } func sendEvent(_ event: NostrEvent, to relayUrls: [String]?) { sentEvents.append(event) } + + func deliver(_ event: NostrEvent, to subscriptionID: String) { + guard activeSubscriptionIDs.contains(subscriptionID), + let subscription = subscriptions.last(where: { + $0.id == subscriptionID + }) + else { + return + } + subscription.handler(event) + } } struct NdrOutOfBandTransportTests { @@ -290,7 +311,12 @@ struct NdrOutOfBandTransportTests { (try? extractNostrKind(json: $0)) == 1059 } ) - #expect(!recipientRelay.unsubscribedIDs.isEmpty) + #expect( + recipientRelay.activeSubscriptions.contains { + $0.filter.kinds == [37368] + && $0.filter.authors == [owner.publicKeyHex] + } + ) } @Test("Delayed owner roster preserves invites from two devices on the same account") @@ -351,10 +377,161 @@ struct NdrOutOfBandTransportTests { #expect(firstResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) #expect(secondResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) - #expect(!relay.unsubscribedIDs.isEmpty) #expect( - Set(relay.unsubscribedIDs) - .isSubset(of: Set(relay.subscriptions.map(\.id))) + relay.activeSubscriptions.contains { + $0.filter.kinds == [37368] + && $0.filter.authors == [owner.publicKeyHex] + } + ) + } + + @Test("Owner roster updates stop outbound fanout to a removed device") + @MainActor + func durableOwnerRoster_removesRevokedDeviceFromOutboundFanout() throws { + let owner = try NostrIdentity.generate() + let firstDevice = try NostrIdentity.generate() + let removedDevice = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let firstManager = try makeChildManager( + identity: firstDevice, + owner: owner, + deviceID: "durable-roster-first" + ) + let removedManager = try makeChildManager( + identity: removedDevice, + owner: owner, + deviceID: "durable-roster-removed" + ) + let firstInvite = try inviteEventJson(from: firstManager) + let removedInvite = try inviteEventJson(from: removedManager) + let relay = FakeRelayManager() + let service = NdrNostrService( + relayManager: relay, + deviceId: "durable-roster-recipient", + rolloutEnabled: true, + storageDirectoryProvider: { + try makeTempDir(label: "ndr-durable-roster-recipient") + } + ) + service.configureIfNeeded(identity: recipient) + var firstResponses: [String] = [] + var removedResponses: [String] = [] + + #expect( + service.processOutOfBandEventJson( + firstInvite, + expectedPeerPubkeyHex: owner.publicKeyHex, + deferredResponseHandler: { firstResponses.append($0) } + ).isEmpty + ) + #expect( + service.processOutOfBandEventJson( + removedInvite, + expectedPeerPubkeyHex: owner.publicKeyHex, + deferredResponseHandler: { removedResponses.append($0) } + ).isEmpty + ) + + let initialTimestamp = Int(Date().timeIntervalSince1970) + service.processInboundRelayEvent( + try makeAppKeysEvent( + owner: owner, + devices: [firstDevice, removedDevice], + timestamp: initialTimestamp + ) + ) + #expect(service.hasActiveSession(with: owner.publicKeyHex)) + #expect(firstResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) + #expect(removedResponses.contains { (try? extractNostrKind(json: $0)) == 1059 }) + + relay.resetSentEvents() + #expect( + service.sendIfPossible( + "bitchat1:before-device-removal", + to: owner.publicKeyHex + ) + ) + #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 2) + + let rosterSubscription = try #require( + relay.activeSubscriptions.first { + $0.filter.kinds == [37368] + && $0.filter.authors == [owner.publicKeyHex] + } + ) + #expect( + !relay.subscriptions.contains { + $0.filter.kinds?.contains(30078) == true + } + ) + relay.deliver( + try makeAppKeysEvent( + owner: owner, + devices: [firstDevice], + timestamp: initialTimestamp + 1 + ), + to: rosterSubscription.id + ) + + relay.resetSentEvents() + #expect( + service.sendIfPossible( + "bitchat1:after-device-removal", + to: owner.publicKeyHex + ) + ) + #expect(relay.sentEvents.filter { $0.kind == 1060 }.count == 1) + } + + @Test("Persisted peer owners restore durable roster subscriptions") + @MainActor + func durableOwnerRoster_restoresAfterRestart() throws { + let peer = try NostrIdentity.generate() + let recipient = try NostrIdentity.generate() + let peerManager = try makeChildManager( + identity: peer, + owner: peer, + deviceID: "durable-restart-peer" + ) + let invite = try inviteEventJson(from: peerManager) + let storage = try makeTempDir(label: "ndr-durable-restart") + + do { + let initialRelay = FakeRelayManager() + let initialService = NdrNostrService( + relayManager: initialRelay, + deviceId: "durable-restart-recipient", + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + initialService.configureIfNeeded(identity: recipient) + _ = initialService.processOutOfBandEventJson( + invite, + expectedPeerPubkeyHex: peer.publicKeyHex + ) + #expect(initialService.hasActiveSession(with: peer.publicKeyHex)) + } + + let restoredRelay = FakeRelayManager() + let restoredService = NdrNostrService( + relayManager: restoredRelay, + deviceId: "durable-restart-recipient", + rolloutEnabled: true, + storageDirectoryProvider: { storage } + ) + restoredService.configureIfNeeded(identity: recipient) + + #expect(restoredService.hasActiveSession(with: peer.publicKeyHex)) + #expect( + restoredRelay.activeSubscriptions.contains { + $0.filter.kinds == [37368] + && $0.filter.authors == [peer.publicKeyHex] + } + ) + #expect( + !restoredRelay.subscriptions.contains { + $0.filter.kinds?.contains(30078) == true + } ) } @@ -733,10 +910,11 @@ struct NdrOutOfBandTransportTests { private func makeAppKeysEvent( owner: NostrIdentity, - devices: [NostrIdentity] + devices: [NostrIdentity], + timestamp: Int? = nil ) throws -> NostrEvent { let profileID = UUID().uuidString.lowercased() - let timestamp = Int(Date().timeIntervalSince1970) + let timestamp = timestamp ?? Int(Date().timeIntervalSince1970) var tags: [[String]] = [ ["d", profileID], ["i", profileID, "subject"],