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.
Overview
| 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.
sequenceDiagram
participant C as Central
participant P as Peripheral
Note over C: Generate ephemeral X25519 keypair
C->>P: Step 1: [step:1] + central_x25519_pubkey (33 bytes)
Note over P: Generate ephemeral X25519 keypair
Note over P: Compute shared_secret = X25519(priv, central_pub)
Note over P: Derive session_key = HKDF(shared_secret)
Note over P: Sign(ed25519_privkey, central_pub || peripheral_pub)
P->>C: Step 2: [step:1] + x25519_pub + signature + ed25519_pub (129 bytes)
Note over C: Compute shared_secret = X25519(priv, peripheral_pub)
Note over C: Derive session_key = HKDF(shared_secret)
Note over C: Verify signature (TOFU for first connection)
Note over C: Generate confirmation = encrypt(random, session_key)
C->>P: Step 3: [step:1] + encrypted_confirmation (45 bytes)
Note over P: Decrypt confirmation to verify session_key match
Note over P: Generate confirmation = encrypt(random, session_key)
P->>C: Step 4: [step:1] + encrypted_confirmation (45 bytes)
Note over C: Decrypt confirmation to verify session_key match
Note over C,P: Session key established - all subsequent data is encrypted
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 16 random bytes encrypted with AES-128-GCM using the derived session key and a random 12-byte nonce.
Step 4: Peripheral → Central (45 bytes)
The peripheral decrypts Step 3 to verify the session key, then sends its own encrypted confirmation.
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)
- Increment the TX counter
- Construct 12-byte nonce:
[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 counter:
[counter:4][ciphertext:N][tag:16] - 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: Incremented before each encryption. Starts at 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 central accepts the peripheral’s Ed25519 public key and stores it. On subsequent connections, the central verifies that the peripheral presents the same key. If the key changes, the connection is rejected (or the user is prompted).
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="...",
)