Files
bitchat/bitchatTests/Services/BLEPublicMessageHandlerTests.swift
T
jackandClaude Fable 5 ca63893197 Fix security audit findings: 3 critical, 7 high
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>
2026-06-12 14:07:28 +02:00

258 lines
10 KiB
Swift

import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct BLEPublicMessageHandlerTests {
private final class Recorder {
var localNickname = "Me"
var peers: [PeerID: BLEPeerInfo] = [:]
var signedName: String?
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
var selfBroadcastMessageID: String?
var peersSnapshotReads = 0
var signedNameQueries: [PeerID] = []
var trackedPackets: [BitchatPacket] = []
var selfBroadcastTakes: [BitchatPacket] = []
var deliveries: [(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)] = []
}
private let localPeerID = PeerID(str: "0102030405060708")
private let remotePeerID = PeerID(str: "1122334455667788")
private func makeHandler(
recorder: Recorder,
localPeerID: PeerID? = nil,
now: Date = Date(timeIntervalSince1970: 1_000)
) -> BLEPublicMessageHandler {
let resolvedLocalPeerID = localPeerID ?? self.localPeerID
let environment = BLEPublicMessageHandlerEnvironment(
localPeerID: { resolvedLocalPeerID },
localNickname: { recorder.localNickname },
now: { now },
peersSnapshot: {
recorder.peersSnapshotReads += 1
return recorder.peers
},
signedSenderDisplayName: { _, peerID in
recorder.signedNameQueries.append(peerID)
return recorder.signedName
},
trackPacketSeen: { packet in
recorder.trackedPackets.append(packet)
},
linkState: { _ in recorder.linkState },
takeSelfBroadcastMessageID: { packet in
recorder.selfBroadcastTakes.append(packet)
return recorder.selfBroadcastMessageID
},
deliverPublicMessage: { peerID, nickname, content, timestamp, messageID in
recorder.deliveries.append((peerID, nickname, content, timestamp, messageID))
}
)
return BLEPublicMessageHandler(environment: environment)
}
@Test
func verifiedPeerBroadcastIsTrackedAndDelivered() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
// A valid packet signature is required even for a registry-verified peer:
// senderID is spoofable, so registry membership alone is not authentication.
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "hello mesh", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
#expect(recorder.peersSnapshotReads == 1)
// Signature is verified, then the registry's collision-resolved name is preferred.
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.selfBroadcastTakes.isEmpty)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.peerID == remotePeerID)
#expect(recorder.deliveries.first?.nickname == "Alice")
#expect(recorder.deliveries.first?.content == "hello mesh")
#expect(recorder.deliveries.first?.timestamp == now)
#expect(recorder.deliveries.first?.messageID == nil)
}
@Test
func selfEchoIsDropped() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: localPeerID, content: "echo", timestamp: timestamp(now), ttl: 3)
handler.handle(packet, from: localPeerID)
expectNoSideEffects(recorder)
}
@Test
func staleBroadcastIsDropped() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
let handler = makeHandler(recorder: recorder, now: now)
let staleTimestamp = UInt64((now.timeIntervalSince1970 - 901) * 1000)
let packet = makeMessagePacket(sender: remotePeerID, content: "old", timestamp: staleTimestamp)
handler.handle(packet, from: remotePeerID)
expectNoSideEffects(recorder)
}
@Test
func unknownPeerWithoutValidSignatureIsDroppedBeforeSyncTracking() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
#expect(recorder.signedNameQueries == [remotePeerID])
// The sender must resolve before the packet is tracked for sync.
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test
func connectedButUnverifiedPeerIsDropped() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Mallory", isVerified: false, isConnected: true)]
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "hi", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
// Public messages never accept connected-but-unverified registry entries.
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
@Test
func signedSenderFallbackDeliversWithSignedName() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, content: "signed hello", timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
#expect(recorder.signedNameQueries == [remotePeerID])
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.nickname == "SignedAlice")
}
@Test
func invalidUTF8PayloadIsTrackedForSyncButNotDelivered() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(sender: remotePeerID, payload: Data([0xFF, 0xFE, 0xFD]), timestamp: timestamp(now))
handler.handle(packet, from: remotePeerID)
// Sync tracking happens before payload decoding, matching the original order.
#expect(recorder.trackedPackets.count == 1)
#expect(recorder.deliveries.isEmpty)
}
@Test
func selfSyncReplayResolvesOriginalMessageID() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.selfBroadcastMessageID = "original-id"
let handler = makeHandler(recorder: recorder, now: now)
// TTL 0 marks a sync replay of our own broadcast, not a self echo.
let packet = makeMessagePacket(sender: localPeerID, content: "mine", timestamp: timestamp(now), ttl: 0)
handler.handle(packet, from: localPeerID)
#expect(recorder.selfBroadcastTakes.count == 1)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.peerID == localPeerID)
#expect(recorder.deliveries.first?.nickname == recorder.localNickname)
#expect(recorder.deliveries.first?.messageID == "original-id")
}
@Test
func directedMessageIsDeliveredWithoutSyncTracking() {
let now = Date(timeIntervalSince1970: 1_000)
let recorder = Recorder()
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
recorder.signedName = "SignedAlice"
let handler = makeHandler(recorder: recorder, now: now)
let packet = makeMessagePacket(
sender: remotePeerID,
content: "direct",
timestamp: timestamp(now),
recipientID: Data(hexString: localPeerID.id)
)
handler.handle(packet, from: remotePeerID)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.deliveries.count == 1)
#expect(recorder.deliveries.first?.content == "direct")
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)
#expect(recorder.selfBroadcastTakes.isEmpty)
#expect(recorder.deliveries.isEmpty)
}
private func makePeerInfo(
_ peerID: PeerID,
nickname: String,
isVerified: Bool,
isConnected: Bool = true
) -> BLEPeerInfo {
BLEPeerInfo(
peerID: peerID,
nickname: nickname,
isConnected: isConnected,
noisePublicKey: nil,
signingPublicKey: nil,
isVerifiedNickname: isVerified,
lastSeen: Date(timeIntervalSince1970: 999)
)
}
private func makeMessagePacket(
sender: PeerID,
content: String? = nil,
payload: Data? = nil,
timestamp: UInt64,
ttl: UInt8 = TransportConfig.messageTTLDefault,
recipientID: Data? = nil
) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: recipientID,
timestamp: timestamp,
payload: payload ?? Data((content ?? "").utf8),
signature: nil,
ttl: ttl
)
}
private func timestamp(_ date: Date) -> UInt64 {
UInt64(date.timeIntervalSince1970 * 1000)
}
}