Skip to content

Protect MQTT telemetry with authenticated encryption

Encrypt one fictional telemetry message before publishing and prove that tamper, wrong context, replay, and wrong-key inputs fail without releasing plaintext.

Shield Shelly: I want you to test the protected message's boundary and prove every unsafe path rejects before plaintext is trusted., your practice guide

Shield Shelly: I want you to test the protected message's boundary and prove every unsafe path rejects before plaintext is trusted.
Predict the reading, then compare it with the measurement.

Python 3 in your browser (JupyterLite)

Python · no install

Tier 2 · Web · paste-in setup · No account

Version tested: Python 3.12.7 / Pyodide 0.27.6 in JupyterLite 0.6.4; Chromium 148.0.7778.96; captured against a local Mosquitto-compatible WebSocket broker because the capture host cannot reach the public broker; the notebook targets test.mosquitto.org; captureSource playwright:jupyterlite. Date: 2026-09-09.

Open the notebook in your browser and run each Python cell; no install or account is needed.

Three ways to run: use JupyterLite here with no install; run main.py locally from the downloadable lab folder; or open the same notebook in Google Colab.

Open in your browser (new tab)

Steps

Screens captured against JupyterLite Python 3.12.7 / Pyodide 0.27.6; Chromium 148.0.7778.96 on 2026-09-10; the tool may have moved on — the text steps are the contract.

  1. 1 Step 1

    Do
    In the JupyterLite notebook, run the Step 1 cell to generate a local AES-256-GCM key and inspect only its boundary metadata.
    You will see
    Input: Run python3 main.py --step 1 to generate a local AES-256-GCM key and inspect only its boundary metadata. Observed heading: "Local key boundary" Observed intermediate: "key output hidden" Observed rule or check: "Rule: never print or transmit the encryption key." Observed result: "STEP 1 key-boundary algorithm=AES-256-GCM key-bytes=32 printed=NO transmitted=NO" Expected from the existing capture: The Local key boundary table shows AES-256-GCM, key length 32 bytes, key output hidden, and the broker-safe fields.
    Why it matters
    The visible metadata proves the key's size and local boundary while keeping the secret itself out of logs and traffic. Intermediate evidence: "key output hidden" gives a value or state to inspect. Comparison cue: "Rule: never print or transmit the encryption key." supplies the rule or adjacent evidence for that check. Result: "STEP 1 key-boundary algorithm=AES-256-GCM key-bytes=32 printed=NO transmitted=NO" records how this input was classified.
    JupyterLite notebook output for mqtt-aead-telemetry step 1, captured after running the real Python cell.
    Step 1 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  2. 2 Step 2

    Do
    In the JupyterLite notebook, run the Step 2 cell to encrypt the fictional reading locally and publish its protected packet to the random broker topic.
    You will see
    The output places the fictional plaintext beside truncated ciphertext, prints the received topic payload, then shows the authenticated decrypted plaintext.
    Why it matters
    The side-by-side forms and actual broker packet show that routing succeeds while object security hides plaintext from the broker.
    JupyterLite notebook output for mqtt-aead-telemetry step 2, captured after running the real Python cell.
    Step 2 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  3. 3 Step 3

    Do
    In the JupyterLite notebook, run the Step 3 cell to authenticate the topic and schema as associated data while decrypting the packet.
    You will see
    The authentication view shows plaintext, truncated ciphertext, AAD topic=iotclass/pump-7/telemetry;schema=1, Tag check VALID, and the released message.
    Why it matters
    Showing all three inputs and the valid-tag result demonstrates how visible routing context is authenticated with encrypted content.
    JupyterLite notebook output for mqtt-aead-telemetry step 3, captured after running the real Python cell.
    Step 3 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  4. 4 Step 4

    Do
    In the JupyterLite notebook, run the Step 4 cell to flip the first ciphertext byte before tag verification.
    You will see
    The original and altered ciphertext differ in the first hex byte beside Rule broken: authentication tag must match every ciphertext byte. and plaintext: NOT RELEASED.
    Why it matters
    The before/after bytes connect a concrete mutation to failed authentication and prove the receiver fails closed.
    JupyterLite notebook output for mqtt-aead-telemetry step 4, captured after running the real Python cell.
    Step 4 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  5. 5 Step 5

    Do
    In the JupyterLite notebook, run the Step 5 cell to verify the pump-7 packet with associated data naming pump-8.
    You will see
    The unchanged ciphertext appears beside expected pump-7 AAD and supplied pump-8 AAD, followed by Tag check: INVALID; plaintext: NOT RELEASED.
    Why it matters
    Comparing the two contexts shows that associated data is authenticated even though it is not encrypted.
    JupyterLite notebook output for mqtt-aead-telemetry step 5, captured after running the real Python cell.
    Step 5 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  6. 6 Step 6

    Do
    In the JupyterLite notebook, run the Step 6 cell to submit message ID pump-7:42 twice to the receiver replay window.
    You will see
    Input: Run python3 main.py --step 6 to submit message ID pump-7:42 twice to the receiver replay window. Observed heading: "Receiver replay window" Observed intermediate: "2 pump-7:42 REJECT_REPLAY" Observed rule or check: "Rule: one accepted message ID may not be accepted again." Observed result: "STEP 6 replay-window message-id=pump-7:42 first=ACCEPT second=REJECT_REPLAY" Expected from the existing capture: The replay table shows arrival 1 for pump-7:42 as ACCEPT and arrival 2 for the same ID as REJECT_REPLAY.
    Why it matters
    The two submissions expose the receiver state that AEAD itself cannot provide and make the freshness rule reproducible. Intermediate evidence: "2 pump-7:42 REJECT_REPLAY" gives a value or state to inspect. Comparison cue: "Rule: one accepted message ID may not be accepted again." supplies the rule or adjacent evidence for that check. Result: "STEP 6 replay-window message-id=pump-7:42 first=ACCEPT second=REJECT_REPLAY" records how this input was classified.
    JupyterLite notebook output for mqtt-aead-telemetry step 6, captured after running the real Python cell.
    Step 6 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  7. 7 Step 7

    Do
    In the JupyterLite notebook, run the Step 7 cell to generate 1,000 96-bit nonces for one bounded test key scope.
    You will see
    Input: Run python3 main.py --step 7 to generate 1,000 96-bit nonces for one bounded test key scope. Observed heading: "Nonce uniqueness check" Observed intermediate: "sample size 1000 random nonces" Observed rule or check: "Rule: never reuse a nonce with the same AES-GCM key." Observed result: "STEP 7 nonce-check generated=1000 unique=1000 collisions=0 scope=one-key" Expected from the existing capture: The check lists AES-GCM, 96-bit nonce width, one-key scope, sample size 1000, and unique count 1000 before the no-reuse rule.
    Why it matters
    The scope and sample rows explain what the bounded uniqueness observation does and does not prove about production nonce allocation. Intermediate evidence: "sample size 1000 random nonces" gives a value or state to inspect. Comparison cue: "Rule: never reuse a nonce with the same AES-GCM key." supplies the rule or adjacent evidence for that check. Result: "STEP 7 nonce-check generated=1000 unique=1000 collisions=0 scope=one-key" records how this input was classified.
    JupyterLite notebook output for mqtt-aead-telemetry step 7, captured after running the real Python cell.
    Step 7 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  8. 8 Step 8

    Do
    In the JupyterLite notebook, run the Step 8 cell to decrypt a valid packet with a different generated key.
    You will see
    The truncated ciphertext appears beside hidden key A and hidden key B, then Tag check: INVALID; plaintext: NOT RELEASED.
    Why it matters
    The named-but-hidden key boundaries demonstrate wrong-key failure without leaking either secret into diagnostics.
    JupyterLite notebook output for mqtt-aead-telemetry step 8, captured after running the real Python cell.
    Step 8 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)
  9. 9 Step 9

    Do
    In the JupyterLite notebook, run the Step 9 cell to review the expected receiver decision for every tested path.
    You will see
    Input: Run python3 main.py --step 9 and review the expected receiver decision for every tested path. Observed heading: "Receiver release matrix" Observed intermediate: "context REJECT NOT RELEASED" Observed rule or check: "Rule: authenticate first; release plaintext only for the valid case." Observed result: "STEP 9 release-matrix valid=ACCEPT tamper=REJECT context=REJECT replay=REJECT wrong-key=REJECT failures-release-plaintext=NO" Expected from the existing capture: The release matrix shows valid as ACCEPT RELEASED while tamper, context, replay, and wrong key each read REJECT NOT RELEASED.
    Why it matters
    The row-by-row matrix consolidates the demonstrated paths into reviewable release evidence for the claimed fail-closed boundary. Intermediate evidence: "context REJECT NOT RELEASED" gives a value or state to inspect. Comparison cue: "Rule: authenticate first; release plaintext only for the valid case." supplies the rule or adjacent evidence for that check. Result: "STEP 9 release-matrix valid=ACCEPT tamper=REJECT context=REJECT replay=REJECT wrong-key=REJECT failures-release-plaintext=NO" records how this input was classified.
    JupyterLite notebook output for mqtt-aead-telemetry step 9, captured after running the real Python cell.
    Step 9 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab)

Chapter checks

These questions refer to the chapter’s examples. Use the return links to review their answers.

  1. A vendor says sensor readings are end-to-end secure because each device uses TLS to the cloud message broker, which then forwards the readings to other services. What is the strongest correction?

    Return to the chapter’s knowledge check
  2. Telemetry must stay confidential from the cloud message broker, which only needs to route it. The team secures every device-to-broker link with TLS. What is the correct review conclusion?

    Return to the chapter’s knowledge check
  3. A device uses a fixed long-term key to derive the same session key for every TLS connection, to save computation. Years of recorded ciphertext are later exposed when that long-term key leaks. Why is this worse than a design that uses ephemeral key agreement per session?

    Return to the chapter’s knowledge check

Caution

The public broker sees topics, associated data, nonces, ciphertext lengths, and timing, and it offers no availability or identity guarantee. Use only the fictional payload. This ephemeral-key, in-memory replay exercise is not production key provisioning, durable nonce allocation, replay persistence, authenticated transport, or endpoint authorization.

Return to Secure Communication and Firmware · Browse Labs