Keep peer AppKeys roster subscriptions live

This commit is contained in:
Dev 2026-07-27 13:07:57 +03:00
parent 716a1235b3
commit 99b0beaf1a
2 changed files with 243 additions and 9 deletions

View File

@ -66,6 +66,11 @@ final class NdrNostrService {
private var activeSubIDs = Set<String>()
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<String>()
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
}

View File

@ -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<String>()
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"],