mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Keep peer AppKeys roster subscriptions live
This commit is contained in:
parent
716a1235b3
commit
99b0beaf1a
@ -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
|
||||
}
|
||||
|
||||
@ -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"],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user