mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Link layer slice 3: bindings and link-auth become engine-owned (the option-B domain flip) (#1547)
* 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> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a0b7985cbe
commit
2f5b56ce57
@ -11,9 +11,9 @@ import Foundation
|
||||
/// stop a replayed announce from flip-flopping bindings or survivor
|
||||
/// selection.
|
||||
///
|
||||
/// bleQueue-confined today, alongside the link bindings it qualifies;
|
||||
/// both move to the engine together in the option-B boundary flip
|
||||
/// (docs/BLE-ARCHITECTURE-V3.md).
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside the link bindings it qualifies: BLEService debug-traps any
|
||||
/// access off the engine queue.
|
||||
struct BLELinkAuthState {
|
||||
private var authenticatedOwners: [BLEIngressLinkID: PeerID] = [:]
|
||||
private var reconnectPolicy = BLENoiseReconnectPolicy()
|
||||
|
||||
@ -5,15 +5,18 @@ import Foundation
|
||||
/// belongs to, in both roles, plus each peer's preferred peripheral link
|
||||
/// for directed sends and fanout collapse.
|
||||
///
|
||||
/// Split from the physical link-state store so the option-B boundary flip
|
||||
/// (docs/BLE-ARCHITECTURE-V3.md) can move ownership of *who owns a link*
|
||||
/// to the engine without touching *what links exist*. bleQueue-confined
|
||||
/// today, alongside the physical store and `BLELinkAuthState`.
|
||||
/// Engine-owned (option-B boundary, docs/BLE-ARCHITECTURE-V3.md),
|
||||
/// alongside `BLELinkAuthState`: *who owns a link* lives on the engine,
|
||||
/// *what links exist* stays on bleQueue in the physical store. BLEService
|
||||
/// debug-traps any access off the engine queue.
|
||||
///
|
||||
/// Lifecycle contract: bindings are only created for live physical links
|
||||
/// (callers guard existence) and are retired through
|
||||
/// `peripheralRemoved`/`centralRemoved`/`clear*` when the physical link
|
||||
/// goes, so binding queries never see departed links.
|
||||
/// (callers check liveness through `readLinkState`) and are retired
|
||||
/// through `peripheralRemoved`/`centralRemoved`/`clear*` on an engine hop
|
||||
/// queued by the physical teardown. A binding can therefore briefly
|
||||
/// outlive its departed link; queries that need liveness join against the
|
||||
/// physical store, and everything converges once the queued retirement
|
||||
/// runs.
|
||||
struct BLELinkBindings {
|
||||
private var peripheralPeers: [String: PeerID] = [:]
|
||||
private var centralPeers: [String: PeerID] = [:]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -554,26 +554,19 @@ struct BLEServiceCoreTests {
|
||||
)
|
||||
let replay = try #require(victim.signPacket(unsigned), "Failed to sign replayed announce")
|
||||
#expect(ble._test_recordIngressIfNew(packet: replay, linkID: attackerLink))
|
||||
let rebindGate = VerifiedDirectRebindGate()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = rebindGate.pause
|
||||
defer {
|
||||
rebindGate.release()
|
||||
ble._test_afterVerifiedDirectRebindEnqueued = nil
|
||||
}
|
||||
ble._test_handlePacket(replay, fromPeerID: victimPeerID, preseedPeer: false)
|
||||
|
||||
let announcePaused = await TestHelpers.waitUntil(
|
||||
{ rebindGate.hasPaused },
|
||||
// The rebind, its Noise-proof retirement, and the ordinary
|
||||
// reconnect preparation are one engine slot: no observer can see
|
||||
// the new binding while the victim's stale sending keys are still
|
||||
// available. Once the binding is visible, the keys must already be
|
||||
// gone.
|
||||
let rebound = await TestHelpers.waitUntil(
|
||||
{ ble._test_centralBinding(attackerLink) == victimPeerID },
|
||||
timeout: TestConstants.longTimeout
|
||||
)
|
||||
try #require(announcePaused)
|
||||
|
||||
// Rebind and ordinary reconnect preparation are one bleQueue
|
||||
// critical section. Once the binding is visible, stale sending keys
|
||||
// must already be unavailable.
|
||||
#expect(ble._test_centralBinding(attackerLink) == victimPeerID)
|
||||
try #require(rebound)
|
||||
#expect(!ble.canDeliverSecurely(to: victimPeerID))
|
||||
rebindGate.release()
|
||||
|
||||
let outbound = OutboundPacketTap()
|
||||
ble._test_onOutboundPacket = { outbound.record($0) }
|
||||
@ -1469,35 +1462,6 @@ private final class SessionReconcileCounter: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
private final class VerifiedDirectRebindGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
private var released = false
|
||||
|
||||
var hasPaused: Bool {
|
||||
condition.lock()
|
||||
defer { condition.unlock() }
|
||||
return paused
|
||||
}
|
||||
|
||||
func pause() {
|
||||
condition.lock()
|
||||
paused = true
|
||||
condition.broadcast()
|
||||
while !released {
|
||||
condition.wait()
|
||||
}
|
||||
condition.unlock()
|
||||
}
|
||||
|
||||
func release() {
|
||||
condition.lock()
|
||||
released = true
|
||||
condition.broadcast()
|
||||
condition.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
private final class ReceivePacketHandoffGate: @unchecked Sendable {
|
||||
private let condition = NSCondition()
|
||||
private var paused = false
|
||||
|
||||
@ -158,6 +158,39 @@ throughput is nowhere near what one serial queue sustains.
|
||||
(it makes no peer decisions); (b) bindings + link-auth migrate to
|
||||
the engine, converting `readLinkState` callers; (c) the delegates
|
||||
shrink to event emission and move behind the port.
|
||||
|
||||
**(a) and (b) are done.** (a) landed as `BLERadioController`
|
||||
(#1539). (b) landed in two steps: #1540 cohered the loose maps into
|
||||
`BLELinkAuthState` + `BLELinkBindings` (still bleQueue-owned,
|
||||
behavior-identical), and the option-B flip then moved ownership to
|
||||
the engine. Since the flip:
|
||||
|
||||
- `linkAuth`/`linkBindings` are engine-owned behind a DEBUG
|
||||
`dispatchPrecondition` trap; bleQueue code cannot touch them.
|
||||
- The receive path is in its sans-I/O shape: bleQueue decodes
|
||||
frames and hands `(packet, linkID)` up through
|
||||
`ingestDecodedPacket` (which captures the panic lifecycle at the
|
||||
handoff); `attributeAndHandlePacket` resolves the sender binding,
|
||||
admits or rejects the claimed sender, applies raw-announce
|
||||
binding, and records ingress — all on the engine. Per-link frame
|
||||
order is preserved end to end (both queues are serial), which
|
||||
supersedes the old batch-local TOCTOU binding.
|
||||
- The rotation rebind is one engine slot
|
||||
(`rebindLinkAfterVerifiedDirectAnnounce`): containment checks,
|
||||
proof retirement, binding flip, reconnect decision, and
|
||||
rotated-identity retirement, with only CoreBluetooth cancels
|
||||
hopping to bleQueue.
|
||||
- Authenticated-send eligibility (`notifyOrEnqueueIfAccepted`,
|
||||
`writeOrEnqueueIfAccepted`) is checked on the engine — serialized
|
||||
against rebinds by construction — and only the physical admission
|
||||
(updateValue / write / backpressure queues) runs on bleQueue.
|
||||
- Teardown splits: bleQueue delegates do physical work inline
|
||||
(`discardPeripheralLinkPhysical`) and queue the identity half
|
||||
(`retirePeripheralLinkIdentity`, binding survivor repair) to the
|
||||
engine. A binding can briefly outlive its physical link; queries
|
||||
that need liveness join against the physical store via
|
||||
`readLinkState` (the engine→bleQueue sync direction), and the
|
||||
queued retirement converges the two.
|
||||
2. **Sans-I/O engine core + simulator.** Make the engine formally
|
||||
`handle(event) -> [Effect]`, feed it from a `SimulatedLinkLayer`, and
|
||||
move the multi-node E2E suite onto deterministic simulation (no
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user