This lesson settles the course's oldest debt. In module 1 you learned cryptography's four goals, and since then you've achieved three: confidentiality (module 2), integrity and authenticity (module 3). The fourth — non-repudiation — was promised, and in 03-02 you saw exactly why a MAC can't give it to you. Today you get it with the digital signature: the owner of a private key signs, and anyone with the public key verifies — no shared secrets, no possibility of denying authorship. You'll see the mechanics (you sign the hash, not the document), the two schemes that matter (RSA-PSS and Ed25519), and you'll apply it to the module's central case: MediNube's electronic prescriptions, which Dr. Lucía Ferrer of Clínica Sol will sign and an external pharmacy will verify. Along the way you'll run into an unexpected enemy — JSON canonicalization — and the module's uncomfortable question, which here turns urgent.

Contents

  1. The fourth goal: why a MAC isn't enough
  2. The definitive table: hash vs. HMAC vs. digital signature
  3. The mechanics: signing the hash, verifying with the public key
  4. Signing with RSA: PSS, and why not PKCS#1 v1.5
  5. Signing with Ed25519: the minimal API
  6. The central case: signed electronic prescriptions
  7. The silent enemy: canonicalization
  8. Signing documents: fingerprint + signature
  9. The legal warning: this is not (yet) a qualified signature
  10. The pharmacy, and the same old question

The fourth goal: why a MAC isn't enough

Recall 03-02's signed webhook: MediNube and Clínica Sol share an HMAC key, and the X-MediNube-Firma header proves the message was generated by someone with that key and wasn't altered. Integrity and authenticity: achieved. But imagine this dispute:

Clínica Sol claims: "MediNube sent us the order to delete ana.perez's record, here's the message with its valid HMAC." MediNube replies: "That HMAC proves nothing: the clinic also has the key and could have fabricated the message itself."

And MediNube is right. With a shared key, anything one party can authenticate, the other can forge. A valid HMAC proves it was generated by one of the two parties, but not which one — and in front of a third party (a judge, an auditor), that proves nothing at all. This is the absence of non-repudiation: the sender can always repudiate (deny) authorship.

The digital signature breaks the tie with the asymmetry from 04-01: you sign with the private key, which only one person has, and verify with the public one, which everyone can have. If the signature verifies, it was generated by the private key's owner — no one else could have, not even the verifier. The sender can no longer deny authorship (as long as their private key hasn't been stolen — hence the obsession with protecting it).

The definitive table: hash vs. HMAC vs. digital signature

In 03-02 we left this table announced with one column in the dark. Here it is, complete — one of the most important tables in the course:

Hash (SHA-256) HMAC (HMAC-SHA256) Digital signature (Ed25519, RSA-PSS)
Uses a key? No Yes, shared Yes, a public/private pair
Integrity (detects tampering) Yes* Yes Yes
Authenticity (know who issued it) No Yes, among those sharing the key Yes, to anyone
Non-repudiation (sender can't deny it) No No (either party can generate the MAC) Yes (only the private key's owner can sign)
Who can verify? Anyone with the reference hash Only whoever has the secret key Anyone with the public key
Computational cost Minimal Minimal Higher (though Ed25519 is very fast)
Use case in MediNube Export fingerprints (integrity.py), tokens (recovery.py) Webhooks (webhooks.py) Electronic prescriptions (this lesson)

* A hash only gives integrity if the reference value arrives over an intact channel — the lesson from 03-01.

Mental rule for choosing: if sender and verifier are the same system, or two mutually trusting parties who only fear third parties, HMAC is enough and simpler. As soon as you need third parties to verify, or the sender to be unable to back out, you need a digital signature. A prescription that any pharmacy in the country will verify is the textbook case.

The mechanics: signing the hash, verifying with the public key

Conceptually, signing a 10 MB document doesn't apply the asymmetric operation to the full 10 MB (recall from 04-01 that RSA only handles a few hundred bytes, and it would be agonizingly slow). The universal scheme is:

  1. Sign: the sender computes the hash of the message (SHA-256 or similar) and applies the signing operation with their private key over that hash. The result — the signature — is a few dozen or a few hundred bytes that travel alongside the message.
  2. Verify: the receiver recomputes the hash of the received message and checks, with the sender's public key, that the signature matches that hash. If the message changed by a single bit, or the signature was made with a different key, verification fails.
sequenceDiagram
    participant D as Dr. Ferrer (private key)
    participant R as Network / storage
    participant F as Pharmacy (Dr. Ferrer's public key)
    D->>D: h = SHA-256(prescription)
    D->>D: signature = sign(private_key, h)
    D->>R: prescription + signature
    R->>F: prescription + signature
    F->>F: h' = SHA-256(received prescription)
    F->>F: verify(public_key, signature, h')
    alt valid signature
        F->>F: the prescription is intact and issued by the doctor ✔
    else InvalidSignature
        F->>F: tampered or from another key: REJECT ✘
    end

Notice the properties that emerge:

  • Everything you learned about hashes in 03-01 carries over: the signature covers the entire document, and the hash's collision resistance is part of the signature's security (that's why signing with MD5/SHA-1 is broken in practice, not just in theory).
  • The signature doesn't encrypt: the message travels in the clear alongside it. Signing and encrypting are independent goals, combined when you need both (you'll do that in 04-05).
  • In pyca/cryptography's APIs, the library does the internal hashing for you: you pass the full message to sign()/verify(). The two-step mechanics stay hidden, but understanding it will matter in section 8.

Signing with RSA: PSS, and why not PKCS#1 v1.5

If the key is RSA (like the public hospital's, which requires RSA in MediNube's integration), signing needs a padding scheme — the story rhymes with 04-01's. There are two:

  • PKCS#1 v1.5 (signing): the legacy one, deterministic, everywhere in old systems. Unlike its encryption cousin, it isn't broken — but its security proofs are weaker, and careless implementations of its verification have produced historical attacks (Bleichenbacher's 2006 forgeries against lax verifiers).
  • RSA-PSS (Probabilistic Signature Scheme): the modern one. It incorporates random salt (each signature of the same document is different) and has solid security proofs. It's to RSA signing what OAEP is to RSA encryption: the correct choice in new code.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa

priv_rsa = rsa.generate_private_key(public_exponent=65537, key_size=3072)
pub_rsa = priv_rsa.public_key()

message = b"Monthly activity report - MediNube"

pss = padding.PSS(
    mgf=padding.MGF1(hashes.SHA256()),
    salt_length=padding.PSS.DIGEST_LENGTH,   # salt = hash size: the recommended value
)

signature = priv_rsa.sign(message, pss, hashes.SHA256())
print(len(signature))   # 384 bytes: always the size of the modulus, as with OAEP

# Verification: doesn't return True/False, RAISES AN EXCEPTION if it fails.
from cryptography.exceptions import InvalidSignature
try:
    pub_rsa.verify(signature, message, pss, hashes.SHA256())
    print("Valid signature")
except InvalidSignature:
    print("INVALID SIGNATURE: document tampered with or wrong key")

Two API details you should memorize:

  • verify doesn't return a boolean: if the signature is valid it returns nothing, and if it isn't, it raises InvalidSignature. It's a deliberate design — impossible to ignore the result by accident. Your code must catch the exception and treat the failure as what it is: a rejection.
  • The RSA-PSS signature is randomized: sign the same message twice and you'll get two different signatures, both valid. Never compare signatures against each other to "verify" — always verify with verify.

Signing with Ed25519: the minimal API

For the prescription system, MediNube decided in 04-02 to use Ed25519. Its API is as small as possible — no padding to configure, no hash to choose (it uses SHA-512 internally), no salt to tune:

from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.exceptions import InvalidSignature

priv = ed25519.Ed25519PrivateKey.generate()
pub = priv.public_key()

message = b"Test prescription"

signature = priv.sign(message)      # and that's it
print(len(signature))               # 64 bytes, ALWAYS

try:
    pub.verify(signature, message)  # and that's it
    print("Valid signature")
except InvalidSignature:
    print("INVALID SIGNATURE")

Compare: 64-byte signatures versus RSA-3072's 384, and zero configuration decisions. On top of that, Ed25519 is deterministic: the same message with the same key always produces the same signature. This isn't a flaw (unlike textbook RSA, here there's a design that makes it safe) but a protection: classic elliptic-curve schemes (ECDSA, the one you'd use with P-256) require a fresh random number per signature, and if that number repeats or is predictable, the private key can be recovered from the signatures themselves — that's how the PlayStation 3's signing key was extracted in 2010. Ed25519 removes that dangerous decision by construction: the Bernstein philosophy from 04-02, applied. If a third party ever requires ECDSA with P-256, pyca/cryptography does it correctly (priv.sign(message, ec.ECDSA(hashes.SHA256()))); but when the choice is yours, Ed25519.

The central case: signed electronic prescriptions

Now, the real system. Requirements from MediNube's medical team:

  • Dr. Lucía Ferrer (Clínica Sol, fictional license number CS-4471) issues prescriptions for her patients from portal.medinube.example.
  • An external pharmacy — let's call it Robles Pharmacy — must be able to verify that the doctor issued the prescription and that no one has altered it (not the dosage, not the medication, not the patient).
  • If there's a dispute ("I never prescribed that"), the signature must be presentable to a third party: non-repudiation.

A prescription is a structured document. We represent it as JSON:

prescription = {
    "format": "receta-v1",
    "doctor": "dra.lucia.ferrer",
    "license": "CS-4471",
    "patient": "ana.perez",
    "medication": "Amoxicillin 500 mg",
    "dosage": "1 tablet every 8 hours, 7 days",
    "issue_date": "2026-07-08",
}

And here a problem shows up that isn't cryptographic but one of representation: the signature is computed over bytes, and the same dictionary can turn into bytes in many different ways.

The silent enemy: canonicalization

MediNube's legacy code's first attempt was this:

import json

def sign_prescription_BAD(prescription: dict, priv) -> bytes:
    # BAD: json.dumps without a fixed order or separators.
    return priv.sign(json.dumps(prescription).encode("utf-8"))

What's wrong with it? That json.dumps doesn't always produce the same bytes for the same content:

  • Key order depends on the dictionary's insertion order. The pharmacy, when rebuilding the prescription from its database, might get the keys in a different order -> different JSON -> different hash -> InvalidSignature on a perfectly legitimate prescription.
  • The default separators (", " and ": ", with spaces) can differ between libraries and languages (what if the pharmacy verifies in Java or JavaScript?).
  • Non-ASCII characters can be serialized escaped ("Pérez") or literally ("Pérez"), depending on the ensure_ascii option.

The result in production: verifications that fail at random, and the fatal temptation to "fix it" by loosening the verification. The correct solution is canonicalization: define one single valid serialization and always use it, both when signing and when verifying. With this we can now write the real module, medinube/prescriptions.py:

# medinube/prescriptions.py
import json
from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.exceptions import InvalidSignature


def canonicalize_prescription(prescription: dict) -> bytes:
    """Canonical serialization: same data -> same bytes, ALWAYS.

    - sort_keys=True: alphabetical key order, not insertion order.
    - separators without spaces: no formatting ambiguity.
    - ensure_ascii=False + UTF-8: a single representation of accents.
    """
    return json.dumps(
        prescription, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
    ).encode("utf-8")


def sign_prescription(prescription: dict, priv_doctor: ed25519.Ed25519PrivateKey) -> str:
    """Signs the prescription and returns the signature in hex, 'v1=<hex>' style (as in 03-02)."""
    signature = priv_doctor.sign(canonicalize_prescription(prescription))
    return "v1=" + signature.hex()


def verify_prescription(prescription: dict, signature: str,
                        pub_doctor: ed25519.Ed25519PublicKey) -> bool:
    """True if the signature matches the given prescription and public key."""
    if not signature.startswith("v1="):
        return False                      # unknown version: reject
    try:
        pub_doctor.verify(bytes.fromhex(signature[3:]), canonicalize_prescription(prescription))
        return True
    except (InvalidSignature, ValueError):
        return False                      # ValueError: malformed hex

And the full flow, from the appointment to the pharmacy:

# At the appointment: Dr. Ferrer signs (her private key was loaded from the
# encrypted PEM in 04-02, with her passphrase — it never lives in the code).
priv_doctor = ed25519.Ed25519PrivateKey.generate()   # in real life: load_pem_private_key(...)
pub_doctor = priv_doctor.public_key()

signature = sign_prescription(prescription, priv_doctor)
print(signature[:20], "...")              # v1=8f3a... (64 bytes in hex)

# At Robles Pharmacy: verification with the doctor's PUBLIC key.
print(verify_prescription(prescription, signature, pub_doctor))          # True

# An attacker (or an error) changes the dosage:
altered_prescription = dict(prescription, medication="Amoxicillin 1000 mg")
print(verify_prescription(altered_prescription, signature, pub_doctor))  # False

# The pharmacy rebuilds the prescription with the keys in a DIFFERENT order:
reordered_prescription = dict(reversed(list(prescription.items())))
print(verify_prescription(reordered_prescription, signature, pub_doctor))  # True <- canonicalization

That last line is the victory over sign_prescription_BAD: key order no longer matters, because both sides canonicalize before touching the cryptography. This problem is universal enough that dedicated standards exist for it (JCS — JSON Canonicalization Scheme, RFC 8785); our sort_keys + separators + UTF-8 recipe is the minimal version of the same principle, sufficient as long as you control both ends.

Signing documents: fingerprint + signature

And what about when what needs signing isn't a 300-byte JSON but the 200 MB GDPR export from 03-01? Passing the whole thing to sign() forces you to hold it in memory and forces the verifier to process it in full. The practical pattern reuses what's already in medinube/integrity.py: the export_fingerprint function that computes the file's SHA-256 in chunks. You sign the fingerprint:

# medinube/integrity.py already gives us the fingerprint (03-01):
#   fingerprint = export_fingerprint("export_ana_perez.zip")   # hex of SHA-256

def sign_export(path: str, priv) -> str:
    """Signs the export's SHA-256 fingerprint: document = fingerprint + signature."""
    fingerprint = export_fingerprint(path)               # chunked hash, from 03-01
    return "v1=" + priv.sign(fingerprint.encode("ascii")).hex()

def verify_signed_export(path: str, signature: str, pub) -> bool:
    fingerprint = export_fingerprint(path)               # ALWAYS recompute
    try:
        pub.verify(bytes.fromhex(signature[3:]), fingerprint.encode("ascii"))
        return True
    except (InvalidSignature, ValueError):
        return False

Conceptually there's nothing new here — signing always signed a hash internally; here we simply make the first step explicit so we can hash in chunks. What MediNube gains: the GDPR export that used to have only a fingerprint (integrity) now has a signed fingerprint (integrity + authenticity + non-repudiation): Ana Pérez can prove to a third party that MediNube issued that export and she didn't fabricate it herself.

The legal warning: this is not (yet) a qualified signature

A mandatory stop before anyone deploys this at a real pharmacy. What we've built is a technically solid cryptographic digital signature. But "electronic signature" with full legal effect is a legal concept regulated in the EU by the eIDAS regulation, which distinguishes levels (simple, advanced, qualified) with requirements that go far beyond mathematics: qualified certificates issued by accredited providers, secure signature-creation devices, timestamping... And a real medical prescription adds its own healthcare and data-protection regulations (GDPR, special-category data).

Our prescription system is a technical example with fictional data. A real deployment would require security and compliance review by specialists (eIDAS, GDPR, electronic prescription regulations) — like every system in this course that touches health data or legally binding signatures. The cryptography you're learning is the common foundation of all those legal tiers; the legal wrapper isn't this course's subject.

The pharmacy, and the same old question

There's an elephant left in the room, and you've already seen it coming. The entire Robles Pharmacy flow starts with "the pharmacy has pub_doctor, Dr. Ferrer's public key." How did it get it? How does it know it's hers?

If an attacker gets the pharmacy to accept their public key as "Dr. Ferrer's," they can sign fake prescriptions that will verify perfectly. The signature's mathematics is flawless; the broken link is, once again, the public key's identity — the same crack from 04-01, now with medication prescriptions at stake. The real solution (someone trustworthy certifying that this key belongs to that doctor, license number included) is exactly module 5. Until then, we leave it explicitly open — and in the next lesson you'll see it turned into an active attack with a name of its own.

Common Mistakes and Tips

  • Treating verify as if it returned a boolean. It returns nothing, or raises InvalidSignature. The typical bug is calling verify without a try/except "just to test it" and letting the exception crash the process, or the opposite: catching bare Exception and swallowing errors that aren't signature failures. Catch InvalidSignature specifically and treat it as a rejection.
  • Comparing signatures against each other. "I sign what I received and compare it to the attached signature" works by coincidence with deterministic schemes and always fails with RSA-PSS (randomized). The verification operation exists for a reason: use it.
  • Signing JSON without canonicalizing. The bug in sign_prescription_BAD: verifications that fail depending on key order, the other end's language, or its library. Canonicalize on both sides, always.
  • PKCS#1 v1.5 for signing in new code. It isn't broken like its encryption cousin, but PSS is superior in every way. Reserve v1.5 for interoperating with systems that require it, and document it.
  • MD5 or SHA-1 as the signing hash. Here collisions kill outright: pairs of different documents with the same valid SHA-1 signature have been produced. SHA-256 minimum (03-01).
  • Believing the signature encrypts. The prescription travels in the clear alongside its signature; anyone can read it. If confidentiality is also needed (a prescription is health data), you must encrypt in addition to signing — the combo arrives in 04-05.
  • Confusing "encrypting with the private key" with signing. It's a folk description of how RSA worked internally, and it's harmful: it doesn't describe PSS, doesn't describe Ed25519, and leads to misused APIs. Signing is signing: its own operation, with its own padding and semantics.
  • Neglecting the signer's private key. Non-repudiation is only worth as much as the custody of the key: if the doctor's private key sits in an unencrypted PEM in a repository, anyone "is" the doctor. Encrypted PEM (04-01/04-02) today; secrets manager and HSM in module 6.

Exercises

  1. Tamper detector. Generate an Ed25519 pair and sign the message b"Referral of ana.perez to Luna Medical Center". (a) Verify the correct signature. (b) Alter a single byte of the message and confirm InvalidSignature is raised. (c) Alter a byte of the signature and confirm the same happens. (d) Verify the original message with the public key of another, freshly generated pair: what happens, and what does it mean?

  2. Hunting the canonicalization bug. Using sign_prescription_BAD (signs json.dumps(prescription).encode() with no options): build prescription_b with the same key-value pairs as prescription but inserted in reverse order, sign prescription, and try to verify that signature against json.dumps(prescription_b).encode(). Check that it fails. Repeat with canonicalize_prescription and check that both serializations produce identical bytes (==). Explain why this bug is especially treacherous in production.

  3. The dispute. Robles Pharmacy presents a prescription for 900 mg of a medication with signature v1=... that verifies with Dr. Ferrer's public key. The doctor claims she prescribed 500 mg. Reason (no code needed): (a) can the doctor argue that the pharmacy altered the dosage after signing? (b) What two explanations remain standing? (c) What would have happened in the same scenario if the system used HMAC with a key shared between MediNube and the pharmacy?

Solutions

from cryptography.hazmat.primitives.asymmetric import ed25519
from cryptography.exceptions import InvalidSignature

priv = ed25519.Ed25519PrivateKey.generate()
pub = priv.public_key()
msg = b"Referral of ana.perez to Luna Medical Center"
signature = priv.sign(msg)

pub.verify(signature, msg)                  # (a) raises nothing: valid

tampered = bytes([msg[0] ^ 1]) + msg[1:]    # (b) one bit of the first byte
try:
    pub.verify(signature, tampered)
except InvalidSignature:
    print("(b) altering the message invalidates the signature")

broken_signature = bytes([signature[0] ^ 1]) + signature[1:]
try:
    pub.verify(broken_signature, msg)       # (c)
except InvalidSignature:
    print("(c) altering the signature invalidates it too")

other_pub = ed25519.Ed25519PrivateKey.generate().public_key()
try:
    other_pub.verify(signature, msg)        # (d)
except InvalidSignature:
    print("(d) different key: invalid")

(d) Raises InvalidSignature: a signature only verifies with the public key of the pair that signed it. That's the property that gives authenticity — and also the one exploited by the attack in section 10: if you're handed a fake public key "of the doctor," the attacker's signatures will verify with it. Verifying binds a signature to a key, not to a person; binding key to person is module 5.

import json
prescription = {"doctor": "dra.lucia.ferrer", "patient": "ana.perez",
                "medication": "Amoxicillin 500 mg"}
prescription_b = dict(reversed(list(prescription.items())))    # same content, different order

print(json.dumps(prescription) == json.dumps(prescription_b))                       # False
print(canonicalize_prescription(prescription) == canonicalize_prescription(prescription_b))  # True

signature = sign_prescription_BAD(prescription, priv)          # signs bytes in order A
try:
    pub.verify(signature, json.dumps(prescription_b).encode())  # verifies bytes in order B
except InvalidSignature:
    print("Legitimate prescription REJECTED because of key order")

It's treacherous because it doesn't fail in testing: as long as signing and verification pass through the same dictionary in the same process, the order matches and everything verifies. It fails weeks later, at the pharmacy's system, with another language or another database that returns the keys in a different order — an intermittent failure that looks like a cryptography bug and ends up tempting someone to "relax" the verification. Canonicalization eliminates the entire class of failures.

  1. (a) No: if the pharmacy had changed the dosage after signing, verification would fail — the signature covers every byte of the canonicalized prescription. That it verifies proves those 900 mg are exactly what was signed with that key. (b) Two remain: either the doctor really did prescribe 900 mg (and is now denying it — exactly what non-repudiation lets you rule out succeeding), or her private key is compromised (someone else signed with it). That's why custody of the private key is inseparable from its evidentiary value, and why revocation and HSMs exist (modules 5 and 6). (c) With a shared HMAC there would be nothing to argue: the pharmacy can generate valid MACs for any prescription, so the MAC doesn't prove who created it. That's the exact difference between authenticity and non-repudiation — the row that was missing from 03-02's table, now closed.

Conclusion

The fourth goal is achieved. The digital signature inverts the flow of asymmetric encryption — you sign with the private key, anyone verifies with the public one — and with that it gives what no MAC could: non-repudiation, because only the private key's owner could have generated the signature. You know how it works internally (you sign the message's hash, inheriting everything you learned about hashes), which schemes to use (RSA-PSS when RSA is imposed on you, Ed25519 when the choice is yours: 64 bytes, minimal API, deterministic, with no dangerous decisions), and that verify speaks in exceptions (InvalidSignature), not booleans. In MediNube, medinube/prescriptions.py is now working — JSON canonicalization (sort_keys, fixed separators, UTF-8), v1=<hex> signatures — with which Dr. Ferrer signs prescriptions that Robles Pharmacy verifies, and the GDPR exports gained a signature over their fingerprint. Remember the two shadows: this is a technical example, not an eIDAS-qualified signature; and the whole edifice rests on the doctor's public key really being hers — a question that stays open until module 5. With signatures and curves in your toolkit, it's finally time to settle the course's original debt, the one we've carried since 02-01: how do two parties who have never met agree on a secret key, talking over a network where everyone listens? It's Diffie-Hellman key exchange, and it's waiting for you in 04-04. See you there.

© Copyright 2026. All rights reserved