The previous lesson left you with a map of primitives and a promise: to start with the oldest and most widely used one, the one that employs a single shared key to encrypt and decrypt. It is the one that moves 99 % of the encrypted bytes on the planet — the content of every TLS connection, every encrypted disk, every Nimbus backup — because it is fast, easy to reason about and has been settled for decades. This lesson explains why AES is the standard, what a mode of operation is and why choosing it badly ruins an impeccable algorithm, what a nonce is and why repeating one is a catastrophe, and how a Nimbus attachment is correctly encrypted with AES-GCM. It ends at the exact point where symmetric cryptography runs out of answers: how to get the key to the other end.
Contents
- What symmetric cryptography is and when it is used
- Block ciphers and stream ciphers
- AES: why it is the standard
- Modes of operation: why one block is not enough
- ECB, CBC and CTR: the classic catalogue and its traps
- Authenticated encryption (AEAD) and additional authenticated data
- Encrypting a Nimbus attachment with AES-GCM
- The nonce rule
- Encrypting a backup with
opensslfrom the command line - Key derivation: HKDF
- Performance and its design consequences
- The problem that remains open: distributing the key
- What symmetric cryptography is and when it is used
In a symmetric scheme, the same key serves to encrypt and to decrypt. Sender and receiver — or, very often, the same machine at two different moments — share that secret.
flowchart LR
P["PLAINTEXT\nclinic attachment"] --> C["ENCRYPT\nAES-GCM"]
K["SINGLE KEY\n256 bits"] --> C
C --> X["CIPHERTEXT\n+ nonce + tag"]
X --> D["DECRYPT\nAES-GCM"]
K --> D
D --> P2["PLAINTEXT\nrecovered"]
The scenarios where it is the natural answer share one trait: the key is already available at both ends, either because there is only one end or because another mechanism put it there.
| Scenario in Nimbus | Why it is symmetric |
|---|---|
| Attachment encryption in the A-05 bucket | The same system encrypts and decrypts, with the key from the secrets manager |
| Encrypted backups (02-04) | They are encrypted today and decrypted months later: a single key holder |
| Encryption at rest of PostgreSQL and the disks | Transparent, high volume, high speed |
| The content of a TLS session | The key was agreed beforehand by asymmetric means (03-03, 03-05) |
| Clinical notes encrypted field by field | The API encrypts and decrypts; see 03-07 |
Its two advantages are speed — orders of magnitude over asymmetric cryptography, with dedicated instructions in current processors — and short keys: 256 bits are enough for the very long term. Its one great shortcoming is distribution: if two parties who have never met need a common key, symmetric cryptography alone cannot give it to them; and the number of keys grows quadratically, because n parties who want to talk in pairs need n(n-1)/2 keys (with 38 employees, 703).
- Block ciphers and stream ciphers
There are two ways to build a symmetric cipher:
| Block cipher | Stream cipher | |
|---|---|---|
| How it operates | On fixed-size chunks (128 bits in AES) | It generates a stream of pseudorandom bytes and combines them with the text (XOR) |
| Needs padding | Yes, in some modes | No: the ciphertext is the same size as the plaintext |
| Current examples | AES | ChaCha20 |
| Forbidden examples | DES, 3DES (obsolete) | RC4 (broken) |
| Characteristic risk | A badly chosen mode reveals structure | Reusing the stream with the same key and nonce is fatal |
The boundary between the two is blurrier than it looks: a block cipher in CTR mode behaves like a stream cipher, because instead of encrypting the data it generates a stream that is then combined with it. That is exactly the idea on which AES-GCM is built. And both share the rule we have been carrying since Vigenère: the same key material is never used twice. In a stream cipher this is literal: if we encrypt two different messages with the same stream, an XOR between the two ciphertexts removes the stream and leaves the two plaintexts combined with each other, which is usually enough to recover them.
- AES: why it is the standard
AES (Advanced Encryption Standard) is a block cipher adopted by NIST in 2001 after a five-year international public competition in which fifteen candidates took part, with open cryptanalysis from the whole community. The Belgian algorithm Rijndael won. That history is the best illustration of Kerckhoffs there is: the algorithm became strong precisely because it was public and had been attacked for decades without practical success.
| Characteristic | Value |
|---|---|
| Block size | 128 bits (16 bytes), always |
| Key sizes | 128, 192 or 256 bits |
| Rounds | 10, 12 or 14 depending on the key |
| Status in 2026 | No practical attacks. Accepted for classified information |
| Hardware acceleration | Yes: AES-NI instructions on x86 and equivalents on ARM |
AES-128 versus AES-256. Both are beyond the reach of any brute force (recall the calculation in 03-01: 2^128 is eleven trillion years with an impossible machine). AES-256 is chosen when the data must stay confidential for decades, when a sector standard requires it, or when you want margin against the long-term quantum threat. In Nimbus we will use 256 because the performance cost is marginal and the conversation with auditors gets simpler. ChaCha20 is the modern alternative: a stream cipher that is very fast in pure software, common on mobile devices without AES acceleration — and therefore relevant to the Nimbus app; combined with the Poly1305 authenticator it gives ChaCha20-Poly1305, an AEAD in the same category as AES-GCM.
The 128-bit block and the problem it creates
AES encrypts exactly 16 bytes. A clinic attachment may weigh 3 MB. How do you encrypt 3 MB with a function that can only handle 16 bytes?
The answer is a mode of operation: the set of rules that says how to chop up the message, how to chain the blocks and how to pad the last one. And here is the most important idea in the lesson:
AES is impeccable; modes are where people go wrong. Practically every symmetric encryption failure you will see in your career is a failure of mode, of nonce or of key management. Never of the cipher.
- Modes of operation: why one block is not enough
To understand the problem, imagine we encrypt each 16-byte block separately with the same key and nothing else. Since AES is deterministic, two identical plaintext blocks produce two identical ciphertext blocks. The structure of the original survives encryption.
The classic demonstration is the penguin: if you take a picture of a penguin and encrypt it in ECB mode, the result is still, visibly, a penguin. The colours change, but areas of one uniform tone become areas of another uniform tone, and the silhouette is recognised without any effort. That is the literal meaning of "the ciphertext must not preserve structure".
Transposed to Nimbus: if appointment records were encrypted in ECB, every row whose status field held CANCELLED would have the same ciphertext block. Without decrypting anything, an observer would count cancellations per clinic and detect patterns. Confidentiality is lost without breaking AES.
What is needed is for the same plaintext, encrypted twice, to produce different results. That is the role of the IV (initialisation vector) or of the nonce: a varying value that is combined into the process so that every encryption is unique.
| Term | Meaning | Requirement |
|---|---|---|
| IV | Initialisation value, in modes such as CBC | Unpredictable (random) and unique |
| Nonce | Number used once, in CTR and GCM | Unique per key; it does not need to be unpredictable, but it must never repeat |
| Tag | Authentication code produced by an AEAD | It is verified on decryption; if it fails, nothing is returned |
Neither the IV nor the nonce is secret: they are stored alongside the ciphertext, in the clear. The only secret is the key.
- ECB, CBC and CTR: the classic catalogue and its traps
| Mode | Confidentiality? | Integrity? | Parallelisable? | Main risk |
|---|---|---|---|---|
| ECB | Not really | No | Yes | It preserves patterns. Forbidden always |
| CBC | Yes, with an unpredictable IV | No | Only on decryption | Padding oracle; predictable IV; malleability |
| CTR | Yes | No | Yes (encrypt and decrypt) | Reusing the nonce is catastrophic |
| GCM (AEAD) | Yes | Yes | Yes | Reusing the nonce also breaks authentication |
| ChaCha20-Poly1305 (AEAD) | Yes | Yes | Partially | Reusing the nonce |
Detail on each one:
- ECB (Electronic Codebook). Each block is encrypted independently. It is the penguin. There is no legitimate use case in an information system. If you find it in the code, it is an audit finding, not a matter of stylistic preference.
- CBC (Cipher Block Chaining). Each block is XORed with the ciphertext of the previous one before being encrypted; the first, with the IV. It solves the pattern problem, but drags along three drawbacks: it needs padding — which historically opened the door to padding oracle attacks, in which the attacker decrypts without the key by observing how the server responds to invalid padding — it demands an unpredictable IV (a predictable one enables chosen-plaintext attacks), and it does not give integrity: an attacker can alter bits of the ciphertext and cause controlled changes in the plaintext.
- CTR (Counter). It turns AES into a stream cipher: an increasing counter is encrypted and the result is XORed with the data. Fast, parallelisable and with no padding. But if the (key, nonce) pair repeats, the stream repeats and the attacker recovers the plaintexts. It does not give integrity either.
The reading of the table is emphatic: no classic mode protects integrity. And without integrity, confidentiality is fragile, because an attacker who can modify the ciphertext and observe the system's behaviour has a route to the data. Hence the modern answer.
- Authenticated encryption (AEAD) and additional authenticated data
AEAD stands for Authenticated Encryption with Associated Data: encryption and authentication in a single operation and with a single key. Encrypting produces, on top of the ciphertext, a 16-byte authentication tag. On decryption, the library verifies the tag before returning anything: if it does not match, it raises an exception and does not hand over a single byte.
Why this is today's default answer:
- It eliminates an entire family of attacks. Without integrity there are padding oracles, malleability and bit manipulation. With AEAD, altering a single bit of the ciphertext produces an error, not a different plaintext.
- It avoids the error of combining things badly. It used to be done as "encrypt and then compute a MAC", and the order mattered: MAC-then-encrypt and encrypt-and-MAC have known problems; only encrypt-then-MAC is correct. AEAD takes that decision out of the developer's hands.
- It is what serious protocols use. TLS 1.3 admits only AEAD suites (03-05).
Additional authenticated data (AAD)
An AEAD accepts a third input: the AAD, which is authenticated but not encrypted. It is metadata that travels in the clear but is cryptographically tied to the ciphertext: if anyone changes it, decryption fails.
Its usefulness in Nimbus is direct. The attachments in the A-05 bucket are independent objects with predictable names. If we only encrypt the content, an attacker with write permission on the bucket could move the encrypted object of clinic CL-014 into the folder of clinic CL-207: the content would still decrypt without any problem, because the key is the same, and the wrong clinic would see another clinic's document. It is a leak between tenants without breaking anything.
The solution is to put the context in the AAD:
Now the ciphertext only decrypts if it is presented with that exact context. Moved to another tenant, decryption raises an exception. It is the cryptographic translation of the WHERE tenant_id that fixed the IDOR in 01-01: the data is bound to its owner.
- Encrypting a Nimbus attachment with AES-GCM
This is the central example of the lesson. It uses the cryptography library, which is the reference implementation in Python and the one you should use in production.
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
# ---------------------------------------------------------------
# 1. THE KEY. In production it is NOT generated here: it comes from
# the secrets manager or the KMS (03-06). Generated only once.
# ---------------------------------------------------------------
key = AESGCM.generate_key(bit_length=256) # 32 bytes from a secure source
aead = AESGCM(key)
# ---------------------------------------------------------------
# 2. THE DATA AND ITS CONTEXT
# ---------------------------------------------------------------
attachment = b"Assessment report. Fictitious patient: Ana Ruiz. Session 7 of 10."
tenant_id = "CL-014"
attachment_id = "9f31c7"
aad = f"tenant:{tenant_id}|attachment:{attachment_id}".encode()
# ---------------------------------------------------------------
# 3. ENCRYPT. A 96-bit nonce (12 bytes), NEW on every operation.
# ---------------------------------------------------------------
nonce = secrets.token_bytes(12)
ciphertext = aead.encrypt(nonce, attachment, aad) # returns ciphertext || tag
# ---------------------------------------------------------------
# 4. WHAT GOES INTO THE BUCKET: nonce + ciphertext. The nonce is NOT
# secret; without it you cannot decrypt, so it is stored next to it.
# ---------------------------------------------------------------
stored = nonce + ciphertext
print(f"Plain: {len(attachment)} B | Stored: {len(stored)} B "
f"(+12 nonce +16 tag)")
# ---------------------------------------------------------------
# 5. DECRYPT with the SAME context
# ---------------------------------------------------------------
nonce_read, ct_read = stored[:12], stored[12:]
recovered = aead.decrypt(nonce_read, ct_read, aad)
print("Decrypted OK:", recovered.decode())
# ---------------------------------------------------------------
# 6. WHAT HAPPENS IF SOMEBODY ALTERS A SINGLE BYTE OF THE CIPHERTEXT
# ---------------------------------------------------------------
tampered = bytearray(stored)
tampered[20] ^= 0x01 # ONE bit is flipped
try:
aead.decrypt(bytes(tampered[:12]), bytes(tampered[12:]), aad)
except InvalidTag:
print("TAMPERING DETECTED: the tag does not validate. Nothing is returned.")
# ---------------------------------------------------------------
# 7. WHAT HAPPENS IF THE OBJECT IS MOVED TO ANOTHER TENANT
# ---------------------------------------------------------------
wrong_aad = f"tenant:CL-207|attachment:{attachment_id}".encode()
try:
aead.decrypt(nonce_read, ct_read, wrong_aad)
except InvalidTag:
print("WRONG CONTEXT: the attachment is bound to CL-014.")Output:
Plain: 65 B | Stored: 93 B (+12 nonce +16 tag)
Decrypted OK: Assessment report. Fictitious patient: Ana Ruiz. Session 7 of 10.
TAMPERING DETECTED: the tag does not validate. Nothing is returned.
WRONG CONTEXT: the attachment is bound to CL-014.Points to understand in this code, one by one:
AESGCM.generate_key(bit_length=256)internally uses the system's secure generator. Never derive a key from a fixedstror from a password withsha256(...): that is done with a KDF (section 10 and 03-04).- The nonce is 12 bytes (96 bits) because that is the size for which GCM is specified and optimised. Other sizes force additional processing and increase the risk of error.
secrets.token_bytes(12)inside the encryption flow, never outside it. A nonce computed once and reused in a loop is the most frequent flaw (section 8).encryptreturns the ciphertext with the tag already concatenated at the end. That is why the result takes 16 bytes more than the original. You do not have to manage the tag separately.decryptverifies before decrypting. If the tag does not match it raisesInvalidTagand does not return partial data. Never catch that exception in order to "carry on with whatever is there": there is nothing valid to recover.- The AAD is not stored encrypted because there is no need:
tenant_idandattachment_idare already in the object path and in the database. What the AAD contributes is that they cannot be changed without invalidating the encryption. - The size is preserved almost exactly: 65 bytes of content produce 93 stored. GCM does not pad, so it reveals the approximate length of the original. If that length is sensitive (for example, telling a long report from a short one), explicit padding is added before encrypting.
The anti-pattern: ECB
# =============== WRONG — NEVER USE ===============
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
bad_key = b"0" * 32
encryptor = Cipher(algorithms.AES(bad_key), modes.ECB()).encryptor()
block_a = b"STATUS: CANCELLED"[:16]
block_b = b"STATUS: CANCELLED"[:16]
print(encryptor.update(block_a).hex())
print(encryptor.update(block_b).hex())
# Both lines print EXACTLY the same thing:
# the attacker deduces that the two appointments have the same status without the key.
# =================================================Three things are wrong here: the ECB mode (visible patterns), the constant key (it is not a secret) and the absence of integrity. The correct version is the one in the previous block: AES-GCM, key from the secrets manager, unique nonce, AAD with the context.
- The nonce rule
It is the most important operational rule in modern symmetric encryption:
With a given key, a nonce is never repeated.
Why in GCM it is catastrophic and not merely bad:
- Confidentiality is lost, just as in CTR: two messages encrypted with the same (key, nonce) pair share the stream; the XOR of the ciphertexts reveals the XOR of the plaintexts.
- Authentication is lost, and permanently. GCM builds its tag with an authentication key derived from the main one. With two messages under the same nonce, an attacker can recover that authentication key and, from there, forge valid tags for messages of their choosing, indefinitely and for every nonce. It is not a one-off leak: it is the loss of the integrity property for that key.
The two valid strategies for generating nonces:
| Strategy | How | When to use it | Risk |
|---|---|---|---|
| Random 96-bit | secrets.token_bytes(12) on every operation |
Distributed systems, several processes, no shared state | Birthday collision. Safe up to ~2^32 messages per key |
| Counter | A persistent monotonic counter, optionally with an instance prefix | A single sender with reliable state | A restart that resets the counter to zero repeats nonces |
Recommendation for Nimbus: a random 12-byte nonce, because the API runs in several containers with no shared state and a counter would require coordination. With the practical limit of rotating the key before getting close to 2^32 messages (some four billion), which at Nimbus's volume means a comfortable annual rotation (03-06).
The three concrete errors to watch for in code review:
# WRONG 1: nonce outside the loop -> it repeats on every iteration
nonce = secrets.token_bytes(12)
for att in attachments:
save(aead.encrypt(nonce, att, aad)) # all with the SAME nonce
# WRONG 2: nonce derived from the identifier -> it repeats on re-editing
nonce = attachment_id.encode()[:12]
# WRONG 3: nonce with random -> predictable (03-01)
nonce = bytes(random.randint(0, 255) for _ in range(12))
# CORRECT: a new one, from a secure source, inside the loop
for att in attachments:
n = secrets.token_bytes(12)
save(n + aead.encrypt(n, att, aad))
- Encrypting a backup with
openssl
opensslLucía needs to encrypt the nightly dump before uploading it to external storage. From the command line:
# 1. Generate a 256-bit key in hexadecimal (64 hex characters)
openssl rand -hex 32 > backup_key.hex
chmod 600 backup_key.hex
# 2. Encrypt the dump
openssl enc -aes-256-cbc \
-pbkdf2 -iter 600000 -md sha256 \
-salt \
-in backup-2026-08-02.tar.gz \
-out backup-2026-08-02.tar.gz.enc \
-pass file:backup_key.hex
# 3. Decrypt (restore test: mandatory, see 02-04)
openssl enc -d -aes-256-cbc \
-pbkdf2 -iter 600000 -md sha256 \
-in backup-2026-08-02.tar.gz.enc \
-out restored-backup.tar.gz \
-pass file:backup_key.hexOption by option:
| Option | What it does | Why it matters |
|---|---|---|
enc |
Subcommand for symmetric file encryption | — |
-aes-256-cbc |
Algorithm and mode | 256-bit AES in CBC |
-pbkdf2 |
Derives the real key with PBKDF2 instead of the legacy method | Without this, openssl enc uses an old derivation based on MD5 and a single iteration: unacceptable |
-iter 600000 |
Iterations of the derivation | It makes attempts expensive should the password be weak (03-04) |
-md sha256 |
Hash used in the derivation | It avoids the legacy MD5 |
-salt |
A random salt per file (the default in current versions) | Two identical backups give different ciphertexts |
-pass file:... |
Reads the secret from a file | Never -pass pass:...: it would stay in the shell history and in ps |
-d |
Decrypt | — |
Important warning about this tool.
openssl encproduces encryption without authentication: CBC carries no tag, so it does not detect tampering with the encrypted file. It is acceptable for a backup that is also accompanied by its verified hash (03-04) and lives in immutable storage, but it is not the recommended pattern for new data. For backups today it is better to use tools that do AEAD by design —age,restic,borgwith encryption, or the provider's server-side encryption with managed keys (03-06, 03-07) — rather than building the scheme by hand. And in every case, the key cannot live on the same machine or in the same cloud account as the backup: if the attacker reaches the backups, they would reach the key too.
- Key derivation: HKDF
A frequent practical problem: Nimbus has one master key in the secrets manager, but it needs different keys for attachments, for backups and for the blind search index (03-07). Using the same key for everything is bad practice — it violates domain separation and multiplies the risk of nonce collisions — but keeping five master keys multiplies the custody work.
The solution is HKDF (HMAC-based Key Derivation Function): it derives multiple independent keys from a single one, in such a way that knowing one derived key reveals nothing about the master or about its siblings.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
master_key = get_from_secrets_manager() # 32 bytes of high entropy
def derive(purpose: str) -> bytes:
"""Derives a 256-bit key for one specific purpose."""
return HKDF(
algorithm=hashes.SHA256(),
length=32, # 32 bytes = 256 bits
salt=None, # optional; a salt is better
info=purpose.encode(), # DOMAIN SEPARATION
).derive(master_key)
k_attachments = derive("nimbus/v1/attachments")
k_backups = derive("nimbus/v1/backups")
k_index = derive("nimbus/v1/blind-index")The keys to this code:
infois the purpose label. Changing a single letter produces a completely different key: that is what guarantees the three derived keys are independent. Including the version (v1) allows rotation without ambiguity (03-06).length=32fixes the output size. HKDF can produce any length from the same input.- The master key must already have high entropy. HKDF expands and separates, it does not strengthen. It is no use for deriving from a human password.
The distinction you must not confuse: HKDF derives keys from other keys and is deliberately fast. Deriving from human passwords requires a deliberately slow function — Argon2id, scrypt, bcrypt, PBKDF2 — because the input has little entropy and each attempt by the attacker has to be made expensive. That is studied in 03-04.
- Performance and its design consequences
The orders of magnitude explain why real systems are built the way they are. Indicative figures on a modern server CPU with AES-NI:
| Operation | Approximate throughput | Ratio |
|---|---|---|
| AES-256-GCM (encrypt/decrypt) | ~1–5 GB/s per core | Reference |
| ChaCha20-Poly1305 | ~1–2 GB/s (better without AES-NI) | Comparable |
| SHA-256 | ~1–2 GB/s | Comparable |
| Ed25519 signature | ~tens of thousands/s | Far lower in volume |
| RSA-2048, private operation | ~1,000–2,000/s | ~10,000 times slower per byte |
| RSA-4096, private operation | ~150–300/s | Prohibitive for data |
The three design consequences: (1) large data is never encrypted with asymmetric cryptography — RSA cannot even encrypt more bytes than its modulus — which is where the hybrid encryption of 03-03 comes from; (2) encrypting at rest is practically free, so "it slows the database down" stopped being an acceptable argument fifteen years ago; and (3) the cost is in the asymmetric operations per connection, not in the bytes, which is why TLS reuses sessions and why an API with many short connections spends more CPU on handshakes than on encrypting traffic (03-05).
- The problem that remains open: distributing the key
Everything above works if the two parties already share the key. And that is where symmetric cryptography runs into its limit. Think about the most everyday Nimbus case:
A physiotherapist opens the app on her phone, in the waiting room, connected to the clinic's Wi-Fi. Her device and the Nimbus API have never met before. They need a common symmetric key to encrypt the session. How do they communicate it to each other, if the only channel they have is precisely the one they want to protect?
Sending it over the channel is no good: whoever is listening captures it. Distributing it in advance is no good either: the clients number in the thousands and change daily. And the scale problem is devastating: with 10,000 end users you would need almost fifty million keys. This is the key distribution problem, which went unsolved for the first four thousand years of cryptography's history; it was solved in the 1970s, and its solution is the reason internet commerce exists.
Common Mistakes and Tips
Mode and parameter mistakes
- Using ECB. No exceptions, no nuances. If it turns up in the code, it is a finding.
- Choosing CBC for new code. It is not broken, but it demands correct padding, an unpredictable IV and a properly added MAC. AEAD saves you all three decisions.
- Reusing the nonce. The most serious and most common flaw. A new nonce inside the loop, always, with
secrets. - Believing that encryption gives integrity. Only AEAD does.
- Catching
InvalidTagand carrying on. That exception means "this data has been altered or the context is wrong". You log the event and abort.
Key mistakes
- Deriving the key with
sha256(password). That is not a KDF. Passwords: Argon2id (03-04). Keys from keys: HKDF. - A key written in the code or in a versioned
.env. It is exactly the flaw that enabled the escalation in the ransomware of 02-06 and it is dealt with in 03-06. - A single key for the whole system, or storing it next to the encrypted data. Derive per purpose with HKDF, and remember that a backup encrypted with its key in the same bucket is decorative encryption.
Tips
- This course's default answer for new symmetric encryption is: AES-256-GCM (or ChaCha20-Poly1305 without hardware acceleration), a random 12-byte nonce, AAD with the business context, key from the secrets manager.
- Store the encrypted object as
version || nonce || ciphertext+tag. That first version byte will let you change algorithm in three years' time without rewriting the old data. - Bind the encryption to its context with AAD whenever the data has an owner: in a multi-tenant SaaS, that is always. Encrypting is cheap; designing where the key lives is the real work.
Exercises
Exercise 1 — Diagnosing an implementation
Iván has written this function to encrypt the internal notes on bookings:
import random
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
KEY = b"NimbusReservas2026SecretKey12345" # (1)
IV = b"0123456789abcdef" # (2)
def encrypt_note(note: str) -> bytes:
padding = 16 - (len(note) % 16)
note += chr(padding) * padding
c = Cipher(algorithms.AES(KEY), modes.CBC(IV)).encryptor() # (3)
return c.update(note.encode()) + c.finalize()
def operation_id() -> str:
return "".join(random.choice("abcdef0123456789") for _ in range(16)) # (4)You are asked to: (a) list the four marked problems and explain the concrete attack each one enables; (b) state which security property is missing entirely and what consequence that has; (c) rewrite the function with AES-GCM, including the AAD appropriate for Nimbus and the storage format; (d) explain what has to be done with the notes already encrypted with the old code.
Exercise 2 — Choosing mode and parameters
For each Nimbus case, decide: algorithm and mode, key size, how the nonce/IV is generated, what you put in the AAD (or why it does not apply) and where the key lives.
- Clinic attachments in the A-05 bucket, up to 20 MB, thousands a day, several API containers in parallel.
- The nightly PostgreSQL dump, 8 GB, a single process, restoration possible within the next five years.
- The
clinical_notesfield of the appointments table, around 500 characters, encrypted at application level. - The content of the session between the mobile app and the API.
- A configuration file with third-party credentials that is deployed with the containers.
Exercise 3 — Explaining the nonce to management
Marta has read that "a nonce failure" brought another company down and asks you for a three-paragraph explanation, without mathematics, for the committee.
You are asked to: (a) explain with an analogy what a nonce is and why it must be unique; (b) explain why repeating it in GCM is worse than repeating it in CTR; (c) state which two concrete controls you would put in place at Nimbus so that this failure cannot happen, and who is responsible for each.
Solutions
Exercise 1
(a) The four problems:
| # | Problem | Attack it enables |
|---|---|---|
| 1 | Key written in the code, and derived from a readable phrase on top of that | Anybody with access to the repository, to a backup of the code or to the container has the key. It is the .env pattern from 02-06. It is not real entropy either: they are ASCII characters |
| 2 | Constant IV | With a fixed IV, CBC becomes deterministic again for the first block: two notes with the same beginning give the same initial ciphertext. It allows correlation and enables chosen plaintext |
| 3 | CBC without authentication and with hand-rolled padding | Malleability (altering bits of the ciphertext alters the plaintext predictably) and the risk of a padding oracle if the server distinguishes between errors |
| 4 | random for an identifier |
Predictable (03-01). If that identifier is used in URLs or references, it is enumerable |
(b) Integrity/authenticity is missing. Consequence: nobody detects that an encrypted note has been replaced or modified; the system will decrypt rubbish or manipulated text and display it as legitimate.
(c) Correct version:
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSION = b"\x01"
def encrypt_note(note: str, tenant_id: str, booking_id: int, key: bytes) -> bytes:
aead = AESGCM(key) # key from the manager (03-06)
nonce = secrets.token_bytes(12) # NEW on every call
aad = f"v1|tenant:{tenant_id}|booking:{booking_id}".encode()
ciphertext = aead.encrypt(nonce, note.encode("utf-8"), aad)
return VERSION + nonce + ciphertext # versioned format
def decrypt_note(blob: bytes, tenant_id: str, booking_id: int, key: bytes) -> str:
assert blob[:1] == VERSION, "unknown encryption version"
nonce, ciphertext = blob[1:13], blob[13:]
aad = f"v1|tenant:{tenant_id}|booking:{booking_id}".encode()
return AESGCM(key).decrypt(nonce, ciphertext, aad).decode("utf-8")(d) The old notes. Changing the code is not enough: (1) the old key is considered compromised from the moment it was in the repository, so it is rotated; (2) a migration is run that decrypts with the old scheme and re-encrypts with the new one, tenant by tenant; (3) the version byte allows both formats to coexist during the migration; (4) the old key is removed from the repository history in the knowledge that deleting the commit revokes nothing (03-06); (5) the incident is recorded and it is assessed whether there was improper access.
Exercise 2
| Case | Algorithm/mode | Key | Nonce/IV | AAD | Where the key lives |
|---|---|---|---|---|---|
| 1. Attachments | AES-256-GCM (or chunked encryption for large files) | 256 bits derived with HKDF info="attachments" |
Random 12 B per object (several containers: a counter does not fit) | tenant_id, attachment_id, version |
KMS/secrets manager; a data key per object with envelope encryption (03-06) |
| 2. Dump | AEAD via a dedicated tool (age, restic) rather than openssl enc |
256 bits | Managed by the tool | Backup identifier and date | Outside the production cloud account. A copy held offline |
3. clinical_notes field |
AES-256-GCM at application level | Derived with HKDF info="notes" |
Random 12 B per write | tenant_id, appointment_id, version |
KMS. See 03-07 and the search problem |
| 4. App-API session | Do not implement it yourself: it is TLS 1.3 with AES-GCM or ChaCha20-Poly1305 | Negotiated by the protocol | The protocol | The protocol | Ephemeral, in memory (03-05) |
| 5. Configuration with credentials | Do not encrypt a secrets file: do not have one. Injection from the secrets manager at start-up | — | — | — | Secrets manager (03-06) |
A note on case 3: if those notes contain health data, the design must be validated with the Data Protection Officer before it is put in place; the legal framework is covered in 06-03.
Exercise 3
(a) Analogy. A nonce is like a hotel room number combined with the master key: the key is always the same, but it opens a different room each time. If two guests are given the same number, they end up in the same room and see each other's things. The nonce is what makes the same secret produce a different result on every use; if it repeats, the result repeats and relationships appear between messages that should not exist.
(b) Why it is worse in GCM. In CTR, repeating the nonce breaks the confidentiality of those two messages: the damage is bounded. In GCM, on top of that, it lets the attacker deduce the internal value with which the authentication tags are computed and, with it, fabricate false messages that the system will accept as authentic, forever and with any nonce. You go from "two messages read" to "anybody can sign in your name with that key".
(c) Two controls.
- Technical: a single internal encryption function in the code base — a
vault.encrypt(data, context)— that generates the nonce internally and does not accept one being passed in from outside. If the parameter does not exist, it cannot be reused. Owner: Iván, with review by Marta. - Process: a mandatory two-person review rule for any change touching that module, plus an automated check in CI (02-04) that rejects
Cipher(,modes.ECB,random.and literal nonces outside the vault. Owner: Lucía (pipeline), with Marta as approver.
Conclusion
You now know how to use the fast half of cryptography. You are clear about what a symmetric scheme is — one key that encrypts and decrypts — when it is the natural answer at Nimbus (attachments, backups, encryption at rest, session content) and what its two virtues are, speed and short keys. You can tell a block cipher from a stream cipher, and you know why AES is the standard: a public competition, twenty-five years of open cryptanalysis and hardware acceleration; the best practical demonstration of Kerckhoffs's principle.
But the lesson you must take away is the one about the mode: AES is impeccable and even so ECB lets the penguin show through, CBC demands an unpredictable IV and correct padding, and CTR falls apart if you repeat the nonce. None of the three protects integrity, and that is why today's default answer is authenticated encryption: AES-GCM or ChaCha20-Poly1305, with the tag verified before a single byte is returned, and with AAD to tie the ciphertext to its context — Nimbus's tenant_id — which is the cryptographic version of the WHERE tenant_id that fixed the IDOR. You have seen it working: altering one bit produces InvalidTag, and so does moving an attachment from CL-014 to CL-207. You also take away the most important rule in the section, a nonce is never repeated under the same key, with the exact reason why in GCM that is not a one-off failure but the permanent loss of authentication; the practice with openssl enc and its warning — it encrypts but does not authenticate, and the key never lives next to the backup; HKDF for deriving keys per purpose from a master key; and the orders of magnitude that explain why large data is never encrypted with asymmetric cryptography.
And you are left with an unsolved problem, the same one humanity had for four thousand years: if the only channel between the physiotherapist's mobile app and the Nimbus API is the one we want to protect, how do they agree on the key? There is no answer inside the symmetric world.
In Asymmetric Cryptography (03-03) you will see the idea that changed everything: two different, mathematically related keys, a public one that is handed out without fear and a private one that never leaves. With it, distribution is solved through the Diffie-Hellman exchange and its most valuable property, forward secrecy; hybrid encryption appears, which is the real pattern behind TLS; and the digital signature arrives, the only primitive capable of giving non-repudiation. At the end another question will be left open, the one that gives PKI its purpose: this public key — whose is it really?
Fundamentals of Information Security Course
Module 1: Introduction to Information Security
- Basic Concepts of Information Security
- Types of Threats and Vulnerabilities
- Principles of Information Security
- Assets, Attack Surface and Threat Actors
Module 2: Cybersecurity
- Definition and Scope of Cybersecurity
- Types of Cyber Attacks
- Social Engineering and Phishing
- Protection Measures in Cybersecurity
- Identity, Authentication and Access Control
- Cybersecurity Incident Case Studies
Module 3: Cryptography
- Introduction to Cryptography
- Symmetric Cryptography
- Asymmetric Cryptography
- Hash Functions, HMAC and Password Storage
- Cryptographic Protocols
- Key Management, Certificates and PKI
- Applications of Cryptography
Module 4: Risk Management and Protection Measures
- Risk Assessment
- Security Policies
- Security Controls
- Third-Party and Supply Chain Risk
- Incident Response Plan
- Disaster Recovery and Business Continuity
Module 5: Security Tools and Techniques
- Vulnerability Analysis Tools
- Monitoring and Detection Techniques
- Penetration Testing
- Network Security
- Application Security
- System Hardening and Endpoint Security
- Cloud and Container Security
Module 6: Best Practices and Regulations
- Best Practices in Information Security
- Security Regulations and Standards
- Personal Data Protection and GDPR in Practice
- Compliance and Auditing
- Training and Awareness
- Ethics, Legal Aspects and Responsible Disclosure
