Files
bitchat/bitchatTests/Protocols/BridgeWireFormatTests.swift
T
84d315e62d Bridge dedup: content-derived stable mesh message ID (#1419)
* 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>
2026-07-09 16:44:47 +02:00

197 lines
7.8 KiB
Swift

//
// BridgeWireFormatTests.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import BitFoundation
import Foundation
import Testing
@testable import bitchat
@Suite("Bridge wire formats")
struct BridgeWireFormatTests {
// MARK: - Announce bridgeGeohash TLV
@Test func announceRoundTripsBridgeGeohash() throws {
let packet = AnnouncementPacket(
nickname: "gw",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil,
capabilities: [.bridge, .gateway],
bridgeGeohash: "u4pruy"
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == "u4pruy")
#expect(decoded.capabilities?.contains(.bridge) == true)
}
@Test func announceWithoutBridgeCellDecodesNil() throws {
let packet = AnnouncementPacket(
nickname: "plain",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil
)
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == nil)
}
@Test func announceRejectsOversizedBridgeCellAtEncode() throws {
let packet = AnnouncementPacket(
nickname: "gw",
noisePublicKey: Data(repeating: 1, count: 32),
signingPublicKey: Data(repeating: 2, count: 32),
directNeighbors: nil,
bridgeGeohash: String(repeating: "u", count: 13)
)
// Oversized cell is silently omitted, not a hard failure.
let encoded = try #require(packet.encode())
let decoded = try #require(AnnouncementPacket.decode(from: encoded))
#expect(decoded.bridgeGeohash == nil)
}
// MARK: - Carrier directions
@Test func bridgeCarrierDirectionsRoundTrip() throws {
for direction in [NostrCarrierPacket.Direction.toBridge, .fromBridge] {
let packet = try #require(NostrCarrierPacket(
direction: direction,
geohash: "u4pruy",
eventJSON: Data("{\"id\":\"x\"}".utf8)
))
let encoded = try #require(packet.encode())
let decoded = try #require(NostrCarrierPacket.decode(encoded))
#expect(decoded.direction == direction)
#expect(decoded.geohash == "u4pruy")
}
}
// MARK: - BitchatMessage bridged flag
// (Binary round-trip lives in BitFoundation's own tests —
// `toBinaryPayload` is internal to the package.)
@Test func bridgedFlagSurvivesCodableRoundTrip() throws {
let message = BitchatMessage(
sender: "far-friend",
content: "hi",
timestamp: Date(),
isRelay: false,
isBridged: true
)
let data = try JSONEncoder().encode(message)
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: data)
#expect(decoded.isBridged)
}
@Test func legacyJSONWithoutBridgedFlagDecodes() throws {
let plain = BitchatMessage(
sender: "old-client",
content: "hi",
timestamp: Date(),
isRelay: false
)
let encoded = try JSONEncoder().encode(plain)
var json = try #require(try JSONSerialization.jsonObject(with: encoded) as? [String: Any])
json.removeValue(forKey: "isBridged")
let decoded = try JSONDecoder().decode(BitchatMessage.self, from: JSONSerialization.data(withJSONObject: json))
#expect(!decoded.isBridged)
}
@Test func bridgePeerIDParsesAndClassifies() {
let peerID = PeerID(str: "bridge:deadbeefcafe0123")
#expect(peerID.isBridge)
#expect(peerID.bare == "deadbeefcafe0123")
#expect(!peerID.isGeoChat)
}
}
@Suite("Mesh message identity")
struct MeshMessageIdentityTests {
@Test func stableIDIsDeterministicHex() {
let id = MeshMessageIdentity.stableID(
senderIDHex: "0011223344556677",
timestampMs: 1_750_000_000_123,
content: "hello mesh"
)
// Pinned vector: first 32 hex chars of
// SHA256("0011223344556677|1750000000123|hello mesh"). Any drift
// breaks cross-device (and cross-version) dedup.
#expect(id == "b83f94d81dcdd1b0c0048f6645995dd4")
#expect(id == MeshMessageIdentity.stableID(
senderIDHex: "0011223344556677",
timestampMs: 1_750_000_000_123,
content: "hello mesh"
))
}
@Test func senderIDIsCaseInsensitive() {
let lower = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "x")
let upper = MeshMessageIdentity.stableID(senderIDHex: "AABBCCDD00112233", timestampMs: 1, content: "x")
#expect(lower == upper)
}
@Test func contentWhitespaceIsNormalized() {
// Senders bridge the trimmed content while the radio carries the
// original; both must derive the same key.
let raw = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: " hello mesh \n")
let trimmed = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 1, content: "hello mesh")
#expect(raw == trimmed)
}
@Test func anyCoordinateChangeChangesTheID() {
let base = MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "x")
#expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112234", timestampMs: 5, content: "x"))
#expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 6, content: "x"))
#expect(base != MeshMessageIdentity.stableID(senderIDHex: "aabbccdd00112233", timestampMs: 5, content: "y"))
}
@Test func millisecondTimestampTruncatesLikeTheWire() {
// Must match `BLEService.sendMessage`'s UInt64(seconds * 1000).
#expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000.9996)) == 1_000_999)
#expect(MeshMessageIdentity.millisecondTimestamp(Date(timeIntervalSince1970: 1_000)) == 1_000_000)
}
}
@Suite("Courier store bridge publish")
struct CourierStoreBridgePublishTests {
private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore {
CourierStore(persistsToDisk: false, now: now)
}
private func makeEnvelope(now: Date = Date()) -> CourierEnvelope {
CourierEnvelope(
recipientTag: Data(repeating: 3, count: 16),
expiry: UInt64((now.timeIntervalSince1970 + 3600) * 1000),
ciphertext: Data(repeating: 9, count: 64),
copies: 4
)
}
@Test func bridgePublishIsNonDestructiveAndCooledDown() {
var currentDate = Date()
let store = makeStore(now: { currentDate })
#expect(store.deposit(makeEnvelope(now: currentDate), from: Data(repeating: 5, count: 32)))
let first = store.envelopesForBridgePublish(cooldown: 600)
#expect(first.count == 1)
// The relay copy is carry-only regardless of stored spray budget.
#expect(first.first?.copies == 1)
// (Non-destructiveness is proven below: the same envelope is
// eligible again after the cooldown. `carriedCount` publishes
// asynchronously, so it is not asserted here.)
// Within cooldown: nothing to publish.
#expect(store.envelopesForBridgePublish(cooldown: 600).isEmpty)
// After cooldown: eligible again.
currentDate = currentDate.addingTimeInterval(601)
#expect(store.envelopesForBridgePublish(cooldown: 600).count == 1)
}
}