Repository navigation
Phase 7: Bluetooth offline pairing (v0.2.0) - #14
Merged
Merged
Conversation
swift test output was getting truncated before the full failure list reached chat (task #196); this writes everything to .build-logs/<package>-test.log and exits with swift test's own code.
Adds ConnectionRoute.bluetooth and ConnectionState.connectingBT (0.11, M7.1a), with the pre-authentication rule expressed as phoneAuthenticatedByTLS instead of scattered route == .relay checks: handshakeFailed, ControlSession's bye-before-close and the upgrade-to-LAN recheck now treat relay and Bluetooth alike. A session over Bluetooth is no longer torn down on networkLost (CONN-05 E11). Also: scheduleBackoff now sets nextRetry itself instead of leaving it to waitBackoff(). That gap predates this change (every existing path to it runs through the live run() loop, which always reaches waitBackoff() before anything reads nextRetry) but had never been visible until a direct, non-integration test of the Bluetooth path exposed it.
ConnectionStateMachineTests: every new 0.11 edge (bluetoothInstanceFound, linkSecurityVerified, bluetoothConnectFailed, bluetoothPairingLinkKept, the Bluetooth upgrade to LAN), networkLost ignored while connected(.bluetooth), and phoneAuthenticatedByTLS per route. ConnectionManagerBluetoothTests: PAIR_UNKNOWN/PAIR_REVOKED and AUTH_FAILED/UNSUPPORTED_VERSION before authentication over Bluetooth never unpair or stop reconnecting, matching the relay's existing coverage. setup() walks the same bluetoothInstanceFound -> linkSecurityVerified edges a real connect attempt would instead of calling handshakeFailed from .idle, where it has no edge and silently did nothing; UNSUPPORTED_VERSION uses minProtocol 2, matching the FakePhone fixture's existing realistic rejection. swift test --package-path Packages/HLTransport: 129/129, exit 0.
…'s status/revoke switches CallActionSender and SmsEngine+Send now log `via` through ConnectionRoute.benchChannel instead of a lan/relay ternary, so a Bluetooth bench line reads "bluetooth" instead of being misreported as relay. AppModel+Account (mac) and IOSAppModel+Data (iOS) send the pair-revoke notice over any live local session (LAN or Bluetooth on mac; LAN on iOS), not just `.lan`: a relay session already gets the revocation from `api.deleteDevice(revokePairs: true)`, so excluding only `.relay` avoids sending it twice without missing Bluetooth. HLConnectionStatus(link:lastSeen:) on both platforms adds `.connected(.bluetooth)` so the switch stays exhaustive now that ConnectionRoute has a third case; both map to `.connectedWiFi` as a placeholder with a TODO(task #190/S7.1) until the shared catalog gets a dedicated Bluetooth status string.
…NNpsk0 initiator (0.4.5, Apple)
Mirrors Android's A7.1a on a fake pipe (no radio yet, M7.1c): frame
codec and record framing in HLProtocol (BtLinkConstants/BtPreauthRecord/
BtRecordCodec/BtTransportFrame, rlen checked before allocating per
review #165), the Mac's initiator ("psk, e" / "e, ee") side of
Noise_NNpsk0_25519_ChaChaPoly_SHA256 and K_bt_disc/K_bt_link derivation
in HLCrypto, and the per-link mux/cipher in HLTransport
(BtTransportCipher with auto Rekey(), BluetoothLinkScheduler's
short > long > bulk priority queues, BluetoothLongLaneReassembler, and
BluetoothLinkSecurityClient threading the handshake end to end).
Adds plain ChaCha20-Poly1305 (12-byte nonce) to HLCrypto alongside the
existing XChaCha20Poly1305, since Noise's CipherState needs the
IETF-standard construction, not the X-variant's HChaCha20 subkey step.
…oise NNpsk0 initiator shared/test-vectors/bt-frame.json and bt-link.json against every new type: frame/record round trips and all 6 close-the-link bounds (rlen/dlen out of range, unknown chan, more without long, message over BT_MESSAGE_MAX); firstMessage/tryOpenSecondMessage/Split() byte-exact against the handshake vector plus its tag_mismatch_noise2 negative; K_bt_disc/K_bt_link derivation; BtTransportCipher seal/open at the vectors' own counters and its Rekey() rollover; the scheduler's short > long > bulk priority and same-type ordering; the long-lane reassembler's BT_MESSAGE_MAX enforcement mid-stream; and a fake-pipe run of BluetoothLinkSecurityClient through challenge/noise1/noise2, a tampered noise2 tag, and a fresh challenge changing noise1 so a recorded one from another channel never reapplies. swift build (layered swiftc, swift build/test itself is sandboxed): HLProtocol, HLCrypto, HLTransport and all their Bluetooth test files typecheck clean. A standalone smoke run against the same vectors confirmed firstMessage/Split()/frame encode-decode/cipher seal are byte-exact before this commit.
…navailable Phase 7 (#189) added bulk/direct to envelope.schema.json's type enum and DIRECT_WIFI_UNAVAILABLE to error.schema.json, but the Swift enums hadn't caught up — PrimitiveEncodingTests' schema-sync check caught the drift. directWifiUnavailable is appended last to match 0.8.1's table order. Neither case is added to any handledTypes list: no module processes them yet (0.4.6/0.4.7 land later).
…and route .bluetooth wiring (M7.1c) Gói A — wires M7.1b's link-security/codec/scheduler into ConnectionManager so `.bluetooth` is a real, connectable route instead of a state-machine label: - BluetoothCentral/BluetoothByteStream (HLTransport): the scan+connect seam, parallel to LANDiscovering/ ChannelConnecting — discovery and the L2CAP byte stream are both platform-injectable, so a fake central can drive a full connect attempt without any radio. - BluetoothChannel: MessageChannel over a BluetoothByteStream, threading BtTransportCipher/BtTransportFrame/ BluetoothLinkScheduler/BluetoothLongLaneReassembler (all already built and vector-tested in M7.1b) to turn ChannelMessages into chan-multiplexed Noise records and back. `ping()` has no transport-level round trip to perform (0.4.5 has no record kind for it, unlike a WebSocket ping) — it reports what the continuously-running read loop already knows about the link's liveness. - BluetoothLinkConnector: runs link-security steps 1-4 (challenge/noise1/noise2) over an open stream and hands back the resulting channel; keyConfirmationFailed (CONN-05 E3: try the next candidate) is distinguished from linkRejected (E4: back off) for the caller. - ConnectionManager: new `bluetooth` dependency (CoreBluetoothCentral by default on macOS, NullBluetoothCentral on iOS — 0.4.5 "Roles", decided at compile time in HLTransport so neither HLMacUI/HLiOSUI nor the app targets need to know); `connectBluetooth` mirrors connectLAN/connectRelay's shape, trying every matching advertisement before giving up to Backoff. `discover()` now falls through to a Bluetooth candidate once no (untried) LAN candidate is in sight (CONN-05 step 4 priority), and `stayIdle()` does the same without a network at all (0.11 "start without a network when Bluetooth is usable" — M7.1a built the FSM edge, this wires the trigger). - BtAdvertisementHint (HLCrypto) / BtAdvertisementServiceData (HLProtocol): the `e-4..e+4` hint window and the `ver`/`psm`/`hints` scan-response layout (0.4.5 "Advertisement"). Gói B — CoreBluetoothCentral/CoreBluetoothL2CAPStream: real CBCentralManager scanning and CBL2CAPChannel stream wrapping. **Not measured on a real device yet**: NSBluetoothAlwaysUsageDescription is M7.7a's (split from M7.7 2026-10-28), so CBCentralManager cannot even start on a Mac until that Info.plist entry lands. Out of scope, intentionally: the Bluetooth-off status line and its Settings button (M7.7a); a dedicated status.connected_bluetooth string (not written yet — kept M7.1a's .connectedWiFi placeholder).
… and connect wiring (M7.1c) BtAdvertisementHint against bt-link.json's "hint" vector (epoch window, rounding for negative clocks); BtAdvertisementServiceData against every bt-advertisement.json vector plus wrong_version/malformed-length. FakeBluetoothCentral/FakeBluetoothByteStream (parallel to FakeDiscovery/InMemoryChannel) and FakeBluetoothPeripheral, which scripts S's half of the handshake with canned bt-link.json bytes (Apple has no S-role Noise implementation to compute it, M-APP is always C) then hands back a real BluetoothChannel with S's keys swapped, so FakePhone runs on top of it unmodified. BluetoothLinkConnectorTests: a full handshake against the vector, a tampered noise2 throwing keyConfirmationFailed, a reject record throwing the same, and a stream closed before the challenge arrives throwing linkRejected. ConnectionManagerBluetoothConnectTests (the task's 4 required fake-central scenarios): a matching hint connects end to end over `discover()`'s real BT branch; the connector always opens the latest advertised PSM after a mid-flight rotation, never a cached one; a tampered noise2 from one advertiser tries the next candidate before giving up (CONN-05 E3); a dropped link after Connected ends the session and reaches Backoff (E6). ManagerHarness.make gained a `pair:` parameter so these tests can pin the PairContext's PRK to the vector's own. swift build/test itself is sandboxed; verified by layered swiftc (typecheck clean for all three packages and every test file) plus a standalone smoke executable driving the real ConnectionManager end to end through a hand-rolled fake central: discover() finds the matching hint, openChannel receives the right peripheralId/psm, the link-security handshake over the fake byte pipe reaches handshaking(.bluetooth), and the session/hello envelope decrypts correctly on the peripheral side through the production BluetoothChannel/BtTransportCipher/ scheduler pipeline — confirming the wire-format plumbing end to end beyond what typechecking alone shows.
…(Khoa #3547) The brief lists 4 fake-central scenarios for #194 (no peer seen, peer appears, disconnect, Bluetooth toggled off/on); only 2 had tests. Adds the other 2, no production code touched: - noPeerSeenNeverAttemptsAConnection: Bluetooth powered on, no advertisement ever matches — the manager keeps discovering and backs off on the ordinary LAN_DISCOVERY_GRACE schedule (CONN-05 E2), never calling openChannel. - poweredOffThenOnClearsStaleAdvertisements: once connected, powering the radio off clears matchingBluetoothCandidates() (0.4.5 E1) without dropping the already-open session; powering back on without a fresh advertisement must not resurrect the stale one. Verified by a standalone smoke executable (same layered-swiftc approach as the rest of M7.1c, swift test itself is sandboxed) driving the real ConnectionManager through both scenarios before porting them into the Testing-framework file.
… concurrency-safe CoreBluetoothCentral.serviceUUID broke Swift 6 build (CBUUID non-Sendable static let, Khoa #3547 / Huy #3574 / Trang #3575): mark nonisolated(unsafe), it is set once and never mutated. Past that, a full -emit-module pass (swift test stopped before reaching this file once the first error blocked typecheck) surfaced a second, unrelated concurrency gap: ConnectionManager.bluetoothEphemeralKey crossed into BluetoothLinkConnector.connect's nonisolated static context without a @sendable closure type, and three BluetoothLinkConnectorTests cases captured the non-Sendable vector dictionary across an unstructured Task. Both fixed by narrowing closure types to @sendable and extracting the needed Data values before spawning the Task — no behavior change, confirmed by rerunning the Bluetooth smoke executable against the rebuilt module.
…rministic The rotation test raced the manager loop against the event watcher: the loop could open the first advertised PSM before the rotated snapshot was drained. Publishing .poweredOn last makes a candidate usable only once both snapshots are seen; the expectation is unchanged. The power-off test never set the vector's ephemeral key, so the canned noise2 could not authenticate and the session never connected. It now also checks the latest status, not any status in the history, after the radio turns off.
…oth central Without this key macOS never grants the Bluetooth permission, so CBCentralManager can't start scanning at all (Khoa #3608) — M7.1c's acceptance criterion of connecting to a real S25 is unreachable without it. iOS never creates a real central (ConnectionManager.defaultBluetoothCentral() is macOS-only), so its Info.plist is untouched. The status line, "Open Settings" button, primer and poweredOff/unauthorized split stay out of scope here per Lead's #3609 — M7.7a.
…ion race ConnectionManager is an actor with watchBluetoothCentral() and run() as two separate tasks (Huy #3613, Trang #3622, Khoa #3623). Posting .bluetooth on every watcher event let run()'s loop read bluetoothDiscovered between two advertisements of the same rotation and open a PSM that had already been superseded (0.4.5 "Rotation"), not just in the test's event ordering but with the realistic one too: CoreBluetooth never reports a scan result while the radio is off, so .poweredOn always comes first in practice. scheduleBluetoothSignal() cancels and restarts a short-lived task on every watcher event, posting .bluetooth only once the burst has quieted down, by which point bluetoothDiscovered already holds the latest snapshot. The test is back to the real event order ([poweredOn, results(11111), results(22222)]) with the original assertion ([22_222]) and no race-avoidance comment. Verified against the real compiled module (swift test is still sandboxed here): a scratch build with the fix reverted reproduces the stale PSM 10/10 runs against this exact event order; with the fix, 10/10 runs open only the latest PSM.
Debouncing only the .bluetooth signal left the rotation race open: the run loop also reads matchingBluetoothCandidates() when another signal wakes it or right after entering Discovering, so it could still open the superseded PSM between two advertisements of the same rotation (about 1 run in 10 of the rotation test in its real event order). A bluetoothSettled flag now drops to false on every scan event and only turns true when the debounce task fires; no candidate is offered before that.
The debounce only coalesces the signal; the actual block against reading a mid-rotation snapshot is bluetoothSettled gating matchingBluetoothCandidates() at its read sites, regardless of what woke the run loop. No behavior change.
cpace.json: draft Appendix B.1 vectors, HandLive's PIN usage end to end (including K_bt_pinlink/K_bt_pin and a leading-zero PIN), and the 4 negative cases (low-order Ya/Yb, sid mismatch, wrong PIN). Field25519Tests adds direct sanity checks on the field arithmetic underneath.
…etooth (M7.2a) Field25519: constant-time GF(2^255-19) arithmetic (add/sub/mul, Fermat inversion and Euler's criterion via fixed-exponent square-and-multiply) written from scratch since CryptoKit has no field arithmetic. CPaceX25519SHA512: calculate_generator (Elligator2 per RFC 9380/draft Appendix A.5), scalar_mult_vfy (X25519 via CryptoKit, nil on a low-order point), transcript_ir and ISK (draft-irtf-cfrg-cpace-21 section 7.2) — the suite 0.4.5 "PIN (PAIR-01 flow C)" uses so a Bluetooth PIN pairing never puts the PIN on the air.
cpace.json transcript (Ya for the vector's n_s and ya, K_bt_pinlink and K_bt_pin), timeout, an advertiser without BT_PIN_HINT, a pre-auth failure that costs no attempt, a phone in PIN mode with another PIN, the third wrong attempt asking for a new PIN, and an attacker answering cpace1 with a low-order Yb. FakeBtPinPhone plays Android's CPace and Noise responder.
BtPinPairingSearch scans for BT_PIN_HINT, runs the CPace initiator (cpace1/cpace2) and the link-security handshake with K_bt_pinlink, then PairingExchange with K_bt_pin in place of the Argon2id K_pin. Every channel that fails after cpace1 spends one attempt of the PIN counter shared with the LAN; the last one ends the search so a new PIN is shown.
Vector bt-pairing.json (K_bt_pdisc, K_bt_plink, pr_bt, first frame), timeout, wrong pr_bt, spoofed advertiser with the right pr_bt, hand-off to PairingExchange.
Scan for pr_bt, try every matching advertiser with K_bt_plink as the link psk, then run the unchanged PairingExchange over the secured BluetoothChannel.
…wire) Bluetooth winning when the LAN never finds a candidate, a wrong PIN over Bluetooth propagating the same PairingFailure.pinInvalid(attemptsLeft:) PairingController already handles for the LAN, a soft Bluetooth timeout being retried instead of ending the search, NullBluetoothCentral (iOS) running the LAN side only, and the LAN side still winning on its own failure.
…troller (M7.2-wire) BluetoothPairingSearch adapts BtQrPairingSearch/BtPinPairingSearch to the PairingSearching interface; BluetoothFallbackPairingSearch races it against the LAN search and skips it outright for NullBluetoothCentral (iOS never has a real one). PairingController's default search now wires this in automatically, the same way ConnectionManager already picks CoreBluetoothCentral vs NullBluetoothCentral by platform — no change needed in HLMacUI/HLiOSUI/the app targets themselves.
Trang found that BluetoothFallbackPairingSearch races the LAN and Bluetooth searches from the same attemptsLeft: Int but each side decrements it independently; cancelAll() on the losing side discards any attempts it already spent, letting an attacker split guesses across Wi-Fi and Bluetooth past the 3-attempt limit.
PairingCredential.pin now carries an optional PinAttemptCounter actor (promoted out of BtPinPairingSearch, now shared via reference). PairingController creates one per PIN and reattaches it on every retry; PairingExchange and BtPinPairingSearch spend from it when present instead of each keeping an independent local count. Because BluetoothFallbackPairingSearch hands the same credential to both transports, a wrong guess on either side is committed to the shared actor immediately, so cancelAll() on the losing side can no longer discard attempts it already spent.
Apple's generated string catalogs had fallen behind several Bluetooth- related keys already merged into shared/strings/ui-strings.json (permission primers, status.bluetooth_off/_denied, the updated NSBluetoothAlwaysUsageDescription wording, and the new status.connected_bluetooth/_to pair). Re-run generate-strings.py to bring Localizable.xcstrings, the CommandLineTools .strings fallback, L10n.swift and the Info.plist purpose strings back in sync.
…eholder HLConnectionStatus gains a connectedBluetooth case backed by the new status.connected_bluetooth/_to catalog keys, with the same connected color as Wi-Fi and its own VoiceOver sentence. AppModel+Link (Mac) and IOSAppModel+Link map LinkStatus .connected(.bluetooth) to it instead of the connectedWiFi placeholder, and the menu bar icon treats it as a connected state.
Drop the per-connection bulk stream keys (K_bulk) claim and the Bluetooth-off/denied status line, permission-primer and settings-label strings into a Specs section: HLLocalization's generated L10n has the catalog entries, but no HLMacUI/HLiOSUI view calls them yet. Credit the UI status label and the Info.plist-driven system Bluetooth prompt that are actually wired up.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ghep doi Bluetooth lan dau khong can mang (QR luong B, PIN luong C dung CPace-X25519-SHA512), nhan 'Connected via Bluetooth'. Bao cao bao mat PASS o hub reports/security-scan-0.2.0-2026-10-30.md. Merge bang 'Create a merge commit'.