Kleidos Linux QA harness#
A reusable Linux QA-mode harness for the Kleidos hardware password manager: BLE-HID + WiFi-admin, normal and attack paths. It runs on a Linux QA server and is driven either from the dev machine (over SSH) or directly on the box.
The QA server is typically a PRODUCTION machine. Read the safety model below before running anything. The harness is built so that it can only ever touch the one Bluetooth controller and the one WiFi NIC named in
.env.
Configuration (no secrets in git)#
All coordinates — SSH host/port/user, interface names, SoftAP subnet, optional
DUT addresses — live in a gitignored .env. Tracked code carries
placeholders only and reads everything from .env (or matching QA_* env vars
/ CLI flags).
cd scripts/qa/linux-harness
cp .env.example .env
$EDITOR .env # fill in QA_SSH_*, QA_BT_HCI, QA_WIFI_IFACE, QA_ETH_IFACE, ...
Key variables (see .env.example for the full, commented set):
| Variable | Meaning |
|---|---|
QA_SSH_HOST / QA_SSH_PORT / QA_SSH_USER |
how run.py reaches the QA server |
QA_BT_HCI |
Bluetooth controller used for BLE (e.g. hciN) |
QA_WIFI_IFACE |
WiFi NIC used for WiFi tests |
QA_ETH_IFACE |
NO-TOUCH Ethernet iface carrying the default route |
QA_WIFI_STATIC_IP |
static station IP in the SoftAP subnet (subnet route only) |
QA_SOFTAP_IP |
admin-portal gateway on the Kleidos SoftAP |
QA_DUT_NAME_PREFIX |
BLE name / SoftAP SSID prefix to match (Kleidos) |
QA_DUT_ADDR / QA_SPOOF_ADDR / QA_AP_SSID / QA_AP_PSK / QA_AP_MODE |
optional run-later DUT coordinates |
The harness fails closed: if QA_BT_HCI, QA_WIFI_IFACE, or QA_ETH_IFACE
are unset, it aborts (SAFETY_FAIL) rather than guessing an interface.
Harness selection policy#
- Preferred: the Linux QA server (this harness) when
.envis configured. It gives a real BlueZ + WiFi stack (numeric-comparison pairing agent, monitor mode, evil-twin, portal fuzzing) on commodity hardware. - Fallback: when no Linux server is available, use the ESP32 serial
harness — build env
m5stickc_plus1_hostand run the Plus1 serial harness against a DUT.
run.py enforces this: if QA_SSH_HOST is empty and you did not pass --local,
it stops and points you at the Plus1 serial fallback.
Safety model (non-negotiable)#
- Interface pinning. Every script reads the controller / WiFi / Ethernet
names from
.env.run.pyrefuses an--iface/--hcithat does not match the configured ones; the bash guards (lib/safety.sh) refuse the Ethernet iface and Docker bridge names (br-*/docker0/veth*) outright. - The default route is sacred.
kqa_assert_route_safeassertsip route show defaultis via$QA_ETH_IFACEand not via$QA_WIFI_IFACE— it runs before and after every WiFi op and aborts (SAFETY_FAIL, exit 3) otherwise. - No DHCP, no default route via WiFi.
$QA_WIFI_IFACEis brought up with a static IP in the SoftAP subnet (subnet route only). No DHCP client is ever started. - WiFi iface left DOWN when not actively testing; the controller's BLE
knobs (
sc,public-addr) are always restored to their original values. - The bluetooth service is used only via
btmgmt/D-Bus;hostapdis masked so it never auto-starts, and is run by hand for the rogue-AP scenario only.
Layout#
scripts/qa/linux-harness/
├── .env.example # config template (committed); copy to .env (gitignored)
├── run.py # QA-mode runner: SSH wrapper + local dispatcher
├── setup.sh # idempotent provisioning (venv + apt tools + qa-env)
├── selftest.sh # device-independent self-test orchestrator
├── lib/
│ ├── safety.sh # route/iface/hci guards (sourced)
│ └── qa_common.sh # .env loader + OK/FAIL/SKIP emitters (sourced)
├── ble/
│ ├── nc_agent.py # BlueZ numeric-comparison auto-confirm agent (system py)
│ ├── bluez_util.py # BlueZ bond-state + uhid/evdev helpers (no bleak dep)
│ ├── ble_normal.py # scan/connect/pair/HOGP/subscribe/reconnect (bleak)
│ ├── ble_attack.sh # E1-E6 attack scenarios (btmgmt) + N2 fuzz hook
│ └── ble_fuzz.py # N2 malformed-ATT-PDU robustness fuzz (raw L2CAP/ATT)
└── wifi/
├── wifi_net.sh # WiFi iface up(static)/connect/down helpers (sourced)
├── wifi_normal.sh # station connect (WPA2/SAE) + portal checks
├── portal_checks.py# admin-portal HTTP normal + negative/fuzz (requests)
└── wifi_attack.sh # monitor capture, evil-twin, injection probe
Output uses the machine-greppable OK/FAIL/SKIP marker convention:
*_OK / *_FAIL / *_SKIP lines plus a SUITE_SUMMARY ... pass= fail= skip=.
*_SKIP means intentionally not run (e.g. no DUT in range) and does not
fail the suite.
Quick start#
# First time: provision the server (idempotent — safe to re-run).
python3 scripts/qa/linux-harness/run.py --suite setup
# Device-independent self-test (no DUT needed).
python3 scripts/qa/linux-harness/run.py --suite selftest
# Individual suites.
python3 scripts/qa/linux-harness/run.py --suite ble-normal
python3 scripts/qa/linux-harness/run.py --suite ble-attack --scenario blerp
python3 scripts/qa/linux-harness/run.py --suite ble-fuzz
python3 scripts/qa/linux-harness/run.py --suite wifi-normal
python3 scripts/qa/linux-harness/run.py --suite wifi-attack --scenario evil-twin
run.py SSHes to $QA_SSH_HOST, syncs the harness to $QA_DEPLOY_DIR/harness
(tar-over-ssh, no rsync needed — the gitignored .env is carried along so the
server-side suites read the same config), and runs the suite there. To run on
the server directly, add --local.
Suites#
setup#
Creates $QA_DEPLOY_DIR/venv (--system-site-packages so it inherits
python3-dbus/gi/requests) and installs bleak; apt-installs only
iw wireless-tools wpasupplicant hostapd aircrack-ng tcpdump rfkill; masks the
hostapd service; writes the pinned $QA_DEPLOY_DIR/qa-env. Re-runnable.
ble-normal (BLE controller)#
Scans for the $QA_DUT_NAME_PREFIX* device (a re-scan loop that covers the
DUT's ~30 s advertising window — a single short scan can race), hands the
discovered BLEDevice object straight to BleakClient, connects, pairs via
LE Secure Connections + Numeric Comparison (nc_agent.py D-Bus agent
auto-confirms the matched value), validates HOGP, decodes typed keystrokes,
reconnects the bonded peer, lists bonds. If no DUT is advertising, the scan
mechanics still run and the DUT steps report *_SKIP.
Two robustness details:
- Bond is asserted on the persisted record, not
Pair()'s return value or a transient live flag. BlueZDevice1.Pair()returns void/Noneon success, so a real bond was being mis-scored asAUTH_FAIL; and the liveDevice1.BondedD-Bus property can momentarily readFalseright afterPair()whilebluetoothdis still committing the keys, mis-scoring a real bond as not-bonded. The suite now polls for the persisted bond record (/var/lib/bluetooth/<adapter>/<dev>/infocarrying a long-term-key section), which BlueZ only writes once the bond is settled to disk, and scoresAUTH_OKon that — falling back to the livePaired/Bonded/Connected+ServicesResolvedflags only if the persisted record never appears. (BlueZ names the bond dir by the peer's identity address — which differs from the address bleak connected on when the DUT advertises an RPA — and writes the key under[PeripheralLongTermKey], so the check accepts any long-term-key section and scans every bondinforather than keying on one address/name; the suite bonds exactly one DUT per run.) - HOGP is validated through the kernel uhid node, not GATT. The default
bluetoothdinput (HoG) plugin claims HID0x1812and builds a real Linux keyboard, so a GATT central (bleak) can't discover/subscribe the report characteristic. The suite locates the/dev/input/eventNnode the plugin created for the DUT (matched by BD address in/proc/bus/input/devices) and decodes theEV_KEYkeydowns — exactly what a real host's HID stack does, and non-invasive (nobluetoothdrestart on the production box). If the HID service is exposed to GATT (input plugin disabled), it falls back to a GATT subscribe.
Keystroke privacy: by default only the decoded length / keydown count is printed. Pass
--show-text(via-- --show-text) only with a known test credential.
Driving the DUT (serial debug commands)#
To stage the DUT for ble-normal/ble-attack, drive it over its serial debug
console (a _debug build). The commands that actually exist on the firmware
are BLETYPE [id] [ALL|USER|PASS] (begin advertising + stage typing),
BLESEND (send the staged payload), BLEPAIR <ACCEPT|REJECT> (resolve a pending
numeric-comparison request — requires the argument), BLEAUTOACCEPT ON|OFF,
BLESTOP, BLEFORGET ALL, BLE?/BLEDIAG (introspection).
Doc discrepancy flagged for the firmware team (do not "fix" in the harness):
docs/testing/serial-debug-commands.mdlists "BLEPAIR/BLETYPE| Begin advertising / typing flows", but bareBLEPAIRis "Unknown command" — the firmware registers it as aMatch::Prefixon"BLEPAIR "(trailing space), so it only matchesBLEPAIR <ACCEPT|REJECT>and never begins advertising.BLETYPEis what begins advertising;BLEPAIRonly resolves the NC prompt. The doc conflates the two. The harness usesBLETYPE.
ble-attack (BLE controller) — maps to enhancements E1–E6 + N2#
| Scenario | Enh. | What it does |
|---|---|---|
sc-reject |
E3 | btmgmt sc off (force legacy). Positive reached-then-refused assert: first scan to prove the DUT is in range (SC_REACHED), then classify the btmgmt pair -c 0x01 status — an authentication/pairing rejection = SC_REJECT_OK, a successful pair = FAIL, a mere connection failure = SKIP (inconclusive, never a false OK). Restore sc on. |
iocap |
E3/N6 | register Just-Works / Passkey / DisplayYesNo agents; assert DUT keeps NC-only |
accept-list |
E2 | Real: clear this controller's local pairing (→ unbonded identity), drive an unbonded connect to the DUT, and assert the result. E2 is not implemented yet, so today this records a BASELINE (unbonded connect accepted) and flips to ACCEPTLIST_REJECT_OK automatically when E2 lands. |
rpa |
E1 | Real: with E1 the DUT advertises a rotating RPA. Asserts two complementary, both-desired outcomes: anti-tracking (RPA_ANTITRACK_OK — the identity address is never seen raw on air, only rotating RPAs, so a non-bonded scanner can't link the DUT) and resolution (RPA_RESOLVE_OK — a bonded host holding the stored IRK resolves the rotating RPA back to the identity). A resolved identity for a bonded host is success, not a tracking leak. RPA_IRK_STORED is the resolution prerequisite. |
blerp |
E4 | spoof a bonded central's public address (CVE-2025-62235 / BLERP), attempt to overwrite the bond; assert the on-device re-pair-confirm gate blocks it; restore the original address |
fuzz |
N2 | malformed / oversized / boundary ATT-GATT PDUs; assert the DUT survives (stays responsive, no reboot/hang). Delegates to ble_fuzz.py (see ble-fuzz below). |
DUT-side assertions need QA_DUT_ADDR (and QA_SPOOF_ADDR for blerp);
without them the controller-side mechanics (knob toggles, agent registration,
PDU crafting) are self-tested and the DUT assertions are *_SKIP / BASELINE
notes. The rpa scenario asserts E1 for real (anti-tracking + IRK resolution);
accept-list is already wired for real DUT assertions so it needs no rewrite
when E2 ships — it just starts passing.
accept-listclears the local bond (to become an unbonded identity) by default; re-runble-normalafterwards to re-bond, or setQA_ACCEPTLIST_CLEAR_BOND=0to keep the bond and skip the unbonded assertion.BLERP public-address spoof is opt-in. Overriding the controller's public address is default-SKIP and only runs with
QA_ALLOW_ADDR_SPOOF=1. It needs a spoof-capable controller (e.g. a CSR8510 USB dongle): Intel controllers tx-timeout on the vendor command (HCI0xfc31) and wedge with "No way to reset", recoverable only by a USB re-enumeration of the btusb device. The default run never issues that command.
ble-fuzz (BLE controller) — N2 malformed-PDU / ATT-GATT robustness#
Sends a small, bounded catalogue of malformed / oversized / boundary ATT
PDUs at the DUT and asserts it survives — a secrets-typing peripheral must
never crash on hostile input. Run standalone (--suite ble-fuzz) or as the
fuzz scenario of ble-attack.
- Transport (safety): a kernel-managed
AF_BLUETOOTH/BTPROTO_L2CAPsocket connected to the ATT fixed CID0x0004via a rawsockaddr_l2(ctypes). This rides the normal BLE stack —bluetoothdstays up, the controller is not seized with an HCI user channel, and nothing touches WiFi / Ethernet / the default route. Bounded: a fixed PDU catalogue, short timeouts, one connection at a time, no flooding. - What it sends (tagged
FUZZ_PDU_SENT <tag>): reserved opcode0xFF, truncated/under-length requests, invalid/reversed/zero/out-of-range handles, Read-By-Group/Type length mismatches, oversized writes (> MTU), prepared-write with a bogus offset, a truncated signed write, an unsolicited confirmation, and a spoofed server PDU. - Survival check: after every burst it re-opens an ATT connection +
Exchange-MTU. Still answering ⇒
FUZZ_BURST_SURVIVED_OK; stops answering ⇒FUZZ_DUT_UNRESPONSIVE_FAIL(a firmware finding — the harness reports, it does not fix firmware). - Address type: set
QA_DUT_ADDR_TYPE(randomfor the Kleidos static-random address, the default;publicotherwise). - No DUT: self-tests the PDU crafting +
sockaddr_l2packing and prints the tagged catalogue (green inselftest); the live sends are*_SKIP. - Live fuzz needs the ATT CID free. Opening the ATT fixed CID (
0x0004) from a userspace L2CAP socket requiresbind()ing to the local controller and only succeeds whenbluetoothddoes not own that CID. On a bluetoothd-managed adapter the connect hangs inEINPROGRESSand the scenarioFUZZ_LIVENESS_SKIPs with that explanation; validated live on mastodon by briefly freeing the CID (DUT returned proper ATT Error Responses to every malformed PDU and stayed responsive —FUZZ_DONE_OK).
wifi-normal (WiFi iface)#
Brings the WiFi iface up (static IP, no default route), scans for the
$QA_DUT_NAME_PREFIX* SoftAP, associates (WPA2 or QA_AP_MODE=sae for WPA3),
runs the portal checks, tears the iface back DOWN. Provide QA_AP_SSID +
QA_AP_PSK to run the full station+portal pass; otherwise it self-tests
bring-up/scan/teardown with route assertions and *_SKIPs the connect.
portal_checks.py: portal reachable; status endpoint is accepted either as a
public 200 with the expected shape or as an auth-required 401/403
(requiring auth on /api/status is the more-secure behavior and must not be
scored as a failure); auth-required sensitive endpoints reject unauthenticated
access; and a negative/fuzz pass (path traversal, oversize paths, wrong methods,
malformed bodies) that must not 5xx. Response bodies are never printed.
wifi-attack (WiFi iface)#
inject-probe— empirically runsaireplay-ng --testin monitor mode. If the WiFi chip cannot inject, the deauth/injection scenarios auto-disable (INJECT_PROBE_SKIP+DEAUTH_SKIP, clean exit — not a failure). The probe verdict is printed asINJECT_SUPPORTED=yes|no.monitor— monitor-mode capture of management frames near the SoftAP.evil-twin— rogue AP cloning the Kleidos admin SSID viahostapd.
Monitor + evil-twin work on most chips; injection/deauth generally do not on Intel
iwlwifi— they need an mt76/Atheros/Realtek USB dongle. The harness detects this and skips cleanly.
Run-later (DUT-dependent) staging#
When a Kleidos DUT is available, set its coordinates in .env (or pass as env):
# BLE: drive the DUT with BLETYPE (begin advertising + stage typing) on its
# serial debug console, put it in pairing range, then:
python3 .../run.py --suite ble-normal -- --show-text # known test credential only
QA_DUT_ADDR=<dut-ble-addr> \
python3 .../run.py --suite ble-attack --scenario sc-reject
QA_DUT_ADDR=<dut-ble-addr> \
python3 .../run.py --suite ble-attack --scenario accept-list # E2 baseline
QA_DUT_ADDR=<dut-ble-addr> \
python3 .../run.py --suite ble-attack --scenario rpa # E1 anti-tracking + IRK resolution
QA_DUT_ADDR=<dut-ble-addr> QA_DUT_ADDR_TYPE=random \
python3 .../run.py --suite ble-fuzz # N2 robustness
QA_DUT_ADDR=<dut-ble-addr> QA_SPOOF_ADDR=<bonded-central-addr> \
python3 .../run.py --suite ble-attack --scenario blerp
# WiFi: put the DUT in ADMIN_MODE (SoftAP up), then:
QA_AP_SSID=<softap-ssid> QA_AP_PSK=<softap-pass> QA_AP_MODE=wpa2 \
python3 .../run.py --suite wifi-normal
(run.py forwards these to the remote suite.)