Generating Part 11-Compliant Signed PDF Lab Reports in Python

Problem Statement

A laboratory report is not compliant because it looks official — it is compliant because a specific signer is cryptographically bound to the exact document a clinician reads, and that binding survives audit years later. The mistake that breaks this is subtle: teams sign the database row, or sign a freshly re-rendered PDF at verification time, so the signature no longer covers the bytes the provider actually received. This page implements the correct chain in Python end to end: compose a report record, render it to a deterministic PDF, compute a SHA-256 over the rendered bytes, sign that digest with a private key, store the signature alongside the signer identity and reason, and verify the whole thing — including proving that a single altered byte fails verification. It is the concrete implementation behind the architecture described in Report Generation and Part 11 E-Signatures.

Prerequisites

Before wiring this into the release path, confirm the following baseline:

  • Runtime: Python 3.11+, pydantic>=2.6 for the typed report record, reportlab>=4 (or weasyprint) for rendering, cryptography>=42 for hashing and signing, and pytest>=8 for the verification fixtures.
  • Input contract: a result already verified and canonicalized by the Clinical Result Validation and Rule Engine Architecture tier — units harmonized to UCUM, analyte resolved to LOINC — so this page composes and signs, it does not re-validate clinical content.
  • Key material: a signing key pair whose private key lives in a hardware security module or a key-management service, never in application source. The examples generate an ephemeral key for clarity; production code fetches a handle to a managed key.
  • Storage: an append-only sink for the signed artifact and its signing event, consistent with HIPAA-compliant audit trails and CLIA §493.1105 retention.

Step-by-Step Implementation

Step 1: Build the report record

The report record is the machine-readable source of truth carrying the CLIA §493.1291 required elements. Model it with Pydantic v2 so a missing unit or a naïve timestamp fails at construction rather than on a released report.

python
from __future__ import annotations

from datetime import datetime, timezone
from decimal import Decimal

from pydantic import BaseModel, ConfigDict, Field


class ReportRecord(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    report_id: str
    version: int = Field(ge=1)
    patient_id: str
    patient_name: str
    accession: str
    test_name: str
    loinc: str
    value: Decimal
    unit: str  # UCUM
    reference_interval: str
    status: str  # "final" | "corrected"
    report_datetime: datetime
    performing_lab: str


def sample_record() -> ReportRecord:
    return ReportRecord(
        report_id="RPT-88421",
        version=1,
        patient_id="MRN-0093122",
        patient_name="Rivera, Dana",
        accession="ACC-2026-118844",
        test_name="Potassium, serum",
        loinc="2823-3",
        value=Decimal("5.1"),
        unit="mmol/L",
        reference_interval="3.5-5.1",
        status="final",
        report_datetime=datetime(2026, 6, 18, 14, 5, tzinfo=timezone.utc),
    )

Step 2: Render a deterministic PDF

The signature will bind to the rendered bytes, so the render must be deterministic — the same record must produce identical bytes on every run. reportlab defaults sabotage this in two ways: it stamps the current time into /CreationDate and embeds a random document ID. Override both. Pin the creation date to the record’s report_datetime and set a stable document ID derived from the report identity.

python
from io import BytesIO

from reportlab.lib.pagesizes import LETTER
from reportlab.pdfgen import canvas


def render_pdf(record: ReportRecord) -> bytes:
    """Render the report to deterministic PDF bytes.

    Determinism requires pinning every source of wall-clock or random data:
    the creation date is derived from the record, and the document ID is fixed.
    """
    buffer = BytesIO()
    c = canvas.Canvas(buffer, pagesize=LETTER)

    # Pin metadata so identical records yield identical bytes.
    c.setTitle(f"{record.report_id} v{record.version}")
    c.setProducer("clinicallims-report-composer")
    fixed = record.report_datetime.strftime("D:%Y%m%d%H%M%SZ")
    c._doc.info.creationDate = fixed  # type: ignore[attr-defined]
    c._doc.info.modDate = fixed       # type: ignore[attr-defined]
    doc_id = record.report_id.encode() + str(record.version).encode()
    c._doc._ID = b"[<%s><%s>]" % (doc_id.hex().encode(), doc_id.hex().encode())  # type: ignore[attr-defined]

    text = c.beginText(72, 720)
    if record.status == "corrected":
        text.textLine("*** CORRECTED REPORT ***")
    text.textLine(record.performing_lab)
    text.textLine(f"Patient: {record.patient_name}  ({record.patient_id})")
    text.textLine(f"Accession: {record.accession}")
    text.textLine(f"Test: {record.test_name} [{record.loinc}]")
    text.textLine(f"Result: {record.value} {record.unit}")
    text.textLine(f"Reference interval: {record.reference_interval} {record.unit}")
    text.textLine(f"Status: {record.status}")
    text.textLine(f"Reported: {record.report_datetime.isoformat()}")
    c.drawText(text)
    c.showPage()
    c.save()
    return buffer.getvalue()

The _doc overrides reach into reportlab internals because the public API offers no supported hook for a fixed creation date; a weasyprint build achieves the same by rendering from a fixed-string template and stripping the /CreationDate after generation. Either way, the test in the next section is the real guarantee — it renders twice and asserts byte-equality, so any residual nondeterminism fails loudly.

Step 3: Hash the rendered bytes

Compute a SHA-256 over the exact bytes render_pdf returned. This digest — not the record, not a re-render — is what the signature will cover.

python
import hashlib


def report_hash(pdf_bytes: bytes) -> bytes:
    return hashlib.sha256(pdf_bytes).digest()

Step 4: Sign the digest with a private key

Sign the prehashed digest so the signature is bound to the hash of the rendered document. Using utils.Prehashed tells cryptography that the input is already a SHA-256 digest rather than a message to hash again — the mathematically correct way to sign “the hash of what the signer saw.”

python
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding, rsa, utils


def load_signing_key() -> rsa.RSAPrivateKey:
    # Illustrative: production code returns a handle to an HSM/KMS-held key.
    return rsa.generate_private_key(public_exponent=65537, key_size=2048)


def sign_digest(digest: bytes, private_key: rsa.RSAPrivateKey) -> bytes:
    return private_key.sign(
        digest,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH,
        ),
        utils.Prehashed(hashes.SHA256()),
    )

Step 5: Store the signature, signer identity, and reason

A raw signature is not a Part 11 signature. Section 11.50 requires the record to manifest who signed, when, and the meaning of the signing. Package these in a manifest and store it with the PDF and the record under a shared report identity — that shared identity plus the content hash is the §11.70 linkage that stops a signature being copied onto another record.

python
class SignatureManifest(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")

    report_id: str
    version: int
    signer_id: str
    signer_role: str
    reason: str
    signed_at: datetime
    algorithm: str = "RSA-PSS-SHA256"
    content_sha256: str  # hex
    signature: str       # hex


def build_manifest(
    record: ReportRecord, digest: bytes, signature: bytes,
    *, signer_id: str, signer_role: str, reason: str,
) -> SignatureManifest:
    return SignatureManifest(
        report_id=record.report_id,
        version=record.version,
        signer_id=signer_id,
        signer_role=signer_role,
        reason=reason,
        signed_at=datetime.now(timezone.utc),
        content_sha256=digest.hex(),
        signature=signature.hex(),
    )

Step 6: Verify

Verification recomputes the hash from the stored PDF and checks the signature against it. It fails on both tampering (the PDF changed) and drift (the record was re-rendered without re-signing). Run it before a report is ever considered released, and again on any read from the archive.

python
from cryptography.exceptions import InvalidSignature


def verify(pdf_bytes: bytes, manifest: SignatureManifest,
           public_key: rsa.RSAPublicKey) -> bool:
    digest = report_hash(pdf_bytes)
    if digest.hex() != manifest.content_sha256:
        return False  # stored bytes no longer match the signed hash
    try:
        public_key.verify(
            bytes.fromhex(manifest.signature),
            digest,
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH,
            ),
            utils.Prehashed(hashes.SHA256()),
        )
        return True
    except InvalidSignature:
        return False
Sign and verify flow for a Part 11 signed PDF report A vertical flow. The signing path starts with the report record, renders it to deterministic PDF bytes, hashes those bytes with SHA-256 into a digest, and signs the digest with a private key. The result is a signed artifact bundling the PDF, signature, and manifest. The verifying path then recomputes the SHA-256 over the stored PDF bytes and verifies the signature with the public key, branching to a valid outcome when the recomputed hash and signature check out, or a tamper-detected outcome when either fails. SIGN VERIFY Report Record CLIA §493.1291 elements Deterministic Render → PDF bytes SHA-256 over bytes → digest Sign digest private key · RSA-PSS · prehashed Signed Artifact PDF + signature + manifest Recompute SHA-256 over stored PDF bytes Verify signature public key · compare hash match mismatch Valid signature confirmed Tamper detected reject · alarm

Verification & Testing

Two properties carry the compliance claim: rendering is deterministic, and the signature detects a single altered byte. Prove both with pytest.

python
def test_render_is_deterministic() -> None:
    record = sample_record()
    assert render_pdf(record) == render_pdf(record)


def test_sign_verify_and_tamper() -> None:
    key = load_signing_key()
    record = sample_record()
    pdf = render_pdf(record)
    digest = report_hash(pdf)
    signature = sign_digest(digest, key)
    manifest = build_manifest(
        record, digest, signature,
        signer_id="path-0447", signer_role="clinical pathologist",
        reason="verified and released",
    )

    assert verify(pdf, manifest, key.public_key()) is True

    tampered = pdf[:-1] + bytes([pdf[-1] ^ 0x01])
    assert verify(tampered, manifest, key.public_key()) is False


def test_manifest_round_trips() -> None:
    record = sample_record()
    digest = report_hash(render_pdf(record))
    manifest = build_manifest(
        record, digest, sign_digest(digest, load_signing_key()),
        signer_id="path-0447", signer_role="clinical pathologist",
        reason="verified and released",
    )
    assert SignatureManifest.model_validate_json(
        manifest.model_dump_json()
    ) == manifest

Expected: all three pass. test_render_is_deterministic is the load-bearing one — if it fails, the render is smuggling wall-clock or random data into the bytes and no signature can be re-verified. test_sign_verify_and_tamper proves the binding both accepts the true document and rejects a one-bit change. test_manifest_round_trips confirms the manifest written to the archive deserializes back identically.

Compliance Note

This chain maps directly onto 21 CFR Part 11. Section 11.10 (controls for closed systems) is served by the deterministic render, the recorded content hash, and writing the signing event to an append-only sink so the record is attributable and tamper-evident. Section 11.50 (signature manifestations) is served by the manifest and the rendered report both carrying the signer’s identity, the signing timestamp, and the reason — the meaning of the signature. Section 11.70 (signature/record linking) is served by binding the signature to the SHA-256 of the rendered bytes and storing it under the same report_id and version as the record, so the signature cannot be excised or transplanted onto another report. CLIA §493.1291 governs the required elements the record carries and the visible marking of a corrected report; CLIA §493.1105 sets the retention window the archived artifact must survive.

Troubleshooting

The determinism test fails — two renders of the same record differ.

Root cause: the PDF library stamped the current time into /CreationDate or /ModDate, or embedded a random document ID. Fix: pin creationDate, modDate, and the document ID to values derived from the record (as render_pdf does), and diff the two byte strings to locate the varying offset — it is almost always in the trailer or the info dictionary.

Verification fails even though nothing looks changed.

Root cause: the report was re-rendered at verification time and the new bytes differ from the signed ones, or the stored PDF passed through a normalizer that rewrote it. Fix: verify against the stored bytes, never a fresh render; treat the signed PDF as immutable and store it verbatim, so report_hash of the archive equals the content_sha256 in the manifest.

Should I sign the digest or let the library hash the message?

Either is cryptographically valid, but signing the prehashed digest with utils.Prehashed(hashes.SHA256()) makes the intent explicit and lets you compute the hash once, store it in the manifest, and reuse it for both signing and verification. Passing the full PDF and a bare hashes.SHA256() would rehash the bytes internally and produce the same signature, but you would lose the stored digest that the audit record cites.

Where should the private key live?

Never in application source or config. The examples generate an ephemeral key only for readability; production code holds the key in an HSM or a key-management service and calls a signing operation that never exports the private key. sign_digest then takes a key handle rather than an in-process key object, and key rotation is recorded so an old signature still verifies against the public key that was active when it was produced.

Part of: Report Generation and Part 11 E-Signatures.