Gate connected-link trust on a secure session; deny forged direct announces the connected shortcut

Residual gap after the rotation-heal containment (#1401): "verified
direct" announces prove the signature but not directness — TTL is
unsigned — so a malicious connected peer can replay a victim's fresh
announce with its TTL restored. When the victim has no live link, the
replayer's link rebinds to the victim's ID and reads as "connected",
and MessageRouter's connected fast-path then trusts it outright: every
DM stalls on a Noise handshake the replayer can never complete and is
silently lost while showing "sent".

Router-level trust gate (no wire change):
- Transport gains canDeliverSecurely(to:) — BLE answers with an
  established Noise session; Nostr keeps its prompt-delivery predicate;
  the protocol default forwards to canDeliverPromptly for transports
  without a forgeable link layer.
- MessageRouter.sendPrivate only trusts a connected link outright when
  it can deliver securely; otherwise it still sends (kicking the
  handshake on a genuine link) but retains a copy and hands a sealed
  copy to couriers, like the reachable path. flushOutbox gets the same
  gate so a flush over an insecure link resends instead of dropping the
  retained copy.

Presence hardening (defense in depth): a "direct" announce arriving on
a link already bound to a different peer no longer shortcuts the
claimed peer into "connected" — only real link state does. Genuine
first-contact and direct announces are unaffected; only the ambiguous
heal path loses the forgeable shortcut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-09 11:55:32 +02:00
co-authored by Claude Opus 4.8
parent 021af3a22d
commit 18862102ea
10 changed files with 269 additions and 9 deletions
+8
View File
@@ -50,6 +50,10 @@ final class MockTransport: Transport {
var connectedPeers: Set<PeerID> = []
var reachablePeers: Set<PeerID> = []
/// Peers with an established secure session. `nil` mirrors the protocol
/// default (prompt delivery), so connected peers stay "secure" for tests
/// that never care about the distinction.
var securePeers: Set<PeerID>?
var peerNicknames: [PeerID: String] = [:]
var peerFingerprints: [PeerID: String] = [:]
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
@@ -87,6 +91,10 @@ final class MockTransport: Transport {
reachablePeers.contains(peerID) || connectedPeers.contains(peerID)
}
func canDeliverSecurely(to peerID: PeerID) -> Bool {
securePeers?.contains(peerID) ?? canDeliverPromptly(to: peerID)
}
func peerNickname(peerID: PeerID) -> String? {
peerNicknames[peerID]
}