mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
* Cohere per-link Noise auth and rebind containment into BLELinkAuthState The authenticated-link owners, the reconnect revalidation policy, and the two rebind-containment cooldowns were four loose bleQueue-owned maps whose invariants lived in call-site discipline: every teardown path had to remember to retire the proof AND close the revalidation epoch (the pair appeared seven times), and both cooldowns hand-rolled the same prune-check-record dance. BLELinkAuthState owns them as whole transitions — retireLink, retireLinks(ownedBy:), permitRebind, permitRedundantRetirement — with the ownership question (bleQueue today, engine after the option-B flip) answered in one place. No behavior change; the one call-site reordering (redundant retirement computes the survivor before the cooldown check instead of after) is outcome-equivalent since the cooldown only ever recorded when a survivor existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Split identity-link bindings out of the physical link store BLELinkStateStore owned two different kinds of truth: what physical links exist (CB handles, connect lifecycles, characteristics, stream assemblers) and who each link belongs to (peer bindings in both roles plus the preferred-peripheral reverse map for directed sends and fanout collapse). The bindings now live on BLELinkBindings — same bleQueue ownership, whole-transition methods, direct tests for the rotation reverse-map cleanup and the preferred-link survivor repair that were previously only exercised end to end. Composed operations that need both truths (remove-with-repair, direct link state, the subscribed- central snapshot, bind-only-live-links) live on the transport as explicitly bleQueue-confined helpers. This is the structural half of the option-B boundary flip (docs/BLE-ARCHITECTURE-V3.md): ownership of the bindings can now move to the engine without touching what-links-exist. An audit of every physical clear/remove found three sites (emergency clear, both unauthorized branches) that needed explicit binding-clear pairing under the split — each now clears both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix iOS-gated constructors and preserve containment cooldowns on reset CI caught what the macOS SwiftPM build cannot see: two #if os(iOS) sites still passed the peerID field that slice B1 removed from BLEPeripheralLinkState (willRestoreState in BLEService and armPendingBackgroundConnects in BLERadioController). Both fixed and verified with a local iOS simulator xcodebuild. Codex also caught a real regression: BLELinkAuthState.removeAll() cleared the rebind/retirement cooldown maps, which the original panic and emergency reset paths deliberately left alive. A stable CoreBluetooth UUID must not earn a fresh rebind allowance just because the session state around it was wiped. removeAll() now clears only the proofs and revalidation epochs, and BLELinkAuthStateTests pins the survival invariant along with the other auth-state transitions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Link layer slice 3: the option-B domain flip — bindings and link-auth move to the engine The identity domain (BLELinkBindings + BLELinkAuthState) is now owned by the engine queue, with a DEBUG dispatchPrecondition trapping any access from another queue. bleQueue keeps only physical link state. What changed shape: - Receive path is sans-I/O: bleQueue decodes frames and hands (packet, linkID) up through ingestDecodedPacket (panic lifecycle captured at the handoff); attributeAndHandlePacket resolves the sender binding, rejects spoofed senders, applies raw-announce binding, and records ingress on the engine. Per-link frame order is preserved end to end (both queues serial), which supersedes the old batch-local TOCTOU binding in the notification path. - The rotation rebind is one engine slot: containment checks, proof retirement, binding flip, reconnect decision, and rotated-identity retirement run straight-line; only CoreBluetooth cancels hop to bleQueue. The engine->bleQueue->engine ping-pong is gone, along with the _test_afterVerifiedDirectRebindEnqueued pause hook — the test that used it now asserts the atomicity directly (a paused engine wedged the old gate design into a three-queue deadlock). - Authenticated-send eligibility (notifyOrEnqueueIfAccepted, writeOrEnqueueIfAccepted) is checked on the engine, serialized against rebinds by construction; only physical admission (updateValue/write/backpressure) runs on bleQueue. - Teardown splits into discardPeripheralLinkPhysical (bleQueue, inline in the delegates) + retirePeripheralLinkIdentity (engine hop with survivor repair reading liveness via readLinkState). A binding can briefly outlive its physical link; liveness queries join against the physical store and the queued retirement converges the two. - Gossip delegate sends enter the engine via onEngine — safe because mesh.sync sits above the engine in the sync order (production engine code only async-dispatches into the manager). - checkPeerConnectivity rides an engine slot from the bleQueue maintenance tick. No wire changes. 1,974 tests green (parallel and serial), iOS simulator build clean, Periphery clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Link layer slice 4: deterministic multi-node mesh simulation — and the panic-announce bug it caught SimulatedMesh wires real CoreBluetooth-free BLEService engines edge-to-edge through the outbound packet tap and _test_ingestFrame (the production attribution path the B2 flip created), with per-edge synthetic link IDs and manual-scheduler time. Five multi-node tests run in ~40ms with no wall-clock waits: - announce exchange binds simulated links and connects peers - Noise sessions establish end-to-end (real crypto, both directions) - a public message relays across a line topology inside a TTL/frame budget (storm bound asserted) - an 8x duplicate flood delivers exactly once - a panic rotation rebinds the survivor's link exactly once and stays — the scenario that previously needed two phones and log archaeology Fidelity boundary (documented in the harness): no physical links, so fanout planning and backpressure are not exercised; attribution, binding, dedup, TTL, relay decisions, and sessions are the real engine code. The simulator found a real bug on its first run: the forced-announce throttle's lastSent survived a panic, so a rotation within bleForceAnnounceMinIntervalSeconds of the last announce silently swallowed the new identity's announce — leaving it invisible to the mesh until the next maintenance cycle. Today's device test only passed because the previous announce happened to be minutes old. BLEAnnounceThrottle gains reset(), called from the panic slot so the rotated identity owes no throttle debt; pinned by a unit test and the mesh rotation test. New DEBUG seams: _test_ingestFrame (production ingress attribution), _test_forceAnnounce, _test_fenceEngine. 1,980 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Link layer slice 5: name the port — BLELinkEvent, one engine entry, delegates in their own files The upward half of the link-layer port is now a type. BLELinkEvent enumerates everything the bleQueue link layer tells the engine: frameDecoded plus the four physical lifecycle transitions (peripheralLinkEnded, centralLinkEnded, allPeripheralLinksEnded, allCentralLinksEnded). Every bleQueue→engine crossing goes through emitLinkEvent into one engine consumer (handleLinkEvent) — the scattered messageQueue.async identity hops in the delegates collapse into event emission, and the engine-side retirement/bookkeeping logic now lives in one switch. The CoreBluetooth delegate extensions move to their own files as physical bookkeeping plus event emission: - BLEService+LinkLayerCentralRole.swift (CBCentralManagerDelegate + CBPeripheralDelegate) - BLEService+LinkLayerPeripheralRole.swift (CBPeripheralManagerDelegate + write accumulation) BLEService.swift drops from 7,836 to ~7,100 lines. The physical-domain members the role files share flip private→internal; the queue contract is enforced by the existing DEBUG traps and grep guards, not access control. (Two of the flips — isAppActive, logBluetoothStatus — only surfaced on the iOS build; macOS SwiftPM cannot see #if os(iOS) code. Verified with a local iOS simulator build.) The simulated mesh now drives lifecycle events through the identical enum a radio does: linkDropEventRetiresBindingAndReconnectHeals covers drop → identity retirement → last-link peer bookkeeping → re-announce heal, entirely through the port. New seam _test_resetAnnounceThrottle models elapsed wall-clock for the throttle (deliberately separate from _test_forceAnnounce so the panic-rotation test keeps its regression value: the production panic path must do its own reset). The panic test's containment re-announces reset throttles explicitly so those assertions exercise real delivered announces instead of silently throttled ones. noiseSessionEstablishesEndToEnd gains a bounded scheduler-time settle loop after a one-in-many parallel-suite flake (no wall-clock waits). Deliberately not done (recorded in docs/BLE-ARCHITECTURE-V3.md): a formal handle(event)->[Effect] system and further engine-domain file splits — both would flip the engine's private state to internal for cosmetic file counts; the effect formalization rides future feature- module extractions instead. 1,981 tests green, Periphery clean, iOS simulator build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Baseline logBluetoothStatus for the macOS Periphery scan Its callers are all inside #if os(iOS) (willRestoreState in both role files plus the app-state handlers), so the macOS-scheme scan sees the now-internal declaration with zero callers — the same class as the baselined candidateCount. Verified 1-USR diff; the previously private mangled variant was already baselined, which is why the pre-split scan never flagged it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix #1538: release stale bindings on rotation instead of leaving a ghost With two live links to one phone, a panic rotation healed only the link the verified announce arrived on. The second link kept its binding to the retired identity, so that dead ID stayed in the peer list — and was kept alive by the NEW identity's own traffic, since a bound link attributes non-announce frames to its bound peer. It only healed when the stale link physically dropped. The issue proposed exempting the containment rule via retiredBy[X] = Y so the second link could rebind. Two problems: the exemption's stated precondition (X removed by retireRotatedPeer) can never hold in this scenario — the retire is gated on X having no remaining links, which is false precisely because the stale link exists — and it would loosen a security rule to fix a liveness bug. Instead the rotation now RELEASES every link still bound to the rotated-away identity (unbind + retire that link's Noise proof) and retires the identity. No containment rule changes: unbinding is strictly less trusting than any binding, and it is correct under both readings of a second link bound to the retired ID — same physical device (the field case), or one link is a spoofer holding a forged binding, since a peer ID is a Noise-key fingerprint and two devices cannot both legitimately own it. Released links reconverge through the ordinary unbound-link path: the next raw direct announce binds them to whoever they actually carry. Reproduced and fixed under the slice-4 simulator, which is why this lands as tests rather than another two-phone session: - duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks fails without the fix (ghost in both knownPeers and getConnectedPeers, duplicate link still bound to the dead ID) - replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim pins the #1401 containment rule against exactly the attack this fix had to avoid re-opening, with a positive control proving the refusal is the containment check and not duplicate suppression Harness gains connectDuplicateLinks (two links to one peer, modelled in the central role — the links we cannot cancel, and the only role whose bindings a CB-free harness can form), silence (range loss without a link event, so a packet can be captured that the far side never saw), and emittedPackets (the attacker's capture buffer). Residual, documented at the fix: an attacker who binds their own link to X by replaying X's raw announce can drive a rebind there and so evict X's registry entry; X's next announce restores it, and the per-link rebind cooldown bounds the rate. This is the same class of capability the containment already accepts, not a new one. 1,983 tests green, Periphery clean, iOS simulator build clean. Closes #1538 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
4226f01503
commit
e2b409e466
@ -3199,6 +3199,14 @@ extension BLEService {
|
||||
onEngine { linkBindings.peer(forCentralUUID: centralUUID) }
|
||||
}
|
||||
|
||||
func _test_linkBinding(_ link: BLEIngressLinkID) -> PeerID? {
|
||||
onEngine { linkBindings.boundPeer(for: link) }
|
||||
}
|
||||
|
||||
func _test_knownPeerIDs() -> [PeerID] {
|
||||
peerRegistry.peerIDs
|
||||
}
|
||||
|
||||
func _test_markNoiseAuthenticatedCentral(_ centralUUID: String, to peerID: PeerID) {
|
||||
onEngine {
|
||||
guard linkBindings.peer(forCentralUUID: centralUUID) == peerID else { return }
|
||||
@ -6241,12 +6249,55 @@ extension BLEService {
|
||||
// them now instead of leaving ghost links that spray duplicate
|
||||
// traffic until the inactivity timeout.
|
||||
cancelBoundPeripheralLinks(to: previousPeerID, keeping: linkUUID)
|
||||
// Retire the rotated-away ID only once its last link is gone; a
|
||||
// remaining stale link heals the same way or ages out.
|
||||
guard linkBindings.links(to: previousPeerID).isEmpty else { return }
|
||||
// Links we cannot cancel (the remote owns its central connections)
|
||||
// must still stop claiming the dead identity, or it lingers as a
|
||||
// ghost peer that the NEW identity's own traffic keeps refreshing
|
||||
// (issue #1538).
|
||||
releaseLinksBoundToRotatedPeer(previousPeerID)
|
||||
retireRotatedPeer(previousPeerID)
|
||||
}
|
||||
|
||||
/// Unbinds every link still bound to an identity a verified direct
|
||||
/// announce just rotated away from, and retires those links' Noise
|
||||
/// proofs.
|
||||
///
|
||||
/// Release, deliberately not rebind: a rotation announce proves only
|
||||
/// that *its own* link's device now presents as the new ID, so binding
|
||||
/// a different link to that ID on this evidence is exactly what the
|
||||
/// #1401 containment rule ("never steal an identity another live link
|
||||
/// already owns") forbids — and that rule stays intact. Unbinding is
|
||||
/// strictly less trusting than any binding, and it is correct under
|
||||
/// both readings of a second link bound to the retired ID: either it is
|
||||
/// the same physical device (dual links to one phone, the field case),
|
||||
/// or one of the two links is a spoofer holding a forged binding —
|
||||
/// since a peer ID is derived from a Noise key fingerprint, two devices
|
||||
/// cannot both legitimately own it. Dropping the binding is right in
|
||||
/// the first case and a win in the second.
|
||||
///
|
||||
/// Released links then converge through the ordinary unbound-link path:
|
||||
/// the next raw direct announce on the link binds it to whoever it
|
||||
/// actually carries. Until then the link's frames attribute to their
|
||||
/// claimed sender rather than to a dead ID.
|
||||
///
|
||||
/// Residual (unchanged in kind from what the containment already
|
||||
/// accepts): an attacker who has bound their own link to X — possible
|
||||
/// by replaying X's raw announce onto an unbound link — can drive a
|
||||
/// rebind on it and so evict X's registry entry. X's next announce
|
||||
/// re-binds its real links and restores presence, and the per-link
|
||||
/// rebind cooldown bounds the repetition rate.
|
||||
private func releaseLinksBoundToRotatedPeer(_ peerID: PeerID) {
|
||||
for link in linkBindings.links(to: peerID) {
|
||||
linkAuth.retireLink(link)
|
||||
switch link {
|
||||
case .peripheral(let peripheralUUID):
|
||||
// No survivor: every link this peer holds is being released.
|
||||
_ = linkBindings.peripheralRemoved(peripheralUUID) { _ in nil }
|
||||
case .central(let centralUUID):
|
||||
_ = linkBindings.centralRemoved(centralUUID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a restore relaunch the same phone can reappear under a fresh
|
||||
/// peripheral UUID while its restored connection lives on, leaving
|
||||
/// several live central-role connections to one peer that each carry
|
||||
|
||||
@ -32,6 +32,16 @@ final class SimulatedMesh {
|
||||
|
||||
private(set) var nodes: [Node] = []
|
||||
private var neighbors: [Set<Int>] = []
|
||||
private var duplicateLinkEdges: Set<String> = []
|
||||
private var emitted: [[BitchatPacket]] = []
|
||||
|
||||
/// Every packet a node has put on the wire — the attacker's capture
|
||||
/// buffer for replay tests.
|
||||
func emittedPackets(from index: Int) -> [BitchatPacket] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return emitted[index]
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func addNode(nickname: String) -> Node {
|
||||
@ -50,6 +60,7 @@ final class SimulatedMesh {
|
||||
let node = Node(service: service, scheduler: scheduler)
|
||||
nodes.append(node)
|
||||
neighbors.append([])
|
||||
emitted.append([])
|
||||
service.setNickname(nickname)
|
||||
service._test_onOutboundPacket = { [weak self] packet in
|
||||
// Runs on the sender's engine; only buffer here — delivering
|
||||
@ -57,6 +68,7 @@ final class SimulatedMesh {
|
||||
guard let self else { return }
|
||||
self.lock.lock()
|
||||
self.pendingDeliveries.append((from: index, packet: packet))
|
||||
self.emitted[index].append(packet)
|
||||
self.lock.unlock()
|
||||
}
|
||||
return node
|
||||
@ -67,12 +79,56 @@ final class SimulatedMesh {
|
||||
neighbors[b].insert(a)
|
||||
}
|
||||
|
||||
/// The synthetic link a frame from `sender` arrives on at `receiver`.
|
||||
/// Stable per directed edge, like a CoreBluetooth central UUID.
|
||||
/// Radio silence: stops delivering between two nodes without reporting
|
||||
/// any link event, so existing bindings persist exactly as they do when
|
||||
/// a peer walks out of range before its link times out. Lets a test
|
||||
/// capture a packet the far side never received.
|
||||
func silence(_ a: Int, _ b: Int) {
|
||||
neighbors[a].remove(b)
|
||||
neighbors[b].remove(a)
|
||||
}
|
||||
|
||||
/// Models two live links to the same phone (issue #1538): every frame
|
||||
/// from the neighbour arrives twice, on two link IDs that both bind to
|
||||
/// the sender.
|
||||
///
|
||||
/// Both are central links — the remote's connections to our peripheral
|
||||
/// role. That is deliberate and faithful to the defect: central links
|
||||
/// are the ones we cannot cancel (they belong to the remote), so they
|
||||
/// are exactly the links the peripheral-cancel path cannot reach after
|
||||
/// a rotation. Peripheral-role bindings additionally require physical
|
||||
/// link state keyed by a real CBPeripheral, which no CB-free harness
|
||||
/// can fabricate.
|
||||
func connectDuplicateLinks(_ a: Int, _ b: Int) {
|
||||
connect(a, b)
|
||||
duplicateLinkEdges.insert(Self.edgeKey(a, b))
|
||||
}
|
||||
|
||||
/// The synthetic central link a frame from `sender` arrives on at
|
||||
/// `receiver`. Stable per directed edge, like a CoreBluetooth central
|
||||
/// UUID.
|
||||
func linkUUID(from sender: Int, at receiver: Int) -> String {
|
||||
"SIM-\(sender)-TO-\(receiver)"
|
||||
}
|
||||
|
||||
/// Order-independent edge key.
|
||||
private static func edgeKey(_ a: Int, _ b: Int) -> String {
|
||||
"\(min(a, b))-\(max(a, b))"
|
||||
}
|
||||
|
||||
/// The second link of a duplicate-link edge.
|
||||
func duplicateLinkUUID(from sender: Int, at receiver: Int) -> String {
|
||||
"SIM-DUP-\(sender)-TO-\(receiver)"
|
||||
}
|
||||
|
||||
private func links(from sender: Int, at receiver: Int) -> [BLEIngressLinkID] {
|
||||
var links: [BLEIngressLinkID] = [.central(linkUUID(from: sender, at: receiver))]
|
||||
if duplicateLinkEdges.contains(Self.edgeKey(sender, receiver)) {
|
||||
links.append(.central(duplicateLinkUUID(from: sender, at: receiver)))
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func forceAnnounce(from index: Int) {
|
||||
nodes[index].service._test_forceAnnounce()
|
||||
pump()
|
||||
@ -100,11 +156,10 @@ final class SimulatedMesh {
|
||||
|
||||
for (from, packet) in batch {
|
||||
for receiver in neighbors[from] {
|
||||
deliveredFrameCount += 1
|
||||
nodes[receiver].service._test_ingestFrame(
|
||||
packet,
|
||||
link: .central(linkUUID(from: from, at: receiver))
|
||||
)
|
||||
for link in links(from: from, at: receiver) {
|
||||
deliveredFrameCount += 1
|
||||
nodes[receiver].service._test_ingestFrame(packet, link: link)
|
||||
}
|
||||
}
|
||||
}
|
||||
nodes.forEach { $0.service._test_fenceEngine() }
|
||||
|
||||
@ -140,6 +140,124 @@ struct SimulatedMeshTests {
|
||||
#expect(a.service.getConnectedPeers().contains(b.service.myPeerID))
|
||||
}
|
||||
|
||||
/// Issue #1538: with two live links to the same phone, a panic
|
||||
/// rotation used to heal only the link the verified announce arrived
|
||||
/// on. The second link kept its binding to
|
||||
/// the retired identity, which therefore stayed in the peer list as a
|
||||
/// ghost — and, worse, kept being refreshed by the *new* identity's
|
||||
/// traffic (a bound link attributes non-announce frames to its bound
|
||||
/// peer, so the dead ID looked alive for as long as the link lived).
|
||||
@Test
|
||||
func duplicateLinkPanicRotationLeavesNoGhostAndHealsBothLinks() {
|
||||
let mesh = SimulatedMesh()
|
||||
let a = mesh.addNode(nickname: "alice")
|
||||
let b = mesh.addNode(nickname: "bob")
|
||||
mesh.connectDuplicateLinks(0, 1)
|
||||
mesh.announceAll()
|
||||
|
||||
let centralLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
|
||||
let duplicateLink = BLEIngressLinkID.central(mesh.duplicateLinkUUID(from: 1, at: 0))
|
||||
let oldBobID = b.service.myPeerID
|
||||
// Both links bind to bob: raw direct announces bind unbound links,
|
||||
// and that happens before duplicate suppression.
|
||||
#expect(a.service._test_linkBinding(centralLink) == oldBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) == oldBobID)
|
||||
|
||||
b.service.suspendForPanicReset()
|
||||
b.service.resetIdentityForPanic(currentNickname: "anon", restartServices: false)
|
||||
b.service.completePanicReset(restartServices: false)
|
||||
mesh.pump()
|
||||
let newBobID = b.service.myPeerID
|
||||
#expect(newBobID != oldBobID)
|
||||
|
||||
// One verified direct announce must retire the old identity
|
||||
// outright — no ghost survives on the link it did not arrive on.
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.settleUntil { !a.service._test_knownPeerIDs().contains(oldBobID) }
|
||||
#expect(!a.service._test_knownPeerIDs().contains(oldBobID))
|
||||
#expect(a.service._test_linkBinding(centralLink) != oldBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) != oldBobID)
|
||||
|
||||
// Both links converge onto the new identity as its announces land
|
||||
// (the released link binds through the ordinary unbound-link path,
|
||||
// so no containment rule has to be relaxed).
|
||||
for _ in 0..<4 {
|
||||
b.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
mesh.advanceTime(by: 1)
|
||||
}
|
||||
#expect(a.service._test_linkBinding(centralLink) == newBobID)
|
||||
#expect(a.service._test_linkBinding(duplicateLink) == newBobID)
|
||||
#expect(a.service.getConnectedPeers() == [newBobID])
|
||||
}
|
||||
|
||||
/// The #1401 containment rule, pinned against the attack the #1538 fix
|
||||
/// had to avoid re-opening: a captured verified direct announce replayed
|
||||
/// onto a link the attacker controls must NOT bind that link to the
|
||||
/// victim while the victim holds a live link of its own — and must not
|
||||
/// evict the victim either (the rotation release only runs after a
|
||||
/// rebind the containment actually permitted).
|
||||
@Test
|
||||
func replayedVerifiedAnnounceCannotStealALinkOrEvictTheVictim() {
|
||||
let mesh = SimulatedMesh()
|
||||
let alice = mesh.addNode(nickname: "alice")
|
||||
let bob = mesh.addNode(nickname: "bob")
|
||||
let mallory = mesh.addNode(nickname: "mallory")
|
||||
mesh.connect(0, 1)
|
||||
mesh.connect(0, 2)
|
||||
mesh.announceAll()
|
||||
|
||||
let bobLink = BLEIngressLinkID.central(mesh.linkUUID(from: 1, at: 0))
|
||||
let malloryLink = BLEIngressLinkID.central(mesh.linkUUID(from: 2, at: 0))
|
||||
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
|
||||
|
||||
// Mallory captures a signed direct announce alice has NOT seen, so
|
||||
// duplicate suppression cannot mask the containment check: bob
|
||||
// announces while out of alice's range, and mallory replays it on
|
||||
// her own link. Directness is forgeable; the signature is real.
|
||||
mesh.silence(0, 1)
|
||||
bob.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
let replay = mesh.emittedPackets(from: 1).last {
|
||||
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
|
||||
}
|
||||
guard let replay else {
|
||||
Issue.record("bob emitted no direct announce to capture")
|
||||
return
|
||||
}
|
||||
alice.service._test_ingestFrame(replay, link: malloryLink)
|
||||
mesh.pump()
|
||||
mesh.advanceTime(by: 1)
|
||||
|
||||
// The link is not stolen, and bob keeps both his binding and his
|
||||
// place in the peer list.
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == mallory.service.myPeerID)
|
||||
#expect(alice.service._test_linkBinding(bobLink) == bob.service.myPeerID)
|
||||
#expect(alice.service._test_knownPeerIDs().contains(bob.service.myPeerID))
|
||||
#expect(alice.service.getConnectedPeers().contains(bob.service.myPeerID))
|
||||
|
||||
// Positive control — proves the refusal above was the containment
|
||||
// rule and not duplicate suppression: once bob holds no live link,
|
||||
// the very same replayed announce on the very same link does take
|
||||
// effect. (Long-standing accepted residual: a stolen link carries
|
||||
// only Noise ciphertext, and the rebind retires the link's proof.)
|
||||
alice.service.emitLinkEvent(.centralLinkEnded(centralUUID: mesh.linkUUID(from: 1, at: 0)))
|
||||
alice.service._test_fenceEngine()
|
||||
bob.service._test_resetAnnounceThrottle()
|
||||
mesh.forceAnnounce(from: 1)
|
||||
let secondReplay = mesh.emittedPackets(from: 1).last {
|
||||
$0.type == MessageType.announce.rawValue && $0.ttl == TransportConfig.messageTTLDefault
|
||||
}
|
||||
#expect(secondReplay?.timestamp != replay.timestamp)
|
||||
if let secondReplay {
|
||||
alice.service._test_ingestFrame(secondReplay, link: malloryLink)
|
||||
mesh.pump()
|
||||
mesh.advanceTime(by: 1)
|
||||
}
|
||||
#expect(alice.service._test_linkBinding(malloryLink) == bob.service.myPeerID)
|
||||
}
|
||||
|
||||
@Test
|
||||
func panicRotationRebindsSurvivorExactlyOnceAndStays() {
|
||||
let mesh = SimulatedMesh()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user