Introduction
netprov provides BLE-provisioned network configuration for headless embedded
Linux. A systemd service, netprovd, advertises a GATT service that a paired
client can use to list interfaces, read IP configuration, scan Wi-Fi, and set
DHCP, static IPv4, and Wi-Fi credentials.
The server is written in Rust and talks to NetworkManager over D-Bus. The
companion netprov CLI speaks the same protocol over BLE in production or TCP
loopback during development. A Dioxus desktop client provides the same BLE
workflow through a graphical interface.
Start with Installation, or use the Development guide to run the complete request/response flow without BLE hardware or NetworkManager.
netprov is licensed under MIT OR Apache-2.0.
Installation
CI produces Debian packages for amd64 and aarch64. On the target device:
sudo dpkg -i netprov_<version>_arm64.deb
sudo netprovd keygen --install
sudo systemctl enable --now netprovd
The package does not start the daemon automatically. Install a production PSK first, then enable the service.
The shipped netprovd.service sets NETPROV_PRODUCTION=1, so the daemon
refuses to start without a real key at /etc/netprov/key. It never silently
falls back to the public development key in production.
For a hardware checkout after installation, follow the two-box BLE smoke test.
Development
Loopback quick start
The complete request/response surface can run over TCP against an in-memory network facade, without BLE or NetworkManager.
In one terminal:
cp packaging/dev-key.bin /tmp/netprov-key.bin
chmod 600 /tmp/netprov-key.bin
cargo run -p netprov-server --bin netprovd -- serve-tcp --listen 127.0.0.1:9600
In another terminal:
cargo run -p netprov-client --features dev-tcp --bin netprov -- \
--key-path /tmp/netprov-key.bin --endpoint 127.0.0.1:9600 list
The mock supports list, ip <iface>, wifi-status, wifi-scan,
wifi-connect, set-dhcp, and set-static.
The committed key under packaging/ is public and is only for local
development and CI loopback tests. When it is active, the server logs a warning
every 60 seconds.
See Build and Test for the workspace commands.
BLE Clients
netprovd is a GATT peripheral and remains Linux-only. The CLI and desktop app
are GATT centrals: the SDK uses BlueZ through bluer on Linux and
btleplug on macOS and Windows.
Finding a peer
macOS does not expose peer MAC addresses. CoreBluetooth returns an opaque UUID that is stable for that Mac but meaningless on other hosts. Discover it with the scan command, which needs neither a PSK nor a connection:
cargo run -p netprov-client --features ble --bin netprov -- ble-scan
# 6E4A1B0C-9E3F-4A21-B0D4-7C2F1A8E55D9 netprovd-a1b2 -57 dBm
cargo run -p netprov-client --features ble --bin netprov -- \
--key-path ./netprov-key.bin \
--ble-peer 6E4A1B0C-9E3F-4A21-B0D4-7C2F1A8E55D9 list
The advertised device name also works as --ble-peer. Prefer the identifier
printed by ble-scan: CoreBluetooth may replace its cached advertised name
with the peer’s GATT device name after the first connection. On Linux and
Windows, a Bluetooth device address still works.
macOS notes
- The first connection raises a system pairing prompt. Accept it; a headless peripheral uses Just Works and has no passkey to compare.
- A terminal-launched CLI needs Bluetooth permission for the terminal app in System Settings → Privacy & Security → Bluetooth.
- CoreBluetooth does not expose the negotiated ATT MTU, so requests fragment
at the mandatory 20-byte floor. Set
NETPROV_BLE_MAX_FRAGMENTto a larger value when the peer is known to negotiate it.
Desktop App
The Dioxus desktop app is gated behind the desktop feature. macOS needs no
additional packages because WebKit and CoreBluetooth are provided by the OS.
On Ubuntu or Debian, install the native development libraries:
sudo apt-get install -y \
pkg-config \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
libxdo-dev
Run the BLE-first app with:
cargo run -p netprov-app --features desktop
The app communicates with target devices over BLE. TCP remains a development and protocol-regression path.
Two-box BLE Smoke Test
Use a Linux server with a BLE adapter, BlueZ, and NetworkManager. The client can be Linux or macOS and also needs a BLE adapter.
Server
sudo dpkg -i netprov_1.1.0-1_arm64.deb
sudo netprovd keygen --install | tee /tmp/key.txt
# Copy the base64 PSK output to the client.
sudo systemctl enable --now netprovd
sudo systemctl status netprovd
sudo timeout 10 bluetoothctl scan on | grep netprovd-
Linux client
echo "<paste-base64-from-server>" | base64 -d > /tmp/netprov-key.bin
chmod 600 /tmp/netprov-key.bin
sudo timeout 10 bluetoothctl scan on | head
netprov --key-path /tmp/netprov-key.bin --ble-peer AA:BB:CC:DD:EE:FF list
netprov --key-path /tmp/netprov-key.bin --ble-peer AA:BB:CC:DD:EE:FF wifi-scan
netprov --key-path /tmp/netprov-key.bin --ble-peer AA:BB:CC:DD:EE:FF ip wlan0
list should return real interface names from the server.
macOS client
macOS uses CoreBluetooth and needs no Homebrew packages. It does not expose
peer MAC addresses, so use ble-scan to get the identifier.
echo "<paste-base64-from-server>" | base64 -d > /tmp/netprov-key.bin
chmod 600 /tmp/netprov-key.bin
cargo run -p netprov-client --features ble --bin netprov -- ble-scan
# 6E4A1B0C-9E3F-4A21-B0D4-7C2F1A8E55D9 netprovd-a1b2 -57 dBm
PEER=6E4A1B0C-9E3F-4A21-B0D4-7C2F1A8E55D9
cargo run -p netprov-client --features ble --bin netprov -- \
--key-path /tmp/netprov-key.bin --ble-peer "$PEER" list
cargo run -p netprov-client --features ble --bin netprov -- \
--key-path /tmp/netprov-key.bin --ble-peer "$PEER" wifi-status
cargo run -p netprov-app --features desktop
The first connection raises a system pairing prompt before the first encrypted read. Accept it while the command is running. Later connections reuse the bond. The mutual PSK handshake provides MITM protection, and the client sends no request until the server proves that it holds the PSK.
Troubleshooting
netprov: could not find service: checkjournalctl -u netprovd -n 50for adapter errors.auth failed: copy the PSK again.connect timed out: check range or remove a stale bond withsudo bluetoothctl remove AA:BB:CC:DD:EE:FF.no Bluetooth adapter available, or an empty macOS scan: grant the terminal Bluetooth permission in System Settings → Privacy & Security → Bluetooth.peer ... not seen within 10s: rerunble-scan; CoreBluetooth identifiers are per-host and can be reissued.- Repeated authentication failures after replacing the key: forget the device
on the Mac and remove its bond from the server with
bluetoothctl. peer refused the operation because the link is not encrypted enough: clear the bond and pair again. If it persists, verifygatt.rsusesencrypt_*, notencrypt_authenticated_*.server failed to prove it holds the PSK: the peer has the wrong PSK or is not the expected device. No request is sent.peer closed the connection during authentication: on macOS this usually means the PSK is wrong; CoreBluetooth may hide the ATT authorization error behind the disconnect.- Requests timing out while the daemon logs
dropping notify frame for inactive peer: clear stale CoreBluetooth notification state by power-cycling Bluetooth when testing an older client. - Slow macOS round trips are expected at the 20-byte ATT floor. Raise
NETPROV_BLE_MAX_FRAGMENTonly when the peer supports a larger value.
Architecture and Security
flowchart TD
Client["BLE client (netprov CLI or desktop app)"]
NM["NetworkManager (system)"]
subgraph Daemon["netprovd (systemd service, runs as root)"]
direction LR
GATT["BLE GATT Server"]
Session["Auth & Session"]
Facade["Network Facade"]
GATT --> Session --> Facade
end
Client -->|"GATT over LE (Just Works-encrypted link + app-layer HMAC auth)"| GATT
Facade -->|D-Bus| NM
Security posture
The sensitive characteristics—ChallengeNonce, AuthResponse, Request, and
Response—require an encrypted link through encrypt_read and encrypt_write
in crates/server/src/ble/gatt.rs. The Info characteristic, which contains only
the model and protocol version, stays unauthenticated and unencrypted.
Because netprovd runs headless, it registers a no-input/no-output BlueZ agent.
BlueZ therefore uses Just Works pairing. The characteristics deliberately do
not use encrypt_authenticated_*: those flags require an MITM-protected LTK,
which a headless peripheral cannot produce.
MITM protection comes from the application layer, where both sides hold a
pre-shared key. The server issues a nonce, and the client returns its own nonce
plus HMAC(PSK, "client" || Ns || Nc). After verifying it, the server returns
HMAC(PSK, "server" || Ns || Nc). The client verifies that tag before sending
any request, including Wi-Fi credentials. Domain separation prevents either
tag from being replayed as the other.
An active MITM can observe the pre-authentication exchange during Just Works pairing, including the public challenge nonce, but cannot issue commands or impersonate a device without the PSK.
Workspace Crates
| Crate | Role |
|---|---|
netprov-protocol | Transport-independent CBOR messages, framing, and HMAC authentication helpers. |
netprov-server | The netprovd daemon, BLE GATT driver, session state machine, and mock or NetworkManager facade. |
netprov-sdk | The transport-independent provisioning client and BLE/TCP implementations shared by the CLI and desktop app. |
netprov-client | The netprov CLI, with BLE production transport and a feature-gated TCP development transport. |
netprov-app | The feature-gated Dioxus desktop UI. |
Packaging
The packaging/ directory contains files consumed during build and
installation.
Development key
packaging/dev-key.bin is a 32-byte pre-shared key embedded in development
builds as a fallback. It is committed to the repository and is therefore
public. Use it only for local development and CI loopback tests.
The server logs a recurring warning when this key is active. In production,
install a per-device key with netprovd keygen --install. The shipped service
sets NETPROV_PRODUCTION=1, which disables the fallback.
systemd service
packaging/netprovd.service runs netprovd serve-ble and refuses to start
without a real key at /etc/netprov/key.
The Debian package installs this page under /usr/share/doc/netprov/.
Build and Test
Build matrix
cargo test --workspace
cargo build -p netprov-server --features live-ble
cargo build -p netprov-server --features live-nm
cargo build -p netprov-client --features ble
cargo build -p netprov-client --features ble-btleplug
cargo deb -p netprov-server
live-ble implies live-nm. The live-nm-destructive feature gates mutating
NetworkManager integration tests that are unsafe for CI. ble-btleplug forces
the portable backend on Linux so the macOS path remains covered by Linux CI and
local builds.
Test tiers
| Tier | In CI? | Covers |
|---|---|---|
| Unit tests | Yes | Protocol CBOR round trips, framing, HMAC, bounded strings, and property tests. |
| Session layer | Yes | The Session state machine against MockFacade. |
| Client/server loopback | Yes | The complete request/response surface over tokio::io::duplex. |
| Live NetworkManager | Opt-in | --features live-nm -- --ignored; requires NetworkManager on the system bus. |
| Live BLE end-to-end | Opt-in | --features live-ble and two BLE-equipped hosts. |
Changelog
All notable changes to this project are documented in this file. Format follows Keep a Changelog; the project uses Semantic Versioning.
Unreleased
1.1.0 — 2026-08-01
Post-release hardening pass over the BLE auth and transport path, found during a codebase review.
Added
- macOS (and Windows) BLE support for the client and desktop app, via a second
SDK transport backend built on
btleplug.netprov-sdk’sblefeature now picksblueron Linux andbtleplug(CoreBluetooth / WinRT) elsewhere, socargo build -p netprov-app --features desktopworks on a Mac.netprovdis unchanged and remains Linux-only. (#31) netprov ble-scan— lists nearby netprov devices with the identifier to pass to--ble-peer. Requires no PSK and no connection.netprov-sdk/netprov-clientfeatureble-btleplug, forcing the portable backend on Linux so the macOS code path is covered by Linux CI.NETPROV_BLE_MAX_FRAGMENToverrides the per-frame BLE value length, for platforms that do not report a negotiated ATT MTU.- CI: a macOS job building and testing the client, SDK and desktop app.
Changed
- BLE peers are identified by an opaque
PeerIdrather than a BD_ADDR. CoreBluetooth never discloses peer MAC addresses, so--ble-peerand the app’s peer field now accept a platform handle or the advertised device name; a BD_ADDR still works wherever the platform provides one. netprov_sdk::parse_peer_addressis replaced byparse_peer_id, andBleDevice.address: bluer::AddressbyBleDevice.id: PeerIdplus an optionaladdress: Option<String>.
Fixed
- macOS:
--ble-peer <name>stopped resolving after the first connection. CoreBluetooth replaces its cachedlocal_namewith the peer’s GATT device name (a Pi reports its hostname), so the nameble-scanprinted no longer matched. Scans now prefer the advertised name, and both names resolve. - macOS: every request timed out after a successful session. CoreBluetooth
caches a characteristic’s notify state across connections and answers
subscribefrom that cache without writing the CCCD, sonetprovdnever saw a new subscription and dropped each response withdropping notify frame for inactive peer. The client now clears that state when it connects, which also covers sessions the server ended (a rejected PSK drops the link, leaving nothing to unsubscribe over). - A rejected PSK reported
Device disconnectedon macOS. The server errors theAuthResponsewrite and drops the link, and CoreBluetooth discards the ATT error behind the disconnect, so the client now names the likely cause. - macOS:
ble-scanandconnectintermittently found nothing when run shortly after a previous connection. The client never disconnected, and CoreBluetooth keeps the peripheral connected in the system daemon past process exit; a connected peripheral stops advertising. The CLI now disconnects on exit, including on failure. - A peer refusing an operation for insufficient link security is reported as
such, instead of surfacing as
Device disconnected(CoreBluetooth reports the ATT error and drops the link ~2s later) or being misreported as a wrong PSK. - BLE session ordering: sensitive characteristics are no longer reachable before authentication completes.
- Link-layer encryption enforced on ChallengeNonce, AuthResponse, and
Request/Response characteristics (
encrypt_read/encrypt_writeincrates/server/src/ble/gatt.rs). - MTU-aware fragmentation now respects the negotiated BLE MTU instead of a fixed size.
- Reassembly is gated on authentication, partial-message buffers are bounded, and a peer-session handle leak on disconnect is fixed.
- BLE service/characteristic UUIDs de-duplicated to a single source of truth.
Changed
- Failed-auth rate limiter moved from a per-peer-only tier to an additional global tier, bounding aggregate brute-force attempts across peers.
Added
netprovdinitiates bonding itself when a peer subscribes. The sensitive characteristics need an encrypted link, but a central only discovers that by failing a read: CoreBluetooth does raise its pairing prompt at that point, yet the read has already errored and the link is torn down seconds later, and it never retries. Requesting the bond at subscribe puts the prompt up before the first encrypted read, so a first-time connection can complete instead of having to fail once to provoke pairing.
Security
-
The BLE auth handshake is now mutual, and the protocol version is 2. Previously only the client proved knowledge of the PSK; the server proved nothing, so a peer that merely advertised the netprov service UUID could have collected the Wi-Fi credentials a client sent next.
AuthResponsenow carries the client’s nonce alongside its tag, and the server answers with a tag of its own that the client verifies before issuing any request. The two tags are domain-separated (netprov-auth-client-v2/-server-v2) so neither can be replayed as the other. v1 peers are not wire-compatible. -
Sensitive characteristics ask for
encrypt_*rather thanencrypt_authenticated_*. The authenticated variants are BlueZ’sBT_SECURITY_HIGHand require an MITM-protected LTK, whichnetprovdcannot negotiate: it runs headless and registers aNoInputNoOutputagent, so BlueZ can only do Just Works. The mismatch made every read of a sensitive characteristic fail with “Encryption is insufficient” and the link drop moments later — the BLE transport could not complete authentication on any platform without an out-of-bandbluetoothctlpairing. MITM protection is now provided by the mutual handshake above, at the only layer that holds a shared secret. -
Session::submit_authconsumes the pending nonce before the rate-limit check, so a nonce issued just before lockout no longer survives the whole lockout window. (#19) -
packaging/netprovd.servicenow setsEnvironment=NETPROV_PRODUCTION=1, so the shipped daemon refuses to start on a missing/unreadable/etc/netprov/keyinstead of silently falling back to the public embedded dev key.serve-tcp(dev/loopback) is unaffected. (#10)
1.0.0 — 2026-04-24
First tagged release. Implements the v1 goals.
Added
Protocol (netprov-protocol crate)
- CBOR wire format with a 4 KiB message ceiling enforced at both ends.
- Request/Response envelope with
request_idcorrelation and aResult-shaped response payload. - Seven v1 operations:
ListInterfaces,GetIpConfig,WifiStatus,WifiScan,SetDhcp,SetStaticIpv4,ConnectWifi. - Fragment/reassemble framing for BLE MTUs up to 512 bytes (
[u16 request_id] [u16 seq] [u8 flags]header, FIN bit). BoundedString(512-byte cap) to keep error payloads within frame limits.- HMAC-SHA256 helpers with constant-time verification via
subtle. - Property test for
fragment ∘ reassemble = idacross arbitrary payloads and MTUs.
Server (netprov-server crate, netprovd binary)
NetworkFacadetrait with two implementations:MockFacade(defaultmockfeature) — in-memory state, fault injection, used by all session-layer and loopback tests.NmrsFacade(live-nmfeature) — real NetworkManager vianmrs+ rawzbus. Implements all seven ops.
- Per-connection
Sessionstate machine: challenge/response HMAC auth, single-use nonces, authenticated dispatch. - Per-peer failed-auth rate limiter (5 failures / 60s → 10 min lockout, configurable; mockable clock).
- BLE GATT server (
live-blefeature) overbluer0.17: one primary service with Info, ChallengeNonce, AuthResponse, and Request/Response (write + notify) characteristics.run_ble_serverhandles adapter setup, advertising, per-peerPeerSession, and fragment fan-out. - Static-IPv4 validator: prefix range, loopback/multicast/broadcast rejection, gateway-in-subnet check, DNS sanity.
- Key loading priority:
$NETPROV_KEY_PATH→/etc/netprov/key(mode enforced) → embedded dev key.$NETPROV_PRODUCTION=1disables the dev-key fallback. - Startup banner and periodic WARN loop when the embedded dev key is active.
netprovd keygen [--install]subcommand — generates a 32-byte PSK, prints base64 + QR code, optionally installs to/etc/netprov/keyat mode 0600.netprovd serve-tcpsubcommand — loopback TCP transport for dev (drivesMockFacade).netprovd serve-blesubcommand (live-ble) — production BLE GATT server (drivesNmrsFacade) with sd-notifyType=notifyreadiness signalling.tracing-journaldrouting when the journald socket is reachable; stderr fmt layer as fallback on dev/non-systemd hosts.
Client (netprov-client crate, netprov binary)
- Transport-agnostic
Client<IO>over anyAsyncRead + AsyncWrite. - BLE connector (
blefeature) that discovers the service by UUID, reads the Info + ChallengeNonce characteristics, computes and writes the HMAC tag, then fragments and exchanges Request/Response frames over the notify stream. - Full CLI parity with the protocol:
list,ip <iface>,wifi-status,wifi-scan,wifi-connect,set-dhcp,set-static. --endpointfor TCP transport,--ble-peer <BD_ADDR>for BLE.$NETPROV_KEY_PATHand$NETPROV_ENDPOINTenv fallbacks.
Packaging
cargo-debmetadata producing a singlenetprov_1.0.0-1_<arch>.debcontaining both binaries, the systemd unit, and the README.- Debian maintainer scripts:
postinst(creates/etc/netprovat 0700 and prints key-install guidance — never auto-starts the service);prerm(stops the unit);postrm(removes/etc/netprovonpurgeonly). packaging/netprovd.service—Type=notify,NoNewPrivileges,ProtectSystem=strict,ProtectHome,PrivateTmp, system-call filter restricted to@system-serviceminus@mount @obsolete @privileged @reboot @swap,ReadWritePaths=/etc/netprov.
CI
- GitHub Actions matrix across
ubuntu-latest(amd64) andubuntu-24.04-arm(aarch64). Runs fmt, clippy in three feature configurations (default,live-nm,live-ble), the default test suite, and a release build underlive-ble. Separatedebjob builds and uploads per-arch.debartifacts gated on thetestjob passing.
Documentation
- Design spec.
- Part 1 (core) and Part 2 (BLE + systemd + deb) implementation plans.
- README with architecture diagram, install and dev quickstarts, build matrix, testing tier table.
- Two-box BLE smoke-test runbook, now at
docs/src/guides/ble-smoke-test.md.
Security
- Application-layer HMAC-SHA256 challenge/response defeats active MitM during BLE pairing regardless of Just Works vs. secure pairing at the link layer.
- Per-peer-MAC rate limiter bounds brute-force attempts.
- Single-use nonces — consumed by the first
AuthResponsewrite on each connection. - Key file at
/etc/netprov/keyrefused if any group or world permission bit is set; no silent fallback to the dev key on misconfigured perms. - Embedded dev key is flagged as INSECURE at startup and every 60 seconds thereafter.
- Static-IP input validation runs at the facade boundary before any NetworkManager call.
Non-goals (deferred)
The v1 release deliberately excludes:
- IPv6 configuration.
- Enterprise Wi-Fi (WPA2-EAP / 802.1X).
- Destructive operations (reboot, interface disable, forget connection).
- Mobile and desktop clients.
- Apply-with-rollback for write operations.
- Running as a non-root user with
CAP_NET_ADMIN+ polkit. - Split
netprov/netprovdpackages.