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.
Predict the reading, then compare it with the measurement.
Python 3 in your browser (JupyterLite)
Python · no installOpen 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.
Steps
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 1to 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: TheLocal key boundarytable 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.

Step 1 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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.

Step 2 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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.

Step 3 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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.andplaintext: NOT RELEASED. - Why it matters
- The before/after bytes connect a concrete mutation to failed authentication and prove the receiver fails closed.

Step 4 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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.

Step 5 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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 6to 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 forpump-7:42as 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.

Step 6 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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 7to 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, andunique count 1000before 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.

Step 7 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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.

Step 8 · Python 3 in your browser (JupyterLite); numbered callout added to a real capture. Enlarge screenshot (new tab) 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 9and 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 asACCEPT RELEASEDwhile tamper, context, replay, and wrong key each readREJECT 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.

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.
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 checkTelemetry 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 checkA 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