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:
jack
2025-08-17 21:25:25 +02:00
committed by GitHub
co-authored by jack
parent 6fbf7eee25
commit 4f1ac30f12
8 changed files with 447 additions and 53 deletions
+1 -1
View File
@@ -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 }
+81 -4
View File
@@ -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) }
}
}
+152 -21
View File
@@ -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)
}
}
+41 -4
View File
@@ -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)
}
}
}
+86 -20
View File
@@ -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 {
+7
View File
@@ -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
}()
+64
View File
@@ -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.