In this article:
- Why standard BLE security modes fail for tap-to-pay UX
- X25519 ECDH and why it beats P-256 and RSA on a 20-byte radio
- HKDF and the rules for deterministic key derivation
- How a man-in-the-middle attack works on bare ECDH — and why PKI is the only fix
- An offline certificate model that works without internet
- A 4-step authenticated handshake protocol with triple verification
This is Part 2 of a three-part series. Part 1 covered the BLE transport layer; Part 3 covers per-packet encryption and the defenses on top of the channel established here.
Why not BLE pairing?
The Bluetooth specification defines four security levels for LE links:
| Mode / Level | What you get | Why it does not fit tap-to-pay |
|---|---|---|
| Mode 1, Level 1 | Nothing | Plaintext over the air |
| Mode 1, Level 2 | Just Works pairing | No MITM protection at all |
| Mode 1, Level 3 | Authenticated legacy pairing (PIN) | Weak key exchange, breakable in seconds today |
| Mode 1, Level 4 | LE Secure Connections (P-256 ECDH + numeric compare) | Requires user interaction; not all devices support it |
For a payment terminal we want a frictionless tap, no PIN, no pairing dialog, no per-device trust pre-established by the OS. None of the built-in modes deliver that and resist MITM and work uniformly across the Android/iOS device matrix.
The standard answer is to treat BLE as a dumb transport and build the security layer above it — exactly the way TLS treats TCP.
Middleware architecture
Every byte that enters or leaves the radio passes through a chain of security middlewares. The chain composes like Express or Koa: each middleware wraps the next, gets to inspect or transform the packet, and either passes control downstream or rejects.
typedef SecurityHandler =
Future<SecurityPacket> Function(SecurityPacket packet);
abstract class SecurityMiddleware {
const SecurityMiddleware();
SecurityHandler process(SecurityHandler next);
}
The terminal and the client share the same chain in the same order — that is what makes them able to talk. From outside in:
Outgoing: app payload
│
▼ ValidationMiddleware (well-formed, bounded size)
│
▼ DiffieHellmanMiddleware (DH handshake state machine)
│
▼ AesGcmEncryptionMiddleware (encrypt with session key)
│
▼ BLE radio
Incoming: reverse order
│
▼ decrypt → DH state → validate → application
The order matters: validation runs first on outgoing data so we never sign or encrypt garbage, and last on incoming data so we never validate ciphertext. Each middleware has a single, focused responsibility — easy to test, easy to swap, easy to reason about during a security review.
ECDH — shared secret over an open channel
The fundamental problem: two devices that have never communicated need to agree on a secret key, in public, while an attacker is listening. The classical answer is Diffie–Hellman; the modern answer is Elliptic Curve Diffie–Hellman.
Mathematically, on an elliptic curve with base point G:
$$A_{pub} = a \cdot G \quad B_{pub} = b \cdot G \quad S = a \cdot B_{pub} = b \cdot A_{pub} = a \cdot b \cdot G$$
An eavesdropper sees only $A_{pub}$ and $B_{pub}$. Recovering $a$ or $b$ from those is the elliptic-curve discrete logarithm problem — currently intractable for well-chosen curves.
Why X25519, not P-256 or RSA
| Property | X25519 | P-256 (NIST) | RSA-2048 |
|---|---|---|---|
| Key size | 32 bytes | 32 bytes | 256 bytes |
| Signature size | — | 64 bytes | 256 bytes |
| ops/s on a phone | ~25,000 | ~10,000 | ~1,000 |
| Equivalent strength | 128-bit | 128-bit | 112-bit |
| Side-channel resistance | Built-in (constant-time) | Implementation-dependent | Hard to do right |
| Where it ships | TLS 1.3, Signal, WireGuard | TLS, WebCrypto | Legacy |
The decisive factor for BLE is the radio: 32 bytes fits in a single MTU-247 packet. RSA-2048 would need 12+ chunks at MTU 23 to ferry one public key. Choosing X25519 is not just a security decision; it is a latency decision.
mixin BleKeyExchange {
static final _x25519 = X25519();
static Future<SimpleKeyPair> generateKeyPair() =>
_x25519.newKeyPair();
static Future<SecretKey> deriveSharedSecret({
required SimpleKeyPair localKeyPair,
required SimplePublicKey remotePublicKey,
}) =>
_x25519.sharedSecretKey(
keyPair: localKeyPair,
remotePublicKey: remotePublicKey,
);
}
Ephemeral keys = Perfect Forward Secrecy
Both sides generate a fresh keypair at the start of every session. If a private key is later compromised, only that one session is at risk — past and future sessions are protected. This property is called Perfect Forward Secrecy, and it is the reason "ephemeral DH" is now table stakes for any modern protocol.
HKDF — turning a secret into a key
The raw output of ECDH is a uniformly large integer, but its byte distribution is not necessarily flat — feeding it directly to AES would be a subtle mistake. HKDF (RFC 5869) does two things:
- Extract — compress the raw secret into a uniformly random pseudo-random key.
- Expand — stretch that PRK into as many bytes as you need, optionally bound to a context string.
static Future<String> deriveSessionKey({
required SecretKey sharedSecret,
String? sessionId,
}) async {
final secretBytes = await sharedSecret.extractBytes();
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
final salt = SecretKey(sha256(secretBytes).bytes);
final info = utf8.encode('BLE-Payment-v1:${sessionId ?? ''}');
final out = await hkdf.deriveKey(
secretKey: SecretKey(secretBytes),
nonce: salt.bytes,
info: info,
);
return hex.encode(await out.extractBytes());
}
Everything that goes into HKDF must be identical on both sides:
| Input | Source | Same on both sides? |
|---|---|---|
sharedSecret | ECDH output | Yes — math guarantee |
info | Hardcoded protocol string | Yes — hardcoded constant |
salt | SHA-256 of shared secret | Yes — derived from same input |
outputLength | Hardcoded | Yes — both expect 32 bytes |
Get any of these wrong and the two sides will derive different keys; decryption will fail with no obvious clue why. Treat the HKDF parameters as part of the wire protocol — version them.
The MITM problem with bare ECDH
ECDH gives you a shared secret, but it does not tell you with whom you share it. An active attacker can sit between two parties and run two separate DH exchanges — one with each side — relaying messages while owning both keys.
Client Attacker Terminal │ │ │ │── A_pub ───────────▶│ │ │ │── M_pub_1 ────────────▶│ │ │ │ │ │◀──── B_pub ────────────│ │◀── M_pub_2 ─────────│ │ │ │ │ │ S1 = a · M_pub_2 │ S1 with client │ │ │ S2 with terminal │ S2 = b · M_pub_1 │ │ │ │ Attacker sees plaintext, can modify it │
The attacker is invisible at the BLE layer. Both endpoints think they have a secure channel — they do, with the attacker. The only defense is for one or both endpoints to prove who they are via something the attacker cannot forge: a cryptographic identity bound to a long-lived key.
When bare ECDH is acceptable
- You are layered under something already authenticated (e.g. TLS over BLE).
- The data is non-sensitive (sensor telemetry, room temperature).
- The physical environment is controlled (lab equipment on a closed network).
When you must add PKI
- Payments — our case.
- Medical data (HIPAA/equivalent regulations).
- Personal data (GDPR/CCPA).
- Anything where the attacker can reasonably be in physical proximity to either party.
Offline PKI
The web solves identity verification with online certificate authorities, OCSP, CRLs — none of which is available on a payment terminal that may be offline for hours. So we use an offline PKI: a single Root Public Key burned into every client app, used to verify certificates issued to terminals at provisioning time.
┌──────────────────────────────────┐
│ ROOT DEVELOPER KEY │ stored offline, in HSM / safe
│ (Ed25519) │
│ │
│ signs each terminal certificate │
└────────────────┬─────────────────┘
│ signs
▼
┌──────────────────────────────────┐
│ TERMINAL CERTIFICATE │ issued at provisioning
│ │
│ terminalId : "term_001" │
│ staticPubKey : Ed25519 (32 B) │
│ validUntil : 2027-01-01 │
│ signature : 64 B │
└────────────────┬─────────────────┘
│ proves possession of
▼
┌──────────────────────────────────┐
│ TERMINAL STATIC ED25519 KEYPAIR │ signs ephemeral DH keys per session
│ (private key stays on device) │
└──────────────────────────────────┘
The certificate is a tiny binary blob — small enough to fit in a few BLE chunks:
[ ID Length: 2 B ][ ID: N B ][ Timestamp: 8 B ][ PubKey: 32 B ][ Signature: 64 B ]
Provisioning happens via QR code: the operator scans an encrypted QR containing the terminal id, certificate, and private key. The terminal stores the private key in FlutterSecureStorage (Keychain on iOS, EncryptedSharedPreferences with hardware-backed keystore on Android), never exposes it, and discards it on factory reset.
The Root Public Key on the client side is split into multiple constants and reassembled at runtime — a small obfuscation step that raises the bar against trivial APK tampering. Real protection comes from the math and the keystore, not from hiding the public key, but defense in depth is cheap.
The 4-step authenticated handshake
Combining ephemeral ECDH with the certificate gives us a four-message handshake. Messages 0x11–0x14 are the only packets that travel before encryption is in effect — Part 3 explains why that is safe.
┌─────────┐ ┌──────────┐
│ Client │ │ Terminal │
└────┬────┘ └────┬─────┘
│ │
│ ─── 0x11 client_eph_pubkey ────────────────────────────▶ │
│ │
│ ◀── 0x12 terminal_cert ‖ terminal_eph_pubkey ‖ Sig ────── │
│ Sig = Sign(static_priv, transcript) │
│ │
│ ─── 0x13 client_confirm ──────────────────────────────▶ │
│ │
│ ◀── 0x14 terminal_confirm ────────────────────────────── │
│ │
══╪══════════ Encrypted Session Established ═══════════════╪═══
The interesting work happens when the client receives 0x12. It runs three independent verifications; if any one fails, the connection is torn down before any sensitive data is sent.
| Check | What it proves | Without it… |
|---|---|---|
| ① Certificate signature against Root Public Key | The terminal's static key was issued by us. | An attacker can fabricate a certificate for any identity. |
| ② Signature on the ephemeral key + transcript verifies under the certificate's static public key | The party speaking right now actually owns the certificate's private key. | Someone who copied a leaked certificate can impersonate the terminal. |
③ Certificate validUntil is in the future | The credential is still active. | Compromised keys remain usable indefinitely. |
Check ② is the most subtle and the most important. Just verifying the certificate is not enough — the attacker could replay an old 0x12 message recorded from a real terminal. By signing the current ephemeral DH public key plus the handshake transcript, we bind the certificate to this specific session.
The transcript binding
The "transcript" is simply the concatenation of every byte exchanged so far in the handshake (the protocol version, the client's ephemeral public key, the certificate, the terminal's ephemeral public key). Hashing the transcript and signing the hash means an attacker cannot:
- Replay messages from a previous session — different ephemeral keys → different transcript → invalid signature.
- Re-order or drop messages — different transcript.
- Substitute a different certificate — different transcript.
This is the same trick TLS uses to bind its handshake against downgrade attacks.
Session lifecycle
A successful handshake produces a 32-byte session key, which is registered in a singleton SessionRegistry indexed by device id. Two policy decisions matter here:
- TTL = 15 minutes. After that, the next packet forces a new handshake. Short enough to limit the blast radius of a compromised session, long enough that a customer making several taps in a row does not feel the cost.
- Secure wipe on eviction. When a session expires or the device disconnects, the key bytes are overwritten with zeros before the object is freed. This makes life harder for an attacker who manages to read process memory after the fact.
class SessionEntry {
Uint8List keyBytes; // 32 raw bytes
final DateTime createdAt;
final Duration ttl;
void wipe() {
for (var i = 0; i < keyBytes.length; i++) {
keyBytes[i] = 0;
}
}
}
Takeaways
- Built-in BLE pairing is wrong for tap-to-pay. Either it is not safe enough, or it requires user friction you cannot afford.
- Treat BLE as a dumb transport. Build the security stack above it, just like TLS does over TCP.
- X25519 ECDH gives you 128-bit security in 32 bytes — fitting one BLE packet, fast on every phone, side-channel resistant by construction.
- Bare ECDH is MITM-vulnerable. Any system carrying sensitive data must add an identity layer.
- Offline PKI is the right primitive for a payment terminal that may be offline. Keep the root key out of the build, sign certificates at provisioning, store private keys in the OS keystore.
- Bind the certificate to the live ephemeral key via a transcript signature, or you have not actually authenticated the session.
- Sessions are short-lived and securely wiped. 15 minutes, then re-handshake.
With the channel established, every subsequent packet is encrypted under the session key. Part 3 covers exactly how that encryption works — AES-GCM, nonce design, AAD, replay detection, and rate limiting.