sd: add BLE pairing and bonding support#461
Open
sago35 wants to merge 14 commits into
Open
Conversation
Peripherals had no way to pair or bond: security-related GAP events were ignored, so a central's pairing request hung until the SMP timeout. Add EnablePairing to the Nordic SoftDevice backend, wiring up the SEC_PARAMS_REQUEST/PASSKEY_DISPLAY/AUTH_KEY_REQUEST/ LESC_DHKEY_REQUEST/AUTH_STATUS event flow from a dedicated worker goroutine (event handling happens in interrupt context and cannot block on user input or run the LESC ECDH key exchange). Supports Just Works, passkey display/entry and LE Secure Connections numeric comparison, selected through PairingParams.IOCapabilities. Characteristics can now also require a minimum security level (gatts.go: SecurityLevel, ReadSecurity/WriteSecurity) so a profile can restrict access to a paired link.
A bonded central expects its notification subscriptions (CCCD state) to persist across reconnections and does not necessarily subscribe again. Save the GATT system attributes on every write and at disconnect, and restore them as soon as the link re-encrypts (BLE_GAP_EVT_CONN_SEC_UPDATE) - waiting for a BLE_GATTS_EVT_SYS_ATTR_MISSING event is not enough, since sending a notification the central isn't subscribed to just fails silently instead of generating that event.
Without this, the LTK and CCCD state lived only in RAM, so resetting the device lost the bond: the central would have to be deleted and re-paired. Persist a single bond (LTK + CCCD state) to the last page of the application's flash region, loaded on the first EnablePairing call and saved whenever a new bond forms or the CCCD state changes. SoftDevice flash writes go through sd_flash_page_erase/sd_flash_write (machine.Flash's direct NVMC access is not allowed while the SoftDevice is enabled) and complete asynchronously, reported through a SoC event (sd_evt_get) delivered on the same interrupt as BLE events. The event pump did not previously drain that queue at all; add that here since it is now needed to observe flash operation completion. The writes run on a dedicated bond storage worker goroutine: a flash operation blocks for up to tens of milliseconds (~90ms worst case for a page erase), which must not run in interrupt context and would stall the security worker's handling of new pairing events for its whole duration.
There was no way to make the device forget its bond short of erasing flash externally: RemoveBond clears the bond from RAM and flash and disconnects the current central, so the next central to connect can pair fresh. Useful both for deliberately unpairing a device and for recovering when a central and this device disagree about whether they are still bonded (for example after the bond was deleted only on the central's side). The erase is handed to the bond storage worker through a volatile-flag handshake (the signalling style used everywhere else in this backend), so it cannot race with a save that may be in progress; RemoveBond blocks only its own caller until the erase completes.
bonded and LESC are independent: legacy pairing can bond too, and the CONN_SEC_UPDATE security level alone doesn't distinguish LESC Just Works (level 2) from legacy Just Works (also level 2). Read the lesc bit from the AUTH_STATUS event and log it alongside bonded, so bledebug builds can confirm which key exchange actually secured a given pairing instead of inferring it from the security level.
Debugging a real-hardware pairing failure (Ubuntu 22.04/BlueZ central) showed only "evt: gap lesc dhkey request" followed by "evt: disconnected", with no indication of why: lescComputeAndReply silently disconnects on an invalid peer public key or a failed ECDH, and the disconnect log didn't include the HCI reason code either. Log both, so the next failure at least says which of the two happened.
AdvertisementOptions.Appearance adds the Appearance field (AD type 0x19) to the advertisement payload and, on Nordic SoftDevices, also sets the GAP Appearance characteristic via sd_ble_gap_appearance_set. Hosts use this to recognize device types such as HID keyboards.
CharacteristicConfig.Descriptors adds extra read-only descriptors (DescriptorConfig) to a characteristic, needed by some profiles - for example the HID Report Reference descriptor identifying which characteristic is which report. Also let a characteristic's max_len grow to fit an initial value larger than the previous conservative default of 20 bytes (needed for the HID Report Map, which can be much larger).
Two minimal peripheral-role pairing examples, exercising the pairing,
bond persistence and RemoveBond support added in previous commits:
- pairing-justworks: IOCapsNone, no user interaction and no MITM
protection.
- pairing-passkey: IOCapsDisplayOnly + MITM, the device displays a
passkey that must be entered on the central.
Both advertise as a keyboard accessory (HID over GATT, appearance 961)
without ever sending a key (examples/hidkeyboard is the functional
keyboard): a peripheral exposing only a custom GATT service is
invisible or unusable from most OSes' own Bluetooth settings, each
needing a separate GATT browser app just to read one characteristic.
A recognized accessory type lets pairing, encryption and notification
be verified from standard settings UI on every platform tested
(Windows, Android, Ubuntu, iOS), no extra app required.
The visible signal is a standard Battery Service whose level counts
down every two seconds over a notification on the same link. Both also
accept 'r' on the serial console to call RemoveBond - useful when a
central and this device disagree about whether they are still bonded
(for example after the bond was deleted only on the central's side),
since both sides need to forget each other for a clean re-pair.
Member
Author
|
I have been working with a keyboard I created using TinyGo + BLE for about a week, and so far, no problems have occurred. |
There was a problem hiding this comment.
Pull request overview
This PR adds BLE security support (pairing, bonding, and bond persistence) to the Nordic SoftDevice peripheral backend, along with new GATT security controls and example peripherals that exercise the pairing flows.
Changes:
- Introduces pairing configuration APIs and SoftDevice GAP event handling for pairing/bonding (including LESC and passkey workflows).
- Persists a single bond (LTK + CCCD/system attributes) to flash and restores CCCD state after re-encryption.
- Adds Appearance advertising support, custom GATT descriptors, and two pairing-focused examples.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| security.go | Adds public types for IO capabilities and pairing configuration/error reporting. |
| security_nrf528xx.go | Implements SoftDevice pairing/bonding handling, security worker, CCCD save/restore, and RemoveBond. |
| bond_storage_nrf528xx.go | Persists/erases the bond record in the last flash page and runs flash ops in a dedicated goroutine. |
| adapter_sd.go | Drains SoC events (flash op completion) and exposes flash op result to bond storage. |
| adapter_nrf528xx-peripheral.go | Wires SoftDevice security events into the peripheral GAP event handler; saves sys attrs on writes. |
| adapter_nrf528xx-full.go | Wires SoftDevice security events into the full GAP event handler; saves sys attrs on writes. |
| gatts.go | Adds characteristic/descriptor security levels and descriptor configuration to the public API. |
| gatts_sd.go | Applies SecurityLevel permissions to SoftDevice attributes; supports adding static read-only descriptors. |
| gap.go | Adds Appearance option to advertising payload serialization. |
| gap_nrf528xx-advertisement.go | Sets SoftDevice GAP Appearance characteristic when Appearance is configured. |
| Makefile | Extends TinyGo smoketest builds to include the new pairing examples on supported targets. |
| examples/pairing-justworks/main.go | New Just Works pairing + bonding example advertising as HID keyboard appearance. |
| examples/pairing-passkey/main.go | New passkey display (MITM) pairing + bonding example advertising as HID keyboard appearance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
sd_ble_gap_disconnect is asynchronous: the actual disconnect event can still arrive after RemoveBond returns. peerBonded was left set, so secOnDisconnect -> secSaveSysAttrs would pass its "is this the bonded peer" check, re-read the CCCD state from the SoftDevice, and mark the bond dirty again - causing the bond storage worker to write a bond record back to flash right after RemoveBond had just erased it. Clear peerBonded alongside the other RAM state to close that window. Also check sd_ble_gap_disconnect's return value via makeError instead of discarding it, so a failed disconnect is reported rather than silently continuing as if it had succeeded.
Previously the SoftDevice option was only set when StaticPasskey was non-empty, so calling EnablePairing again with an empty StaticPasskey would leave a previously configured static passkey active. Passing a NULL p_passkey resets the SoftDevice to generating a random passkey, so always call sd_ble_opt_set to make the option match the current params.
sd_ble_gap_appearance_set can fail, and ignoring that left the GAP Appearance characteristic potentially out of sync with what the advertisement payload claims, which can confuse hosts. Return the error from Configure like the other SoftDevice calls it makes.
addFromOptions/addAppearance had no test case, leaving the AD type (0x19), length, and little-endian encoding unverified against regressions.
bondValid was a plain bool, but it is written by the security worker, by RemoveBond (application goroutine), and by loadBondFromFlash (called from EnablePairing), and read by the security worker. Use volatile.Register8 like the other cross-context bond state (peerBonded, bondDirty, sysAttrLen) instead of an unsynchronized bool.
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.
Summary
Adds BLE pairing and bonding support to the Nordic SoftDevice peripheral backend. Previously, security-related GAP events were ignored entirely: a central's pairing request would hang until the SMP timeout, and there was no way to bond, persist a bond across a device reset, or restore CCCD (notification subscription) state on reconnect.
PairingParams.IOCapabilities/LESC/MITM. Characteristics can require a minimum link security level (CharacteristicConfig.ReadSecurity/WriteSecurity).Adapter.RemoveBond(): explicitly forget the current bond (RAM + flash) and disconnect, so the next central can pair fresh. Useful both for deliberately unpairing and for recovering when a central and this device disagree about whether they're still bonded.bledebuglogging: logs whether a given pairing actually used LE Secure Connections (independent of whether it bonded - the security level alone doesn't distinguish LESC Just Works from legacy Just Works) and the HCI disconnect reason, both useful when a real central's IO capabilities cause pairing to behave unexpectedly.gap.Appearance/ GATT descriptors:AdvertisementOptions.AppearanceandCharacteristicConfig.Descriptors, needed so a peripheral can advertise as a recognized accessory type (see below).examples/pairing-justworksandexamples/pairing-passkey, each demonstrating one pairing method. Both advertise as a keyboard accessory (HID over GATT, appearance 961) without ever sending a key - a peripheral exposing only a custom GATT service turned out to be invisible or unusable from most OSes' own Bluetooth settings during testing (each needed a separate GATT browser app just to read one characteristic), while a recognized accessory type lets pairing, encryption and notifications be verified from standard settings UI with no extra app, on every platform tested. The visible signal is a standard Battery Service whose level counts down every two seconds over a notification on the same link. Both also accept'r'on the serial console to callRemoveBond.Testing
go test ./...passes.tinygo buildverified for both examples onpca10056-s140v7,microbit-v2-s113v7, andxiao-ble.bluetoothctl's default agent has no I/O capability, so pairing withpairing-passkey(which requires MITM) needs a capable agent registered first (agent KeyboardDisplay/default-agent) for the passkey dialog to appear.pairing-justworksneeds no such step.Scope and follow-up
This PR is a deliberately-scoped slice of a larger body of work. The full implementation, including a BLE HID keyboard (HOGP) example and additional hardening, is on
sago35/bluetooth@bonding_extras, stacked as:bonding_extrasadds: single-device exclusivity with an opt-in pairing-mode window (reject new pairing once a bond exists), an idle-auth-timeout disconnect for connections that never pair,SDFlash(a SoftDevice-safemachine.BlockDevicefor application data),Characteristic.Write'sErrNotEnoughResourcessentinel with a non-blocking notify-send pattern, and the HID keyboard example itself.A real-world consumer of this work is in progress at sago35/tinygo-keyboard#79, which exercises the full
bonding_extrasstack (including the HID keyboard path) on actual keyboard firmware.Known current limitation in this PR's scope: while disconnected, any nearby device can freely pair and overwrite the existing bond (Just Works has no MITM protection, and this PR does not include the bonded-state gate). That gate is part of
bonding_extrasand is intentionally left out here to keep this PR reviewable.