Skip to content

sd: add BLE pairing and bonding support#461

Open
sago35 wants to merge 14 commits into
devfrom
pairing_persistence_unpair
Open

sd: add BLE pairing and bonding support#461
sago35 wants to merge 14 commits into
devfrom
pairing_persistence_unpair

Conversation

@sago35

@sago35 sago35 commented Jul 13, 2026

Copy link
Copy Markdown
Member

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.

  • Pairing: Just Works, passkey display/entry, and LE Secure Connections, selected through PairingParams.IOCapabilities/LESC/MITM. Characteristics can require a minimum link security level (CharacteristicConfig.ReadSecurity/WriteSecurity).
  • Bonding persistence: the LTK and CCCD state are persisted to the last page of flash, so a bonded central can reconnect and re-encrypt without pairing again, even after a reset of the device. All bond flash operations run on a dedicated worker goroutine, so a flash write (up to tens of milliseconds) never stalls the handling of new pairing events.
  • CCCD restore: a bonded central's notification subscriptions are restored as soon as the link re-encrypts, since it does not necessarily resubscribe on its own.
  • 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.
  • bledebug logging: 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.Appearance and CharacteristicConfig.Descriptors, needed so a peripheral can advertise as a recognized accessory type (see below).
  • Two new examples: examples/pairing-justworks and examples/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 call RemoveBond.

Testing

  • go test ./... passes.
  • tinygo build verified for both examples on pca10056-s140v7, microbit-v2-s113v7, and xiao-ble.
  • Verified on real hardware (xiao-ble): both examples pair successfully starting from each platform's standard BLE device discovery/pairing flow - Windows 11, Android, Ubuntu 22.04, and iOS - with the Battery Service notification visible counting down live on all four.
    • The only platform-specific step was on Ubuntu 22.04: bluetoothctl's default agent has no I/O capability, so pairing with pairing-passkey (which requires MITM) needs a capable agent registered first (agent KeyboardDisplay / default-agent) for the passkey dialog to appear. pairing-justworks needs 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_extras adds: 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-safe machine.BlockDevice for application data), Characteristic.Write's ErrNotEnoughResources sentinel 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_extras stack (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_extras and is intentionally left out here to keep this PR reviewable.

sago35 added 9 commits July 11, 2026 21:53
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.
@sago35

sago35 commented Jul 13, 2026

Copy link
Copy Markdown
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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread security_nrf528xx.go
Comment thread security_nrf528xx.go
Comment thread gap_nrf528xx-advertisement.go
Comment thread gap.go
Comment thread security_nrf528xx.go Outdated
sago35 added 5 commits July 14, 2026 07:38
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.
@sago35 sago35 added the nrf Nordic Semi SoftDevice label Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

nrf Nordic Semi SoftDevice

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants