End-to-End Encryption
bleRPC supports optional end-to-end encryption using a 4-step key exchange handshake followed by AES-128-GCM session encryption. Encryption is negotiated during connection setup and is transparent to the application layer.
Cryptographic Components
| Component | Algorithm |
|---|---|
| Key Agreement | X25519 ECDH |
| Authentication | Ed25519 Signatures |
| Key Derivation | HKDF-SHA256 |
| Session Encryption | AES-128-GCM |
| Replay Protection | Monotonic Counter |
Capability Negotiation
Encryption is advertised by the peripheral in the CAPABILITIES response. The 6-byte payload includes a flags field:
If flags & 0x01 is set, the peripheral supports encryption and the central should initiate the key exchange handshake.
Key Exchange Handshake
The handshake uses 4 steps, each carried in a KEY_EXCHANGE control container (control_cmd=0x6). Each payload is prefixed with a 1-byte step identifier.
Step 1: Central → Peripheral (33 bytes)
The central generates an ephemeral X25519 keypair and sends its public key.
Step 2: Peripheral → Central (129 bytes)
The peripheral generates its own ephemeral X25519 keypair, computes the shared secret, derives the session key, and signs both public keys with its long-term Ed25519 key.
The signature covers central_x25519_pubkey || peripheral_x25519_pubkey (64 bytes), binding both parties' ephemeral keys to prevent man-in-the-middle attacks.
Step 3: Central → Peripheral (45 bytes)
The central verifies the signature, computes the shared secret and session key, then sends an encrypted confirmation to prove it holds the correct session key.
The plaintext is a fixed 16-byte marker (BLERPC_CONFIRM_C) encrypted with AES-128-GCM using the derived session key and a random 12-byte nonce. The peripheral decrypts it and checks for the exact expected marker, proving both sides hold the same session key.
Step 4: Peripheral → Central (45 bytes)
The peripheral decrypts Step 3, verifies it matches the expected BLERPC_CONFIRM_C marker, then sends its own encrypted confirmation containing BLERPC_CONFIRM_P.
After the central successfully decrypts Step 4, the handshake is complete and both sides share the same session key.
Key Derivation
The session key is derived from the X25519 shared secret using HKDF-SHA256:
shared_secret = X25519(my_privkey, peer_pubkey) # 32 bytes
session_key = HKDF-SHA256(
salt = central_x25519_pubkey || peripheral_x25519_pubkey, # 64 bytes
ikm = shared_secret,
info = b"blerpc-session-key",
len = 16 # AES-128 key
)
Both central and peripheral perform the same derivation using the concatenation of both X25519 public keys as the salt, arriving at the same 16-byte session key.
Session Encryption
After the handshake, all command payloads are encrypted before container splitting:
Encrypted Payload Format
Encryption Process
- Serialize the CommandPacket (type + name + protobuf data)
- Construct the 12-byte nonce from the current TX counter:
[counter_LE:4][direction:1][0x00:7]—directionis0for Central→Peripheral,1for Peripheral→Central - Encrypt with AES-128-GCM:
ciphertext, tag = AES-GCM(session_key, nonce, plaintext) - Prepend the current counter:
[counter:4][ciphertext:N][tag:16] - Increment the TX counter for the next message (the first message on a session therefore uses counter
0) - Split into containers as usual
Decryption Process
- Reassemble containers into full payload
- Extract counter (first 4 bytes), ciphertext, and tag (last 16 bytes)
- Verify counter > last received counter (replay detection)
- Construct nonce and decrypt with AES-128-GCM
- Parse the decrypted data as a CommandPacket
Replay Detection
Each direction maintains an independent monotonic counter:
- TX counter: Starts at 0. Each message is encrypted with the current value, which is then incremented — so the first message uses counter 0.
- RX counter: Tracks the last received counter. Any message with a counter ≤ the last received counter is rejected.
This prevents replay attacks where an attacker re-sends previously captured encrypted packets.
Trust Model
Trust On First Use (TOFU)
On the first connection, the client pins the peripheral’s Ed25519 public key. On every later connection it checks that the peripheral presents the same key and rejects the connection if it changed. As of the v0.7.0 client libraries this pinning is on by default and fail-closed: it runs automatically during the key exchange, and the app supplies a small key store (Android SharedPreferences, iOS UserDefaults, AsyncStorage, or a file) to persist pinned keys.
This is similar to SSH’s known_hosts mechanism. It provides protection against man-in-the-middle attacks after the first connection without requiring a PKI or certificate authority.
Platform Implementation
| Platform | Crypto Library | Key Exchange | Session Encryption |
|---|---|---|---|
| Python | cryptography | CentralKeyExchange / PeripheralKeyExchange | BlerpcCryptoSession |
| Kotlin (Android) | Java Crypto | CentralKeyExchange | BlerpcCryptoSession |
| Swift (iOS) | CryptoKit | CentralKeyExchange | BlerpcCryptoSession |
| Dart (Flutter) | cryptography | CentralKeyExchange | BlerpcCryptoSession |
| C (Zephyr) | PSA Crypto (mbedTLS) | blerpc_central_kx_* / blerpc_peripheral_kx_* | blerpc_crypto_session |
Configuration
Enabling Encryption
Encryption is enabled on the peripheral side. When enabled, the peripheral advertises encryption support in its capabilities and expects the central to initiate the key exchange.
Zephyr Firmware
# prj.conf
CONFIG_BLERPC_ENCRYPTION=y
Python Peripheral
# server.py - encryption is enabled when an Ed25519 signing key is provided.
# X25519 keypairs are generated ephemerally for each connection.
peripheral = BlerpcPeripheral(
ed25519_private_key_hex="...",
)