mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
* Bridge dedup keys on a content-derived stable mesh message ID Public mesh messages carry no message ID on the BLE wire, so every non-origin device minted a fresh UUID and the bridge's m-tag dedup only matched on the origin device: duplicate bridged rows, misattributed "across the bridge" counts, redundant downlink rebroadcasts, and an m-tag spoof vector (an attacker could claim a victim's message ID). Every device now derives the same stable ID from the signed wire fields (sender ID + ms timestamp + trimmed content, SHA256/32 hex) via the new MeshMessageIdentity — zero BLE wire change. The bridge event's m tag carries the origin coordinates ["m", senderIDHex, timestampMs] and receivers recompute the key from those plus the event's own content instead of trusting a claimed ID; old-format/absent tags fall back to the event ID as before. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Fix mixed-version message loss: m tag leads with the derived stable ID The previous layout (["m", senderIDHex, timestampMs]) broke v1.7.0 receivers: their parser takes m[1] unconditionally as the timeline dedup key whenever the tag has >= 2 elements, so every bridged message from a new-version sender keyed on the CONSTANT sender hex and inject-dedup dropped all but the first. The tag is now ["m", <derived stable ID>, senderIDHex, timestampMs]: old parsers get a per-message-unique m[1] (exactly today's semantics), while the new parser recomputes the ID from elements 2-3 plus the event's own content and never trusts element 1, keeping the recompute-don't-trust property. Also: - Soften the overstated security claim in MeshMessageIdentity and the BridgeService classify comment: forging a chosen ID onto different content is infeasible, but all three hash inputs are cleartext on the radio, so identical-content front-running by a radio-local attacker remains possible (no worse than the unbridged mesh). - Fix the stale archivedEchoKeys rationale: re-synced copies of others' messages now carry the derived stable ID (insert-by-ID catches them); the content key remains for echo--prefixed archive rows + self echoes. - Tests: old-parser semantics on the new tag (m[1] per-message-unique and equal to the derived ID) and a forged-m[1] event that cannot pre-poison a genuine message's dedup slot. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
328 lines
14 KiB
Swift
328 lines
14 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 verifyPacketSignatureResult = false
|
|
var linkState: (hasPeripheral: Bool, hasCentral: Bool) = (false, false)
|
|
var selfBroadcastMessageID: String?
|
|
|
|
var peersSnapshotReads = 0
|
|
var verifyPacketSignatureQueries: [PeerID] = []
|
|
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
|
|
},
|
|
verifyPacketSignature: { packet, _ in
|
|
recorder.verifyPacketSignatureQueries.append(PeerID(hexData: packet.senderID))
|
|
return recorder.verifyPacketSignatureResult
|
|
},
|
|
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)
|
|
// No message ID on the wire: the handler derives the stable one
|
|
// every device agrees on for the same sender/timestamp/content.
|
|
#expect(recorder.deliveries.first?.messageID == MeshMessageIdentity.stableID(
|
|
senderIDHex: remotePeerID.id,
|
|
timestampMs: timestamp(now),
|
|
content: "hello mesh"
|
|
))
|
|
}
|
|
|
|
@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_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 - TransportConfig.syncPublicMessageMaxAgeSeconds - 1) * 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 registryVerifiedPeerDeliveredBeforeIdentityCachePersists() {
|
|
// A freshly verified announce updates the peer registry synchronously,
|
|
// but identity-cache persistence is async. A message arriving in that
|
|
// window has a valid signature and a registry signing key, yet the
|
|
// persisted-identity lookup (signedName) would still return nil. It must
|
|
// be verified against the registry key and delivered, not dropped.
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(
|
|
remotePeerID,
|
|
nickname: "Alice",
|
|
isVerified: true,
|
|
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
|
)]
|
|
recorder.verifyPacketSignatureResult = true
|
|
recorder.signedName = nil
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "first msg", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
|
// Verified via the registry key, so no fallback to the persisted lookup.
|
|
#expect(recorder.signedNameQueries.isEmpty)
|
|
#expect(recorder.trackedPackets.count == 1)
|
|
#expect(recorder.deliveries.count == 1)
|
|
#expect(recorder.deliveries.first?.nickname == "Alice")
|
|
#expect(recorder.deliveries.first?.content == "first msg")
|
|
}
|
|
|
|
@Test
|
|
func registryPeerWithInvalidSignatureFallsBackAndDrops() {
|
|
// Spoofed senderID: the peer is in the registry with a signing key, but
|
|
// the packet signature does not verify against it. The handler must fall
|
|
// back to the persisted lookup and, finding nothing, drop the message.
|
|
let now = Date(timeIntervalSince1970: 1_000)
|
|
let recorder = Recorder()
|
|
recorder.peers = [remotePeerID: makePeerInfo(
|
|
remotePeerID,
|
|
nickname: "Alice",
|
|
isVerified: true,
|
|
signingPublicKey: Data(repeating: 0xAB, count: 32)
|
|
)]
|
|
recorder.verifyPacketSignatureResult = false
|
|
recorder.signedName = nil
|
|
let handler = makeHandler(recorder: recorder, now: now)
|
|
let packet = makeMessagePacket(sender: remotePeerID, content: "spoofed", timestamp: timestamp(now))
|
|
|
|
handler.handle(packet, from: remotePeerID)
|
|
|
|
#expect(recorder.verifyPacketSignatureQueries == [remotePeerID])
|
|
#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,
|
|
signingPublicKey: Data? = nil
|
|
) -> BLEPeerInfo {
|
|
BLEPeerInfo(
|
|
peerID: peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: nil,
|
|
signingPublicKey: signingPublicKey,
|
|
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)
|
|
}
|
|
}
|