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.

HMAC versus asymmetric signing paths for attributable telemetry Top row shows the HMAC path: canonical bytes plus a shared secret produce a tag the verifier recomputes with the same secret — integrity only. Bottom row shows the asymmetric path: canonical bytes signed by a TPM-held Ed25519 private key are verified with the device public key — integrity plus non-repudiable attribution. HMAC · integrity, shared trust boundary Canonical bytes sorted · compact JSON HMAC(secret, bytes) same secret both ends Recompute & compare integrity only ASYMMETRIC · integrity + non-repudiable attribution · §11.10(e) Canonical bytes sorted · compact JSON Ed25519 sign TPM private key Verify with pubkey attributes to device Shared secret: verifier could forge → integrity, not sole authorship. Private key never leaves the device → only that device could sign.

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

python
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:

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

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

python
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:

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

python
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:

python
# 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

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.