The incident we closed out module 5 with said it all: MediNube's TLS private key didn't fall because of a mathematical flaw — it fell because of a git push. All the cryptography we've built throughout the course — AEAD, Argon2id, Ed25519 signatures, mTLS — shares a single point of failure: keys. If a key lives in the wrong place, it doesn't matter how perfect the primitive using it is. This lesson answers the question we've been deferring since module 1: where should MediNube's secrets actually live? And along the way it settles several outstanding debts: the API_KEYS dictionary from 01-04, the medical-records master key from 02-03, the pepper from 03-03, and the envelope encryption we previewed in 04-05.
Contents
- The real problem: secrets leak through carelessness
- MediNube's secrets inventory
- Key hierarchy: envelope encryption with DEK and KEK
- The secrets-management maturity scale
- Environment variables: correct use and limits
- Encrypted files in the repository: sops and age
- Secrets managers: Vault and KMS in practice
- Rotating the master key: crypto-agility cashes in
- The pepper, finally done right
- Prevention: keeping the secret out of the repository
The real problem: secrets leak through carelessness
No realistic attacker is going to factor MediNube's RSA-3072 modulus. What MalloryClinic will do is search the places where developers accidentally leave secrets lying around:
- Git repositories: the
privkey.pemfrom the 05-03 incident. The commit got deleted, but git history — and the bots that scan GitHub — don't forgive (a secret pushed to a public repo is considered compromised within minutes). - Logs: a
logger.debug(f"Connecting with key {api_key}")written during a debugging session and forgotten. - Shared
.envfiles: over email, over Slack, "just this once so you can get the project running." - Docker images: a
COPY .env /app/freezes the secret into every layer of the image, even if the file is deleted afterward. - Source code, directly: the classic. Our old acquaintance from 01-04:
# medinube/config.py — the course's original debt (01-04)
API_KEYS = {
"clinica-sol": "sk_live_9f8a7b6c5d4e3f2a",
"centro-luna": "sk_live_1a2b3c4d5e6f7a8b",
}This dictionary breaks golden rule 2 (the enemy knows the system: anyone with repo access knows the keys) and makes rotation impossible without a new deployment. Today, we retire it.
MediNube's secrets inventory
Before deciding where to store each secret, you need to know which ones you have. Here's the inventory accumulated over the course — building this table for your own project is the real first step of any secrets-management plan:
| Secret | Origin in the course | Who needs it | Impact if leaked |
|---|---|---|---|
| Clinics' API keys | 01-04 (API_KEYS) |
The app, when authenticating requests | Impersonating Clínica Sol or Luna Medical Center |
| Medical-records master key | 02-03 / 02-04 (AEAD v1 + HKDF) |
Records service | Decrypting every record, including Ana Pérez's |
| Webhook keys (one per clinic) | 03-02 (X-MediNube-Firma) |
Webhook sender | Forging notifications to clinics |
| Password pepper | 03-03 (deferred until today) | medinube/auth.py |
Cancels the extra defense against DB dumps |
| Prescription signing private key | 04-03 (Ed25519, Dr. Ferrer, CS-4471) | Prescriptions service | Forging prescriptions against Robles Pharmacy |
| TLS private keys | 05-02 / 05-03 incident | nginx / load balancer | Impersonating portal.medinube.example (MITM) |
| certbot's DNS credentials | 05-03 (DNS-01 challenge) | ACME renewal | Issuing valid certificates for our domains |
| Database password | (implicit since module 2) | The app, connecting to PostgreSQL | Direct access to encrypted and unencrypted data |
Notice one detail: not all secrets are equal. The medical-records master key protects health data with decades-long GDPR obligations; a webhook key protects notifications. The level of protection can — and should — be proportional to the impact.
Key hierarchy: envelope encryption with DEK and KEK
In 04-05 we built the hybrid envelope and previewed that the DEK/KEK pattern was "exactly what KMSes do." It's time to settle that promise. The idea behind envelope encryption is to organize keys into a hierarchy:
- DEK (data encryption key): the key that encrypts the data. It lives right next to the data, but encrypted.
- KEK (key encryption key): the key that encrypts the DEKs. It lives in the KMS and never leaves it.
flowchart TD
KMS["KMS / Vault<br/>(the KEK never leaves here)"]
KEK["KEK<br/>root master key"]
DEK1["Medical records DEK<br/>(encrypted with the KEK)"]
DEK2["Backups DEK<br/>(encrypted with the KEK)"]
DEK3["Attachments DEK<br/>(encrypted with the KEK)"]
D1["Records, AEAD v1"]
D2["Backups, scrypt/envelope"]
D3["Attachments (X-rays...)"]
KMS --- KEK
KEK -->|"encrypts"| DEK1
KEK -->|"encrypts"| DEK2
KEK -->|"encrypts"| DEK3
DEK1 -->|"encrypts"| D1
DEK2 -->|"encrypts"| D2
DEK3 -->|"encrypts"| D3
The operational flow is the key point, because it's often misunderstood:
- The app wants to decrypt a medical record. Alongside the data it has the encrypted DEK stored.
- It sends the encrypted DEK to the KMS: "decrypt this for me" (a
decryptoperation). - The KMS checks that the app is authorized, decrypts the DEK with the KEK, and returns the DEK in the clear.
- The app uses the DEK in memory for
medinube.crypto's AES-GCM, and discards it when done.
The data never passes through the KMS. Only 32-byte keys travel through the KMS, not megabyte-sized records. This makes the pattern fast, cheap, and auditable: the KMS logs every operation (who asked to decrypt which DEK, and when), and rotating the KEK only requires re-encrypting DEKs (a handful of them), not terabytes of data.
The secrets-management maturity scale
Not every organization needs an HSM. What matters is knowing which rung you're on and what the next one is:
| Level | Where secrets live | When it's enough | Residual risk |
|---|---|---|---|
| 0. Hardcoded | In the code (API_KEYS) |
Never | Anyone who reads the repo has the secrets |
1. .env outside the repo |
Local file, in .gitignore |
Local development, prototypes | Shared over insecure channels; no rotation or auditing |
| 2. Orchestrator/CI secrets | Kubernetes Secrets, GitHub Actions secrets, systemd credentials | Small teams with automated deployment | Whoever administers the platform sees everything; limited auditing |
| 3. Dedicated manager | Vault, cloud KMS (AWS KMS, GCP KMS, Azure Key Vault), step-ca for certificates (05-03) | Serious production; regulated data like MediNube's | The authenticated app is still the frontier |
| 4. HSM | Tamper-proof hardware; the key can't be extracted even with root | Root CAs, qualified eIDAS signing (04-03), legal requirements | Cost and operational complexity |
MediNube, with medical records and GDPR obligations, must be at level 3, with the KEK ideally backed by an HSM (cloud KMSes already do this under the hood). Level 1's .env is still legitimate... for a developer's machine with test data.
Environment variables: correct use and limits
Environment variables are the standard mechanism for delivering a secret to a process, and they're fine for that. But it's worth knowing their limits:
# medinube/config.py — corrected version (goodbye, 01-04 dictionary)
import os
def api_key_for_clinic(clinic_id: str) -> str:
"""Retrieves a clinic's API key from the environment.
In production, the environment is populated by the secrets
manager (level 3); in development, by a local .env that is
NEVER committed.
"""
value = os.environ.get(f"MEDINUBE_API_KEY_{clinic_id.upper().replace('-', '_')}")
if value is None:
raise RuntimeError(f"Missing API key for {clinic_id}: check the secrets manager")
return valueExplanation: instead of having the values in the code, the code only knows the secret's name. Failing loudly (RuntimeError) when it's missing is deliberate: an absent secret should block startup, not degrade into an insecure default.
Limits you should know about:
- Visible in
/proc: on Linux,cat /proc/<pid>/environshows a process's environment. Any user who can read that file (root, or the process's own user) sees the secrets. - They're inherited: every child process gets the full environment. That third-party script you run also sees
MEDINUBE_MASTER_KEY. - They end up in logs and crash dumps: many frameworks print the full environment when reporting an error. Configure your error handler to redact variables with names like
*_KEY,*_SECRET,*_PASSWORD. - They don't rotate on their own: changing the value requires restarting the process.
Encrypted files in the repository: sops and age
Sometimes you want to version configuration with secrets included (for infrastructure, say). The answer isn't "commit the secret," it's committing it encrypted. The sops + age pairing is the lightweight standard:
# Generate an age keypair (X25519 under the hood — 04-04 in action) age-keygen -o ~/.config/sops/age/keys.txt # Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p # Encrypt only the VALUES of a YAML file, leaving the keys readable for diffs sops --encrypt --age age1ql3z...ac8p secrets.yaml > secrets.enc.yaml git add secrets.enc.yaml # this one CAN go in the repo # Decrypt at deploy time (the age private key lives on the server, not in the repo) sops --decrypt secrets.enc.yaml
The elegant part of sops is that it encrypts value by value: the git diff still shows which key changed, without revealing its content. And it can use age, a cloud KMS, or PGP as a backend — crypto-agility here too. Its limit: it decentralizes rotation (re-encrypt and redeploy) and gives no access auditing. It's a level 2.5 on our scale: perfect for infrastructure configuration, insufficient as MediNube's central secrets manager.
Secrets managers: Vault and KMS in practice
A secrets manager (HashiCorp Vault, OpenBao, or your cloud's KMS/Secrets Manager) provides what no earlier level has: application authentication, secrets with expiration, and central auditing. The conceptual flow, common to every provider:
- The app authenticates to the manager. Not with "yet another hardcoded secret" (that would be the same old problem!), but with a platform identity: the Kubernetes service account, the cloud instance's IAM role, an mTLS certificate from our 05-01 PKI.
- The manager returns a token with a TTL (time to live) and, for many secrets, a lease: the secret itself expires (think database credentials generated on the fly, valid for 1 hour).
- The app renews the lease while it's alive; when it dies, the secret expires on its own. It's the same lesson as certificates' short lifetimes from 05-03, applied to every secret.
With the hvac library (Vault's Python client), the code looks like this — treat this example as a flow, not as lock-in to one provider:
import hvac
# 1. Authentication: here with AppRole; on Kubernetes it would be the
# service account's JWT
client = hvac.Client(url="https://vault.medinube.example:8200")
client.auth.approle.login(role_id=ROLE_ID, secret_id=SECRET_ID)
# 2. Read a static, versioned secret (KV v2 engine)
response = client.secrets.kv.v2.read_secret_version(
path="medinube/production/pepper",
)
pepper = response["data"]["data"]["value"].encode()
# 3. Envelope encryption with the "transit" engine: the KEK lives in Vault
# and NEVER leaves it; we send it the encrypted DEK and get back plaintext.
import base64
result = client.secrets.transit.decrypt_data(
name="kek-medical-records", # the KEK, identified by name
ciphertext=encrypted_dek_str, # "vault:v2:..." — notice the versioning!
)
dek = base64.b64decode(result["data"]["plaintext"])Details worth savoring: the transit engine's ciphertext starts with vault:v2: — a versioned format, exactly like our 0x01 byte in medinube.crypto. Everyone arrives at the same conclusion: without a version, there's no rotation.
Compliance note: for MediNube, the secrets manager isn't just good engineering: the GDPR requires "appropriate technical measures" (Art. 32), and in an audit, the manager's access log is the evidence of who could have touched the medical-records key. Coordinate the choice of level with your security lead and your DPO.
Rotating the master key: crypto-agility cashes in
In 02-03 we designed the format v1 = 0x01 || nonce(12) || ciphertext+tag with AAD f"paciente={patient_id};formato=v1" and said that version byte would "save us someday." Today is that day: it's time to rotate the medical-records master key (good periodic hygiene, and mandatory if you suspect compromise). The plan, thanks to versioning:
- Create the new key in the manager (or a new version of the KEK, which re-encrypts the DEKs).
- Define
v2: identical format, byte0x02, AADformato=v2, new key. - Writes: all new encryption goes out as
v2. - Reads:
decrypt_medical_recordlooks at the first byte and picks the key.v1remains readable. - Progressive re-encryption: a background job reads each
v1record, decrypts it, and rewrites it asv2. No service downtime. - Once no
v1remains in the DB, the old key is destroyed (or archived per retention policy).
# medinube/crypto.py — multi-version decryption
KEYS = {
0x01: get_from_secrets_manager("kek-medical-records", version=1), # old key: read-only
0x02: get_from_secrets_manager("kek-medical-records", version=2), # new key: read and write
}
def decrypt_medical_record(blob: bytes, patient_id: str) -> bytes:
version = blob[0]
if version not in KEYS:
raise TamperedMedicalRecord(f"Unknown format version: {version:#04x}")
aad = f"paciente={patient_id};formato=v{version}".encode()
aesgcm = AESGCM(KEYS[version])
return aesgcm.decrypt(blob[1:13], blob[13:], aad)Without that leading byte, rotation would have required either guessing which key encrypted each row (try-and-catch: slow and ugly) or re-encrypting everything at once with the service stopped. Golden rule 8 — crypto-agility — just paid for itself.
The pepper, finally done right
In 03-03 we left the pepper "for when we had somewhere safe to keep it." We have it now. Reminder: the salt lives alongside the hash in the DB (it's public); the pepper is a global secret that lives outside the DB, so that a database dump alone isn't enough to attack the hashes offline. The correct way isn't concatenation, it's HMAC (03-02):
# medinube/auth.py — Argon2id + HMAC pepper
import hmac, hashlib
from argon2 import PasswordHasher
ph = PasswordHasher()
PEPPER = get_from_secrets_manager("medinube/production/pepper") # 32 bytes from the manager's CSPRNG
def _with_pepper(password: str) -> bytes:
# HMAC-SHA256(pepper, password): a uniform 32-byte output,
# without the length/normalization problems of concatenation.
return hmac.new(PEPPER, password.encode(), hashlib.sha256).digest()
def register(password: str) -> str:
return ph.hash(_with_pepper(password))
def verify(stored_hash: str, password: str) -> bool:
ph.verify(stored_hash, _with_pepper(password)) # raises an exception on failure
return TrueNow MalloryClinic needs two breaches (the DB and the secrets manager) to mount a dictionary attack. Watch out: the pepper is hard to rotate (you'd have to re-hash on each user's next login, like the migration in 03-03), so protect it well from the start.
Prevention: keeping the secret out of the repository
The 05-03 incident's runbook was reactive. The mature version is preventing the leak before the commit, and catching it in CI:
# gitleaks: secrets scanner (detects API keys, PEM blocks, tokens...)
# 1) Locally, as a pre-commit hook (in .pre-commit-config.yaml):
# - repo: https://github.com/gitleaks/gitleaks
# rev: v8.24.0
# hooks: [{id: gitleaks}]
pre-commit install
# 2) In CI, scanning the ENTIRE history, not just the last commit:
gitleaks git --redact -v .
# 3) git-secrets (AWS's alternative) with custom patterns:
git secrets --add 'sk_live_[0-9a-f]{16}' # our API keys' formatEssential companions:
- Defensive
.gitignore:.env,*.pem,*.key,credentials.jsonshould be ignored before they exist. - Scanning already-published history: running
gitleaksover the whole history uncovers secrets leaked months ago. If something shows up, apply the generalized 05-03 runbook: rotate first (the secret is already considered compromised), then clean up the history, notify[email protected], and look for suspicious usage in the logs. Cleaning up history without rotating is theater. - Log redaction: logger filters that mask values that look like secrets.
Common Mistakes and Tips
- Mistake: deleting the commit and calling it fixed. The secret is still in the reflog, in forks, in scanners' caches. The only valid response to a leak is rotation.
- Mistake: authenticating the app to Vault with a hardcoded token. You've moved the problem, not solved it. Use platform identity (IAM, service account, mTLS).
- Mistake: sending whole records through the KMS. The KMS encrypts keys (DEKs), not data. That's exactly what envelope encryption is for.
- Mistake: a single secret "for everything." One key per purpose (a rule we already applied with HKDF in 02-04): that way a leak doesn't drag down the whole system, and each key can be rotated separately.
- Tip: measure your maturity with the levels table and climb one rung at a time. Going from level 0 to level 1 in an afternoon eliminates 80% of the immediate risk.
- Tip: treat the secrets inventory as living documentation. Every new secret enters the table with its owner, its impact, and its rotation plan.
Exercises
- Express audit: clone (mentally or for real) one of your own projects and locate its secrets. Classify them with the maturity-levels table: which level is each one at? What's the most urgent level jump?
- Rotation with versioning: starting from this lesson's multi-version
decrypt_medical_record, writeencrypt_medical_record_v2and the skeleton of the progressive re-encryption jobreencrypt_pending(batch=100), which finds blobs starting with0x01, decrypts them, and rewrites them asv2. - Secret hunter: write a regular expression to catch MediNube's API keys (
sk_live_+ 16 hex chars) and PEM private-key blocks in pre-commit. Why is it worth adding it to CI even though it's already in pre-commit?
Solutions
-
Open-ended answer, but the typical pattern: API keys at level 0 or 1, DB password at level 1 or 2, certificates at level 2. The most urgent jump is almost always getting anything at level 0 out of the code (an afternoon's work) and adding gitleaks to CI (an hour).
-
def encrypt_medical_record_v2(data: bytes, patient_id: str) -> bytes: nonce = os.urandom(12) aad = f"paciente={patient_id};formato=v2".encode() ct = AESGCM(KEYS[0x02]).encrypt(nonce, data, aad) return bytes([0x02]) + nonce + ct def reencrypt_pending(batch: int = 100) -> int: rows = db.query("SELECT id, patient_id, record FROM medical_records " "WHERE get_byte(record, 0) = 1 LIMIT %s", [batch]) for row in rows: data = decrypt_medical_record(row.record, row.patient_id) new_blob = encrypt_medical_record_v2(data, row.patient_id) db.execute("UPDATE medical_records SET record = %s WHERE id = %s", [new_blob, row.id]) return len(rows)Design points: the job is idempotent (if interrupted, the next pass picks up what's left), it works in batches so it doesn't overload the DB, and it uses the correct AAD for each version (re-encrypting changes the AAD from
v1tov2, which is why you have to decrypt and re-encrypt, not "just flip the byte"). -
Patterns:
sk_live_[0-9a-f]{16}and-----BEGIN( [A-Z]+)? PRIVATE KEY-----. It's also needed in CI because pre-commit hooks are optional and local: a teammate without the hook installed (or agit commit --no-verify) skips them; CI is the safety net nobody can bypass.
Conclusion
We've closed the wound the whole course had been bleeding from: MediNube's secrets no longer live in dictionaries in the code or shared .env files, but in a manager with authentication, TTLs, and auditing; the medical-records master key rotates thanks to the v1/v2 versioning we planted back in module 2; the pepper finally exists for real; and gitleaks watches so the privkey.pem story never repeats. Envelope encryption with DEK/KEK has gone from a promise (04-05) to an architecture.
But keeping keys well guarded is only half the map: now you have to decide what gets encrypted, where, and against which threat, in every corner of MediNube's architecture — from Ana Pérez's browser to the row in PostgreSQL, backups included. That full map of encryption at rest and in transit is the next lesson. See you there.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
