mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 02:25:20 +00:00
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>
This commit is contained in:
co-authored by
jack
Claude Opus 4.8
parent
dd6b624cae
commit
84d315e62d
@@ -264,22 +264,33 @@ struct NostrProtocol {
|
|||||||
|
|
||||||
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
/// Create a mesh-bridge public message (kind 20000) for a geohash-cell
|
||||||
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
/// rendezvous. The distinct `r` tag keeps bridge traffic out of geohash
|
||||||
/// channel subscriptions (which filter on `#g`); `m` carries the original
|
/// channel subscriptions (which filter on `#g`); `m` is
|
||||||
/// mesh message ID so receivers dedup the bridged copy against the radio
|
/// `[stable ID, mesh sender ID, wire timestamp in ms]`. Element 1 is the
|
||||||
/// copy by timeline ID.
|
/// 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(
|
static func createBridgeMeshEvent(
|
||||||
content: String,
|
content: String,
|
||||||
cell: String,
|
cell: String,
|
||||||
senderIdentity: NostrIdentity,
|
senderIdentity: NostrIdentity,
|
||||||
nickname: String? = nil,
|
nickname: String? = nil,
|
||||||
meshMessageID: String? = nil
|
meshSenderID: String? = nil,
|
||||||
|
meshTimestampMs: UInt64? = nil
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
var tags = [["r", cell]]
|
var tags = [["r", cell]]
|
||||||
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
if let nickname = nickname?.trimmedOrNilIfEmpty {
|
||||||
tags.append(["n", nickname])
|
tags.append(["n", nickname])
|
||||||
}
|
}
|
||||||
if let meshMessageID = meshMessageID?.trimmedOrNilIfEmpty {
|
if let meshSenderID = meshSenderID?.trimmedOrNilIfEmpty, let meshTimestampMs {
|
||||||
tags.append(["m", meshMessageID])
|
let stableID = MeshMessageIdentity.stableID(
|
||||||
|
senderIDHex: meshSenderID,
|
||||||
|
timestampMs: meshTimestampMs,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
tags.append(["m", stableID, meshSenderID, String(meshTimestampMs)])
|
||||||
}
|
}
|
||||||
let event = NostrEvent(
|
let event = NostrEvent(
|
||||||
pubkey: senderIdentity.publicKeyHex,
|
pubkey: senderIdentity.publicKeyHex,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
//
|
||||||
|
// MeshMessageIdentity.swift
|
||||||
|
// bitchat
|
||||||
|
//
|
||||||
|
// This is free and unencumbered software released into the public domain.
|
||||||
|
// For more information, see <https://unlicense.org>
|
||||||
|
//
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,10 +122,18 @@ final class BLEPublicMessageHandler {
|
|||||||
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
|
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
|
||||||
|
|
||||||
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
var resolvedSelfMessageID: String? = nil
|
let messageID: String?
|
||||||
if peerID == env.localPeerID() {
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ import Foundation
|
|||||||
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
/// already holds the radio copy (`isMessageSeenLocally` on the event's
|
||||||
/// mesh message ID) — remote islands' traffic is the only thing worth
|
/// mesh message ID) — remote islands' traffic is the only thing worth
|
||||||
/// airtime.
|
/// airtime.
|
||||||
/// Receivers additionally dedup by timeline message ID: a bridged copy
|
/// Receivers additionally dedup by timeline message ID: the wire carries no
|
||||||
/// carries the original mesh message ID in its `m` tag, so the store's
|
/// public message ID, so every device derives the same content-stable one
|
||||||
/// insert-by-ID absorbs radio/bridge duplicates in either arrival order.
|
/// (`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
|
/// All dependencies are closure-injected (repo convention) so the policy
|
||||||
/// layer is unit-testable without relays, radios, or CoreLocation.
|
/// 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
|
/// Composes and ships the bridged copy of an outgoing public mesh
|
||||||
/// message. Call after the radio send; no-op when the bridge is off,
|
/// 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.
|
/// 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 isEnabled, !nearbyOnly, let cell = activeCell ?? currentCell() else { return }
|
||||||
guard content.utf8.count <= Limits.maxContentBytes 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),
|
guard let identity = try? deriveIdentity?(cell),
|
||||||
let event = try? NostrProtocol.createBridgeMeshEvent(
|
let event = try? NostrProtocol.createBridgeMeshEvent(
|
||||||
content: content,
|
content: content,
|
||||||
cell: cell,
|
cell: cell,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity,
|
||||||
nickname: myNickname?(),
|
nickname: myNickname?(),
|
||||||
meshMessageID: messageID
|
meshSenderID: senderPeerID.id,
|
||||||
|
meshTimestampMs: timestampMs
|
||||||
) else {
|
) else {
|
||||||
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
|
SecureLogger.error("🌉 Bridge: failed to compose rendezvous event", category: .session)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
publishedEventIDs.insert(event.id)
|
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 {
|
if relaysConnected?() ?? false {
|
||||||
publishToRelays?(event, cell)
|
publishToRelays?(event, cell)
|
||||||
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
} else if let carrier = NostrCarrierPacket(direction: .toBridge, geohash: cell, event: event),
|
||||||
@@ -648,8 +660,25 @@ final class BridgeService: ObservableObject {
|
|||||||
let content = event.content
|
let content = event.content
|
||||||
guard !content.trimmed.isEmpty, content.utf8.count <= Limits.maxContentBytes else { return nil }
|
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 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]
|
// Recompute — never trust — the mesh message ID: the `m` tag is
|
||||||
let messageID = (meshID?.trimmedOrNilIfEmpty) ?? event.id
|
// `[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(
|
return .message(InboundBridgeMessage(
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))",
|
senderNickname: nickname?.trimmedOrNilIfEmpty ?? "anon#\(event.pubkey.suffix(4))",
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ protocol ChatOutgoingContext: AnyObject {
|
|||||||
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
|
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
|
||||||
/// Ships the bridged (rendezvous) copy of a just-sent public mesh
|
/// 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.
|
/// 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)
|
// MARK: Geohash identity (shared with the other contexts)
|
||||||
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
@@ -66,8 +69,8 @@ extension ChatViewModel: ChatOutgoingContext {
|
|||||||
lastPublicActivityAt[key] = Date()
|
lastPublicActivityAt[key] = Date()
|
||||||
}
|
}
|
||||||
|
|
||||||
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
|
func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) {
|
||||||
BridgeService.shared.bridgeOutgoing(content: content, messageID: messageID)
|
BridgeService.shared.bridgeOutgoing(content: content, senderPeerID: senderPeerID, timestamp: timestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +150,7 @@ private extension ChatOutgoingCoordinator {
|
|||||||
messageID: message.id,
|
messageID: message.id,
|
||||||
timestamp: message.timestamp
|
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
|
/// Geohash sends mine a NIP-13 nonce tag first (off the main actor, see
|
||||||
|
|||||||
@@ -417,9 +417,12 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|||||||
/// rate limit; low/no-PoW events keep the strict limits so old clients
|
/// rate limit; low/no-PoW events keep the strict limits so old clients
|
||||||
/// still get through at normal rates.
|
/// still get through at normal rates.
|
||||||
/// Identity keys of the archived echoes seeded into the mesh timeline at
|
/// Identity keys of the archived echoes seeded into the mesh timeline at
|
||||||
/// launch. The mesh wire format carries no stable message ID, so a
|
/// launch. Re-synced copies of others' messages now arrive with the same
|
||||||
/// re-synced copy of an already-rendered echo arrives with a fresh UUID —
|
/// derived stable ID (`MeshMessageIdentity`), so the store's insert-by-ID
|
||||||
/// this content identity is the only way to recognize it.
|
/// 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<String>()
|
private var archivedEchoKeys = Set<String>()
|
||||||
|
|
||||||
func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) {
|
func registerArchivedEcho(senderPeerID: PeerID?, timestamp: Date, content: String) {
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ private final class MockChatOutgoingContext: ChatOutgoingContext {
|
|||||||
sentGeohashContexts.append(context)
|
sentGeohashContexts.append(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
private(set) var bridgedMessages: [(content: String, messageID: String)] = []
|
private(set) var bridgedMessages: [(content: String, senderPeerID: PeerID, timestamp: Date)] = []
|
||||||
func bridgeOutgoingPublicMessage(_ content: String, messageID: String) {
|
func bridgeOutgoingPublicMessage(_ content: String, senderPeerID: PeerID, timestamp: Date) {
|
||||||
bridgedMessages.append((content, messageID))
|
bridgedMessages.append((content, senderPeerID, timestamp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Geohash identity
|
// Geohash identity
|
||||||
|
|||||||
@@ -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")
|
@Suite("Courier store bridge publish")
|
||||||
struct CourierStoreBridgePublishTests {
|
struct CourierStoreBridgePublishTests {
|
||||||
private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore {
|
private func makeStore(now: @escaping () -> Date = Date.init) -> CourierStore {
|
||||||
|
|||||||
@@ -83,7 +83,13 @@ struct BLEPublicMessageHandlerTests {
|
|||||||
#expect(recorder.deliveries.first?.nickname == "Alice")
|
#expect(recorder.deliveries.first?.nickname == "Alice")
|
||||||
#expect(recorder.deliveries.first?.content == "hello mesh")
|
#expect(recorder.deliveries.first?.content == "hello mesh")
|
||||||
#expect(recorder.deliveries.first?.timestamp == now)
|
#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
|
@Test
|
||||||
|
|||||||
@@ -117,21 +117,35 @@ struct BridgeServiceTests {
|
|||||||
|
|
||||||
// MARK: Event helpers
|
// MARK: Event helpers
|
||||||
|
|
||||||
|
private static let remoteMeshSenderID = "feedfacecafef00d"
|
||||||
|
private static let remoteMeshTimestampMs: UInt64 = 1_750_000_000_000
|
||||||
|
|
||||||
private func makeRemoteEvent(
|
private func makeRemoteEvent(
|
||||||
cell: String = BridgeServiceTests.cell,
|
cell: String = BridgeServiceTests.cell,
|
||||||
content: String = "hi \(UUID().uuidString.prefix(8))",
|
content: String = "hi \(UUID().uuidString.prefix(8))",
|
||||||
meshMessageID: String? = UUID().uuidString
|
meshSenderID: String = BridgeServiceTests.remoteMeshSenderID,
|
||||||
|
meshTimestampMs: UInt64 = BridgeServiceTests.remoteMeshTimestampMs,
|
||||||
|
identity: NostrIdentity? = nil
|
||||||
) throws -> NostrEvent {
|
) throws -> NostrEvent {
|
||||||
let identity = try NostrIdentity.generate()
|
try NostrProtocol.createBridgeMeshEvent(
|
||||||
return try NostrProtocol.createBridgeMeshEvent(
|
|
||||||
content: content,
|
content: content,
|
||||||
cell: cell,
|
cell: cell,
|
||||||
senderIdentity: identity,
|
senderIdentity: identity ?? NostrIdentity.generate(),
|
||||||
nickname: "remote",
|
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 {
|
private func makePresenceEvent(cell: String = BridgeServiceTests.cell) throws -> NostrEvent {
|
||||||
try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate())
|
try NostrProtocol.createBridgePresenceEvent(cell: cell, senderIdentity: NostrIdentity.generate())
|
||||||
}
|
}
|
||||||
@@ -201,28 +215,64 @@ struct BridgeServiceTests {
|
|||||||
|
|
||||||
// MARK: - Outgoing
|
// MARK: - Outgoing
|
||||||
|
|
||||||
@Test func outgoingPublishesSignedRendezvousEventWithMeshMessageID() throws {
|
@Test func outgoingPublishesSignedRendezvousEventWithOriginCoordinates() throws {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
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)
|
let published = try #require(fixture.published.last)
|
||||||
#expect(published.cell == Self.cell)
|
#expect(published.cell == Self.cell)
|
||||||
#expect(published.event.isValidSignature())
|
#expect(published.event.isValidSignature())
|
||||||
#expect(published.event.content == "hello hill")
|
#expect(published.event.content == "hello hill")
|
||||||
#expect(published.event.tags.contains(["r", Self.cell]))
|
#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"]))
|
#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() {
|
@Test func nearbyOnlySuppressesTheBridgedCopy() {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
fixture.service.nearbyOnly = true
|
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.publishedMessages.isEmpty)
|
||||||
#expect(fixture.uplinkSends.isEmpty)
|
#expect(fixture.uplinkSends.isEmpty)
|
||||||
@@ -234,7 +284,7 @@ struct BridgeServiceTests {
|
|||||||
fixture.relaysConnected = false
|
fixture.relaysConnected = false
|
||||||
fixture.bridgePeers = [PeerID(str: "abcdef0123456789")]
|
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)
|
#expect(fixture.publishedMessages.isEmpty)
|
||||||
let sent = try #require(fixture.uplinkSends.first)
|
let sent = try #require(fixture.uplinkSends.first)
|
||||||
@@ -255,7 +305,8 @@ struct BridgeServiceTests {
|
|||||||
cell: Self.cell,
|
cell: Self.cell,
|
||||||
senderIdentity: fixture.identity, // == deriveIdentity(cell)
|
senderIdentity: fixture.identity, // == deriveIdentity(cell)
|
||||||
nickname: "tester",
|
nickname: "tester",
|
||||||
meshMessageID: UUID().uuidString
|
meshSenderID: "0011223344556677",
|
||||||
|
meshTimestampMs: Self.remoteMeshTimestampMs
|
||||||
)
|
)
|
||||||
|
|
||||||
fixture.service.handleRendezvousEvent(ownOldEvent)
|
fixture.service.handleRendezvousEvent(ownOldEvent)
|
||||||
@@ -268,7 +319,7 @@ struct BridgeServiceTests {
|
|||||||
@Test func ownEventComingBackFromSubscriptionIsIgnored() {
|
@Test func ownEventComingBackFromSubscriptionIsIgnored() {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
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
|
let ownEvent = fixture.published[0].event
|
||||||
|
|
||||||
fixture.service.handleRendezvousEvent(ownEvent)
|
fixture.service.handleRendezvousEvent(ownEvent)
|
||||||
@@ -343,9 +394,10 @@ struct BridgeServiceTests {
|
|||||||
@Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws {
|
@Test func locallySeenMessageIsNeitherInjectedNorDownlinked() throws {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
let messageID = UUID().uuidString
|
let content = "heard on the radio"
|
||||||
fixture.locallySeenMessageIDs = [messageID]
|
// The radio copy's timeline row keys on the same derived stable ID.
|
||||||
let event = try makeRemoteEvent(meshMessageID: messageID)
|
fixture.locallySeenMessageIDs = [stableID(content: content)]
|
||||||
|
let event = try makeRemoteEvent(content: content)
|
||||||
|
|
||||||
fixture.service.handleRendezvousEvent(event)
|
fixture.service.handleRendezvousEvent(event)
|
||||||
|
|
||||||
@@ -357,6 +409,96 @@ struct BridgeServiceTests {
|
|||||||
#expect(fixture.service.bridgedPeerCount == 0)
|
#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 {
|
@Test func duplicateSubscriptionEventInjectsOnce() throws {
|
||||||
let fixture = Fixture(enabled: true)
|
let fixture = Fixture(enabled: true)
|
||||||
fixture.service.refreshRendezvous()
|
fixture.service.refreshRendezvous()
|
||||||
|
|||||||
Reference in New Issue
Block a user