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.6for the typed report record,reportlab>=4(orweasyprint) for rendering,cryptography>=42for hashing and signing, andpytest>=8for 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.
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.
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.
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.”
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.
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.
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
Verification & Testing
Two properties carry the compliance claim: rendering is deterministic, and the signature detects a single altered byte. Prove both with pytest.
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.
Related
- Report Generation and Part 11 E-Signatures — the architecture, report-record schema, and supersession model this implementation realizes.
- Result Reporting, Release and Interoperability — the release tier this signed report is one output of.
- Implementing HIPAA-Compliant Audit Trails in LIMS — the append-only sink the signing and supersession events are written to.
- FHIR R4 Result Publishing — the sibling path that projects the same released result into an
Observation.