Skip to content

Phase 7: Bluetooth offline pairing (v0.2.0) - #14

Merged
xuandung38 merged 30 commits into
mainfrom
feat/p07-ghep-bluetooth-offline
Oct 30, 2026
Merged

xuandung38 merged 30 commits into
mainfrom
feat/p07-ghep-bluetooth-offline

Conversation

@xuandung38

Copy link
Copy Markdown
Contributor

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'.

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.
@xuandung38
xuandung38 merged commit 36f8cc8 into main Oct 30, 2026
4 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant