04 — RedDot Digital
Subscriber Verification Gateway
ECDH key agreement, encrypted payloads, and a reactive trust boundary
- Scope
- Built from scratch, solo
- Key agreement
- ECDH over EC key pairs
- Payload
- AES-GCM · 96-bit IV
- Model
- Reactive · non-blocking
The problem
Subscriber verification is where a telecom decides whether a person is who they claim to be, against national identity data. The payloads are fingerprint templates, national ID document extracts and face-match references — about as sensitive as data gets.
The services behind it were internal, numerous, and each had grown its own ideas about authentication. That is a bad shape for a trust boundary: if eight services each implement their own security, you do not have eight defences, you have one, and it is whichever is weakest.
What was needed was a single front door — and, because of what the payloads contain, a front door that does considerably more than check a token.
Shape of the system
The gateway is small on purpose. It performs key agreement, decrypts, verifies, routes, and forwards. It holds no business logic, because a component that everything depends on should be a component you rarely need to change.
The security model, in four layers
Each layer answers a different question, and none of them substitutes for another:
- TLS — is the transport private? Necessary, and not sufficient, because it terminates at the load balancer.
- ECDH-derived AES-GCM — is the payload private, and unmodified, all the way to the gateway?
- HMAC + timestamp — is this specific request authentic and fresh, rather than replayed?
- JWT — who is calling, and is their claim still valid?
The handshake
A client posts its public key to /key-exchange, signing the request with HMAC and including a
timestamp that must fall inside a configured window. The gateway verifies the signature, rejects
anything stale, and returns its own public key and a session.
From that point both sides hold what they need to derive the same secret — the gateway’s EC private key with the client’s public key on one side, the mirror on the other — without that secret ever crossing the network. The derived value is hashed with SHA-256 and truncated to an AES key.
The steady state
Every subsequent request carries an encrypted body. A RewriteFunction in the reactive pipeline
decrypts it before routing, so the services behind the gateway receive ordinary JSON and never learn
that any of this happened. Each message carries its own 96-bit IV, and GCM’s authentication tag means
a modified ciphertext fails loudly rather than decrypting into garbage that looks like data.
The gateway’s own key pair lives on disk as PKCS#8 and X.509 encoded files, loaded once at startup and cached — the small piece of key-management plumbing that makes the rest of it possible.
Why reactive was the right call here
A gateway is close to a pure I/O machine. For any given request it derives a key, decrypts a body, and then waits — often far longer than it spent working — for a downstream service to answer.
With a thread-per-request model that waiting is expensive: each in-flight request holds a thread and its stack, so the moment a downstream service slows, requests pile up and the gateway exhausts its pool. The failure mode is the worst one available — the component protecting everything falls over precisely when something behind it is already struggling.
WebFlux inverts that. Waiting costs a continuation, not a thread. Downstream slowness becomes latency the gateway survives rather than an outage it causes. That is the whole argument, and it is why the reactive complexity was worth paying for here and would not have been in a CRUD service.
Doing the crypto inside a RewriteFunction rather than a blocking filter is the same reasoning
applied one level down: the decryption participates in the reactive chain instead of blocking a
worker while the body assembles.
The unglamorous part: the bypass list
Some endpoints genuinely cannot be signed or encrypted. Multipart uploads have no stable body to sign the way a JSON request does. Health checks are called by infrastructure that holds no key.
The temptation is to special-case these inside the filter chain with a few conditionals. I made the list explicit and configurable instead, because the question a security review actually asks is “which paths are unprotected, and who decided that?” — and that should have one readable answer rather than requiring someone to trace the filter logic.
What I would change
Key rotation is manual. The gateway loads its key pair at startup and holds it; rotating means replacing the files and restarting. That was an acceptable trade for the first version and it is the first thing I would fix — scheduled rotation with an overlap window, so old sessions drain instead of breaking.
Decisions & trade-offs
Every choice below had a credible alternative. These are the ones I turned down, and why.
- 01
choseEncrypt the payload end to end with a per-client derived key, on top of TLS
notRelying on TLS alone for confidentiality
TLS is terminated at the load balancer, which means the request travels the internal network in plaintext and is readable in any proxy log or traffic capture along the way. For identity documents and biometric references that is not an acceptable trust assumption. Encrypting the body means the payload stays sealed until the gateway itself opens it.
- 02
choseECDH key agreement rather than shipping a shared secret
notProvisioning a static symmetric key per client
A static key is a secret that has to be distributed, stored and rotated everywhere it exists — and it is game over if any copy leaks. With ECDH each side keeps its private key and the shared secret is derived, never transmitted. Elliptic curve also buys smaller keys and a cheaper agreement than the RSA equivalent.
- 03
choseAES-GCM, a single AEAD primitive
notAES-CBC plus a separate HMAC over the ciphertext
Encrypt-then-MAC is correct but only if you assemble it correctly, and the failure mode of getting the order wrong is silent. GCM gives confidentiality and integrity in one construction with an authentication tag, so a tampered ciphertext fails to decrypt instead of decrypting into something plausible.
- 04
choseHMAC signature plus a timestamp window on the key-exchange endpoint
notLeaving the handshake open, since it carries no secret material
The bootstrap is the weakest moment in any key exchange — it is the one call that happens before a session key exists. Signing it proves the caller holds the shared secret, and rejecting stale timestamps means a captured handshake cannot be replayed later to open a session.
- 05
choseOne gateway in front of the estate, not per-service security
notEach verification service implementing its own crypto and auth
Cryptography implemented in eight places is cryptography implemented wrong in at least one. Centralising it means one implementation to review, one place to rotate keys, and services behind it that can go back to being ordinary Spring applications.
Stack
Gas Utility ERP→
One system, three state utilities, and the full life of a gas connection