From 407e7b5e0c23948e37a675b5fc46b6998b890dae Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 6 Oct 2026 12:43:24 -0400 Subject: [PATCH] Re-prove the WHOOP 4.0 encrypted bond when didConnect re-enters a bonded link didConnect clears encryptedBond so each link re-proves it at the WHOOP 4.0 confirmed-write ack (#69), but didBond is only cleared by the disconnect callback. When didConnect fires again with no disconnect in between, the ack was skipped because didBond was still true, so encryptedBond stayed false for the rest of the link: the sidebar read "Live HR (not fully paired)" and buzz (AppModel.canBuzz, BiofeedbackController.canBuzz) was refused, while history sync, which keys off didBond, kept working. Two triggers seen on macOS in one strap log: - the radio powering off and back on: CoreBluetooth sends no disconnect, and poweredOn re-attaches to the strap macOS still holds; - the delayed standing-connect retry from scheduleReconnect firing after the strap has already reconnected. The ack site now classifies the ack through Whoop4BondAck: first bond (unchanged), re-prove (restore encryptedBond and run noteGenuineBond), or already proven (unchanged). didBond and the post-bond handshake are not touched. Co-Authored-By: Claude Opus 5.5 --- Strand/BLE/BLEManager.swift | 12 +++++++++++- Strand/BLE/Whoop4BondAck.swift | 26 ++++++++++++++++++++++++++ StrandTests/Whoop4BondAckTests.swift | 27 +++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 Strand/BLE/Whoop4BondAck.swift create mode 100644 StrandTests/Whoop4BondAckTests.swift diff --git a/Strand/BLE/BLEManager.swift b/Strand/BLE/BLEManager.swift index 0feffde48b..02d9042979 100644 --- a/Strand/BLE/BLEManager.swift +++ b/Strand/BLE/BLEManager.swift @@ -6904,7 +6904,8 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { return } - if !didBond { + switch Whoop4BondAck.classify(didBond: didBond, encryptedBond: state.encryptedBond) { + case .firstBond: didBond = true state.bonded = true state.encryptedBond = true // WHOOP 4 confirmed-write bond is always genuine — #69 @@ -6912,6 +6913,15 @@ extension BLEManager: @preconcurrency CBPeripheralDelegate { noteGenuineBond(of: peripheral) // #52: this strap bonds fine; clears any pin-refusal streak emitConnectionBondState("encryptedBond family=whoop4 (confirmed write acked)") log("BONDED (confirmed write acknowledged) — custom channels should now flow") + case .reprove: + // didConnect re-entered with no disconnect before it (a Bluetooth power cycle) and cleared the + // flag; this ack is the same proof the first bond recorded. See `Whoop4BondAck`. + state.encryptedBond = true + noteGenuineBond(of: peripheral) + emitConnectionBondState("encryptedBond family=whoop4 (confirmed write acked, re-entered link)") + log("BONDED again (confirmed write acknowledged on a re-entered link)") + case .alreadyProven: + break } // Run the connect handshake EXACTLY ONCE per connection. didWriteValueFor re-fires on EVERY // .withResponse write — the bond write, every SEND_HISTORICAL, every HISTORY_END ack. Without diff --git a/Strand/BLE/Whoop4BondAck.swift b/Strand/BLE/Whoop4BondAck.swift new file mode 100644 index 0000000000..49593bb9d8 --- /dev/null +++ b/Strand/BLE/Whoop4BondAck.swift @@ -0,0 +1,26 @@ +import Foundation + +/// What a WHOOP 4.0 confirmed-write acknowledgement records on the current link. +/// +/// The ack is the per-connection proof of the encrypted bond (#69): `didConnect` clears `encryptedBond` so +/// each link earns it again at this site. `didBond` is cleared only by the disconnect callback, and +/// CoreBluetooth sends no disconnect when the Mac's Bluetooth radio powers off. On the next `poweredOn` the +/// app re-attaches to the strap macOS still holds, so `didConnect` arrives with `didBond` still true. +/// Recording the bond only when `didBond` was false then never re-proved it: the strap read "Live HR (not +/// fully paired)" for the rest of the link while history sync, which `didBond` gates, kept working, and +/// buzz, which `encryptedBond` gates, was refused. +/// +/// Pure so the rule is unit-tested without a radio. +enum Whoop4BondAck: Equatable { + /// The first bond on this link: record it and run the post-bond bookkeeping. + case firstBond + /// Already bonded, but a re-entered `didConnect` cleared the encrypted flag: this ack restores it. + case reprove + /// Already bonded and already proven on this link: nothing to record. + case alreadyProven + + static func classify(didBond: Bool, encryptedBond: Bool) -> Whoop4BondAck { + if !didBond { return .firstBond } + return encryptedBond ? .alreadyProven : .reprove + } +} diff --git a/StrandTests/Whoop4BondAckTests.swift b/StrandTests/Whoop4BondAckTests.swift new file mode 100644 index 0000000000..d2f21572d2 --- /dev/null +++ b/StrandTests/Whoop4BondAckTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import Strand + +/// Pins what a WHOOP 4.0 confirmed-write ack records. The case that motivated it: the Mac's Bluetooth radio +/// powers off and back on, CoreBluetooth sends no disconnect, the app re-attaches, and `didConnect` clears +/// `encryptedBond` while `didBond` survives. The next ack must restore the encrypted bond rather than skip it, +/// or the strap reads "Live HR (not fully paired)" and buzz is refused for the rest of the link. +final class Whoop4BondAckTests: XCTestCase { + + func testFirstAckOnALinkIsTheFirstBond() { + XCTAssertEqual(Whoop4BondAck.classify(didBond: false, encryptedBond: false), .firstBond) + } + + func testAckAfterAReenteredConnectReprovesTheEncryptedBond() { + XCTAssertEqual(Whoop4BondAck.classify(didBond: true, encryptedBond: false), .reprove) + } + + func testLaterAcksOnAProvenLinkRecordNothing() { + // Every SEND_HISTORICAL / HISTORY_END ack lands here during an offload; none may re-run bond bookkeeping. + XCTAssertEqual(Whoop4BondAck.classify(didBond: true, encryptedBond: true), .alreadyProven) + } + + func testAStaleEncryptedFlagWithoutABondIsStillTheFirstBond() { + // didBond is the link-level fact; a first ack always runs the full first-bond path. + XCTAssertEqual(Whoop4BondAck.classify(didBond: false, encryptedBond: true), .firstBond) + } +}