Harden the insecure-link outbox bound; document the second-replay presence gap and the courier metadata tradeoff

Review follow-ups on the canDeliverSecurely gate:

- flushOutbox no longer counts connected-but-insecure flushes toward the
  maxSendAttempts drop: the message was actually transmitted over a live
  link, so a peer whose Noise handshake stalls across reconnect flapping
  must not burn through the cap and lose the store-and-forward copy the
  gate exists to preserve. Retention stays bounded by the 24h outbox TTL
  and the per-peer FIFO cap; acks still clear it. Attempt-counting stays
  for reachable-only (heuristic) sends. Regression test: >8 connected-
  insecure flushes keep the retained copy, drop callback never fires.

- Document the known second-replay presence gap: linkBoundToOtherPeer
  reads the binding before rebindLinkAfterVerifiedDirectAnnounce steals
  the link, so once a first replay has rebound a link to an absent
  victim's ID, a second replay marks the victim connected. Presence
  display only — DMs stay on the retain+courier path via the router
  gate. Not closed at the announce layer because the post-rebind state
  is indistinguishable from a legitimate rotation/reconnect heal (a
  supported, field-verified flow). Covered by a two-announce test.

- Note the accepted courier-spray metadata tradeoff at the connected-
  insecure send: nearby verified peers receive a sealed copy (they learn
  a DM exists, never its content), cleared on ack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-09 15:19:38 +02:00
co-authored by Claude Fable 5
parent 18862102ea
commit 5017c232f4
5 changed files with 120 additions and 5 deletions
@@ -145,6 +145,16 @@ final class BLEAnnounceHandler {
// owns is either a rotation heal or a replay with its TTL restored; // owns is either a rotation heal or a replay with its TTL restored;
// both are ambiguous, so only the rebind (which containment-checks // both are ambiguous, so only the rebind (which containment-checks
// the claimed identity) may promote it never this shortcut. // the claimed identity) may promote it never this shortcut.
//
// Known limitation: this reads the binding BEFORE the rebind runs
// (BLEService rebinds after this handler returns), so once a first
// replay has stolen the link for an absent peer, a SECOND replay
// finds the link bound to the claimed peer the live-link terms
// below then mark the absent peer connected. Presence display only:
// DMs stay gated on canDeliverSecurely (no Noise session means
// retain + courier, see MessageRouter.sendPrivate). Not closed here
// because the same post-rebind state is exactly what legitimate
// rotation/reconnect heals look like.
let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID) let linkBoundToOtherPeer = isDirectAnnounce && env.linkBoundToOtherPeer(packet, peerID)
env.withRegistryBarrier { env.withRegistryBarrier {
+6
View File
@@ -4440,6 +4440,12 @@ extension BLEService {
self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false) self?.linkState(for: peerID) ?? (hasPeripheral: false, hasCentral: false)
}, },
linkBoundToOtherPeer: { [weak self] packet, peerID in linkBoundToOtherPeer: { [weak self] packet, peerID in
// Reads the CURRENT binding i.e. the state before
// rebindLinkAfterVerifiedDirectAnnounce (which runs after the
// handler) may steal the link. After a first replayed announce
// has rebound a link to an absent peer's ID, a second replay
// answers false here; see the caller in BLEAnnounceHandler
// for why that residual presence gap is accepted.
guard let self else { return false } guard let self else { return false }
guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false } guard let link = (self.collectionsQueue.sync { self.ingressLinks.link(for: packet) }) else { return false }
let boundPeerID: PeerID? = self.readLinkState { store in let boundPeerID: PeerID? = self.readLinkState { store in
+27 -5
View File
@@ -179,6 +179,13 @@ final class MessageRouter {
// (a genuine link finishes the handshake and delivers), but retain // (a genuine link finishes the handshake and delivers), but retain
// a copy and hand a sealed copy to couriers so nothing is silently // a copy and hand a sealed copy to couriers so nothing is silently
// lost; receivers dedup resends by message ID. // lost; receivers dedup resends by message ID.
//
// Deliberate metadata tradeoff: every pre-handshake first DM to a
// connected peer hands nearby verified peers a sealed copy, so
// they learn a DM to this recipient exists (never its content
// the envelope is opaque). Accepted for delivery robustness; the
// deposit is cleared on ack. Don't "optimize" the courier call
// away.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session) SecureLogger.debug("Routing PM via \(type(of: transport)) (connected, no secure session) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID) transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
enqueue(message, for: peerID) enqueue(message, for: peerID)
@@ -408,11 +415,26 @@ final class MessageRouter {
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session) SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID) transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent) metrics?.record(.outboxResent)
} else if let transport = connectedTransport(for: peerID) ?? reachableTransport(for: peerID) { } else if let transport = connectedTransport(for: peerID) {
// Weak signal (reachability heuristic, or a "connected" link // "Connected" without a secure session possibly a stolen
// without a secure session possibly a stolen binding from a // binding from a replayed announce: send (a genuine link
// replayed announce): send but keep retaining until an ack // finishes the handshake and delivers) but keep retaining
// clears it, bounded by attempt count for peers that never ack. // until an ack clears it. These flushes do NOT count toward
// the attempt-cap drop: the message was transmitted over a
// live link, so a peer whose handshake stalls across
// reconnect flapping must not burn through the cap and lose
// the store-and-forward copy this retention exists to
// preserve. Retention stays bounded by the 24h outbox TTL
// and the per-peer FIFO cap.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected, no secure session) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
metrics?.record(.outboxResent)
remaining.append(message)
} else if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic,
// so the send can silently go nowhere: send but keep retaining
// until an ack clears it, bounded by attempt count for peers
// that never ack.
guard message.sendAttempts < Self.maxSendAttempts else { guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session) SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
dropMessage(message.messageID, for: peerID) dropMessage(message.messageID, for: peerID)
@@ -390,6 +390,51 @@ struct BLEAnnounceHandlerTests {
#expect(recorder.upsertCalls.first?.isConnected == true) #expect(recorder.upsertCalls.first?.isConnected == true)
} }
/// Documents a known, accepted limitation: the linkBoundToOtherPeer read
/// happens before the rebind steals the link, so while the FIRST replayed
/// "direct" announce is denied the connected shortcut, the SECOND finds
/// the link already rebound to the (absent) victim and the live-link
/// terms mark it connected. This is presence display only DMs remain
/// gated on canDeliverSecurely, so without a Noise session they take the
/// retain + courier path (see MessageRouterTests
/// .sendPrivate_connectedWithoutSecureSessionRetainsAndDepositsWithCourier).
/// Not closed at the announce layer because the post-rebind state is
/// indistinguishable from a legitimate rotation/reconnect heal.
@Test
func secondReplayedDirectAnnounceAfterRebindMarksAbsentPeerConnected() throws {
let now = Date(timeIntervalSince1970: 1_000)
let noiseKey = Data(repeating: 0xA1, count: 32)
let victim = PeerID(publicKey: noiseKey)
let packet = try makeAnnouncePacket(
noisePublicKey: noiseKey,
peerID: victim,
timestamp: timestamp(now),
signature: Data(repeating: 0xEE, count: 64)
)
let recorder = Recorder()
let handler = makeHandler(recorder: recorder, now: now)
// First replay: the link still belongs to the replayer and the victim
// has no live link of its own the connected shortcut is denied.
recorder.linkBoundToOtherPeer = true
recorder.linkState = (hasPeripheral: false, hasCentral: false)
handler.handle(packet, from: victim)
#expect(recorder.upsertCalls.count == 1)
#expect(recorder.upsertCalls.first?.isConnected == false)
// The rebind then binds the replayer's link to the victim's ID (the
// rotation-heal path, containment-checked in BLEService). A second
// replay now finds the link owned by the claimed peer
recorder.linkBoundToOtherPeer = false
recorder.linkState = (hasPeripheral: false, hasCentral: true)
handler.handle(packet, from: victim)
// and the absent victim reads as connected: the residual gap.
#expect(recorder.upsertCalls.count == 2)
#expect(recorder.upsertCalls.last?.isConnected == true)
}
@Test @Test
func announceBackIsSkippedWhenAlreadyMarked() throws { func announceBackIsSkippedWhenAlreadyMarked() throws {
let now = Date(timeIntervalSince1970: 1_000) let now = Date(timeIntervalSince1970: 1_000)
@@ -360,6 +360,38 @@ struct MessageRouterTests {
#expect(transport.sentPrivateMessages.count == 3) #expect(transport.sentPrivateMessages.count == 3)
} }
/// Flushes over a connected-but-insecure link never count toward the
/// attempt-cap drop: the message was actually transmitted over a live
/// link, so a peer whose Noise handshake stalls across reconnect flapping
/// must not burn through the cap and lose the store-and-forward copy the
/// secure-session gate exists to preserve. Retention stays bounded by
/// the 24h outbox TTL and the per-peer FIFO cap; an ack clears it.
@Test @MainActor
func flushOutbox_connectedInsecureFlushesNeverDropTheRetainedCopy() async {
let peerID = PeerID(str: "00000000000000ac")
let transport = MockTransport()
transport.connectedPeers.insert(peerID)
transport.securePeers = [] // handshake never completes
let router = MessageRouter(transports: [transport])
var dropped: [String] = []
router.onMessageDropped = { messageID, _ in dropped.append(messageID) }
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "ci1")
// Well past maxSendAttempts (8): every flush resends, none drops.
for _ in 0..<10 {
router.flushOutbox(for: peerID)
}
#expect(dropped.isEmpty)
#expect(transport.sentPrivateMessages.count == 11)
// The copy is still retained and an ack still clears it.
router.markDelivered("ci1")
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 11)
}
/// With an established secure session the connected fast-path stays /// With an established secure session the connected fast-path stays
/// exactly as before: trusted outright, no retained copy, no courier. /// exactly as before: trusted outright, no retained copy, no courier.
@Test @MainActor @Test @MainActor