Back to writing
CryptographyCyber SecurityPython

Implementing End-to-End Encryption (AES-GCM)

January 29, 2026 14 min read

Having spent several months studying the mathematics behind modern encryption standards, I wanted to deconstruct the black-box of cryptographic libraries. For this project, I focused on the manual implementation of elliptic curve arithmetic and the integration of AES-GCM.

Note: this post will assume some knowledge of elliptic curve groups and authenticated encryption terminology.

A glowing digital padlock icon centered on a dark blue circuit board background.

Implementing End-to-End Encryption in Python.

Elliptic Curve Arithmetic

Before a single message can be signed, the underlying mathematical foundation must be established. I chose to implement Point Addition and Point Doubling using only raw big-integer arithmetic. By intentionally avoiding high-level ECC abstractions and working directly with the pycryptodome math primitives, I was able to deconstruct the black-box and gain a hands-on understanding of how elliptic curve groups behave, and where the edge cases lie. Note that beyond this section I use vetted library implementations of elliptic curves. This code is purely to understand the theory!

Handling the point at infinity, O\mathcal{O}, and modular inverses correctly is the key to implementing EC arithmetic. In the snippet below, you’ll notice the explicit handling of (None, None) for infinity and the use of the extended Euclidean algorithm (via .inverse(p)) to calculate the slope λ\lambda.

A critical realisation during this project was that functional correctness does not equal security. While a standard "Double-and-Add" algorithm works, it is vulnerable to timing attacks because the execution path changes based on whether a scalar bit is 0 or 1. While I did implement a "Double-and-Add" algorithm, I also implemented the Montgomery Ladder to ensure a constant-time execution flow. By performing a point addition and a doubling in every iteration regardless of the bit value, we eliminate the timing leakage that could otherwise reveal a private key.

python

Authenticated Encryption with AES-GCM

For symmetric encryption I followed the golden rule of never roll your own. This is an industry rule that states you should never try to implement the low-level cryptographic primitives as these implementations are incredibly delicate; you should always use vetted implementations. I used the industry-standard AES-GCM from pycryptodome.

One of the strengths of AES-GCM is the focus on the Tag (MAC) verification. In high-assurance systems, confidentiality (encryption) is useless if you can't prove the data wasn't tampered with in transit.

A common pitfall in GCM is nonce reuse. If a (Key, Nonce) pair is ever repeated, an attacker can XOR the two ciphertexts to recover the keystream (the "Two-Time Pad" vulnerability). To mitigate this, I utilised os.urandom(12) to ensure a unique 96-bit nonce for every message.

When implementing encrypt_message, the choice of entropy source (randomness) is just as critical as the algorithm itself. I used os.urandom(12) to generate the 96-bit nonce because, unlike standard pseudo-random number generators (like Python’s random module, which is designed for simulations), os.urandom pulls entropy from the operating system's cryptographically secure Pseudo Random Number Generator (CSPRNG). On Linux, this taps into /dev/urandom, which collects environmental noise from device drivers and other hardware sources to produce bits that are statistically indistinguishable from truly random data. Using a weak or predictable source of randomness would make the resulting nonces guessable, completely breaking the security of the AES-GCM mode through "nonce-reuse" or "Two-Time Pad" attacks, regardless of how strong the AES key might be.

Encrypt/Decrypt Functions

For the encrypt/decrypt functions we have the following.

python

Digital Signatures (ECDSA)

To ensure the authenticity of the messages, I implemented ECDSA (Elliptic Curve Digital Signature Algorithm). While encryption hides the data, signatures prove who sent it.

I used the Cryptodome.Signature.DSS module to handle the FIPS-186-3 standard, ensuring that every message is cryptographically bound to the sender's identity key.

python

The Hybrid Pipeline: ECDH & Composition

The final piece of the puzzle was combining the encryption and signatures with a Key Exchange mechanism to create a full secure transport pipeline.

The core components are as follows:

  • Elliptic Curve Diffie-Hellman (ECDH): Using the partner’s public key to derive a shared secret point.
  • Ephemeral Keys: Generating fresh keys for every message to achieve Perfect Forward Secrecy.
  • KDF (Key Derivation): Hashing the shared secret to produce a clean AES key.
  • Encrypt-then-Sign: Encrypting data with AES-GCM and then signing the ciphertext.

The signing process ensures that even if an attacker intercepts a message, they cannot modify the ciphertext without being detected, as the signature is calculated over the ciphertext (an "Encrypt-then-Sign" approach).

After performing the ECDH exchange, the result is a shared point (x,y)(x, y) on the elliptic curve. However, you cannot use raw coordinates as an AES key; they lack the uniform distribution required for symmetric ciphers. To solve this, I passed the shared secret through a Key Derivation Function (KDF)—specifically SHA-256. In a production environment, one might use HKDF (HMAC-based KDF) to further ensure the resulting key is cryptographically strong, but for this implementation, SHA-256 serves as a robust mechanism to ensure that the relationship between the DH shared secret and the resulting session key is non-reversible and uniformly random.

python

Testing the Functions

I wrote some manual tests to check our functions behave as intended. The tests are as follows:

  • Test 1: Ensure a plaintext string is encrypted successfuly and then decrypted back to the original string.
  • Test 2: Check that decryption fails if the Nonce is tampered with.
  • Test 3: Check that decryption fails if the MAC Tag is tampered with.
  • Test 4: Check that decryption fails if the signature is forged.

Here is the function that runs all the above tests.

python

Running these tests provides the following output.

text

Closing Thoughts

Moving from the maths to a working implementation reveals the hidden engineering that keeps a system secure. While the theory might tell you that an algorithm is computationally secure, the implementation is where physical realities, such as CPU timing, power consumption, and memory persistence, can create unexpected vulnerabilities.

The core challenge of cryptographic engineering is paying attention to the myriad subtleties. A program can produce the correct mathematical result while simultaneously leaking its private keys through a side-channel timing discrepancy or failing to clear a sensitive buffer in RAM.

I hope you were able to follow this discussion and implementation. I intend to go backwards a bit soon and write about the foundational building blocks of authenticated encryption schemes or more about elliptic curve groups and why we use them. Stay tuned for that and bye for now!