mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
NotificationService.shared and FavoritesPersistenceService.shared accesses across nine coordinators/components consolidate into ChatViewModel witnesses behind intent-named context members (notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:), allFavoriteRelationships, postLocalNotification, ...). Singleton reach from coordinators is now zero; nine new tests cover previously untestable notification/favorites flows. Also root-causes the long-flaky gift-wrap dedup test: parallel tests share LocationChannelManager.shared, so channel-switch tests trigger clearProcessedNostrEvents() on every live ChatViewModel, wiping the dedup record mid-test. The invariant (forged-signature copies never poison dedup) now lives as a deterministic NostrInboundPipeline mock-context test; two Schnorr concurrency probes added along the way stay as regression guards for the P256K shared-context assumptions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
501 lines
20 KiB
Swift
501 lines
20 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import Foundation
|
|
|
|
/// The narrow surface `NostrInboundPipeline` needs from its owner.
|
|
///
|
|
/// Split out of `ChatNostrContext`: member names are shared with the sibling
|
|
/// component contexts so `ChatViewModel` provides a single witness for each.
|
|
@MainActor
|
|
protocol NostrInboundPipelineContext: AnyObject {
|
|
var currentGeohash: String? { get }
|
|
|
|
// MARK: Event dedup
|
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool
|
|
func recordProcessedNostrEvent(_ eventID: String)
|
|
|
|
// MARK: Nostr identity & blocking
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
|
func currentNostrIdentity() -> NostrIdentity?
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
|
|
|
// MARK: Favorites bridge
|
|
/// All favorite relationships, used to bridge a Nostr pubkey back to a
|
|
/// Noise key on the inbound DM path.
|
|
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
|
|
|
|
// MARK: Presence & key mapping
|
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
|
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
|
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
|
|
func recordGeoParticipant(pubkeyHex: String)
|
|
|
|
// MARK: Inbound public messages
|
|
func handlePublicMessage(_ message: BitchatMessage)
|
|
func checkForMentions(_ message: BitchatMessage)
|
|
func sendHapticFeedback(for message: BitchatMessage)
|
|
func parseMentions(from content: String) -> [String]
|
|
|
|
// MARK: Inbound private (DM) payloads
|
|
func handlePrivateMessage(
|
|
_ payload: NoisePayload,
|
|
senderPubkey: String,
|
|
convKey: PeerID,
|
|
id: NostrIdentity,
|
|
messageTimestamp: Date
|
|
)
|
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
|
|
}
|
|
|
|
extension ChatViewModel: NostrInboundPipelineContext {
|
|
// `currentGeohash`, the identity/blocking members, key mapping, and the
|
|
// inbound message handlers already have witnesses on `ChatViewModel`.
|
|
// The members below flatten nested service accesses into intent-named calls.
|
|
|
|
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
|
|
deduplicationService.hasProcessedNostrEvent(eventID)
|
|
}
|
|
|
|
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
|
|
Array(FavoritesPersistenceService.shared.favorites.values)
|
|
}
|
|
|
|
func recordProcessedNostrEvent(_ eventID: String) {
|
|
deduplicationService.recordNostrEvent(eventID)
|
|
}
|
|
|
|
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
|
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
|
|
}
|
|
|
|
func recordGeoParticipant(pubkeyHex: String) {
|
|
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
|
|
}
|
|
}
|
|
|
|
/// The inbound Nostr hot path: raw relay events in, chat messages / Noise
|
|
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
|
///
|
|
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
|
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
|
/// dominate real relay traffic; events are recorded only AFTER verification so
|
|
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
|
/// verification for the account mailbox runs off-main with an atomic
|
|
/// main-actor check-and-record.
|
|
final class NostrInboundPipeline {
|
|
private weak var context: (any NostrInboundPipelineContext)?
|
|
private let presence: GeoPresenceTracker
|
|
private var geoEventLogCount = 0
|
|
|
|
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
|
|
self.context = context
|
|
self.presence = presence
|
|
}
|
|
|
|
@MainActor
|
|
func subscribeNostrEvent(_ event: NostrEvent) {
|
|
guard let context else { return }
|
|
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
|
// duplicates dominate real traffic and must not pay for crypto.
|
|
// Only verified events are recorded, so a forged-signature copy can
|
|
// never poison the dedup set and suppress the genuine event.
|
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
|
!context.hasProcessedNostrEvent(event.id)
|
|
else {
|
|
return
|
|
}
|
|
guard event.isValidSignature() else { return }
|
|
|
|
context.recordProcessedNostrEvent(event.id)
|
|
|
|
if let gh = context.currentGeohash,
|
|
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
|
|
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
|
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
if Date().timeIntervalSince(eventTime) < 15 {
|
|
return
|
|
}
|
|
}
|
|
|
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
let nick = nickTag[1].trimmed
|
|
context.setGeoNickname(nick, forPubkey: event.pubkey)
|
|
}
|
|
|
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
|
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
|
|
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
return
|
|
}
|
|
|
|
if GeoPresenceTracker.hasTeleportTag(event) {
|
|
let key = event.pubkey.lowercased()
|
|
let isSelf: Bool = {
|
|
if let gh = context.currentGeohash,
|
|
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
|
|
return myIdentity.publicKeyHex.lowercased() == key
|
|
}
|
|
return false
|
|
}()
|
|
if !isSelf {
|
|
presence.scheduleMarkPeerTeleported(key, logged: false)
|
|
}
|
|
}
|
|
|
|
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
|
let content = event.content.trimmed
|
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
let timestamp = min(rawTs, Date())
|
|
let mentions = context.parseMentions(from: content)
|
|
let message = BitchatMessage(
|
|
id: event.id,
|
|
sender: senderName,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false,
|
|
senderPeerID: PeerID(nostr: event.pubkey),
|
|
mentions: mentions.isEmpty ? nil : mentions
|
|
)
|
|
|
|
Task { @MainActor [weak context] in
|
|
guard let context else { return }
|
|
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
|
|
context.handlePublicMessage(message)
|
|
if !isBlocked {
|
|
context.checkForMentions(message)
|
|
context.sendHapticFeedback(for: message)
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handleNostrEvent(_ event: NostrEvent) {
|
|
guard let context else { return }
|
|
// Cheap rejects (kind, dedup lookup) before Schnorr verification —
|
|
// duplicates dominate real traffic and must not pay for crypto.
|
|
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
|
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
|
else {
|
|
return
|
|
}
|
|
if context.hasProcessedNostrEvent(event.id) { return }
|
|
guard event.isValidSignature() else { return }
|
|
context.recordProcessedNostrEvent(event.id)
|
|
|
|
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
|
|
geoEventLogCount += 1
|
|
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
|
|
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
|
|
}
|
|
|
|
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
|
return
|
|
}
|
|
|
|
let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event)
|
|
|
|
let isSelf: Bool = {
|
|
if let gh = context.currentGeohash,
|
|
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
|
|
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
|
|
}
|
|
return false
|
|
}()
|
|
|
|
if hasTeleportTag, !isSelf {
|
|
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
|
|
}
|
|
|
|
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
|
|
|
if isSelf {
|
|
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
if Date().timeIntervalSince(eventTime) < 15 {
|
|
return
|
|
}
|
|
}
|
|
|
|
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
|
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
|
}
|
|
|
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
|
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
|
|
|
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
|
return
|
|
}
|
|
|
|
let senderName = context.displayNameForNostrPubkey(event.pubkey)
|
|
let content = event.content
|
|
|
|
if let teleTag = event.tags.first(where: { $0.first == "t" }),
|
|
teleTag.count >= 2,
|
|
teleTag[1] == "teleport",
|
|
content.trimmed.isEmpty {
|
|
return
|
|
}
|
|
|
|
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
|
let mentions = context.parseMentions(from: content)
|
|
let message = BitchatMessage(
|
|
id: event.id,
|
|
sender: senderName,
|
|
content: content,
|
|
timestamp: min(rawTs, Date()),
|
|
isRelay: false,
|
|
senderPeerID: PeerID(nostr: event.pubkey),
|
|
mentions: mentions.isEmpty ? nil : mentions
|
|
)
|
|
|
|
Task { @MainActor [weak context] in
|
|
guard let context else { return }
|
|
context.handlePublicMessage(message)
|
|
context.checkForMentions(message)
|
|
context.sendHapticFeedback(for: message)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
guard let context else { return }
|
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
|
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
|
guard giftWrap.isValidSignature() else { return }
|
|
context.recordProcessedNostrEvent(giftWrap.id)
|
|
|
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
|
giftWrap: giftWrap,
|
|
recipientIdentity: id
|
|
),
|
|
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
|
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
let noisePayload = NoisePayload.decode(packet.payload)
|
|
else {
|
|
return
|
|
}
|
|
|
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
let convKey = PeerID(nostr_: senderPubkey)
|
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
|
|
|
switch noisePayload.type {
|
|
case .privateMessage:
|
|
context.handlePrivateMessage(
|
|
noisePayload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: id,
|
|
messageTimestamp: messageTimestamp
|
|
)
|
|
case .delivered:
|
|
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
case .readReceipt:
|
|
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
|
case .verifyChallenge, .verifyResponse:
|
|
break
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
|
guard let context else { return }
|
|
// Dedup lookup before Schnorr verification; record only after it passes.
|
|
if context.hasProcessedNostrEvent(giftWrap.id) {
|
|
return
|
|
}
|
|
guard giftWrap.isValidSignature() else { return }
|
|
context.recordProcessedNostrEvent(giftWrap.id)
|
|
|
|
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
|
|
giftWrap: giftWrap,
|
|
recipientIdentity: id
|
|
) else {
|
|
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", category: .session)
|
|
return
|
|
}
|
|
|
|
SecureLogger.debug(
|
|
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
|
|
category: .session
|
|
)
|
|
|
|
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
|
|
packet.type == MessageType.noiseEncrypted.rawValue,
|
|
let payload = NoisePayload.decode(packet.payload)
|
|
else {
|
|
return
|
|
}
|
|
|
|
let convKey = PeerID(nostr_: senderPubkey)
|
|
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
|
|
|
switch payload.type {
|
|
case .privateMessage:
|
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
|
|
context.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: convKey,
|
|
id: id,
|
|
messageTimestamp: messageTimestamp
|
|
)
|
|
case .delivered:
|
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
case .readReceipt:
|
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
|
|
case .verifyChallenge, .verifyResponse:
|
|
break
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
func handleNostrMessage(_ giftWrap: NostrEvent) {
|
|
guard let context else { return }
|
|
// Cheap dedup pre-check only; Schnorr verification runs off-main in
|
|
// processNostrMessage, which then does the authoritative
|
|
// check-and-record. Recording stays after verification so a
|
|
// forged-signature copy can never poison the dedup set and suppress
|
|
// the genuine event.
|
|
if context.hasProcessedNostrEvent(giftWrap.id) { return }
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.processNostrMessage(giftWrap)
|
|
}
|
|
}
|
|
|
|
func processNostrMessage(_ giftWrap: NostrEvent) async {
|
|
guard giftWrap.isValidSignature() else { return }
|
|
guard let context else { return }
|
|
// Authoritative check-and-record, atomic on the main actor so two
|
|
// concurrent detached tasks can't both process the same event.
|
|
let alreadyProcessed: Bool = await MainActor.run {
|
|
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
|
|
context.recordProcessedNostrEvent(giftWrap.id)
|
|
return false
|
|
}
|
|
if alreadyProcessed { return }
|
|
let currentIdentity: NostrIdentity? = await MainActor.run {
|
|
context.currentNostrIdentity()
|
|
}
|
|
guard let currentIdentity else { return }
|
|
|
|
do {
|
|
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
|
|
giftWrap: giftWrap,
|
|
recipientIdentity: currentIdentity
|
|
)
|
|
|
|
if content.hasPrefix("verify:") {
|
|
return
|
|
}
|
|
|
|
if content.hasPrefix("bitchat1:") {
|
|
let packet: BitchatPacket? = await MainActor.run {
|
|
Self.decodeEmbeddedBitChatPacket(from: content)
|
|
}
|
|
guard let packet else {
|
|
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
|
|
return
|
|
}
|
|
|
|
let actualSenderNoiseKey: Data? = await MainActor.run {
|
|
self.findNoiseKey(for: senderPubkey)
|
|
}
|
|
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
|
|
|
|
if packet.type == MessageType.noiseEncrypted.rawValue,
|
|
let payload = NoisePayload.decode(packet.payload) {
|
|
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
|
|
await MainActor.run {
|
|
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
|
|
|
|
switch payload.type {
|
|
case .privateMessage:
|
|
context.handlePrivateMessage(
|
|
payload,
|
|
senderPubkey: senderPubkey,
|
|
convKey: targetPeerID,
|
|
id: currentIdentity,
|
|
messageTimestamp: messageTimestamp
|
|
)
|
|
case .delivered:
|
|
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
case .readReceipt:
|
|
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
|
|
case .verifyChallenge, .verifyResponse:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
|
|
}
|
|
}
|
|
|
|
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
|
|
/// store. Lives here because the inbound DM path needs it per message;
|
|
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
|
|
@MainActor
|
|
func findNoiseKey(for nostrPubkey: String) -> Data? {
|
|
guard let context else { return nil }
|
|
let favorites = context.allFavoriteRelationships()
|
|
var npubToMatch = nostrPubkey
|
|
|
|
if !nostrPubkey.hasPrefix("npub") {
|
|
if let pubkeyData = Data(hexString: nostrPubkey),
|
|
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
|
|
npubToMatch = encoded
|
|
} else {
|
|
SecureLogger.warning(
|
|
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
|
|
category: .session
|
|
)
|
|
}
|
|
}
|
|
|
|
for relationship in favorites {
|
|
if let storedNostrKey = relationship.peerNostrPublicKey {
|
|
if storedNostrKey == npubToMatch {
|
|
return relationship.peerNoisePublicKey
|
|
}
|
|
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
|
|
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
|
|
return relationship.peerNoisePublicKey
|
|
}
|
|
}
|
|
}
|
|
|
|
SecureLogger.debug(
|
|
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
|
|
category: .session
|
|
)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private extension NostrInboundPipeline {
|
|
@MainActor
|
|
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
|
|
guard content.hasPrefix("bitchat1:") else { return nil }
|
|
let encoded = String(content.dropFirst("bitchat1:".count))
|
|
let maxBytes = FileTransferLimits.maxFramedFileBytes
|
|
let maxEncoded = ((maxBytes + 2) / 3) * 4
|
|
guard encoded.count <= maxEncoded else { return nil }
|
|
guard let packetData = Base64URLCoding.decode(encoded),
|
|
packetData.count <= maxBytes
|
|
else {
|
|
return nil
|
|
}
|
|
return BitchatPacket.from(packetData)
|
|
}
|
|
}
|