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
from Cryptodome.Math.Numbers import Integer
from typing import NamedTuple, Optional

Point = NamedTuple("Point", [("x", Optional[Integer]), ("y", Optional[Integer])])

def is_point_on_curve(a: Integer, b: Integer, p: Integer, point: Point) -> bool:
  """
  Check that a point (x, y) is on the curve defined by a,b and prime p.
  Reminder: an Elliptic Curve on a prime field p is defined as:

          y^2 = x^3 + ax + b (mod p)
              (Weierstrass form)

  Return True if point (x,y) is on curve, otherwise False.
  By convention a (None, None) point represents "infinity".
  """
  x, y = point

  if x is None and y is None:
      return True

  lhs = (y * y) % p
  rhs = (x * x * x + a * x + b) % p
  on_curve = lhs == rhs

  return on_curve

def point_add(a: Integer, b: Integer, p: Integer, point0: Point, point1: Point) -> Point:
  """Define the "addition" operation for 2 EC Points.

  Reminder: (xr, yr) = (xq, yq) + (xp, yp)
  is defined as:
      lam = (yq - yp) * (xq - xp)^-1 (mod p)
      xr  = lam^2 - xp - xq (mod p)
      yr  = lam * (xp - xr) - yp (mod p)

  Return the point resulting from the addition by
  implementing the above pseudocode.
  Raises an Exception if the points are equal.
  Make sure you can handle the case where one point is the negation
  of the other: (xq, yq) == -(xp, yp) == (xp, -yp).
  """

  xp, yp = point0
  xq, yq = point1

  # Handle one of the points being infinity
  if xp is None and yp is None:
      return point1

  if xq is None and yq is None:
      return point0

  # Handle both points equal
  if point0 == point1:
      raise Exception("EC Points must not be equal")

  # Handle points being negation of each other (ie. x-coords are equal but the points are different)
  if xp == xq:
      return Point(None, None)

  # Otherwise, compute the addition following the formula
  lam = (yq - yp) * (xq - xp).inverse(p) % p
  xr = (lam * lam - xp - xq) % p
  yr = (lam * (xp - xr) - yp) % p

  return Point(xr, yr)

def point_double(a: Integer, b: Integer, p: Integer, point: Point) -> Point:
  """Define "doubling" an EC point.
  A special case, when a point needs to be added to itself.

  Reminder:
      lam = (3 * xp ^ 2 + a) * (2 * yp) ^ -1 (mod p)
      xr  = lam ^ 2 - 2 * xp
      yr  = lam * (xp - xr) - yp (mod p)

  Returns the point representing the double of the input (x, y).
  """

  xp, yp = point

  # Handle the point at infinity
  if xp is None and yp is None:
      return Point(None, None)

  # Handle the case where yp == 0 (result is point at infinity)
  if yp == 0:
      return Point(None, None)

  # Otherwise, compute the doubling following the formula
  lam = ((Integer(3) * xp * xp + a) * (Integer(2) * yp).inverse(p)) % p
  xr = (lam * lam - (Integer(2) * xp)) % p
  yr = (lam * (xp - xr) - yp) % p

  return Point(xr, yr)

def point_scalar_multiplication_double_and_add(a: Integer, b: Integer, p: Integer, point: Point, scalar: Integer) -> Point:
  """
  Implement Point multiplication with a scalar:
      r * (x, y) = (x, y) + ... + (x, y)    (r times)

  Reminder of Double and Multiply algorithm: r * P
      Q = infinity
      for i = 0 to num_bits(r)-1
          if bit i of r == 1 then
              Q = Q + P
          P = 2 * P
      return Q

  """
  result = Point(None, None)

  for i in range(scalar.size_in_bits()):
      if scalar.get_bit(i) == 1:
          result = point_add(a, b, p, result, point)
      point = point_double(a, b, p, point)

  return result


def point_scalar_multiplication_montgomery_ladder(a: Integer, b: Integer, p: Integer, point: Point, scalar: Integer) -> Point:
  """
  Implement Point multiplication with a scalar:
      r * (x, y) = (x, y) + ... + (x, y)    (r times)

  Reminder of Double and Multiply algorithm: r * P
      R0 = infinity
      R1 = P
      for i in num_bits(P)-1 to zero:
          if di = 0:
              R1 = R0 + R1
              R0 = 2R0
          else
              R0 = R0 + R1
              R1 = 2 R1
      return R0

  """
  res0 = Point(None, None)
  res1 = point

  for i in reversed(range(0, scalar.size_in_bits())):
      if scalar.get_bit(i) == 0:
          res1 = point_add(a, b, p, res0, res1)
          res0 = point_double(a, b, p, res0)
      else:
          res0 = point_add(a, b, p, res0, res1)
          res1 = point_double(a, b, p, res1)

  return res0

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
from os import urandom

from Cryptodome.Cipher import AES

SymKey = bytes
Message = bytes
Nonce = bytes
CipherText = bytes
Tag = bytes
AuthEncryption = tuple[Nonce, CipherText, Tag]

def encrypt_message(key: SymKey, message: Message) -> AuthEncryption:
  """Encrypt a message under a key given as input"""
  nonce = urandom(12)
  cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
  ciphertext, tag = cipher.encrypt_and_digest(message)

  return nonce, ciphertext, tag

def decrypt_message(key: SymKey, auth_ciphertext: AuthEncryption) -> Message:
  """
  Decrypt a cipher text under a key given as input

  In case the decryption fails, throw an exception.
  """
  nonce = auth_ciphertext[0]
  ciphertext = auth_ciphertext[1]
  tag = auth_ciphertext[2]

  cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
  plaintext = cipher.decrypt_and_verify(ciphertext, tag)

  return plaintext

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
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import ECC, _point, _curve
from Cryptodome.Signature import DSS

curves = _point._curves
Curve = _curve._Curve

PrivSignKey = ECC.EccKey
PubVerifyKey = ECC.EccKey
Signature = bytes

def ecdsa_key_gen() -> tuple[PrivSignKey, PubVerifyKey]:
  """Returns an EC group, a random private key for signing
  and the corresponding public key for verification"""
  key_sign = ECC.generate(curve="secp224r1")
  return key_sign, key_sign.public_key()

def ecdsa_sign(priv_sign: PrivSignKey, message: Message) -> Signature:
  """Sign the SHA256 digest of the message using ECDSA and return a signature"""
  h = SHA256.new(message)
  signer = DSS.new(priv_sign, mode='fips-186-3')

  return signer.sign(h)

def ecdsa_verify(pub_verify: PubVerifyKey, message: Message, sig: Signature) -> bool:
  """Verify the ECDSA signature on the message"""
  h = SHA256.new(message)
  verifier = DSS.new(pub_verify, mode='fips-186-3')

  return verifier.verify(h, sig)

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
PrivDHKey = Integer
PubDHKey = ECC.EccPoint

def _point_to_bytes(p: ECC.EccPoint) -> bytes:
x, y = p.xy
return x.to_bytes() + y.to_bytes()

def dh_get_key() -> tuple[Curve, PrivDHKey, PubDHKey]:
  """Generate a DH key pair"""
  group = curves["secp224r1"]
  priv_dec = Integer.random_range(min_inclusive=1, max_exclusive=group.order)
  pub_enc = priv_dec * group.G
  return group, priv_dec, pub_enc

def dh_encrypt(pub: PubDHKey, message: Message, alice_sig: PrivSignKey) -> tuple[PubDHKey, AuthEncryption, Signature]:
  """Assume you know the public key of someone else (Bob),
  and wish to Encrypt a message for them. - Generate a fresh DH key for this message. - Derive a fresh shared key. - Use the shared key to generate a symmetric key - Use the symmetric key to AES_GCM encrypt the message. - Sign the message with Alice's signing key.
  """

  # Generate DH key
  _, priv_dec, pub_enc = dh_get_key()

  # Derive shared key
  shared_key = priv_dec * pub

  # Generate a symmetric key
  sym_key = SHA256.new(_point_to_bytes(shared_key)).digest()

  # AES_GCM encrypt the message
  nonce, ciphertext, tag = encrypt_message(sym_key, message)

  # Sign the encrypted message
  sig = ecdsa_sign(alice_sig, ciphertext)
  return pub_enc, (nonce, ciphertext, tag), sig

def dh_decrypt(priv: PrivDHKey, fresh_pub: PubDHKey, auth_ciphertext: AuthEncryption, sig: Signature, alice_ver: PubVerifyKey) -> Message:
  """Decrypt a received message encrypted using your public key,
  of which the private key is provided.
  Verify the message came from Alice using her verification
  key."""

  shared_point = priv * fresh_pub
  sym_key = SHA256.new(_point_to_bytes(shared_point)).digest()
  nonce, ciphertext, tag = auth_ciphertext

  try:
      verify_result = ecdsa_verify(alice_ver, ciphertext, sig)
  except ValueError:
      raise ValueError("The signature is not authentic")

  decrypted_message = decrypt_message(sym_key, auth_ciphertext)

  return decrypted_message

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
def run_manual_tests():
  print("--- Starting End-to-End Crypto Tests ---")

  # Setup Keys for Alice and Bob
  # Bob needs a static keypair for encryption (so Alice can send him messages)
  _, bob_priv_enc, bob_pub_enc = dh_get_key()
  
  # Alice needs a signing keypair (so Bob knows the message is from her)
  alice_sign, alice_ver = ecdsa_key_gen()
  
  print("[+] Keys generated successfully.")

  # Test Success Case: Encryption & Decryption
  message = b'Hello, Bob! This is a secure message.'
  print(f"[Test 1] Encrypting: {message}")
  
  try:
      # Alice encrypts for Bob
      fresh_pub, auth_ciphertext, sig = dh_encrypt(bob_pub_enc, message, alice_sign)
      nonce, ciphertext, tag = auth_ciphertext

      # View the ciphertext
      print(f"   -> Ciphertext: {ciphertext.hex()}")
      
      # Check output structure
      assert len(nonce) == 12, "Nonce must be 12 bytes"
      assert len(tag) == 16, "Tag must be 16 bytes"
      
      # Bob decrypts
      decrypted = dh_decrypt(bob_priv_enc, fresh_pub, auth_ciphertext, sig, alice_ver)
      print(f"   -> Plaintext: {decrypted}")
      
      if decrypted == message:
          print("   -> PASS: Message decrypted correctly.")
      else:
          print(f"   -> FAIL: Decrypted content mismatch: {decrypted}")
          
  except Exception as e:
      print(f"   -> FAIL: Unexpected exception: {e}")

  # Test Failure Case: Tampered Nonce
  print("[Test 2] Attempting decryption with tampered Nonce...")
  wrong_nonce = urandom(12)
  tampered_ciphertext = (wrong_nonce, ciphertext, tag)
  
  try:
      dh_decrypt(bob_priv_enc, fresh_pub, tampered_ciphertext, sig, alice_ver)
      print("   -> FAIL: Decryption succeeded with wrong nonce (INSECURE!)")
  except ValueError as e:
      # We expect a MAC check failure here
      print(f"   -> PASS: Caught expected error: {e}")
  except Exception as e:
      print(f"   -> PASS: Caught expected error type: {type(e).__name__}")

  # Test Failure Case: Tampered Tag
  print("[Test 3] Attempting decryption with tampered MAC Tag...")
  wrong_tag = urandom(16)
  tampered_ciphertext = (nonce, ciphertext, wrong_tag)
  
  try:
      dh_decrypt(bob_priv_enc, fresh_pub, tampered_ciphertext, sig, alice_ver)
      print("   -> FAIL: Decryption succeeded with wrong tag (INSECURE!)")
  except ValueError as e:
       print(f"   -> PASS: Caught expected error: {e}")
  except Exception as e:
      print(f"   -> PASS: Caught expected error type: {type(e).__name__}")

  # Test Failure Case: Invalid Signature
  print("[Test 4] Attempting decryption with forged signature...")
  wrong_sig = urandom(len(sig))
  
  try:
      dh_decrypt(bob_priv_enc, fresh_pub, auth_ciphertext, wrong_sig, alice_ver)
      print("   -> FAIL: Decryption succeeded with invalid signature (INSECURE!)")
  except ValueError as e:
      if "signature is not authentic" in str(e):
           print(f"   -> PASS: Caught expected signature error: {e}")
      else:
           print(f"   -> WARN: Caught error, but message differed: {e}")

Running these tests provides the following output.

text
[+] Keys generated successfully.

[Test 1] Encrypting: b'Hello, Bob! This is a secure message.'
 -> Ciphertext: 48c1a83483656b9dba91b2fea156162c5652e9a5c2f82cd3e1ad0af7ffc9acaaadfa1d4879
 -> Plaintext: b'Hello, Bob! This is a secure message.'
 -> PASS: Message decrypted correctly.

[Test 2] Attempting decryption with tampered Nonce...
 -> PASS: Caught expected error: MAC check failed

[Test 3] Attempting decryption with tampered MAC Tag...
 -> PASS: Caught expected error: MAC check failed

[Test 4] Attempting decryption with forged signature...
 -> PASS: Caught expected signature error: The signature is not authentic

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!