mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:45:18 +00:00
Feat/mesh robustness efficiency (#451)
* chat: de-dup private chats across ephemeral/stable IDs; prefer most advanced delivery status\n\n- Fixes LazyVStack duplicate ID warnings and blank row in PM\n- Merges messages by id from ephemeral and Noise-key stores\n- Chooses read > delivered > partiallyDelivered > sent > sending > failed (newer wins on tie)\n- Ensures status icon updates immediately without waiting for another send\n- Adds exhaustive handling for DeliveryStatus in ranking * logging: reduce noisy info logs to debug; keep errors/warnings\n\n- Downgrade routing/ACK/subscription/connect logs to debug\n- Retain security/fingerprint/keychain info logs\n- Keep errors and warnings intact\n\ndocs: add docs/privacy-assessment.md covering BLE privacy, routing TTL/jitter, Nostr E2E gift wraps, ACK throttling, and logging posture --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -97,7 +97,7 @@ class NostrRelayManager: ObservableObject {
|
||||
) {
|
||||
messageHandlers[id] = handler
|
||||
|
||||
SecureLogger.log("📡 Setting up subscription '\(id)' with filter - kinds: \(filter.kinds ?? []), since: \(filter.since ?? 0)",
|
||||
SecureLogger.log("📡 Subscribing to Nostr filter id=\(id) kinds=\(filter.kinds ?? []) since=\(filter.since ?? 0)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
|
||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||
|
||||
@@ -177,6 +177,18 @@ class FavoritesPersistenceService: ObservableObject {
|
||||
favorites[peerNoisePublicKey]
|
||||
}
|
||||
|
||||
/// Resolve favorite status by short peer ID (16-hex derived from Noise pubkey)
|
||||
/// Falls back to scanning favorites and matching on derived peer ID.
|
||||
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
|
||||
// Quick sanity: peerID should be 16 hex chars (8 bytes)
|
||||
guard peerID.count == 16 else { return nil }
|
||||
for (pubkey, rel) in favorites {
|
||||
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
|
||||
if derived == peerID { return rel }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Update Nostr public key for a peer
|
||||
func updateNostrPublicKey(for peerNoisePublicKey: Data, nostrPubkey: String) {
|
||||
guard let existing = favorites[peerNoisePublicKey] else { return }
|
||||
|
||||
@@ -5,31 +5,72 @@ import Foundation
|
||||
final class MessageRouter {
|
||||
private let mesh: Transport
|
||||
private let nostr: NostrTransport
|
||||
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
|
||||
|
||||
init(mesh: Transport, nostr: NostrTransport) {
|
||||
self.mesh = mesh
|
||||
self.nostr = nostr
|
||||
self.nostr.senderPeerID = mesh.myPeerID
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .favoriteStatusChanged,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] note in
|
||||
guard let self = self else { return }
|
||||
if let data = note.userInfo?["peerPublicKey"] as? Data {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: data)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
}
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
let peerID = PeerIDUtils.derivePeerID(fromPublicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
let hasMesh = mesh.isPeerConnected(peerID)
|
||||
let hasEstablished = mesh.getNoiseService().hasEstablishedSession(with: peerID)
|
||||
if hasMesh && hasEstablished {
|
||||
SecureLogger.log("Routing PM via mesh to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||
} else {
|
||||
// Queue for later (when mesh connects or Nostr mapping appears)
|
||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
||||
outbox[peerID]?.append((content, recipientNickname, messageID))
|
||||
SecureLogger.log("Queued PM for \(peerID.prefix(8))… (no mesh, no Nostr mapping) id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
// Prefer mesh only if a Noise session is established; else use Nostr to avoid handshakeRequired spam
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
SecureLogger.log("Routing READ ack via mesh to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendReadReceipt(receipt, to: peerID)
|
||||
} else {
|
||||
SecureLogger.log("Routing READ ack via Nostr to \(peerID.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendReadReceipt(receipt, to: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
func sendDeliveryAck(_ messageID: String, to peerID: String) {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
if mesh.isPeerConnected(peerID) && mesh.getNoiseService().hasEstablishedSession(with: peerID) {
|
||||
mesh.sendDeliveryAck(for: messageID, to: peerID)
|
||||
} else {
|
||||
nostr.sendDeliveryAck(for: messageID, to: peerID)
|
||||
@@ -43,4 +84,40 @@ final class MessageRouter {
|
||||
nostr.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Outbox Management
|
||||
private func canSendViaNostr(peerID: String) -> Bool {
|
||||
guard let noiseKey = Data(hexString: peerID) else { return false }
|
||||
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
fav.peerNostrPublicKey != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func flushOutbox(for peerID: String) {
|
||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||
SecureLogger.log("Flushing outbox for \(peerID.prefix(8))… count=\(queued.count)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Prefer mesh if connected; else try Nostr if mapping exists
|
||||
for (content, nickname, messageID) in queued {
|
||||
if mesh.isPeerConnected(peerID) {
|
||||
SecureLogger.log("Outbox -> mesh for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
mesh.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else if canSendViaNostr(peerID: peerID) {
|
||||
SecureLogger.log("Outbox -> Nostr for \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Remove all flushed items (remaining ones, if any, will be re-queued on next call)
|
||||
outbox[peerID]?.removeAll()
|
||||
}
|
||||
|
||||
func flushAllOutbox() {
|
||||
for key in outbox.keys { flushOutbox(for: key) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,15 @@ final class NostrTransport: Transport {
|
||||
// Provide BLE short peer ID for BitChat embedding
|
||||
var senderPeerID: String = ""
|
||||
|
||||
// Throttle READ receipts to avoid relay rate limits
|
||||
private struct QueuedRead {
|
||||
let receipt: ReadReceipt
|
||||
let peerID: String
|
||||
}
|
||||
private var readQueue: [QueuedRead] = []
|
||||
private var isSendingReadAcks = false
|
||||
private let readAckInterval: TimeInterval = 0.35 // ~3 per second
|
||||
|
||||
var myPeerID: String { senderPeerID }
|
||||
var myNickname: String { "" }
|
||||
func setNickname(_ nickname: String) { /* not used for Nostr */ }
|
||||
@@ -34,37 +43,137 @@ final class NostrTransport: Transport {
|
||||
|
||||
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
// Resolve favorite by full noise key or by short peerID fallback
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Convert recipient npub -> hex (x-only)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else {
|
||||
SecureLogger.log("NostrTransport: recipient key not npub (hrp=\(hrp))", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch {
|
||||
SecureLogger.log("NostrTransport: failed to decode npub -> hex: \(error)", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.log("NostrTransport: failed to embed PM packet", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.log("NostrTransport: failed to build Nostr event for PM", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
|
||||
// Enqueue and process with throttling to avoid relay rate limits
|
||||
readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||
processReadQueueIfNeeded()
|
||||
}
|
||||
|
||||
private func processReadQueueIfNeeded() {
|
||||
guard !isSendingReadAcks else { return }
|
||||
guard !readQueue.isEmpty else { return }
|
||||
isSendingReadAcks = true
|
||||
sendNextReadAck()
|
||||
}
|
||||
|
||||
private func sendNextReadAck() {
|
||||
guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
|
||||
let item = readQueue.removeFirst()
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: receipt.originalMessageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: item.peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, item.peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
|
||||
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { scheduleNextReadAck(); return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { scheduleNextReadAck(); return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.log("NostrTransport: failed to embed READ ack", category: SecureLogger.session, level: .error)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.log("NostrTransport: failed to build Nostr event for READ ack", category: SecureLogger.session, level: .error)
|
||||
scheduleNextReadAck(); return
|
||||
}
|
||||
SecureLogger.log("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
scheduleNextReadAck()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextReadAck() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + readAckInterval) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.isSendingReadAcks = false
|
||||
self.processReadQueueIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Convert recipient npub -> hex
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.log("NostrTransport: failed to embed favorite notification", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.log("NostrTransport: failed to build Nostr event for favorite notification", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
@@ -72,12 +181,34 @@ final class NostrTransport: Transport {
|
||||
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
|
||||
func sendDeliveryAck(for messageID: String, to peerID: String) {
|
||||
Task { @MainActor in
|
||||
guard let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||
let recipientNostrPubkey = fav.peerNostrPublicKey else { return }
|
||||
var recipientNostrPubkey: String?
|
||||
if let noiseKey = Data(hexString: peerID),
|
||||
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
|
||||
recipientNostrPubkey = fav.peerNostrPublicKey
|
||||
}
|
||||
if recipientNostrPubkey == nil, peerID.count == 16 {
|
||||
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
|
||||
}
|
||||
guard let recipientNpub = recipientNostrPubkey else { return }
|
||||
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID),
|
||||
let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientNostrPubkey, senderIdentity: senderIdentity) else { return }
|
||||
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))…",
|
||||
category: SecureLogger.session, level: .info)
|
||||
let recipientHex: String
|
||||
do {
|
||||
let (hrp, data) = try Bech32.decode(recipientNpub)
|
||||
guard hrp == "npub" else { return }
|
||||
recipientHex = data.hexEncodedString()
|
||||
} catch { return }
|
||||
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.log("NostrTransport: failed to embed DELIVERED ack", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
|
||||
SecureLogger.log("NostrTransport: failed to build Nostr event for DELIVERED ack", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))…",
|
||||
category: SecureLogger.session, level: .info)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ class PrivateChatManager: ObservableObject {
|
||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||
|
||||
weak var meshService: Transport?
|
||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||
weak var messageRouter: MessageRouter?
|
||||
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
@@ -91,8 +93,15 @@ class PrivateChatManager: ObservableObject {
|
||||
privateChats[senderPeerID] = []
|
||||
}
|
||||
|
||||
// Add message
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
// Deduplicate by ID: replace existing message if present, else append
|
||||
if let idx = privateChats[senderPeerID]?.firstIndex(where: { $0.id == message.id }) {
|
||||
privateChats[senderPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[senderPeerID]?.append(message)
|
||||
}
|
||||
|
||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||
sanitizeChat(for: senderPeerID)
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
@@ -110,6 +119,25 @@ class PrivateChatManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove duplicate messages by ID and keep chronological order
|
||||
func sanitizeChat(for peerID: String) {
|
||||
guard let arr = privateChats[peerID] else { return }
|
||||
var seen = Set<String>()
|
||||
var deduped: [BitchatMessage] = []
|
||||
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
|
||||
if !seen.contains(msg.id) {
|
||||
seen.insert(msg.id)
|
||||
deduped.append(msg)
|
||||
} else {
|
||||
// Replace previous with the latest occurrence (which is later in sort)
|
||||
if let index = deduped.firstIndex(where: { $0.id == msg.id }) {
|
||||
deduped[index] = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
privateChats[peerID] = deduped
|
||||
}
|
||||
|
||||
/// Mark messages from a peer as read
|
||||
func markAsRead(from peerID: String) {
|
||||
unreadMessages.remove(peerID)
|
||||
@@ -183,7 +211,16 @@ class PrivateChatManager: ObservableObject {
|
||||
readerNickname: meshService?.myNickname ?? ""
|
||||
)
|
||||
|
||||
// Send through mesh service's read receipt method
|
||||
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
||||
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
|
||||
if let router = messageRouter {
|
||||
SecureLogger.log("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
Task { @MainActor in
|
||||
router.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
} else {
|
||||
// Fallback: preserve previous behavior
|
||||
meshService?.sendReadReceipt(receipt, to: senderPeerID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve short mesh ID (16-hex) from a full Noise public key hex (64-hex)
|
||||
@MainActor
|
||||
func getShortIDForNoiseKey(_ fullNoiseKeyHex: String) -> String? {
|
||||
// Check known peers for a noise key match
|
||||
if let match = allPeers.first(where: { $0.noisePublicKey.hexEncodedString() == fullNoiseKeyHex }) {
|
||||
return match.id
|
||||
}
|
||||
// Also search cache mapping
|
||||
if let pair = shortIDToNoiseKey.first(where: { $0.value == fullNoiseKeyHex }) {
|
||||
return pair.key
|
||||
}
|
||||
return nil
|
||||
}
|
||||
private var peerIndex: [String: BitchatPeer] = [:]
|
||||
|
||||
// MARK: - Autocomplete Properties
|
||||
@@ -277,6 +291,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.unifiedPeerService = UnifiedPeerService(meshService: meshService)
|
||||
let nostrTransport = NostrTransport()
|
||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||
// Route receipts from PrivateChatManager through MessageRouter
|
||||
self.privateChatManager.messageRouter = self.messageRouter
|
||||
self.autocompleteService = AutocompleteService()
|
||||
|
||||
// Wire up dependencies
|
||||
@@ -312,6 +328,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Initialize Nostr services
|
||||
Task { @MainActor in
|
||||
nostrRelayManager = NostrRelayManager.shared
|
||||
SecureLogger.log("Initializing Nostr relay connections", category: SecureLogger.session, level: .debug)
|
||||
nostrRelayManager?.connect()
|
||||
|
||||
// Small delay to ensure read receipts are fully loaded
|
||||
@@ -321,6 +338,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Set up Nostr message handling directly
|
||||
setupNostrMessageHandling()
|
||||
|
||||
// Attempt to flush any queued outbox after Nostr comes online
|
||||
messageRouter.flushAllOutbox()
|
||||
|
||||
// End startup phase after 2 seconds
|
||||
// During startup phase, we:
|
||||
// 1. Skip cleanup of read receipts
|
||||
@@ -863,6 +883,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// Send via appropriate transport (BLE if connected, else Nostr when possible)
|
||||
if isConnected || (isMutualFavorite && hasNostrKey) {
|
||||
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname ?? "user", messageID: messageID)
|
||||
// Optimistically mark as sent for both transports; delivery/read will update subsequently
|
||||
if let idx = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
privateChats[peerID]?[idx].deliveryStatus = .sent
|
||||
}
|
||||
} else {
|
||||
// Update delivery status to failed
|
||||
if let index = privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
|
||||
@@ -1469,27 +1493,51 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
@MainActor
|
||||
func getPrivateChatMessages(for peerID: String) -> [BitchatMessage] {
|
||||
var allMessages: [BitchatMessage] = []
|
||||
var combined: [BitchatMessage] = []
|
||||
|
||||
// Get messages stored under the ephemeral peer ID
|
||||
// Gather messages under the ephemeral peer ID
|
||||
if let ephemeralMessages = privateChats[peerID] {
|
||||
allMessages.append(contentsOf: ephemeralMessages)
|
||||
combined.append(contentsOf: ephemeralMessages)
|
||||
}
|
||||
|
||||
// Also check if we have messages stored under the stable Noise key hex
|
||||
// This happens for Nostr messages which use stable keys to persist across reconnections
|
||||
// Also include messages stored under the stable Noise key (Nostr path)
|
||||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
||||
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
|
||||
|
||||
// Only add if it's different from the ephemeral peer ID (to avoid duplicates)
|
||||
if noiseKeyHex != peerID,
|
||||
let nostrMessages = privateChats[noiseKeyHex] {
|
||||
allMessages.append(contentsOf: nostrMessages)
|
||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex] {
|
||||
combined.append(contentsOf: nostrMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by timestamp to maintain chronological order
|
||||
return allMessages.sorted { $0.timestamp < $1.timestamp }
|
||||
// De-duplicate by message ID: keep the item with the most advanced delivery status.
|
||||
// This prevents duplicate IDs causing LazyVStack warnings and blank rows, and ensures
|
||||
// we show the row whose status has already progressed to delivered/read.
|
||||
func statusRank(_ s: DeliveryStatus?) -> Int {
|
||||
guard let s = s else { return 0 }
|
||||
switch s {
|
||||
case .failed: return 1
|
||||
case .sending: return 2
|
||||
case .sent: return 3
|
||||
case .partiallyDelivered: return 4
|
||||
case .delivered: return 5
|
||||
case .read: return 6
|
||||
}
|
||||
}
|
||||
|
||||
var bestByID: [String: BitchatMessage] = [:]
|
||||
for msg in combined {
|
||||
if let existing = bestByID[msg.id] {
|
||||
let lhs = statusRank(existing.deliveryStatus)
|
||||
let rhs = statusRank(msg.deliveryStatus)
|
||||
if rhs > lhs || (rhs == lhs && msg.timestamp > existing.timestamp) {
|
||||
bestByID[msg.id] = msg
|
||||
}
|
||||
} else {
|
||||
bestByID[msg.id] = msg
|
||||
}
|
||||
}
|
||||
|
||||
// Return chronologically sorted, de-duplicated list
|
||||
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@@ -2395,7 +2443,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
// MARK: - Peer Connection Events
|
||||
|
||||
func didConnectToPeer(_ peerID: String) {
|
||||
SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .info)
|
||||
SecureLogger.log("🤝 Peer connected: \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Handle all main actor work async
|
||||
Task { @MainActor in
|
||||
@@ -2424,13 +2472,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let noiseKeyHex = peer.noisePublicKey.hexEncodedString()
|
||||
shortIDToNoiseKey[peerID] = noiseKeyHex
|
||||
}
|
||||
|
||||
// Flush any queued messages for this peer via router
|
||||
messageRouter.flushOutbox(for: peerID)
|
||||
}
|
||||
|
||||
// Connection messages removed to reduce chat noise
|
||||
}
|
||||
|
||||
func didDisconnectFromPeer(_ peerID: String) {
|
||||
SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .info)
|
||||
SecureLogger.log("👋 Peer disconnected: \(peerID)", category: SecureLogger.session, level: .debug)
|
||||
|
||||
// Remove ephemeral session from identity manager
|
||||
SecureIdentityStateManager.shared.removeEphemeralSession(peerID: peerID)
|
||||
@@ -2902,7 +2953,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
)
|
||||
|
||||
if privateChats[targetPeerID] == nil { privateChats[targetPeerID] = [] }
|
||||
privateChats[targetPeerID]?.append(message)
|
||||
if let idx = privateChats[targetPeerID]?.firstIndex(where: { $0.id == messageId }) {
|
||||
privateChats[targetPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[targetPeerID]?.append(message)
|
||||
}
|
||||
// Sanitize to avoid duplicate IDs
|
||||
privateChatManager.sanitizeChat(for: targetPeerID)
|
||||
trimPrivateChatMessagesIfNeeded(for: targetPeerID)
|
||||
|
||||
// Mirror to ephemeral if applicable
|
||||
@@ -2910,14 +2967,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
let ephemeralPeerID = unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.id,
|
||||
ephemeralPeerID != targetPeerID {
|
||||
if privateChats[ephemeralPeerID] == nil { privateChats[ephemeralPeerID] = [] }
|
||||
if !privateChats[ephemeralPeerID]!.contains(where: { $0.id == messageId }) {
|
||||
if let idx = privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == messageId }) {
|
||||
privateChats[ephemeralPeerID]?[idx] = message
|
||||
} else {
|
||||
privateChats[ephemeralPeerID]?.append(message)
|
||||
trimPrivateChatMessagesIfNeeded(for: ephemeralPeerID)
|
||||
}
|
||||
privateChatManager.sanitizeChat(for: ephemeralPeerID)
|
||||
}
|
||||
|
||||
// Send delivery ack via Nostr embedded if not previously read and we know sender's Noise key
|
||||
if !wasReadBefore, let key = actualSenderNoiseKey {
|
||||
SecureLogger.log("Sending DELIVERED ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendDeliveryAck(messageId, to: key.hexEncodedString())
|
||||
}
|
||||
|
||||
@@ -2931,6 +2992,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}
|
||||
if !sentReadReceipts.contains(messageId), let key = actualSenderNoiseKey {
|
||||
let receipt = ReadReceipt(originalMessageID: messageId, readerID: meshService.myPeerID, readerNickname: nickname)
|
||||
SecureLogger.log("Viewing chat; sending READ ack for \(messageId.prefix(8))… via router", category: SecureLogger.session, level: .debug)
|
||||
messageRouter.sendReadReceipt(receipt, to: key.hexEncodedString())
|
||||
sentReadReceipts.insert(messageId)
|
||||
}
|
||||
@@ -3409,10 +3471,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
// Add or update messages for the new peer ID
|
||||
if var existingMessages = privateChats[peerID] {
|
||||
// Merge with existing messages, avoiding duplicates
|
||||
let existingIds = Set(existingMessages.map { $0.id })
|
||||
for msg in migratedMessages where !existingIds.contains(msg.id) {
|
||||
existingMessages.append(msg)
|
||||
// Merge with existing messages, replace-by-id semantics
|
||||
for msg in migratedMessages {
|
||||
if let i = existingMessages.firstIndex(where: { $0.id == msg.id }) {
|
||||
existingMessages[i] = msg
|
||||
} else {
|
||||
existingMessages.append(msg)
|
||||
}
|
||||
}
|
||||
existingMessages.sort { $0.timestamp < $1.timestamp }
|
||||
privateChats[peerID] = existingMessages
|
||||
@@ -3421,6 +3486,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
privateChats[peerID] = migratedMessages
|
||||
}
|
||||
trimPrivateChatMessagesIfNeeded(for: peerID)
|
||||
privateChatManager.sanitizeChat(for: peerID)
|
||||
|
||||
// Update selectedPrivateChatPeer if it was pointing to an old ID
|
||||
if needsSelectedUpdate {
|
||||
|
||||
@@ -956,6 +956,13 @@ struct ContentView: View {
|
||||
if !isMeshConnected, let stable = viewModel.getNoiseKeyForShortID(privatePeerID) {
|
||||
return stable
|
||||
}
|
||||
} else if privatePeerID.count == 64 {
|
||||
// If we have a full Noise key and a corresponding short ID is currently mesh-connected, prefer short ID
|
||||
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
|
||||
if viewModel.meshService.isPeerConnected(short) || viewModel.connectedPeers.contains(short) {
|
||||
return short
|
||||
}
|
||||
}
|
||||
}
|
||||
return privatePeerID
|
||||
}()
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
BitChat Privacy Assessment
|
||||
==========================
|
||||
|
||||
Scope
|
||||
- Mesh transport (BLE) behavior and metadata minimization
|
||||
- Nostr-based private message fallback (gift-wrapped, end-to-end encrypted)
|
||||
- Read receipts and delivery acknowledgments
|
||||
- Logging/telemetry posture and controls
|
||||
|
||||
Summary
|
||||
- No accounts, no servers for mesh; Nostr used only for mutual favorites, with end-to-end Noise encryption encapsulated in gift wraps.
|
||||
- BLE announces contain only nickname and Noise pubkey. No device name, no plaintext identity beyond what the user broadcasts.
|
||||
- Discovery and flooding incorporate jitter and TTL caps to reduce linkability and propagation radius of encrypted payloads.
|
||||
- UI and storage remain ephemeral; message content is not persisted to disk. Minimal state (e.g., read-receipt IDs) is stored for UX and is bounded/cleaned.
|
||||
- Logging defaults to conservative levels; debug verbosity is suppressed for release builds. A single env var can raise/lower threshold when needed.
|
||||
|
||||
BLE Privacy Considerations
|
||||
- Announce content: Unchanged — nickname + Noise public key only.
|
||||
- Local Name: Not used (explicitly disabled). Avoids leaking device/OS identity.
|
||||
- Address: iOS uses BLE MAC randomization; BitChat does not attempt to set static addresses.
|
||||
- Announce jitter: Each announce is delayed by a small random jitter to avoid synchronization-based correlation.
|
||||
- Scanning: Foreground scanning uses “allow duplicates” briefly to improve discovery latency; background uses standard scanning parameters.
|
||||
- RSSI gating: The acceptance threshold adapts to nearby density (approx. -95 to -80 dBm) to reduce long-distance observations in dense areas and improve connectivity in sparse ones.
|
||||
- Fragmentation: Fragments use write-with-response for reliability (less re-broadcast churn = fewer repeated signals).
|
||||
- GATT permissions: Private characteristic disallows .read; we use notify/write/writeWithoutResponse to avoid exposing plaintext attributes over GATT.
|
||||
|
||||
Mesh Routing and Multi-hop Limits
|
||||
- Encrypted relays permitted with random per-hop delay (small jitter) to smooth floods.
|
||||
- TTL cap: Encrypted payloads are capped at 2 hops, limiting metadata spread and path reconstruction risk while enabling close-range relays.
|
||||
|
||||
Nostr Private Messaging Fallback
|
||||
- Usage criteria: Only attempted for mutual favorites or where a Nostr key has been exchanged (stored in favorites).
|
||||
- Payload confidentiality: Messages embed a BitChat Noise-encrypted packet inside a NIP-17 gift wrap; relays see only random-looking ciphertext.
|
||||
- Timestamp handling: Gift wraps add small randomized offsets to reduce exact timing correlation.
|
||||
- Read/delivery acks: Also encapsulated in gift wraps, preserving content secrecy and minimizing metadata.
|
||||
- Relay policy variance: Some relays apply “web-of-trust” policies and may reject events; BitChat tolerates partial delivery and still prefers mesh when available.
|
||||
|
||||
Read Receipts and Delivery Acks
|
||||
- Routing policy: Prefer mesh if Noise session established; otherwise use Nostr when mapping exists.
|
||||
- Throttling: Nostr READ acks are queued and rate-limited (~3/s) to prevent relay rate limits during backlogs.
|
||||
- Coalescing (optional future): When entering a chat with many unread, only send READ for the latest message, marking older as read locally to reduce metadata.
|
||||
|
||||
Data Retention and State
|
||||
- Messages: Ephemeral in-memory only; history is bounded per chat and trimmed.
|
||||
- Read-receipt IDs: Stored in `UserDefaults` for UX continuity; periodically pruned to IDs present in memory.
|
||||
- Favorites: Noise and optional Nostr keys with petnames; can be wiped via panic action.
|
||||
- Panic: Triple-tap clears keys, sessions, cached state, and disconnects transports.
|
||||
|
||||
Logging and Telemetry
|
||||
- Centralized `SecureLogger` filters potential secrets and uses OSLog privacy markers.
|
||||
- Default level: `info`; release builds suppress debug. Developers can set `BITCHAT_LOG_LEVEL=debug|info|warning|error|fault`.
|
||||
- Transport routing, ACK sends, subscribe/connect noise were downgraded from info→debug.
|
||||
- OS/system errors (e.g., transient WebSocket disconnects) may still appear in system logs; BitChat avoids re-logging those unless actionable.
|
||||
|
||||
Residual Risks and Mitigations
|
||||
- RF fingerprinting: BLE presence is observable at the RF layer; mitigated by minimal announce content and platform MAC randomization.
|
||||
- Timing correlation: Announce/relay jitter reduces but does not eliminate timing analysis. Avoids synchronized bursts.
|
||||
- Relay metadata: Nostr relays can see that an account posts gift wraps; content remains end-to-end encrypted. Favor mesh path when in range.
|
||||
|
||||
Recommendations (Next)
|
||||
- Add optional coalesced READ behavior for large backlogs.
|
||||
- Expose a “low-visibility mode” to reduce scanning aggressiveness in sensitive contexts.
|
||||
- Allow user-configurable Nostr relay set with a “private relays only” toggle.
|
||||
|
||||
Reference in New Issue
Block a user