diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 39417883..41565fc9 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -264,22 +264,33 @@ struct NostrProtocol { /// Create a mesh-bridge public message (kind 20000) for a geohash-cell /// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash - /// channel subscriptions (which filter on `#g`); `m` carries the original - /// mesh message ID so receivers dedup the bridged copy against the radio - /// copy by timeline ID. + /// channel subscriptions (which filter on `#g`); `m` is + /// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the + /// content-stable mesh message ID (`MeshMessageIdentity`) for v1.7.0 + /// parsers, which key their dedup on `m[1]` unconditionally and need it + /// per-message-unique; current parsers ignore it and recompute the ID + /// from the origin coordinates (elements 2-3) plus the event's own + /// content to dedup the bridged copy against the radio copy by + /// timeline ID. static func createBridgeMeshEvent( content: String, cell: String, senderIdentity: NostrIdentity, nickname: String? = nil, - meshMessageID: String? = nil + meshSenderID: String? = nil, + meshTimestampMs: UInt64? = nil ) throws -> NostrEvent { var tags = [["r", cell]] if let nickname = nickname?.trimmedOrNilIfEmpty { tags.append(["n", nickname]) } - if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty { - tags.append(["m", meshMessageID]) + if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs { + let stableID = MeshMessageIdentity.stableID( + senderIDHex: meshSenderID, + timestampMs: meshTimestampMs, + content: content + ) + tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)]) } let event = NostrEvent( pubkey: senderIdentity.publicKeyHex, diff --git a/bitchat/Protocols/MeshMessageIdentity.swift b/bitchat/Protocols/MeshMessageIdentity.swift new file mode 100644 index 00000000..1696c001 --- /dev/null +++ b/bitchat/Protocols/MeshMessageIdentity.swift @@ -0,0 +1,35 @@ +// +// MeshMessageIdentity.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import BitFoundation +import Foundation + +/// Content-derived identity for public mesh messages. +/// +/// The BLE wire carries no message ID for public broadcasts, so every device +/// recomputes the same stable ID from the signed wire fields (sender ID, +/// millisecond timestamp, content). That gives the mesh bridge a +/// cross-device-consistent dedup/attribution key with zero wire change — and +/// because receivers derive the key themselves instead of trusting a claimed +/// ID, forging a bridge tag that binds a chosen ID to *different* content is +/// infeasible. The inputs are cleartext on the radio, though: an attacker in +/// radio range can re-sign the identical sender/timestamp/content under +/// their own Nostr key and win first-wins injection on remote islands — +/// duplicate-content spoofing the unbridged mesh already permits, so no +/// worse than before. +enum MeshMessageIdentity { + /// Matches the wire truncation in `BLEService.sendMessage`. + static func millisecondTimestamp(_ date: Date) -> UInt64 { + UInt64(date.timeIntervalSince1970 * 1000) + } + + static func stableID(senderIDHex: String, timestampMs: UInt64, content: String) -> String { + let input = senderIDHex.lowercased() + "|" + String(timestampMs) + "|" + content.trimmed + return String(Data(input.utf8).sha256Hex().prefix(32)) + } +} diff --git a/bitchat/Services/BLE/BLEPublicMessageHandler.swift b/bitchat/Services/BLE/BLEPublicMessageHandler.swift index 49699c81..7ff786be 100644 --- a/bitchat/Services/BLE/BLEPublicMessageHandler.swift +++ b/bitchat/Services/BLE/BLEPublicMessageHandler.swift @@ -122,10 +122,18 @@ final class BLEPublicMessageHandler { SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) - var resolvedSelfMessageID: String? = nil + let messageID: String? if peerID == env.localPeerID() { - resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet) + messageID = env.takeSelfBroadcastMessageID(packet) + } else { + // The wire carries no message ID; derive the stable one every + // device agrees on so bridged copies dedup against the radio copy. + messageID = MeshMessageIdentity.stableID( + senderIDHex: peerID.id, + timestampMs: packet.timestamp, + content: content + ) } - env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID) + env.deliverPublicMessage(peerID, senderNickname, content, ts, messageID) } } diff --git a/bitchat/Services/Gateway/BridgeService.swift b/bitchat/Services/Gateway/BridgeService.swift index fed06020..7f5d64c6 100644 --- a/bitchat/Services/Gateway/BridgeService.swift +++ b/bitchat/Services/Gateway/BridgeService.swift @@ -47,9 +47,11 @@ import Foundation /// already holds the radio copy (`isMessageSeenLocally` on the event's /// mesh message ID) — remote islands' traffic is the only thing worth /// airtime. -/// Receivers additionally dedup by timeline message ID: a bridged copy -/// carries the original mesh message ID in its `m` tag, so the store's -/// insert-by-ID absorbs radio/bridge duplicates in either arrival order. +/// Receivers additionally dedup by timeline message ID: the wire carries no +/// public message ID, so every device derives the same content-stable one +/// (`MeshMessageIdentity`) from the origin coordinates in the event's `m` +/// tag — recomputed locally, never trusted — and the store's insert-by-ID +/// absorbs radio/bridge duplicates in either arrival order. /// /// All dependencies are closure-injected (repo convention) so the policy /// layer is unit-testable without relays, radios, or CoreLocation. @@ -292,22 +294,32 @@ final class BridgeService: ObservableObject { /// Composes and ships the bridged copy of an outgoing public mesh /// message. Call after the radio send; no-op when the bridge is off, /// no cell is known, or the message was flagged nearby-only upstream. - func bridgeOutgoing(content: String, messageID: String) { + /// `senderPeerID`/`timestamp` are the origin coordinates of the radio + /// send — they (with the content) derive the cross-device-stable mesh + /// message ID that receivers dedup on. + func bridgeOutgoing(content: String, senderPeerID: PeerID, timestamp: Date) { guard isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return } guard content.utf8.count <= Limits.maxContentBytes else { return } + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + let stableID = MeshMessageIdentity.stableID( + senderIDHex: senderPeerID.id, + timestampMs: timestampMs, + content: content + ) guard let identity = try? deriveIdentity?(cell), let event = try? NostrProtocol.createBridgeMeshEvent( content: content, cell: cell, senderIdentity: identity, nickname: myNickname?(), - meshMessageID: messageID + meshSenderID: senderPeerID.id, + meshTimestampMs: timestampMs ) else { SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session) return } publishedEventIDs.insert(event.id) - injectedMessageIDs.insert(messageID) // our own timeline already has it + injectedMessageIDs.insert(stableID) // our own timeline already has it if relaysConnected?() ?? false { publishToRelays?(event, cell) } else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event), @@ -648,8 +660,25 @@ final class BridgeService: ObservableObject { let content = event.content guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil } let nickname = event.tags.first(where: { $0.count >= 2 && $0[0] == "n" })?[1] - let meshID = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1] - let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id + // Recompute — never trust — the mesh message ID: the `m` tag is + // `[stable ID, sender ID, wire timestamp ms]`. Element 1 exists + // for v1.7.0 parsers; we ignore it and derive the ID from the + // origin coordinates (elements 2-3) plus the event's own content, + // so a forged tag cannot bind a chosen ID to different content + // (see `MeshMessageIdentity` for the exact property and its + // limits). + let m = event.tags.first(where: { $0.count >= 2 && $0[0] == "m" }) + let messageID: String + if let m, m.count >= 4, m[2].count == 16, m[2].allSatisfy(\.isHexDigit), + let timestampMs = UInt64(m[3]) { + messageID = MeshMessageIdentity.stableID( + senderIDHex: m[2], + timestampMs: timestampMs, + content: content + ) + } else { + messageID = event.id // old-format or absent tag + } return .message(InboundBridgeMessage( messageID: messageID, senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))", diff --git a/bitchat/ViewModels/ChatOutgoingCoordinator.swift b/bitchat/ViewModels/ChatOutgoingCoordinator.swift index d0638ebd..1bb3e213 100644 --- a/bitchat/ViewModels/ChatOutgoingCoordinator.swift +++ b/bitchat/ViewModels/ChatOutgoingCoordinator.swift @@ -44,7 +44,10 @@ protocol ChatOutgoingContext: AnyObject { func sendGeohash(context: ChatViewModel.GeoOutgoingContext) /// Ships the bridged (rendezvous) copy of a just-sent public mesh /// message; no-op when the bridge is off or the send is nearby-only. - func bridgeOutgoingPublicMessage(_ content: String, messageID: String) + /// Takes the origin coordinates (sender + wire timestamp) — the bridge + /// derives the cross-device-stable mesh message ID from them, not from + /// our local timeline UUID (which no other device can recompute). + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) // MARK: Geohash identity (shared with the other contexts) func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity @@ -66,8 +69,8 @@ extension ChatViewModel: ChatOutgoingContext { lastPublicActivityAt[key] = Date() } - func bridgeOutgoingPublicMessage(_ content: String, messageID: String) { - BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID) + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { + BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp) } } @@ -147,7 +150,7 @@ private extension ChatOutgoingCoordinator { messageID: message.id, timestamp: message.timestamp ) - context.bridgeOutgoingPublicMessage(trimmed, messageID: message.id) + context.bridgeOutgoingPublicMessage(trimmed, senderPeerID: context.myPeerID, timestamp: message.timestamp) } /// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see diff --git a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift index b6623f3a..2cd1b4a9 100644 --- a/bitchat/ViewModels/ChatPublicConversationCoordinator.swift +++ b/bitchat/ViewModels/ChatPublicConversationCoordinator.swift @@ -417,9 +417,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate { /// rate limit; low/no-PoW events keep the strict limits so old clients /// still get through at normal rates. /// Identity keys of the archived echoes seeded into the mesh timeline at - /// launch. The mesh wire format carries no stable message ID, so a - /// re-synced copy of an already-rendered echo arrives with a fresh UUID — - /// this content identity is the only way to recognize it. + /// launch. Re-synced copies of others' messages now arrive with the same + /// derived stable ID (`MeshMessageIdentity`), so the store's insert-by-ID + /// catches those — but the archive-restored rows themselves carry + /// `echo-`-prefixed IDs, and self echoes get fresh UUIDs, so this content + /// identity remains the way to recognize a live copy of an + /// already-rendered echo. private var archivedEchoKeys = Set() func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) { diff --git a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift index 28dabfa9..34963f0a 100644 --- a/bitchatTests/ChatOutgoingCoordinatorContextTests.swift +++ b/bitchatTests/ChatOutgoingCoordinatorContextTests.swift @@ -87,9 +87,9 @@ private final class MockChatOutgoingContext: ChatOutgoingContext { sentGeohashContexts.append(context) } - private(set) var bridgedMessages: [(content: String, messageID: String)] = [] - func bridgeOutgoingPublicMessage(_ content: String, messageID: String) { - bridgedMessages.append((content, messageID)) + private(set) var bridgedMessages: [(content: String, senderPeerID: PeerID, timestamp: Date)] = [] + func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) { + bridgedMessages.append((content, senderPeerID, timestamp)) } // Geohash identity diff --git a/bitchatTests/Protocols/BridgeWireFormatTests.swift b/bitchatTests/Protocols/BridgeWireFormatTests.swift index 95e56920..d4eddd0c 100644 --- a/bitchatTests/Protocols/BridgeWireFormatTests.swift +++ b/bitchatTests/Protocols/BridgeWireFormatTests.swift @@ -111,6 +111,53 @@ struct BridgeWireFormatTests { } } +@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 { diff --git a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift index 74a4441d..4e9b027b 100644 --- a/bitchatTests/Services/BLEPublicMessageHandlerTests.swift +++ b/bitchatTests/Services/BLEPublicMessageHandlerTests.swift @@ -83,7 +83,13 @@ struct BLEPublicMessageHandlerTests { #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) + // 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 diff --git a/bitchatTests/Services/BridgeServiceTests.swift b/bitchatTests/Services/BridgeServiceTests.swift index 31107310..dd813636 100644 --- a/bitchatTests/Services/BridgeServiceTests.swift +++ b/bitchatTests/Services/BridgeServiceTests.swift @@ -117,21 +117,35 @@ struct BridgeServiceTests { // MARK: Event helpers + private static let remoteMeshSenderID = "feedfacecafef00d" + private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000 + private func makeRemoteEvent( cell: String = BridgeServiceTests.cell, content: String = "hi \(UUID().uuidString.prefix(8))", - meshMessageID: String? = UUID().uuidString + meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, + meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs, + identity: NostrIdentity? = nil ) throws -> NostrEvent { - let identity = try NostrIdentity.generate() - return try NostrProtocol.createBridgeMeshEvent( + try NostrProtocol.createBridgeMeshEvent( content: content, cell: cell, - senderIdentity: identity, + senderIdentity: identity ?? NostrIdentity.generate(), nickname: "remote", - meshMessageID: meshMessageID + meshSenderID: meshSenderID, + meshTimestampMs: meshTimestampMs ) } + /// The dedup key receivers derive for an event built by `makeRemoteEvent`. + private func stableID( + content: String, + meshSenderID: String = BridgeServiceTests.remoteMeshSenderID, + meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs + ) -> String { + MeshMessageIdentity.stableID(senderIDHex: meshSenderID, timestampMs: meshTimestampMs, content: content) + } + private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent { try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate()) } @@ -201,28 +215,64 @@ struct BridgeServiceTests { // MARK: - Outgoing - @Test func outgoingPublishesSignedRendezvousEventWithMeshMessageID() throws { + @Test func outgoingPublishesSignedRendezvousEventWithOriginCoordinates() throws { let fixture = Fixture(enabled: true) fixture.service.refreshRendezvous() - let messageID = UUID().uuidString + let sender = PeerID(str: "0011223344556677") + let timestamp = Date() - fixture.service.bridgeOutgoing(content: "hello hill", messageID: messageID) + fixture.service.bridgeOutgoing(content: "hello hill", senderPeerID: sender, timestamp: timestamp) let published = try #require(fixture.published.last) #expect(published.cell == Self.cell) #expect(published.event.isValidSignature()) #expect(published.event.content == "hello hill") #expect(published.event.tags.contains(["r", Self.cell])) - #expect(published.event.tags.contains(["m", messageID])) + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + #expect(published.event.tags.contains([ + "m", + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "hello hill"), + sender.id, + String(timestampMs), + ])) #expect(published.event.tags.contains(["n", "tester"])) } + @Test func newMeshTagStaysPerMessageUniqueForOldParsers() throws { + // v1.7.0 parsers take m[1] unconditionally as the dedup key whenever + // the tag has >= 2 elements. A constant m[1] (e.g. the bare sender + // ID) would make old receivers inject-dedup away every message from + // a sender after their first — so element 1 must be the + // per-message-unique stable ID itself. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let sender = PeerID(str: "0011223344556677") + let timestamp = Date() + + fixture.service.bridgeOutgoing(content: "first", senderPeerID: sender, timestamp: timestamp) + fixture.service.bridgeOutgoing(content: "second", senderPeerID: sender, timestamp: timestamp) + + // Old-parser extraction: m[1] of the first `m` tag with >= 2 elements. + let oldParserKeys = fixture.publishedMessages.compactMap { + $0.event.tags.first(where: { $0.count >= 2 && $0[0] == "m" })?[1] + } + #expect(oldParserKeys.count == 2) + #expect(oldParserKeys[0] != oldParserKeys[1]) + // And old and new receivers key the same message identically: m[1] + // equals the ID the new parser recomputes from elements 2-3. + let timestampMs = MeshMessageIdentity.millisecondTimestamp(timestamp) + #expect(oldParserKeys == [ + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "first"), + MeshMessageIdentity.stableID(senderIDHex: sender.id, timestampMs: timestampMs, content: "second"), + ]) + } + @Test func nearbyOnlySuppressesTheBridgedCopy() { let fixture = Fixture(enabled: true) fixture.service.refreshRendezvous() fixture.service.nearbyOnly = true - fixture.service.bridgeOutgoing(content: "just us", messageID: UUID().uuidString) + fixture.service.bridgeOutgoing(content: "just us", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) #expect(fixture.publishedMessages.isEmpty) #expect(fixture.uplinkSends.isEmpty) @@ -234,7 +284,7 @@ struct BridgeServiceTests { fixture.relaysConnected = false fixture.bridgePeers = [PeerID(str: "abcdef0123456789")] - fixture.service.bridgeOutgoing(content: "no internet here", messageID: UUID().uuidString) + fixture.service.bridgeOutgoing(content: "no internet here", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) #expect(fixture.publishedMessages.isEmpty) let sent = try #require(fixture.uplinkSends.first) @@ -255,7 +305,8 @@ struct BridgeServiceTests { cell: Self.cell, senderIdentity: fixture.identity, // == deriveIdentity(cell) nickname: "tester", - meshMessageID: UUID().uuidString + meshSenderID: "0011223344556677", + meshTimestampMs: Self.remoteMeshTimestampMs ) fixture.service.handleRendezvousEvent(ownOldEvent) @@ -268,7 +319,7 @@ struct BridgeServiceTests { @Test func ownEventComingBackFromSubscriptionIsIgnored() { let fixture = Fixture(enabled: true) fixture.service.refreshRendezvous() - fixture.service.bridgeOutgoing(content: "echo me", messageID: UUID().uuidString) + fixture.service.bridgeOutgoing(content: "echo me", senderPeerID: PeerID(str: "0011223344556677"), timestamp: Date()) let ownEvent = fixture.published[0].event fixture.service.handleRendezvousEvent(ownEvent) @@ -343,9 +394,10 @@ struct BridgeServiceTests { @Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws { let fixture = Fixture(enabled: true) fixture.service.refreshRendezvous() - let messageID = UUID().uuidString - fixture.locallySeenMessageIDs = [messageID] - let event = try makeRemoteEvent(meshMessageID: messageID) + let content = "heard on the radio" + // The radio copy's timeline row keys on the same derived stable ID. + fixture.locallySeenMessageIDs = [stableID(content: content)] + let event = try makeRemoteEvent(content: content) fixture.service.handleRendezvousEvent(event) @@ -357,6 +409,96 @@ struct BridgeServiceTests { #expect(fixture.service.bridgedPeerCount == 0) } + @Test func radioConfirmedSenderStaysLocalForLaterEvents() throws { + // Sticky local attribution keyed on the derived stable ID: one + // radio-confirmed message marks the pubkey as an islander, so their + // later events never inflate the bridged count. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let identity = try NostrIdentity.generate() + let content = "island echo" + fixture.locallySeenMessageIDs = [stableID(content: content)] + + fixture.service.handleRendezvousEvent(try makeRemoteEvent(content: content, identity: identity)) + #expect(fixture.service.bridgedPeerCount == 0) + + fixture.service.handleRendezvousEvent(try makeRemoteEvent( + content: "not on the radio yet", + meshTimestampMs: Self.remoteMeshTimestampMs + 1, + identity: identity + )) + #expect(fixture.service.bridgedPeerCount == 0) + } + + @Test func spoofedOriginCoordinatesCannotSuppressTheGenuineMessage() throws { + // Attack: sign an event claiming a victim's origin coordinates in the + // `m` tag to pre-poison the dedup key. The key is recomputed from the + // event's own content, so the spoof lands under a different ID and the + // genuine message still renders. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let spoof = try makeRemoteEvent(content: "impostor payload") + let genuine = try makeRemoteEvent(content: "the real message") + + fixture.service.handleRendezvousEvent(spoof) + fixture.service.handleRendezvousEvent(genuine) + + #expect(fixture.injected.count == 2) + #expect(fixture.injected.map(\.messageID) == [ + stableID(content: "impostor payload"), + stableID(content: "the real message"), + ]) + } + + @Test func forgedStableIDElementIsNeverTrusted() throws { + // Element 1 of the `m` tag exists only for old parsers. An event + // claiming the genuine message's stable ID there — over different + // content — must still key on its own derived ID, so it cannot + // pre-poison the genuine message's dedup slot. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let genuineID = stableID(content: "the real message") + let identity = try NostrIdentity.generate() + let forged = try NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [ + ["r", Self.cell], + ["m", genuineID, Self.remoteMeshSenderID, String(Self.remoteMeshTimestampMs)], + ], + content: "impostor payload" + ).sign(with: identity.schnorrSigningKey()) + let genuine = try makeRemoteEvent(content: "the real message") + + fixture.service.handleRendezvousEvent(forged) + fixture.service.handleRendezvousEvent(genuine) + + #expect(fixture.injected.map(\.messageID) == [ + stableID(content: "impostor payload"), + genuineID, + ]) + } + + @Test func oldFormatMeshTagFallsBackToEventID() throws { + // A 2-element `m` tag from an old sender carries an ID no other + // device can recompute; the event ID keeps today's behavior. + let fixture = Fixture(enabled: true) + fixture.service.refreshRendezvous() + let identity = try NostrIdentity.generate() + let legacy = try NostrEvent( + pubkey: identity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: [["r", Self.cell], ["m", UUID().uuidString]], + content: "legacy copy" + ).sign(with: identity.schnorrSigningKey()) + + fixture.service.handleRendezvousEvent(legacy) + + #expect(fixture.injected.map(\.messageID) == [legacy.id]) + } + @Test func duplicateSubscriptionEventInjectsOnce() throws { let fixture = Fixture(enabled: true) fixture.service.refreshRendezvous()