mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 20:45:19 +00:00
* 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>
* 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>
* Promote a healed rotation to connected; normalize the secure-delivery probe; retain Codable properties in Periphery
Codex review follow-ups on 5017c232:
- P1: a legitimate rotation announce necessarily arrives on a link still
bound to the OLD peer ID, so the linkBoundToOtherPeer denial stored the
new identity as disconnected — and the rebind only fixed link state,
never the registry. A healed rotation then read as disconnected until
the peer happened to announce again. rebindLinkAfterVerifiedDirect-
Announce now promotes the rebound identity to connected after the
containment checks pass (registry markConnected + topology/snapshot/
peer-list refresh; the .peerConnected UI event already fired from the
announce path). This consciously moves the forged-presence residue
from second-replay to first-successful-rebind — bounded by the rebind
containment (never steals a live identity, one rebind per link per
cooldown) and still display-only: DMs stay gated on canDeliverSecurely.
Comments and the pinned tests updated; new regression test covers
rotate-on-open-link -> rebind -> isPeerConnected(new) == true.
- P2: BLEService.canDeliverSecurely probed the Noise session with the
peer ID as given, but sessions are keyed by the short wire ID — a send
keyed by the full 64-hex Noise key (favorites resolution) misread an
established session as insecure and needlessly retained + couriered
every DM until ack. Normalize with toShort() like isPeerConnected.
Test drives a real XX handshake and asserts both ID forms pass.
- Periphery: the PrekeyBundleStore.StoredBundle.noiseKey "assign-only"
flake fired again and slipped past its baselined USR (read exists in
loadFromDisk; the indexer intermittently misses it). Replace the
baseline entry with retain_codable_properties: true — deterministic,
and a truly-dead Codable field is a persisted-format change anyway.
Local periphery scan --strict: no unused code detected.
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>
240 lines
7.5 KiB
Swift
240 lines
7.5 KiB
Swift
import BitFoundation
|
|
import Foundation
|
|
|
|
struct BLEPeerInfo: Equatable {
|
|
let peerID: PeerID
|
|
var nickname: String
|
|
var isConnected: Bool
|
|
var noisePublicKey: Data?
|
|
var signingPublicKey: Data?
|
|
var isVerifiedNickname: Bool
|
|
var lastSeen: Date
|
|
var capabilities: PeerCapabilities = []
|
|
/// Rendezvous cell from the peer's announce when it advertises `.bridge`.
|
|
var bridgeGeohash: String?
|
|
}
|
|
|
|
struct BLEPeerAnnounceUpdate: Equatable {
|
|
let isNewPeer: Bool
|
|
let wasDisconnected: Bool
|
|
let previousNickname: String?
|
|
}
|
|
|
|
struct BLEPeerLinkPresence: Equatable {
|
|
var hasPeripheral: Bool
|
|
var hasCentral: Bool
|
|
}
|
|
|
|
struct BLERemovedPeer: Equatable {
|
|
let peerID: PeerID
|
|
let nickname: String
|
|
}
|
|
|
|
struct BLEPeerConnectivityChanges: Equatable {
|
|
var disconnectedPeerIDs: [PeerID] = []
|
|
var removedPeers: [BLERemovedPeer] = []
|
|
}
|
|
|
|
struct BLEPeerRegistry {
|
|
private var peers: [PeerID: BLEPeerInfo] = [:]
|
|
|
|
var isEmpty: Bool {
|
|
peers.isEmpty
|
|
}
|
|
|
|
var count: Int {
|
|
peers.count
|
|
}
|
|
|
|
var peerIDs: [PeerID] {
|
|
Array(peers.keys)
|
|
}
|
|
|
|
var connectedCount: Int {
|
|
peers.values.filter(\.isConnected).count
|
|
}
|
|
|
|
var connectedPeerIDs: [PeerID] {
|
|
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
|
}
|
|
|
|
var connectedRoutingData: [Data] {
|
|
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
|
}
|
|
|
|
var snapshotByID: [PeerID: BLEPeerInfo] {
|
|
peers
|
|
}
|
|
|
|
mutating func removeAll() {
|
|
peers.removeAll()
|
|
}
|
|
|
|
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
|
peers[peerID]
|
|
}
|
|
|
|
mutating func upsert(_ info: BLEPeerInfo) {
|
|
peers[info.peerID] = info
|
|
}
|
|
|
|
@discardableResult
|
|
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
|
peers.removeValue(forKey: peerID)
|
|
}
|
|
|
|
func isConnected(_ peerID: PeerID) -> Bool {
|
|
peers[peerID.toShort()]?.isConnected ?? false
|
|
}
|
|
|
|
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
|
let shortID = peerID.toShort()
|
|
let meshAttached = connectedCount > 0
|
|
guard let info = peers[shortID] else { return false }
|
|
if info.isConnected { return true }
|
|
guard meshAttached else { return false }
|
|
|
|
let retention: TimeInterval = info.isVerifiedNickname
|
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
return now.timeIntervalSince(info.lastSeen) <= retention
|
|
}
|
|
|
|
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
|
guard let peer = peers[peerID] else { return nil }
|
|
if connectedOnly && !peer.isConnected { return nil }
|
|
return peer.nickname
|
|
}
|
|
|
|
func fingerprint(for peerID: PeerID) -> String? {
|
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
|
}
|
|
|
|
func capabilities(for peerID: PeerID) -> PeerCapabilities {
|
|
peers[peerID.toShort()]?.capabilities ?? []
|
|
}
|
|
|
|
/// Peers whose last verified announce advertised the given capability.
|
|
func peers(advertising capability: PeerCapabilities) -> [PeerID] {
|
|
peers.values.filter { $0.capabilities.contains(capability) }.map(\.peerID)
|
|
}
|
|
|
|
/// A rendezvous cell advertised by any bridge-capable peer, if one is
|
|
/// known — lets location-less devices join the island's rendezvous.
|
|
func advertisedBridgeGeohash() -> String? {
|
|
peers.values
|
|
.filter { $0.capabilities.contains(.bridge) }
|
|
.compactMap(\.bridgeGeohash)
|
|
.first
|
|
}
|
|
|
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
|
let connected = peers.filter { $0.value.isConnected }
|
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
|
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
|
}
|
|
|
|
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
|
let snapshot = Array(peers.values)
|
|
let resolvedNames = PeerDisplayNameResolver.resolve(
|
|
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
|
selfNickname: selfNickname
|
|
)
|
|
return snapshot.map { info in
|
|
TransportPeerSnapshot(
|
|
peerID: info.peerID,
|
|
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
|
isConnected: info.isConnected,
|
|
noisePublicKey: info.noisePublicKey,
|
|
lastSeen: info.lastSeen,
|
|
isVerified: info.isVerifiedNickname
|
|
)
|
|
}
|
|
}
|
|
|
|
mutating func markDisconnected(_ peerID: PeerID) {
|
|
guard var info = peers[peerID] else { return }
|
|
info.isConnected = false
|
|
peers[peerID] = info
|
|
}
|
|
|
|
/// Flips an already-known peer to connected. Returns false when the peer
|
|
/// is unknown or already connected (nothing changed).
|
|
@discardableResult
|
|
mutating func markConnected(_ peerID: PeerID) -> Bool {
|
|
guard var info = peers[peerID], !info.isConnected else { return false }
|
|
info.isConnected = true
|
|
peers[peerID] = info
|
|
return true
|
|
}
|
|
|
|
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
|
guard var peer = peers[peerID] else { return }
|
|
peer.lastSeen = date
|
|
peers[peerID] = peer
|
|
}
|
|
|
|
mutating func upsertVerifiedAnnounce(
|
|
peerID: PeerID,
|
|
nickname: String,
|
|
noisePublicKey: Data,
|
|
signingPublicKey: Data?,
|
|
isConnected: Bool,
|
|
now: Date,
|
|
capabilities: PeerCapabilities = [],
|
|
bridgeGeohash: String? = nil
|
|
) -> BLEPeerAnnounceUpdate {
|
|
let existing = peers[peerID]
|
|
let update = BLEPeerAnnounceUpdate(
|
|
isNewPeer: existing == nil,
|
|
wasDisconnected: existing?.isConnected == false,
|
|
previousNickname: existing?.nickname
|
|
)
|
|
|
|
peers[peerID] = BLEPeerInfo(
|
|
peerID: existing?.peerID ?? peerID,
|
|
nickname: nickname,
|
|
isConnected: isConnected,
|
|
noisePublicKey: noisePublicKey,
|
|
signingPublicKey: signingPublicKey,
|
|
isVerifiedNickname: true,
|
|
lastSeen: now,
|
|
capabilities: capabilities,
|
|
bridgeGeohash: bridgeGeohash
|
|
)
|
|
|
|
return update
|
|
}
|
|
|
|
mutating func reconcileConnectivity(
|
|
now: Date,
|
|
linkStates: [PeerID: BLEPeerLinkPresence]
|
|
) -> BLEPeerConnectivityChanges {
|
|
var changes = BLEPeerConnectivityChanges()
|
|
|
|
for (peerID, peer) in Array(peers) {
|
|
let age = now.timeIntervalSince(peer.lastSeen)
|
|
let retention: TimeInterval = peer.isVerifiedNickname
|
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
|
|
|
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
|
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
|
if !state.hasPeripheral && !state.hasCentral {
|
|
var updated = peer
|
|
updated.isConnected = false
|
|
peers[peerID] = updated
|
|
changes.disconnectedPeerIDs.append(peerID)
|
|
}
|
|
}
|
|
|
|
if !peer.isConnected && age > retention {
|
|
peers.removeValue(forKey: peerID)
|
|
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
|
}
|
|
}
|
|
|
|
return changes
|
|
}
|
|
}
|