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

ComponentAlgorithm
Key AgreementX25519 ECDH
AuthenticationEd25519 Signatures
Key DerivationHKDF-SHA256
Session EncryptionAES-128-GCM
Replay ProtectionMonotonic Counter

Capability Negotiation

Encryption is advertised by the peripheral in the CAPABILITIES response. The 6-byte payload includes a flags field:

max_request
16 bits (LE)
max_response
16 bits (LE)
flags
16 bits (LE)

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
1 byte
central_x25519_pubkey
32 bytes

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.

step
1 byte
peripheral_x25519_pubkey
32 bytes
ed25519_signature
64 bytes
ed25519_pubkey
32 bytes

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.

step
1 byte
nonce
12 bytes
ciphertext
16 bytes
tag
16 bytes

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.

step
1 byte
nonce
12 bytes
ciphertext
16 bytes
tag
16 bytes

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

counter
32 bits (LE)
ciphertext
variable
tag
128 bits

Encryption Process

  1. Serialize the CommandPacket (type + name + protobuf data)
  2. Increment the TX counter
  3. Construct 12-byte nonce: [counter_LE:4][direction:1][0x00:7]direction is 0 for Central→Peripheral, 1 for Peripheral→Central
  4. Encrypt with AES-128-GCM: ciphertext, tag = AES-GCM(session_key, nonce, plaintext)
  5. Prepend counter: [counter:4][ciphertext:N][tag:16]
  6. Split into containers as usual

Decryption Process

  1. Reassemble containers into full payload
  2. Extract counter (first 4 bytes), ciphertext, and tag (last 16 bytes)
  3. Verify counter > last received counter (replay detection)
  4. Construct nonce and decrypt with AES-128-GCM
  5. Parse the decrypted data as a CommandPacket

Replay Detection

Each direction maintains an independent monotonic counter:

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

PlatformCrypto LibraryKey ExchangeSession Encryption
PythoncryptographyCentralKeyExchange / PeripheralKeyExchangeBlerpcCryptoSession
Kotlin (Android)Java CryptoCentralKeyExchangeBlerpcCryptoSession
Swift (iOS)CryptoKitCentralKeyExchangeBlerpcCryptoSession
Dart (Flutter)cryptographyCentralKeyExchangeBlerpcCryptoSession
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="...",
)