You close the module with the integrating lesson. The previous six gave you the pieces separately — primitives, protocols and key management; this one puts them all at once on top of the real Nimbus system and answers the question a professional has to be able to answer in front of their management: which data is protected, against which threat, with which primitive and where does the key live. You will see the decisive contrast between encryption in transit and at rest, the three levels of encryption at rest with the point almost nobody is clear about — that disk encryption does not protect against a compromised API — field-level encryption with the practical problem it introduces, artefact signing in the CI/CD, the 120-second signed URLs explained from the inside and the real difference between pseudonymising and anonymising.
Contents
- An end-to-end cryptographic map of Nimbus
- In transit versus at rest
- The three levels of encryption at rest
- Field-level encryption: the clinical notes
- The problem it introduces: you can no longer search
- Backup encryption
- Code and artefact signing in the CI/CD
- Signed tokens and signed URLs from the inside
- Pseudonymisation and anonymisation
- Cases that are usually got wrong
- Reference table: data, threat, protection and key
- An end-to-end cryptographic map of Nimbus
flowchart TB
U["Browser and mobile app\nPasskeys in the device\nsecure module (02-05)"]
U -->|"TLS 1.3: ECDHE + AES-GCM\nHSTS, forward secrecy"| API["FastAPI API\nValidates the signed JWT (kid)\nAuthorises per tenant"]
API -->|"TLS + credentials\nfrom the secrets manager"| DB["PostgreSQL\nProvider disk encryption\nArgon2id on credentials\nAES-GCM on clinical notes"]
API -->|"URLs signed with HMAC\n120 s expiry"| S3["A-05 bucket\nAttachments with envelope encryption\ndata key per object"]
API -->|"TLS + HMAC-SHA256\non the webhooks"| PG["Payment gateway"]
API -->|"TLS + DKIM on outbound"| EM["Transactional e-mail"]
DB --> BK["3-2-1-1-0 backups\nEncrypted and immutable.\nKey OUTSIDE production"]
S3 --> BK
CI["CI/CD GitHub Actions\nSBOM, secret scanning,\nSIGNED artefacts"] -->|"verified deployment"| API
KMS["KMS / secrets manager\nMaster key that never leaves.\nAuditing per operation"] -.->|"data keys"| API
KMS -.-> BK
Nine legs, nine cryptographic decisions. Note one detail of the diagram: the KMS appears with a dashed line because it is not on the data path but on the key path. That separation is exactly what stops a compromise of the API handing over the master key (03-06).
And a warning that runs through the whole lesson: on this map there are legs where cryptography protects nothing, and knowing which ones is as important as knowing how to encrypt. An attacker who obtains a valid API token walks in through the front door: TLS protects them, disk encryption decrypts for them and the KMS serves them the data keys entirely correctly. Cryptography is not the defence against that attack; authorisation, rate limiting and detection are.
- In transit versus at rest
| In transit | At rest | |
|---|---|---|
| What it protects | The data while it travels over a network | The data while it is stored |
| Mechanism | TLS (03-05), SSH, VPN | Symmetric encryption: disk, database or field |
| Threat it stops | Eavesdropping and interception on the network | Theft of the media, a copy of the volume, access to the file |
| Duration | Milliseconds | Years |
| In Nimbus | All external and internal communication | PostgreSQL, the A-05 bucket, backups, laptops |
Encryption in transit was covered completely by lesson 03-05, so all that matters here is its role within the whole: it protects the leg of the journey and ends at the endpoint. When the data reaches the Nimbus server, TLS has done its job and disappears; from then on, protection has to come from somewhere else. The rest of the lesson is precisely about that somewhere else.
- The three levels of encryption at rest
This is the section that clears up the most misunderstandings. "We encrypt at rest" can mean three very different things, and each one stops a different threat.
| Full disk (LUKS, BitLocker, provider encryption) | Database level (TDE, managed volume encryption) | Application level (field by field) | |
|---|---|---|---|
| Who encrypts | The operating system or the provider | The database engine | Your code, before storing |
| Who has the key | The system, at boot | The engine | Only the application, via the KMS |
| It stops | Theft of the laptop or the disk; hardware decommissioning; access to the physical storage | On top of that: a copy of the data file or the volume; access to the engine's backup store | On top of that: a database administrator, a dump, a SQL injection or a backup restored somewhere else |
| It does NOT stop | Anything that happens while the system is switched on | Anything that arrives through the engine: a legitimate SELECT returns plaintext |
An attacker who controls the application |
| Cost | None, transparent | Low, transparent | High: it changes the data model and breaks searches |
| Use | Always. It is basic hygiene | Whenever the provider offers it | Only for the most sensitive data |
The key point, stated bluntly: disk encryption protects against a thief who walks off with the hardware. It protects absolutely nothing against a compromised API, a SQL injection or an IDOR, because in all those cases the system is switched on and the engine happily decrypts for whoever asks. It is exactly what happened in the leak through a misconfigured bucket in 02-06: the data was encrypted at rest and left anyway, because the access was "legitimate".
Hence the rule:
Disk and database encryption: always, because they are free. But do not count them as a defence against the attacks that really threaten Nimbus. For that you need application-level encryption — for the most sensitive data — and, above all, access control (02-05).
- Field-level encryption: the clinical notes
Nimbus stores, alongside each appointment, a notes field which in physiotherapy clinics may contain information revealing the patient's health status. It is the most sensitive data in the system and the natural candidate for application-level encryption.
Professional validation note. Processing data that reveals health information is subject to reinforced requirements. The technical design that follows is necessary but not sufficient: the lawful basis, the minimisation, the retention periods and the risk analysis must be documented, and validated with the Data Protection Officer and with legal advice. The regulatory framework is covered in 06-03.
ALTER TABLE appointments
ADD COLUMN notes_encrypted BYTEA, -- version || nonce || ct+tag
ADD COLUMN notes_data_key BYTEA, -- data key ENCRYPTED by the KMS
ADD COLUMN notes_kms_kid TEXT, -- which master key encrypted it (03-06)
ADD COLUMN notes_index BYTEA; -- blind index (section 5)
-- The plaintext column is dropped AFTER migrating and verifying
-- ALTER TABLE appointments DROP COLUMN notes;
CREATE INDEX idx_appointments_notes_index ON appointments (tenant_id, notes_index);import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
VERSION = b"\x01"
def encrypt_notes(text: str, tenant_id: str, appointment_id: int, kms) -> dict:
# 1. ENVELOPE ENCRYPTION (03-06): a NEW data key per record
dk_plain, dk_encrypted, kid = kms.generate_data_key()
# 2. AAD with the context: ties the ciphertext to its owner (03-02)
aad = f"v1|tenant:{tenant_id}|appointment:{appointment_id}".encode()
nonce = secrets.token_bytes(12)
ct = AESGCM(dk_plain).encrypt(nonce, text.encode("utf-8"), aad)
del dk_plain # 3. out of memory as soon as possible
return {
"notes_encrypted": VERSION + nonce + ct,
"notes_data_key": dk_encrypted,
"notes_kms_kid": kid,
}
def decrypt_notes(row, tenant_id: str, appointment_id: int, kms) -> str:
dk_plain = kms.decrypt_data_key(row["notes_data_key"],
row["notes_kms_kid"])
blob = row["notes_encrypted"]
assert blob[:1] == VERSION, "unknown encryption version"
aad = f"v1|tenant:{tenant_id}|appointment:{appointment_id}".encode()
try:
return AESGCM(dk_plain).decrypt(blob[1:13], blob[13:], aad).decode()
finally:
del dk_plainThe decisions and why they are as they are:
- Envelope encryption per record. Each note has its own data key, encrypted with the KMS master. That way rotating the master does not require re-encrypting the content, and every decryption operation leaves an audit trail in the KMS (03-06): if somebody decrypts ten thousand notes in an hour, it shows.
- AAD with
tenant_idandappointment_id. Moving the row to another tenant or copying it to another appointment invalidates decryption. It is the cryptographic extension of the multi-tenant isolation of 02-05. - A version byte. It allows the algorithm to be changed in three years' time without migrating all the old data at once.
del dk_plain. It reduces the window in which the key is in memory. It is not a strong guarantee in Python — the garbage collector decides — but it is the correct practice and it stops the key ending up in an exception dump or a debug log.- The plaintext column is dropped at the end, after migrating and verifying. And it is worth remembering that dropping it does not delete the earlier backups, which still contain the plaintext until their retention expires.
- The problem it introduces: you can no longer search
Here is the real cost of application-level encryption, and the reason it is not applied to everything:
-- This NO longer works: the engine only sees bytes indistinguishable from random
SELECT * FROM appointments WHERE notes ILIKE '%knee%';When you encrypt properly, the ciphertext is indistinguishable from random data. That is precisely what we want — and what stops you searching, sorting, indexing, grouping or doing partial matching. The options and their trade-offs:
| Option | What it allows | What it costs |
|---|---|---|
| Searching only by unencrypted metadata | Filtering by tenant, date, practitioner, status | No searching by content at all. The safest option |
| Blind index with a deterministic HMAC | Exact matching on specific values | It reveals equality: two records with the same value share an index, which allows frequency analysis |
| Decrypting in the application and filtering | Full search | Unfeasible at volume: everything has to be fetched and decrypted |
| Order-preserving or searchable encryption | Ranges and searches | Known information leaks and high complexity. Not advisable for an SME |
The blind index, which is the most common practical solution, works like this: instead of encrypting the value you want to search on, you compute a deterministic HMAC with a dedicated key, and you index that result. Since the HMAC is deterministic, the same value always produces the same index; and since it has a key, nobody without it can compute it or build a dictionary.
import hmac, hashlib
def blind_index(value: str, tenant_id: str, index_key: bytes) -> bytes:
"""Deterministic HMAC for exact search. The key is DIFFERENT from the
encryption key, derived with HKDF (03-02). The tenant_id goes into the
message so that the same value in two clinics produces different indexes."""
normalised = value.strip().casefold() # same form -> same index
message = f"{tenant_id}|{normalised}".encode("utf-8")
return hmac.new(index_key, message, hashlib.sha256).digest()[:16]-- Exact search over the index, without decrypting anything
SELECT id, date FROM appointments
WHERE tenant_id = $1 AND notes_index = $2;Three details that make this correct rather than decorative: the index key is different from the encryption key, derived with HKDF, so that compromising one does not give the other; the tenant_id goes into the message, so that the same value in two clinics produces different indexes and cross-tenant analysis is cut off; and the prior normalisation guarantees that "Knee" and " knee " match, because the HMAC does not forgive so much as a space.
And its limit, which has to be accepted knowingly: it reveals equality. An attacker with access to the table sees which rows share a value and can make inferences from frequency. That is why the blind index is applied to fields with high cardinality — a reference, an identifier — and not to fields with few possible values, where frequency would give everything away.
Decision for Nimbus: encrypt the body of the clinical notes and do not allow searching by their content; searching is done by metadata (patient, practitioner, date), which is not the most sensitive data. The blind index is reserved for the patient identifier, where exact matching is needed.
- Backup encryption
In 02-04 you established the 3-2-1-1-0 scheme, with one immutable copy. This lesson adds the requirement that was missing: that copy must also be encrypted, and its key must live outside the same environment.
The reasoning comes straight from the ransomware case of 02-06. There the attacker came in through the consultancy's remote access, escalated with a forgotten secret and destroyed backups that were in the same account as production. Now add the modern variant, double extortion: encrypting your data is not enough, they also take it and threaten to publish it.
| Property of the backup | Against which threat |
|---|---|
| Immutable (02-04) | That the attacker deletes or encrypts the backups |
| Encrypted | That the attacker reads and publishes their content |
| Key outside the environment | That the same compromise that gives access to the backup also gives the key |
| Tested restore | That the backup exists and is useless |
The three practical rules: the backup key lives in another account or with another provider, with independent access and not reachable from the production roles; never next to the backup, which is the most frequent error; and the restore is rehearsed at least once a year, including the step of recovering the key from its custody (03-06), because untested custody is an assumption.
- Code and artefact signing in the CI/CD
An uncomfortable lesson came out of SolarWinds (02-06): the attackers compromised the build process, so the malicious updates were signed with the company's legitimate key and customers installed them in complete confidence. The conclusion you drew then still holds: a valid signature vouches for the origin, not for the absence of malice.
So why sign at all? Because without a signature you have neither of the two things. Signing gives three concrete, verifiable guarantees:
| Guarantee | What it means |
|---|---|
| Integrity | The artefact has not been modified since it was built |
| Origin | It was produced by your CI/CD and not by a third party |
| Traceability | With signed provenance, you can say which commit it came from, with which dependencies and in which run |
# .github/workflows/publish.yml (extract)
permissions:
contents: read
id-token: write # ephemeral identity for signing WITHOUT stored keys
packages: write
steps:
- name: Build image
run: docker build -t ghcr.io/nimbus/api:${{ github.sha }} .
- name: Generate SBOM (02-04)
run: syft ghcr.io/nimbus/api:${{ github.sha }} -o spdx-json > sbom.json
- name: Sign image and SBOM
run: |
cosign sign --yes ghcr.io/nimbus/api:${{ github.sha }}
cosign attest --yes --predicate sbom.json \
ghcr.io/nimbus/api:${{ github.sha }}# At deployment: do NOT deploy anything that does not verify
cosign verify \
--certificate-identity-regexp "https://github.com/nimbus/api/.github/workflows/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/nimbus/api:$SHAThe important thing about this scheme: there is no private signing key stored anywhere. An ephemeral identity from the CI itself is used (the id-token), signing is done with a pair that exists for a few seconds and the proof is anchored in a public transparency log, with the same logic as the CT of 03-06. It is the best answer to the problem in the previous paragraph: it eliminates the risk of the signing key being stolen, because there is no key to steal. And verification at deployment checks the identity of the signer, not merely that the signature is valid: without --certificate-identity-regexp, you would accept anybody's signature.
Dependency integrity. The other end of the same problem is what goes into your build. The defence is to pin hashes, so that a version republished with different content does not get installed:
# requirements.txt with pinned hashes
cryptography==43.0.1 \
--hash=sha256:8f1e2c9a... \
--hash=sha256:2d7b40f3...--require-hashes makes the installation fail if the downloaded package does not match the declared hash. It is a direct application of the hash functions of 03-04 to supply chain risk.
- Signed tokens and signed URLs from the inside
Signed is not encrypted: JWS versus JWE
In 02-05 you used JWTs without going into their cryptography. Here only that part matters, along with a distinction that is constantly confused:
| JWS (signed) | JWE (encrypted) | |
|---|---|---|
| What it guarantees | Integrity and authenticity | Confidentiality (and integrity too, if it is AEAD) |
| Is the content readable? | Yes. Base64 is encoding, not encryption (03-01) | No |
| Common use | 99 % of JWTs | When the token has to carry data the client cannot see |
| Algorithms | EdDSA, ES256, RS256, HS256 | Combinations of key management + AEAD |
The practical consequence is very concrete: an ordinary JWT is readable by anybody who has it. Anybody can paste it into a viewer and see its content. That is why you never put personal data or secrets into a signed token: you put opaque identifiers in. A sub: "u_88421" is correct; an email: "ana.ruiz@..." is a leak that will travel on every request and will end up in some log.
And the choice of algorithm: EdDSA or ES256 (asymmetric) when the verifier is another system, because it only needs the public key and cannot fabricate tokens; HS256 (symmetric) only if issuer and verifier are the same service, because sharing the HMAC key amounts to sharing the ability to issue. It is the MAC versus signature distinction of 03-03 applied to sessions.
The 120-second signed URLs, from the inside
You have been seeing this piece all course long without opening it. A signed URL lets the browser download an attachment straight from the A-05 bucket, without the bucket being public and without the file passing through the API. What gets signed is exactly this:
Message that goes into the signature:
method : GET
resource : /nimbus-adjuntos/CL-014/9f31c7.pdf
expires : 1785938400 (UNIX timestamp)
headers : hostimport hmac, hashlib, time, base64
def signed_url(resource: str, key: bytes, seconds: int = 120) -> str:
expires = int(time.time()) + seconds
message = f"GET\n{resource}\n{expires}".encode("utf-8")
signature = hmac.new(key, message, hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(signature).decode().rstrip("=")
return f"https://cdn.nimbusreservas.example{resource}?exp={expires}&sig={sig_b64}"
def validate(resource: str, exp: str, sig: str, key: bytes) -> bool:
if int(exp) < time.time(): # 1. expiry BEFORE the signature
return False
message = f"GET\n{resource}\n{exp}".encode("utf-8")
expected = base64.urlsafe_b64encode(
hmac.new(key, message, hashlib.sha256).digest()
).decode().rstrip("=")
return hmac.compare_digest(expected, sig) # 2. constant time (03-04)Why it is designed this way, point by point:
- The expiry is inside the signed message. If it were outside, the user could change
expto a year ahead and the URL would still work. Being inside, altering it invalidates the signature. - The resource too. Without it, a valid signature for one attachment would work for any other: it would be an IDOR on steroids.
- The method. It stops a read permission being turned into a write permission.
- 120 seconds. It is just enough time for the browser to start the download. URLs end up in browsing history, in proxy logs, in screenshots and in forwarded messages; a short expiry makes all of that harmless minutes later.
hmac.compare_digest. Constant time, as in the webhook of 03-04.- Authorisation is checked before signing, not afterwards. The API verifies that the user has access to that attachment of that tenant, and only then issues the URL. The signature does not authorise: it transports an authorisation already granted.
- Pseudonymisation and anonymisation
Nimbus needs data for the test environment and for its product analytics, and the temptation is to copy production "with the names taken out". Here terminological precision has real consequences.
| Pseudonymisation | Anonymisation | |
|---|---|---|
| Definition | Replacing identifiers with other values, in such a way that re-identification remains possible with additional information | Transforming the data so that re-identification is impossible, for anybody and irreversibly |
| Reversible? | Yes, with the key or the mapping table | No |
| Techniques | Tokenisation, hashing with a secret salt, encryption | Aggregation, generalisation, suppression, differential privacy |
| Status | They are still personal data | They stop being personal data |
| Use in Nimbus | Test environments, support, internal analytics | Publishable statistics |
Why hashing a DNI is not anonymisation. It is the most expensive conceptual error in this section. A Spanish DNI (the national ID number) has a small value space and a known structure: generating the hash of every possible DNI and building the reverse table is a matter of minutes. The same goes for a telephone number, an e-mail address or a number plate. An unkeyed hash over an identifier from a bounded domain is reversible in practice, and therefore it is still personal data.
What to do instead, depending on the objective:
| Objective | Correct technique |
|---|---|
| A realistic test environment | Generate synthetic data. The preferable option: there is no personal data to protect |
| If you must start from real data | Reversible tokenisation with the mapping table in a separate, tightly restricted service, or encryption with a key that is not in the test environment |
| Internal per-user analytics | HMAC with a secret key (not a bare hash), with a different key per purpose and rotated |
| Publishable statistics | Aggregation with a minimum threshold (do not publish cells with fewer than N cases), generalisation of ranges and suppression of rare values |
Professional validation note. The boundary between pseudonymising and anonymising is legal as well as technical, and real anonymisation is hard to achieve: the combination of apparently innocuous fields — postcode, date of birth and sex — re-identifies a substantial part of a population. Before declaring a data set "anonymised" and treating it as non-personal, validate it with the Data Protection Officer and with legal advice. The GDPR framework is covered in 06-03.
- Cases that are usually got wrong
Encrypting and storing the key next to it. Error number one. The encrypted backup and its key in the same bucket; the field key in a column of the same database; the disk key in the same repository as the docker-compose. In all those cases, the attacker who reaches the data reaches the key, and the encryption has added nothing but false reassurance. The control question is always the same: what single compromise would have to happen for somebody to obtain both data and key? If the answer is "just one", it needs redesigning.
Encrypting to tick a box, with no threat model. Disk encryption is switched on, "data encrypted at rest" is written into the customer questionnaire and the matter is considered settled. But disk encryption stops none of Nimbus's real threats — IDOR, stolen credentials, a leaked token, a misconfigured bucket, excessive consultancy access. Encrypting is an answer; the question is against whom. Without that question, you buy reassurance instead of security.
Confusing encryption with access control. They are different layers and they do not replace each other. The IDOR of 01-01 happened over impeccable TLS and with the database encrypted at rest; it was fixed with a WHERE tenant_id, not with more cryptography. Encryption decides what can be read without the key; access control decides who gets the key or the result. A system that encrypts everything and authorises badly is broken.
Three smaller but frequent errors: encrypting the field and leaving the same data in a log or in a notification e-mail; not versioning the format, which turns any future change into an all-or-nothing migration; and having no rotation plan, which is what makes a key last seven years.
- Reference table: data, threat, protection and key
| Nimbus data | Main threat | Cryptographic protection | Where the key lives |
|---|---|---|---|
| App/SPA ↔ API traffic | Eavesdropping and interception on a public network | TLS 1.3 with ECDHE and AEAD; HSTS | Ephemeral per session (forward secrecy) |
| User passwords | A dump of the credentials table | Argon2id with a unique salt and rehash | There is no key; there are parameters (an optional pepper in the manager) |
| Sessions and tokens | Forging or tampering with a token | JWS with EdDSA and a kid |
The private key in the manager; the public one in the JWKS |
| Attachments in the A-05 bucket | Access to the storage; movement between tenants | AES-256-GCM with the tenant in the AAD, envelope encryption | A data key per object; the master in the KMS |
| Downloading an attachment | A forwarded link or one leaked in logs | URL signed with HMAC, 120 s expiry | The signing key in the secrets manager |
| Clinical notes | A dump, a SQL injection, a backup restored elsewhere | AES-256-GCM field by field with an envelope | A data key per record; the master in the KMS |
| The rest of the database | Theft of the media or the volume | Disk and database encryption | Managed by the provider |
| Backups | Ransomware with double extortion | Encryption + immutability + hash verification | Outside production, with split custody |
| The gateway webhook | A forged payment notification | HMAC-SHA256 with a timestamp | A shared secret, in the manager |
| Outbound e-mail | Domain impersonation | DKIM (with SPF and DMARC) | The private key at the e-mail provider; the public one in the DNS |
| CI/CD artefacts | Supply chain (SolarWinds) | Signing with an ephemeral identity + provenance + pinned hashes | There is no persistent key: the CI's OIDC identity |
| Administrative access | Credential theft | SSH with Ed25519; mTLS between services | The private key on the device; certificates from the internal PKI |
| Test environment data | An unprotected copy of production | Synthetic data or reversible tokenisation | The mapping in a separate service, outside the test environment |
This table is the executive summary of the whole module and the artefact you should know how to produce for any system you work on.
Common Mistakes and Tips
- Counting disk encryption as a defence against attacks on the application. It is not. Switch it on always, but do not enter it in the wrong column.
- Encrypting and storing the key next to it. Apply the control question: does a single compromise give both data and key?
- Encrypting without a threat model. Write down first who you are protecting against.
- Confusing encryption with authorisation. An IDOR is not fixed by encrypting.
- Putting personal data in a JWT. Signed is not encrypted: it is readable by anybody.
- Using HS256 when the verifier is another system. Sharing the HMAC key is giving away the ability to issue.
- Signing a URL without including the expiry and the resource in the message. It can be extended or repointed.
- Issuing a signed URL without checking authorisation first. The signature transports permission, it does not grant it.
- Calling a hash of an identifier anonymisation. It is reversible and it is still personal data.
- Encrypting a field and leaving the same data in a log, an e-mail or an old backup.
- Not versioning the encrypted format. It condemns any future change to a total migration.
- Verifying an artefact's signature without checking the signer's identity.
Tips
- Build the table from section 11 for your own system. The cells you cannot fill in are precisely your outstanding work.
- Encrypt at application level only what deserves it. Every encrypted field is functionality lost and complexity gained; paying that for everything is the fastest route to an unmanageable system.
- Design rotation and the versioned format from the start. Adding them later costs ten times as much.
- Remember the real hierarchy of effectiveness for an SME: access control > key management > choice of algorithm. The module has gone in the reverse order for teaching reasons, but the impact goes in this one.
Exercises
Exercise 1 — Answering the customer's questionnaire
A customer clinic sends a security questionnaire with these statements and asks Nimbus to confirm or qualify each one:
- "The data is encrypted at rest, so an attacker cannot read it."
- "You use HTTPS, so our patients' data is safe."
- "The passwords are encrypted in your database."
- "Your backups are encrypted, so ransomware is not a risk."
- "The data you use for testing is anonymised."
You are asked to: for each statement, say whether it is correct, incorrect or incomplete; explain with technical precision why; and draft the answer Nimbus should give — honest, free of marketing and understandable to a non-technical reader.
Exercise 2 — Designing the encryption of a new field
Nimbus is adding an allergies field to the patient record, visible to the practitioner during the appointment.
You are asked to: (a) decide whether it should be encrypted at application level and justify it; (b) design the complete scheme: columns, algorithm, AAD content, where the key lives and how it is rotated; (c) solve the requirement that the practitioner be able to filter their appointments for the day by the presence of allergies, without exposing the content; (d) list the three side effects the encryption will cause in the rest of the system (logs, backups, support) and how you address them; (e) state what non-technical validation is needed before deploying it.
Exercise 3 — Auditing a download flow
This is Nimbus's attachment download code:
@app.get("/api/v1/adjuntos/{attachment_id}/url")
def get_url(attachment_id: str, current_user = Depends(get_current_user)):
resource = f"/nimbus-adjuntos/{attachment_id}.pdf" # (1)
exp = int(time.time()) + 86400 # (2)
sig = hashlib.sha256(f"{resource}{KEY}".encode()).hexdigest() # (3)
logger.info("URL issued: %s?exp=%s&sig=%s", resource, exp, sig) # (4)
return {"url": f"https://cdn.nimbus.example{resource}?exp={exp}&sig={sig}"}You are asked to: (a) identify the problems in the four marked lines and the concrete attack behind each one; (b) point out the most serious problem, which is not marked; (c) rewrite the endpoint correctly; (d) state which event you would log and with which fields.
Solutions
Exercise 1
| # | Verdict | Why | Nimbus's answer |
|---|---|---|---|
| 1 | Incomplete | Encryption at rest stops theft of the media, not access through the application with valid credentials. With the system switched on, the engine decrypts for whoever asks | "Yes, we encrypt at rest at disk and database level, and we also encrypt clinical data field by field. It is worth being precise: encryption at rest protects against theft of the media; against improper access through the application, the protection is access control, isolation between clinics and detection, which we describe separately" |
| 2 | Incomplete | HTTPS protects the network leg. It does not protect against application flaws such as IDOR or injection, nor against a compromised client | "Yes, all the traffic goes over TLS 1.3 with HSTS. HTTPS guarantees that nobody can read or alter the data along the way; protecting the data once it arrives depends on other controls, which we set out below" |
| 3 | Incorrect | Passwords are not encrypted: they are derived with a slow, one-way function. If they were encrypted, there would be a key capable of recovering them all | "Let us correct the term: we do not encrypt them, we process them with Argon2id, with a unique salt per user and parameters reviewed periodically. It is an irreversible process: not even we can recover a password" |
| 4 | Incomplete | Encryption protects against the publication of the data (double extortion), not against the deletion or encryption of the backups. For that you need immutability, isolation and a tested restore | "The backups are encrypted and immutable, with the key outside the production environment and restores tested periodically. Encryption covers the risk of publication; immutability covers the risk of destruction. They are complementary measures" |
| 5 | Probably incorrect | If the technique is a hash of identifiers, it is pseudonymisation that is reversible in practice, and it is still personal data | "We use synthetic data in testing and, when we start from real data, tokenisation with the mapping held separately. We avoid the term 'anonymised' because it demands demonstrable irreversibility; the correct term is 'pseudonymised', and that is why the test environment keeps controls equivalent to production" |
Exercise 2
(a) Should it be encrypted? Yes. An allergy is health information, of the same level of sensitivity as the clinical notes, and the criterion in section 3 is clear: application-level encryption is reserved for the most sensitive data, and this is such data.
(b) Scheme. Columns allergies_encrypted BYTEA, allergies_data_key BYTEA, allergies_kms_kid TEXT and allergies_present BOOLEAN. Algorithm AES-256-GCM with envelope encryption and a data key per record. AAD: v1|tenant:{tenant_id}|patient:{patient_id}. Master key in the KMS, rotated annually; since the envelope decouples the master from the content, rotating it only re-encrypts the data keys.
(c) Filtering by presence without exposing the content. An unencrypted boolean column allergies_present. It reveals only whether there are allergies recorded — a far less sensitive piece of metadata — and it allows a trivial index and filter without decrypting anything. It is preferable to a blind index, which would add nothing here (the requirement is not exact matching) and would reveal equality between patients. Important: it has to be decided knowingly that this boolean is acceptable, because it is also information.
(d) Three side effects. (1) Logs: any logger that dumps the complete row reintroduces the data in the clear; the logging has to be reviewed (02-04) and the field excluded explicitly. (2) Backups: those taken before the migration contain the plaintext until their retention expires, which must be documented. (3) Support: Rubén will stop seeing the field in the internal tools, which requires an explicit procedure — decryption under a specific permission, logged and audited — instead of general access.
(e) Non-technical validation. Before deploying it you have to validate with the Data Protection Officer and with legal advice: the lawful basis for the processing, the information given to the data subject, the retention period, who may access it and in what circumstances, and updating the record of processing activities and the risk analysis (06-03).
Exercise 3
(a) Marked problems:
| # | Problem | Attack |
|---|---|---|
| 1 | The resource is built with the attachment_id the client sends, without validating its format |
Path traversal: an identifier containing ../ can point at other objects in the bucket |
| 2 | An expiry of 86,400 s (24 h) | A URL that is forwarded, saved in browsing history or leaked into a log keeps working for a whole day |
| 3 | sha256(resource + KEY) instead of HMAC, and without including exp or the method in the message |
An insecure construction (03-04) and, above all, exp is not signed: the user changes it to a year ahead and the URL is still valid |
| 4 | The complete URL with the signature is logged | The log becomes a store of access credentials: whoever reads the logs downloads the attachments |
(b) The most serious problem, unmarked: there is no authorisation check at all. The function receives current_user and does not use it. Any authenticated user from any clinic can request a URL for any other clinic's attachment. It is the IDOR of 01-01 reappearing in a new endpoint, and no cryptographic improvement fixes it.
(c) Correct version:
@app.get("/api/v1/adjuntos/{attachment_id}/url")
def get_url(attachment_id: str, current_user = Depends(get_current_user)):
# 1. AUTHORISATION FIRST: the attachment must belong to the user's tenant
attachment = repo.find_attachment(attachment_id,
tenant_id=current_user.tenant_id)
if attachment is None:
raise HTTPException(404, "Not found") # same error as if it did not exist
# 2. Resource built from TRUSTED data (from the database)
resource = f"/nimbus-adjuntos/{attachment.tenant_id}/{attachment.object_key}"
# 3. HMAC signature over method + resource + expiry, with a 120 s lifetime
url = signed_url(resource, URL_SIGNING_KEY, seconds=120)
# 4. Logging WITHOUT the signature or the complete URL
record_event("attachment.url_issued",
user_id=current_user.id, tenant_id=current_user.tenant_id,
attachment_id=attachment.id, expiry_s=120)
return {"url": url}(d) The event to log. Fields: timestamp, user_id, tenant_id, attachment_id, source IP, expiry granted and outcome. Never: the complete URL, the signature, the file name if it describes clinical content, or any patient data. A useful alert is built on top of that log: a single user requesting many URLs for different attachments in a short time is the pattern of an exfiltration in progress, exactly the kind of signal that was missing in the case of 02-06.
Conclusion
You have closed the module by integrating everything that came before on top of a real system. You have the cryptographic map of Nimbus end to end, with its nine legs and with the detail that matters most: the KMS is not on the data path but on the key path. You distinguish transit from rest, and you know that TLS ends where the server begins. And you take away the section that clears up the most misunderstandings: the three levels of encryption at rest — disk, database and application — with the conclusion that disk encryption protects against theft of the hardware and protects nothing against a compromised API, a SQL injection or an IDOR, because the system is switched on and the engine happily decrypts for whoever asks.
You have encrypted the clinical notes field by field with envelope encryption, the tenant in the AAD and a versioned format, and you have paid its price honestly: you can no longer search, with the options laid out — metadata, a blind index with a deterministic HMAC and its frequency leaks, or decrypting and filtering — and a reasoned decision for Nimbus. You have added the requirement that was missing from the 3-2-1-1-0 backups: encrypted, with the key outside the environment, because immutability covers destruction and encryption covers publication in double extortion. You have signed the CI/CD artefacts with an ephemeral identity — with no key to steal — verifying the signer's identity and pinning dependency hashes, with SolarWinds as a reminder that a valid signature vouches for the origin and not for the absence of malice. You have opened up signed tokens from the inside, with the distinction between JWS and JWE and the rule against putting personal data into something anybody can read, and the 120-second signed URLs, understanding why the expiry and the resource go inside the signed message and why the signature transports an authorisation, it does not grant it. And you have separated pseudonymisation from anonymisation, with the reason why hashing a DNI is reversible in practice and remains personal data. All of it condensed into the final table data → threat → protection → where the key lives, which is the artefact you should know how to produce for any system.
The module closes with the warning that runs right through it: encrypting and storing the key next to it, encrypting to tick a box with no threat model, and confusing encryption with access control are the three errors that turn a cryptographic deployment into false reassurance. And with them, the real hierarchy of effectiveness for an SME like Nimbus: access control, key management and, in last place, choice of algorithm.
With this you have the complete catalogue of what can be protected and how. But the questions a management team really decides have been left unanswered: out of all this, what do we do first? Nimbus has 38 people, a limited budget and a list of improvements that does not fit into a year. What is it worth to avoid the leak of the clinical notes compared with avoiding a two-day outage? Which risks are accepted knowingly and who signs that acceptance? How is it put in writing so that the decision survives the person who took it?
In Module 4: Risk Management and Protection Measures we stop asking how something is protected and start asking what is worth protecting and with what priority. We will start with risk assessment (04-01), which is the method for turning that unmanageable list into a defensible order, and we will continue with the policies that set it down in writing, the controls, third-party risk — where the consultancy from the incident of 02-06 will return — the incident response plan and disaster recovery.
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
