Chapter 15 — Critical infrastructure and sector security: guided lab
Lesson map
Lesson 15 published. Map, example, EX043.
flowchart TD
N0["Learning outcomes and connection"]
N1["15.1 Integrity and authenticity"]
N2["15.2 Canonical form"]
N3["15.3 Constant-time comparison"]
N4["15.4 Freshness and ordering"]
N5["15.5 Guided build"]
N6["15.6 What this build does not do"]
N7["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7
N7 --> EX["Worked example"]
EX --> Q["Exercise EX043"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["15.1 Integrity and authenticity"]
N2["15.2 Canonical form"]
N3["15.3 Constant-time comparison"]
N4["15.4 Freshness and ordering"]
N5["15.5 Guided build"]
N6["15.6 What this build does not do"]
N7["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> N7 --> Q["EX043"]
[Learning outcomes and connection]
|
v
[15.1 Integrity and authenticity]
|
v
[15.2 Canonical form]
|
v
[15.3 Constant-time comparison]
|
v
[15.4 Freshness and ordering]
|
v
[15.5 Guided build]
|
v
[15.6 What this build does not do]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX043]
Learning outcomes and connection
After this chapter you should be able to:
- explain what a message authentication code (MAC) proves and what it does not;
- implement HMAC-SHA-256 sealing and verification with constant-time comparison;
- add freshness (time window) and ordering (sequence numbers) to defeat replay;
- test the build with deliberately bad messages and record the results as evidence.
This is the first guided build of LANTERN's core component. Chapter 3's validator checks that messages are well-formed and plausible; it cannot tell whether a message really came from a station. Here you add authentication. The design is deliberately simple so that every line can be understood; a production design would add key management and transport security (see "What this build does not do" below).
15.1 Integrity and authenticity
A hash such as SHA-256 detects accidental change but anyone can compute it, so an attacker who changes a message can also recompute its hash. A MAC is computed with a secret key; only holders of the key can produce a valid MAC. HMAC, defined in RFC 2104, builds a MAC from a hash function. If a message carries a valid HMAC under a station's key, the receiver knows it was produced by a key holder and not changed in transit.
A MAC does not provide confidentiality (the message is still readable), does not prove the station's sensor is right, and does not stop an attacker re-sending a message they captured earlier — a replay.
15.2 Canonical form
A MAC is computed over bytes. If sender and receiver serialize the same message differently — different key order, different spacing — verification fails. LANTERN's messages are serialized as JSON with sorted keys and no spaces, and the MAC is computed over exactly those bytes. Always define the canonical form explicitly.
A relay that re-serializes the parsed JSON before checking the MAC can accept bytes the station never signed.
Lab 15 serializes with sorted keys and no spaces on both sides, so the station gateway and the relay compute the MAC over identical bytes; a gateway firmware update that changed the spacing would show up at once as a burst of MAC failures from that station.
15.3 Constant-time comparison
Comparing the received MAC with the expected one using an ordinary string comparison can leak, through timing, how many leading characters matched. Python's hmac.compare_digest compares in time independent of where the inputs differ (Python hmac documentation). Use it for every MAC comparison.
Lab 15 and Lab 29 both use hmac.compare_digest, never ==, and the relay that rejected 40 of 40 MAC-failure tests in Chapter 23 must do the same.
An ordinary comparison would let an attacker on the station network learn a valid MAC for a forged chlorine reading one character at a time.
15.4 Freshness and ordering
Two cheap checks defeat replay. A time window rejects messages whose timestamp is more than a set number of seconds from the receiver's clock (here 120 s); it depends on both clocks being right (Chapter 17). A sequence number per station, increasing with each message, lets the receiver reject any message whose number is not greater than the last accepted. Both values are inside the MAC'd body, so an attacker cannot change them without invalidating the MAC.
PS-09's 422-second clock error shows the cost: its genuine messages would fail the 120 s window.
15.5 Guided build
The lab below implements seal (the station side) and Relay.receive (the relay side) and runs seven tests: two good messages, a replay, a tampered value, a message sealed with the wrong key, a stale message and a truncated message.
Lab 15 (save as lab15.py, run with python3 lab15.py):
# Lab 15 - guided build: an authenticated telemetry relay message with replay protection (offline, stdlib only)
# Synthetic key and messages. A real deployment keeps the key in a protected store, never in source code.
import hmac, hashlib, json
KEY = bytes.fromhex("6c616e7465726e2d6c61622d6b65792d6e6f742d7265616c2d30303031") # "lantern-lab-key-not-real-0001"
WINDOW = 120 # seconds
def seal(station, tag, value, ts, seq, key=KEY):
body = json.dumps({"st": station, "tag": tag, "v": value, "ts": ts, "seq": seq}, sort_keys=True, separators=(",", ":"))
mac = hmac.new(key, body.encode(), hashlib.sha256).hexdigest()
return body + "." + mac
class Relay:
def __init__(self, key): self.key, self.last = key, {}
def receive(self, wire, now):
try:
body, mac = wire.rsplit(".", 1)
except ValueError:
return "reject: malformed"
good = hmac.new(self.key, body.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(good, mac):
return "reject: bad MAC"
m = json.loads(body)
if abs(now - m["ts"]) > WINDOW:
return "reject: outside time window"
if m["seq"] <= self.last.get(m["st"], -1):
return "reject: replay"
self.last[m["st"]] = m["seq"]
return f"accept {m['st']} {m['tag']}={m['v']}"
r = Relay(KEY)
now = 1_790_000_000
a = seal("PS-04", "LEVEL_M", 3.42, now - 5, 1)
b = seal("PS-04", "LEVEL_M", 3.44, now - 1, 2)
tampered = a.replace('"v":3.42', '"v":9.42')
forged = seal("PS-04", "LEVEL_M", 1.00, now, 3, key=b"guessed-key")
old = seal("PS-07", "CL2_MGL", 0.6, now - 900, 1)
tests = [("first", a), ("second", b), ("replay of first", a), ("tampered value", tampered),
("wrong key", forged), ("stale", old), ("truncated", a[:20])]
for name, wire in tests:
print(f"{name:<16} {r.receive(wire, now)}")
print("MAC length (hex chars):", len(a.rsplit('.', 1)[1]))
Expected output (Python 3.13.5):
first accept PS-04 LEVEL_M=3.42
second accept PS-04 LEVEL_M=3.44
replay of first reject: replay
tampered value reject: bad MAC
wrong key reject: bad MAC
stale reject: outside time window
truncated reject: malformed
MAC length (hex chars): 64
Work through it in this order: (1) read seal and identify the canonical form; (2) read receive and list the checks in order; (3) predict each test's result; (4) run it; (5) for each rejection, name the check that caught it. Then change the order of checks so that the replay check and its state update run before the MAC check, and think about what an attacker could now do: send unauthenticated messages with high sequence numbers and block a station. Order matters — authenticate first, then trust the contents.
15.6 What this build does not do
The lab key is printed in the source because it is fake; in production the key lives in a protected store, readable only by the relay's service account, and each station has its own key so that one stolen key does not let an attacker impersonate every station. Keys need generation, distribution, rotation and revocation procedures (Chapter 18). The transport here is not encrypted; in production the relay–historian leg uses TLS 1.3 (RFC 8446), and whether the station leg can use TLS depends on what the field devices support. Many older field devices cannot compute HMACs at all; then the MAC is applied by a small gateway at the station, and the gateway–device link must be physically protected.
15.7 Common errors and limits
- Using a plain hash and calling it authentication.
- Computing the MAC over a re-serialized object rather than the received bytes.
- Comparing MACs with
==. - Updating replay state before authentication — or before all other checks have passed.
- One key shared by every station.
- Keys in source code, configuration repositories or tickets.
- Assuming a valid MAC means a correct reading.
Each has a LANTERN consequence. A plain hash lets anyone who can reach the relay compute a valid-looking digest. One key for every station means a key lifted from PS-07's gateway, where the key file sat on a removable card, forges messages for all fourteen stations. And a valid MAC on a 9.80 mg/L chlorine reading proves only that PS-07 sent it, not that the analyser is right.
Exercises — chapter 15
EX043 — Order of checks. Objective: see why authentication comes first. Inputs: Lab 15. Constraints: modify a copy. Steps: (1) move the replay check before the MAC check, keeping the state update where it is; (2) craft an unauthenticated message for PS-04 with seq 1000000 and a wrong MAC; (3) send it, then a genuine message with seq 3; (4) report what happens and why the original order is safer. Expected evidence: code, output and explanation. Rubric: 3 = shows that with state updated before authentication an attacker can block a station's future messages (or, if the state update stays after the MAC check, shows no blocking and explains why the update's position is the real safeguard); explanation links to "authenticate, then trust"; 2 = observation without explanation; 1 = not run; 0 = none.
EX044 — Per-station keys. Objective: extend the design. Inputs: Lab 15. Constraints: standard library only. Steps: (1) give each station its own key in a dictionary; (2) look up the key by station ID — but note the station ID is inside the body; (3) explain how you can safely read the station ID before verifying the MAC, and what you must not do with it before verification. Expected evidence: code and explanation. Rubric: 3 = parses the body only to select a key, verifies MAC with that key before using any other field or updating state, rejects unknown stations; explains that the unverified ID is only a key selector; 2 = works, explanation thin; 1 = uses one key; 0 = none.
EX045 — Window size. Objective: choose the freshness window. Inputs: stations' clocks are kept within ±2 s by NTP from the relay; the carrier occasionally delays messages by up to 40 s; after a carrier outage, stations upload a 24-hour backlog. Constraints: one design that handles all three. Steps: (1) choose a window for live messages; (2) explain how backlog uploads should be handled without opening a replay hole. Expected evidence: half page. Rubric: 3 = live window of about 60–120 s with reasoning; backlog accepted through a separate, explicit path (for example flagged backlog mode that still requires valid MAC and strictly increasing sequence numbers, with a bounded age and an alert when used); 2 = window right, backlog unsafe or vague; 1 = very large window for everything; 0 = none.
Chapter synthesis
Authenticate messages with HMAC over a defined canonical form, compare MACs in constant time, and defeat replay with a time window and per-station sequence numbers carried inside the authenticated body. Authenticate first, then trust and record the contents. A MAC proves origin and integrity, not confidentiality and not sensor correctness. Keys need their own life cycle, and each station should have its own.
Continue studying — chapter 15 of 30
← Chapter 14 · Course chapter index · Chapter 16 →
Continue to the next authored lesson.