mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 05:25:19 +00:00
A broad audit surfaced ten critical/high issues across the crypto, transport, identity, and panic-wipe layers. This fixes all ten. Critical: - Nostr DMs were unauthenticated. The NIP-17 seal was signed with a throwaway ephemeral key and the receiver never verified it, so anyone who knows a recipient's npub could forge messages (and delivery/read receipts) into an existing trusted conversation. The seal is now signed with the sender's real identity key, and the receiver verifies the seal signature and that seal.pubkey == rumor.pubkey. NOTE: this is a breaking wire-protocol change (see PR). - Public BLE messages trusted registry membership instead of the packet signature. Since senderID is attacker-controlled, any verified peer could be impersonated in public chat. A valid signature from the claimed sender is now required before any registry identity is used. - Unverified announces still persisted the announced identity, letting a replayed noisePublicKey overwrite a victim's stored signing key and nickname. persistIdentity is now gated on verification. High: - Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix length after nonce extraction) — a remote crash. Now validated. - Identity-cache debounce save used Timer.scheduledTimer on a GCD queue with no run loop, so it never fired; block/verify/favorite changes only persisted on explicit forceSave. Replaced with a DispatchSourceTimer on the queue; forceSave is now serialized. - Identity-cache key load couldn't tell "missing" from a transient keychain failure and would regenerate (deleting) the key, orphaning the cache. Now uses getIdentityKeyWithResult and falls back to a session-only ephemeral key without clobbering the persisted key/cache. - BLE receive-dedup key lacked a payload digest, so post-handshake flushes (queued msgs + delivery/read acks in the same ms) were dropped as duplicates. Digest added, matching the ingress registry. - Maintenance timer was created only in init and never recreated after a panic stop/start, silently degrading the mesh until app restart. Now recreated in startServices. - Panic wipe left persisted location state (selected channel, teleport set, bookmarks) and cached per-geohash Nostr private keys behind. Both are now cleared. - Panic spawned an orphan NostrRelayManager instead of reusing .shared, splitting relay state from every other component. Now reuses .shared. Tests updated to assert the fixed behavior (announce no longer persists unverified identities; public messages require a signature; receive dedup ID includes the payload digest). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
3.4 KiB
Swift
91 lines
3.4 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
|
|
struct BLEReceivedPacketContext: Equatable {
|
|
let senderID: PeerID
|
|
let messageID: String
|
|
let messageType: MessageType?
|
|
let shouldDeduplicate: Bool
|
|
let logsHandlingDetails: Bool
|
|
}
|
|
|
|
struct BLEReceivePipeline {
|
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
|
let senderID = PeerID(hexData: packet.senderID)
|
|
// Include a payload digest so that distinct packets sharing the same
|
|
// sender/timestamp(ms)/type are not collapsed as duplicates. The
|
|
// post-handshake flush sends queued messages, delivery and read receipts
|
|
// back-to-back within a single millisecond; without the digest every
|
|
// packet after the first would be silently dropped.
|
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
|
let messageType = MessageType(rawValue: packet.type)
|
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
|
|
|
return BLEReceivedPacketContext(
|
|
senderID: senderID,
|
|
messageID: messageID,
|
|
messageType: messageType,
|
|
shouldDeduplicate: shouldDeduplicate,
|
|
logsHandlingDetails: messageType != .announce
|
|
)
|
|
}
|
|
|
|
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
|
connectedPeerCount > 2
|
|
}
|
|
|
|
static func relayDecision(
|
|
for packet: BitchatPacket,
|
|
senderID: PeerID,
|
|
localPeerID: PeerID,
|
|
degree: Int,
|
|
highDegreeThreshold: Int
|
|
) -> RelayDecision {
|
|
RelayController.decide(
|
|
ttl: packet.ttl,
|
|
senderIsSelf: senderID == localPeerID,
|
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
|
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
|
degree: degree,
|
|
highDegreeThreshold: highDegreeThreshold
|
|
)
|
|
}
|
|
}
|
|
|
|
struct BLERecentTrafficTracker: Equatable {
|
|
private var packetTimestamps: [Date] = []
|
|
|
|
var count: Int {
|
|
packetTimestamps.count
|
|
}
|
|
|
|
mutating func removeAll() {
|
|
packetTimestamps.removeAll()
|
|
}
|
|
|
|
mutating func recordPacket(at now: Date) {
|
|
packetTimestamps.append(now)
|
|
prune(at: now)
|
|
}
|
|
|
|
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
|
let cutoff = now.addingTimeInterval(-seconds)
|
|
return packetTimestamps.contains { $0 >= cutoff }
|
|
}
|
|
|
|
private mutating func prune(at now: Date) {
|
|
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
|
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
|
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
|
}
|
|
packetTimestamps.removeAll { $0 < cutoff }
|
|
}
|
|
}
|