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:
jack
2026-07-09 16:44:47 +02:00
committed by GitHub
co-authored by jack Claude Opus 4.8
parent dd6b624cae
commit 84d315e62d
10 changed files with 328 additions and 44 deletions
@@ -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)
}
}
+37 -8
View File
@@ -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))",