Back to blog
Security10 min read

Pairing Flow

This article explains how two PlainApp devices establish trust for the first time — how they discover each other, exchange keys, and arrive at the shared ChaCha20 transport key that every chat message, file transfer, and presence ping is encrypted with afterwards. The chat and channel architecture that consumes this key is covered in the separate Chat Architecture article.

Table of Contents

Why Pairing Exists

PlainApp has no central account server. Devices must therefore answer two questions before they can talk:

  1. "Who are you?" — every device generates a stable clientId on first launch (a 13-character id derived from its Ed25519 key material). This is the only identifier used for routing, presence, and channel membership.
  2. "Can I trust you?" — without a server to vouch for identity, the only way to be sure a peer is who they claim to be is for a human to confirm the pairing on both devices and for the protocol to verify cryptographic signatures.

Pairing produces a single artifact: a DPeer row in the database with status="paired", a ChaCha20 key (the shared transport secret), and the peer's Ed25519 public_key (for verifying future message signatures). Every later protocol in the chat subsystem assumes these two fields exist.

Diagram 1
1

Trust Model & Cryptography

Pairing uses two independent cryptographic primitives:

PrimitivePurposeLifecycle
Ed25519 (signature)Authenticate the pairing request and response. Verifies "this really came from the device claiming to send it" and binds the timestamp to prevent replay.The signing key is the device's long-term identity key. Its public half is stored as DPeer.public_key and later used by PeerChatParser.decrypt to verify every chat message signature.
X25519-style ECDH (key agreement)Produce a shared secret that becomes the ChaCha20 transport key. The two devices compute the same secret without ever transmitting it.Ephemeral key pair generated per pairing session, discarded immediately after the shared key is computed. The resulting 32-byte secret is stored as DPeer.key and reused for the lifetime of the pairing.

There is no PIN, no QR code, no out-of-band code. Trust is established by:

  1. A human tapping Accept on the responder device (the user is asserting "yes, this is the device I want to pair with").
  2. Both sides verifying the other's Ed25519 signature on the request/response (proving the responder is talking to the same device that started the session and vice versa).
  3. A ±5 min timestamp window on both messages (preventing replay of an old captured handshake).

The asymmetry matters: a single human confirmation would be vulnerable to a man-in-the-middle (the attacker could pair with both sides separately). The Ed25519 signature on the ECDH public key prevents this — the responder verifies the request was signed by the same Ed25519 key that initiated the session, and vice versa, so an MITM cannot transparently substitute its own ECDH key without also controlling the long-term signing key.

Component Map

All pairing code lives in the discover/ package (not chat/peer/pair/):

Diagram 2
2

File locations

ComponentPath (under shared/src/commonMain/kotlin/com/ismartcoding/plain/)
LANDiscoverManagerdiscover/LANDiscoverManager.kt
PairingCorediscover/PairingCore.kt
PairingInitiatordiscover/PairingInitiator.kt
PairingResponderdiscover/PairingResponder.kt
PairingSecuritydiscover/PairingSecurity.kt
PairingSessionStorediscover/PairingSessionStore.kt
PairingPeerStorediscover/PairingPeerStore.kt
PairingMessengerdiscover/PairingMessenger.kt

Discovery Phase

Before pairing can happen, devices must find each other. LANDiscoverManager runs continuously once the app starts:

Diagram 3
3

Why directed discovery is encrypted

The broadcast DISCOVER reveals nothing sensitive (just fromId=clientId), so it's fine for any device on the LAN to see it. The directed variant, however, is used when one device already knows another's clientId (e.g. it's paired but the peer's IP has changed) and wants to wake it up. Encrypting the target clientId with the peer's shared key means:

  • The right peer can decrypt the toId, recognize itself, and reply.
  • Every other device on the LAN sees only ciphertext — they cannot enumerate which clientIds the sender is trying to reach.

This is a small but real privacy property: passive LAN observers cannot build a graph of who is paired with whom.

Aware flags in the reply

The DISCOVER_REPLY carries awareSupported and awareRunning. These are not persisted to the database — they're stored in-memory in PeerCacher and refreshed on every reply (and also from BLE scan-response serviceData). The transport layer consults them to decide whether to attempt a Wi-Fi Aware link or skip straight to BLE.

Pairing Sequence (Happy Path)

The end-to-end flow when both devices are on the same LAN and the user accepts the pairing:

Diagram 4
4

Why both sides store the peer independently

Notice that both the initiator (step 9) and the responder (step 7) call PairingPeerStore.save(...) for the other device. This is intentional: each device ends up with a DPeer row keyed by the other's clientId, containing its own copy of the shared ChaCha20 key and the other's Ed25519 public key. There is no central registry — the pairing is symmetric and self-contained.

Why the responder computes the key first

The responder's acceptPairingRequest computes the shared key immediately upon acceptance and persists it. This means the responder can start receiving encrypted traffic before the response arrives back at the initiator. If the response is lost in transit, the responder is still paired — only the initiator needs to retry.

Key Exchange Details

The cryptographic core of pairing is a standard X25519-style ECDH key agreement, but with an Ed25519 signature layered on top to authenticate it.

Diagram 5
5

What the signature actually protects

The signed payload (toSignatureData()) is a canonical concatenation of the stable request fields: fromId, fromName, port, deviceType, ecdhPublicKey, signaturePublicKey, timestamp, and ips. By signing the ecdhPublicKey together with the long-term signaturePublicKey, the protocol binds the ephemeral key to the device's identity. An attacker cannot substitute their own ECDH public key in transit without invalidating the signature — and they cannot forge the signature without controlling the long-term Ed25519 key.

This is what defeats a man-in-the-middle: even if the attacker relays every packet between the two devices, they cannot read the encrypted traffic (because they don't have either side's ECDH private key) and they cannot substitute their own ECDH keys (because the signatures would break).

Responder Accept/Decline Flow

The responder side exposes a UI dialog when a PAIR_REQUEST arrives. The user can either accept or decline.

Diagram 6
6

Why the responder fires PairingSuccessEvent immediately on accept

The responder's acceptPairingRequest calls PairingPeerStore.save(...) and fires PairingSuccessEvent before sending the response. This is deliberate: if the response never reaches the initiator (network glitch), the responder is still paired — the next time the initiator tries to pair, the responder's already-existing DPeer row will be picked up by the presence system. The initiator simply retries; the responder does not need to re-confirm.

Cancel Flow

Either side can cancel an in-flight pairing.

Diagram 7
7

Note that DPairingCancel is sent over LAN unicast only (the initiator already has the responder's IP from the discovery phase), whereas the decline response is sent over both LAN and BLE because the responder cannot be sure which transport the initiator is reachable on.

Dual-Channel Delivery (LAN + BLE)

When the responder sends the DPairingResponse, it does so over both LAN and BLE simultaneously. The initiator accepts the first copy and silently discards the duplicate.

Diagram 8
8

Why BlePairingSessionStore exists

When a PAIR_REQUEST arrives over BLE, the responder doesn't have a LAN IP for the initiator — only its BLE MAC address. BlePairingSessionStore maps peerId → MAC so the response can be routed back over BLE if needed. This is a small, in-memory, ephemeral map that is only populated for BLE-routed requests and cleared once the response is sent.

Session & Peer Storage

Two stores participate in pairing, with very different lifetimes:

Diagram 9
9

Why clientId is the only persisted identifier

Android randomizes the BLE MAC address on every connection, so storing it would be useless. The clientId is derived from the device's long-term Ed25519 key material, so it is:

  • Stable across app reinstalls (the key is in the platform keystore).
  • Self-authenticating — anyone claiming a clientId must prove they hold the corresponding Ed25519 private key (verified on every signed message).
  • Privacy-preserving — only an 8-byte SHA-256 prefix (shortId) is ever broadcast over BLE for discovery; the full clientId is only revealed to devices you actually pair with.

Security Properties

PropertyHow it's achieved
ConfidentialityAll transport is ChaCha20 encrypted with the ECDH-derived shared key. The key never leaves the two devices after pairing.
AuthenticationEvery signed message (pairing request/response, chat createChatItem, channel invite/update/kick) is Ed25519-verified against the sender's stored public_key.
IntegrityEd25519 signatures cover the full request body; any tampering invalidates the signature.
Replay resistance±5 min timestamp window (enforced by PeerChatParser and PairingSecurity). ChatMessageReceiver.seenSignatures dedups within the window.
Man-in-the-middle resistanceThe ephemeral ECDH public key is signed together with the long-term Ed25519 public key. An MITM cannot substitute its own ECDH key without breaking the signature.
Forward secrecy (limited)ECDH key pairs are ephemeral per pairing session. Compromising the long-term Ed25519 key later does not decrypt past traffic (the shared key is also still needed — but if both the ECDH private keys and the stored DPeer.key are wiped, past captures cannot be decrypted).
Denial-of-service resistanceonDatagram wraps every message in try/catch so a malformed packet cannot kill the discovery receiver. PeerCircuitBreaker skips a flaky transport for 30 s after 2 failures.
Privacy (directed discovery)LANDiscoverManager.discoverSpecificDevice encrypts the target clientId with the peer's key — passive LAN observers cannot enumerate who is paired with whom.
Identity stabilityclientId is derived from long-term Ed25519 key material in the platform keystore — stable across reinstalls, self-authenticating, and not tied to a phone number or email.

What pairing does NOT defend against

  • Physical device compromise. If an attacker gains root on a paired device, they can read the shared key from the database and impersonate that peer. There is no hardware-backed key store enforcement for the shared transport key (only for the Ed25519 signing key, via SignatureHelper).
  • Active relay attacks. An attacker who can simultaneously relay BLE and LAN traffic between two devices that think they're pairing with each other could theoretically position themselves in the middle — but the Ed25519 signature on the ECDH public key means they cannot read the traffic, only relay it. This is the same trade-off as Bluetooth pairing without numeric comparison.
  • Network-level blocking. A firewall can block UDP multicast, BLE can be jammed, and Wi-Fi Aware can be unavailable. The system degrades gracefully (BLE is the guaranteed fallback for paired peers) but cannot bypass an actively hostile network.

State Machine Recap

Diagram 10
10

Further Reading

  • Chat Architecture — what the shared key is used for: peer chat send/receive, channel fan-out, presence, file downloads.
  • apitest/groups/discovery.sh — executable test plan exercising the discovery and pairing API surface end-to-end.