Signing MQTT Payloads for Part 11 Attributability
A temperature reading that arrives unsigned proves nothing about who produced it. It might have come from the sensor whose device_id it carries, from a mislabeled test rig, or from anything that can open a socket to the broker — and under an audit that ambiguity is fatal to the Attributable attribute of ALCOA+. This guide is about the signing mechanics themselves: how to bind a cryptographic signature to each MQTT payload so a reading is provably attributable to a specific device and provably unaltered since it was signed, how to hold the signing keys so the binding means something, and how the downstream verifier establishes trust. It deliberately leaves the field-by-field mapping of Part 11 controls into the payload — which lives in how to map 21 CFR Part 11 requirements to MQTT payloads — and focuses entirely on the signature: choosing HMAC versus asymmetric, custody of the key material, and verification. The broader clause mapping this feeds is mapping FDA 21 CFR Part 11 to cold chain sensors.
Regulatory hook
Three clauses converge on the signature. §11.10(a) requires validated systems that produce accurate, reliable records, which means a reading whose origin cannot be verified is not a reliable record. §11.10© requires records be protected so they can be accurately reproduced throughout retention — a signature makes any post-signing alteration mathematically detectable rather than a matter of trust. §11.10(e) requires a secure, attributable audit trail; the signature is what makes each entry attributable to its source and tamper-evident. The engineering requirement is that every telemetry payload carry a signature computed over its canonical bytes with a key whose custody uniquely identifies the signer, and that the verifier reject any payload whose signature does not recompute.
HMAC vs asymmetric: what the signature can actually prove
The signing primitive decides what an inspector can conclude. The difference is not academic — it is the difference between “this reading was not altered” and “this specific device, and only this device, produced this reading.”
- HMAC (shared secret). Fast, tiny, standard-library, and ideal for tamper-evidence: anyone holding the secret can both sign and verify. Its limit is non-repudiation. Because the verifier holds the same secret the signer used, the verifier could have forged the signature, so HMAC cannot prove attribution to the device against a party who also holds the key. It proves integrity, not sole authorship.
- Asymmetric (Ed25519 or ECDSA). The device signs with a private key it never shares; the verifier checks with the corresponding public key. Only the holder of the private key could have produced the signature, so a valid signature attributes the reading to that device beyond repudiation — the strongest form of the §11.10(e) attribution guarantee. The cost is a third-party dependency (
cryptography) and slightly heavier compute, trivial on any modern gateway.
For a regulated cold chain record where attribution must survive challenge, prefer per-device asymmetric keys held in a TPM or secure element, and reserve HMAC for cases where integrity alone is the requirement and all parties are inside the same trust boundary. The two are shown side by side below so the mechanics are concrete.
Prerequisites
- Python 3.11 or newer.
- For asymmetric signing:
pip install "cryptography>=42"for Ed25519. HMAC uses only the standard library (hashlib,hmac). - Transport: an MQTT v5 broker with TLS enabled and anonymous access disabled — the channel is secured by the mutual-TLS pattern from designing secure IoT gateways for pharma logistics. Signing protects the payload; TLS protects the channel. They are independent controls and you need both.
- Key custody: the signing key — the HMAC secret or the Ed25519 private key — must live in a TPM, secure element, HSM, or KMS. A private key that a maintenance script can read is a private key an attacker can read, and it destroys the attribution guarantee.
- Key distribution for verification: the verifier needs the HMAC secret (asymmetric-symmetric trade-off aside) or the device’s public key. Public keys are distributed through the same credential inventory governed by certificate lifecycle management for cold chain gateways.
- Time source: an NTP client disciplined to a NIST-traceable reference, so the signed
signed_attimestamp is trustworthy under §11.10(e).
Step-by-Step Implementation
Step 1 — Canonicalize once, sign the exact same bytes
A signature is only verifiable if signer and verifier serialize identically, and the signature and any hash must be computed over bytes that exclude the signature field itself. Fix a deterministic canonical form and an exclusion set before anything else.
import hashlib
import hmac
import json
from typing import Any
# The signature block is attached AFTER signing, so it is excluded from the bytes
# the signature covers. The verifier applies the identical exclusion (§11.10(a)).
_SIG_EXCLUDED = {"signature"}
def canonical_bytes(payload: dict[str, Any]) -> bytes:
"""Deterministic serialization for signing/verifying (§11.10(a) reproducibility)."""
body = {k: v for k, v in payload.items() if k not in _SIG_EXCLUDED}
return json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8")
Prove the serialization is order-independent before relying on it:
assert canonical_bytes({"b": 1, "a": 2}) == canonical_bytes({"a": 2, "b": 1})
Step 2 — Sign with a per-device key from custody
Both signers take the canonical bytes and a custody-held key. The Ed25519 signer loads its private key from the TPM handle; the HMAC signer loads its secret from the KMS. Neither key is ever inlined.
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey, Ed25519PublicKey,
)
from cryptography.exceptions import InvalidSignature
def sign_hmac(payload: dict[str, Any], secret: bytes, key_id: str) -> dict[str, Any]:
if len(secret) < 32:
raise ValueError("HMAC secret must be at least 32 bytes")
# Integrity + tamper evidence within a shared trust boundary (§11.10(c)).
tag = hmac.new(secret, canonical_bytes(payload), hashlib.sha256).hexdigest()
payload["signature"] = {"alg": "HS256", "key_id": key_id, "value": tag}
return payload
def sign_ed25519(payload: dict[str, Any], private_key: Ed25519PrivateKey,
key_id: str) -> dict[str, Any]:
# Non-repudiable attribution: only the holder of this private key could sign,
# so the reading is attributable to the device beyond challenge (§11.10(e)).
sig = private_key.sign(canonical_bytes(payload)).hex()
payload["signature"] = {"alg": "Ed25519", "key_id": key_id, "value": sig}
return payload
Step 3 — Verify downstream and reject on any mismatch
The verifier reconstructs the canonical bytes with the identical exclusion, recomputes or checks the signature with the key material bound to key_id, and rejects anything that does not match. HMAC uses a constant-time compare; Ed25519 verification raises on mismatch.
def verify_hmac(payload: dict[str, Any], secret: bytes) -> bool:
try:
expected = hmac.new(secret, canonical_bytes(payload), hashlib.sha256).hexdigest()
actual = payload["signature"]["value"]
except (KeyError, TypeError):
return False
# Constant-time comparison avoids leaking the secret via timing (§11.10(c)).
return hmac.compare_digest(expected, actual)
def verify_ed25519(payload: dict[str, Any], public_key: Ed25519PublicKey) -> bool:
try:
sig = bytes.fromhex(payload["signature"]["value"])
public_key.verify(sig, canonical_bytes(payload)) # raises on mismatch
return True
except (KeyError, TypeError, ValueError, InvalidSignature):
# A failed signature makes the reading non-attributable — it must be rejected,
# never persisted as if it were a valid record (§11.10(e)).
return False
Prove that any mutation of a signed reading invalidates the signature:
priv = Ed25519PrivateKey.generate()
p = sign_ed25519({"device_id": "TH-0042", "temp_c": 2.4, "signed_at": "2026-07-14T09:00:00Z"},
priv, key_id="TH-0042")
assert verify_ed25519(p, priv.public_key()) is True
p["temp_c"] = 9.9 # tamper with the sealed reading
assert verify_ed25519(p, priv.public_key()) is False
Step 4 — Bind the key identity and resolve it at the verifier
The key_id in the signature block is what lets the verifier fetch the right public key or secret from the credential inventory. Resolve it against the provisioned device registry so a payload signed by an unknown or decommissioned key is refused, not merely unverified. This is where signing attribution meets the authority check of §11.10(g).
def verify_payload(payload: dict[str, Any], key_resolver) -> bool:
sig = payload.get("signature") or {}
key_id, alg = sig.get("key_id"), sig.get("alg")
material = key_resolver(key_id) # None if unknown/decommissioned
if material is None:
return False # unattributable source rejected
if alg == "Ed25519":
return verify_ed25519(payload, material)
if alg == "HS256":
return verify_hmac(payload, material)
return False # unknown algorithm is not trusted
Confirm an unknown signer is refused before the reading can reach persistence:
# A key_id absent from the registry must fail closed (§11.10(g) authority check).
assert verify_payload({"device_id": "X", "signature": {"key_id": "ghost", "alg": "Ed25519", "value": "00"}},
key_resolver=lambda k: None) is False
Compliance Validation Checklist
Run this as part of computerized-system validation; every item is independently confirmable for the signing control.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Verification fails despite unchanged content | Non-deterministic serialization or float precision differs across ends | Use sort_keys=True and compact separators everywhere; round floats before signing |
| HMAC verifies but attribution is challenged in audit | Shared secret means the verifier could have forged it — no non-repudiation | Move to per-device Ed25519 keys held in a TPM for records that must survive challenge |
Signature valid but key_id unknown to verifier |
Public key not yet distributed, or device decommissioned | Resolve key_id against the credential inventory; fail closed and provision the key before trusting |
| Verification passes for a payload from a revoked device | Key resolver not consulting revocation state | Have the resolver return None for revoked/superseded keys so the reading is rejected (§11.10(g)) |
| Private key readable by a maintenance script | Key stored on disk instead of in a secure element | Generate and hold the key in a TPM/HSM; expose only sign/verify operations, never the key bytes |
Related
- How to Map 21 CFR Part 11 Requirements to MQTT Payloads — the field-by-field payload mapping this signature protects.
- Mapping FDA 21 CFR Part 11 to Cold Chain Sensors — the clause-to-control context for attributability.
- Certificate Lifecycle Management for Cold Chain Gateways — how the verification public keys are inventoried, rotated, and revoked.
- Designing Secure IoT Gateways for Pharma Logistics — the mTLS channel that complements payload signing.
- Schema Validation Pipelines for Temperature Telemetry — the structural gate that runs alongside signature verification.
For architectural context, this guide sits under Mapping FDA 21 CFR Part 11 to Cold Chain Sensors, part of the broader Pharmaceutical Cold Chain Architecture & Compliance Foundations section.