An encrypted handshake on top of TLS — and why I built one
TLS already encrypts traffic in transit. So why wrap signup and login payloads in another layer of encryption? Not because TLS is weak — but because an application-layer envelope demonstrates a few properties that are worth owning explicitly, and it makes the trust boundary visible in the code.
The scheme
Every request generates a fresh ephemeral ECDH key pair on the client. The client performs ECDH against the server's published public key, runs the shared secret through HKDF-SHA256 to derive a 256-bit key, and encrypts the JSON payload with AES-256-GCM.
{
"v": 1,
"epk": "<client ephemeral public key>",
"iv": "...",
"ct": "...",
"ts": 1733400000000,
"nonce": "..."
}
Because the key pair is ephemeral per request, a leaked long-term server key cannot decrypt past traffic captured on the wire. That is forward secrecy, at the message level.
Replay protection is mandatory
A captured envelope must not be replayable. Two controls handle this:
- Reject any request whose
tsis outside a ±60s window. - Store the
noncein Redis with a TTL equal to the window; a repeated nonce is rejected.
Both ts and nonce are bound into the AES-GCM additional authenticated data, so they cannot be tampered with without failing authentication.
What it does not replace
This is defense in depth, not a TLS substitute. TLS still terminates at the edge. The handshake is an application-layer guarantee layered on top — and, honestly, a clear demonstration of security-engineering intent.
The implementation lives in a shared packages/crypto workspace package so the client and server use one implementation, with no drift between encrypt and decrypt.
Comments (3)
Would love a follow-up on key rotation for the long-term server key. How do you stage it without breaking in-flight handshakes?
Great write-up. The ephemeral ECDH per request is what sells the forward-secrecy story — most demos reuse a static key.
Agreed. Binding ts+nonce as GCM AAD for replay protection is a nice touch too.