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

  1. An end-to-end cryptographic map of Nimbus
  2. In transit versus at rest
  3. The three levels of encryption at rest
  4. Field-level encryption: the clinical notes
  5. The problem it introduces: you can no longer search
  6. Backup encryption
  7. Code and artefact signing in the CI/CD
  8. Signed tokens and signed URLs from the inside
  9. Pseudonymisation and anonymisation
  10. Cases that are usually got wrong
  11. Reference table: data, threat, protection and key

  1. 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.


  1. 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.


  1. 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).


  1. 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_plain

The 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_id and appointment_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.

  1. 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.


  1. 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.


  1. 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:$SHA

The 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...
pip install --require-hashes -r requirements.txt

--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.


  1. 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  : host
import 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 exp to 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.

  1. 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.


  1. 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.


  1. 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

  1. 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.
  2. Encrypting and storing the key next to it. Apply the control question: does a single compromise give both data and key?
  3. Encrypting without a threat model. Write down first who you are protecting against.
  4. Confusing encryption with authorisation. An IDOR is not fixed by encrypting.
  5. Putting personal data in a JWT. Signed is not encrypted: it is readable by anybody.
  6. Using HS256 when the verifier is another system. Sharing the HMAC key is giving away the ability to issue.
  7. Signing a URL without including the expiry and the resource in the message. It can be extended or repointed.
  8. Issuing a signed URL without checking authorisation first. The signature transports permission, it does not grant it.
  9. Calling a hash of an identifier anonymisation. It is reversible and it is still personal data.
  10. Encrypting a field and leaving the same data in a log, an e-mail or an old backup.
  11. Not versioning the encrypted format. It condemns any future change to a total migration.
  12. 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:

  1. "The data is encrypted at rest, so an attacker cannot read it."
  2. "You use HTTPS, so our patients' data is safe."
  3. "The passwords are encrypted in your database."
  4. "Your backups are encrypted, so ransomware is not a risk."
  5. "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

Module 2: Cybersecurity

Module 3: Cryptography

Module 4: Risk Management and Protection Measures

Module 5: Security Tools and Techniques

Module 6: Best Practices and Regulations

Module 7: Final Project

© Copyright 2026. All rights reserved