Lesson map
Lesson 29 published. Map, example, EX085.
flowchart TD
N0["Learning outcomes and connection"]
N1["29.1 What the capstone must demonstrate"]
N2["29.2 The build"]
N3["29.3 Reading the evidence record"]
N4["29.4 A defect that only appears in combination"]
N5["29.5 Building the real capstone"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6
N6 --> EX["Worked example"]
EX --> Q["Exercise EX085"]
Logical drawing
flowchart LR
N0["Learning outcomes and connection"]
N1["29.1 What the capstone must demonstrate"]
N2["29.2 The build"]
N3["29.3 Reading the evidence record"]
N4["29.4 A defect that only appears in combination"]
N5["29.5 Building the real capstone"]
N6["Chapter synthesis"]
N0 --> N1 --> N2 --> N3 --> N4 --> N5 --> N6 --> Q["EX085"]
[Learning outcomes and connection]
|
v
[29.1 What the capstone must demonstrate]
|
v
[29.2 The build]
|
v
[29.3 Reading the evidence record]
|
v
[29.4 A defect that only appears in combination]
|
v
[29.5 Building the real capstone]
|
v
[Chapter synthesis]
|
v
[Worked example] --> [Exercise EX085]
Learning outcomes and connection
After this chapter you should be able to:
- integrate the components built through the course into one end-to-end monitored service;
- inject safe test faults and show that the service detects or rejects each one;
- produce a service evidence record that someone else can check;
- find and explain a defect that only appears when components are combined.
This is the course's practical outcome: implement an end-to-end service with monitored controls. Lab 29 combines, in one file, the station sealing of Chapter 15, the validation of Chapter 3, a historian in a relational model (Chapter 4), monitoring with an absence rule (Chapter 19) and an evidence record with a digest (Chapters 6 and 20). It is small enough to read in full; the capstone project asks you to build the same shape at larger scale.
29.1 What the capstone must demonstrate
- Data path works: readings from all stations reach the historian, validated and authenticated.
- Controls work: injected faults — a replay, a forged message, an out-of-range value and a station silence — are each rejected, quarantined or alerted.
- Monitoring works: alerts are raised for each injected fault and for nothing else.
- Evidence exists: a machine-readable record of the run, with counts that add up and a digest so that later changes to it are detectable.
- Limits are stated: what the run does not show.
29.2 The build
Lab 29 generates two hours of synthetic readings from three stations, with PS-09 silent from minute 50 to 84. It injects four tests: a replay of an earlier PS-04 message, a message from PS-07 sealed with the wrong key, and an out-of-range chlorine reading of 7.5 mg/L from PS-07 that takes the slot of minute 20, plus the PS-09 silence. The relay verifies the MAC, rejects replays, quarantines out-of-range values and stores the rest; monitoring adds a silence rule; the evidence record summarizes the run.
Lab 29 (save as lab29.py, run with python3 lab29.py):
# Lab 29 - capstone: an end-to-end monitored telemetry service in one file (offline, standard library only)
# Synthetic stations -> authenticated relay -> validated historian (sqlite3) -> monitoring -> evidence record.
import hashlib, hmac, json, random, sqlite3
random.seed(29)
KEY = b"lantern-lab-key-not-real-0001"
LIMITS = {"LEVEL_M": (0.0, 12.0), "CL2_MGL": (0.0, 5.0)}
def seal(m, key=KEY):
body = json.dumps(m, sort_keys=True, separators=(",", ":"))
return body + "." + hmac.new(key, body.encode(), hashlib.sha256).hexdigest()
# 1. stations emit one reading per minute for 120 minutes; PS-09 goes silent 50-84; one injected test of each fault
wire, now0 = [], 1_790_000_000
for st, tag, base in (("PS-04", "LEVEL_M", 3.4), ("PS-07", "CL2_MGL", 0.6), ("PS-09", "LEVEL_M", 5.1)):
for i in range(120):
if st == "PS-09" and 50 <= i < 85: continue
if st == "PS-07" and i == 20: continue # the injected out-of-range test takes this slot
wire.append((now0 + 60*i, seal({"st": st, "tag": tag, "v": round(base + random.gauss(0, 0.05), 3), "ts": now0 + 60*i, "seq": i})))
wire.append((now0 + 600, wire[3][1])) # TEST: replay
wire.append((now0 + 900, seal({"st": "PS-07", "tag": "CL2_MGL", "v": 0.6, "ts": now0 + 900, "seq": 999}, key=b"x"))) # TEST: forged
wire.append((now0 + 1200, seal({"st": "PS-07", "tag": "CL2_MGL", "v": 7.5, "ts": now0 + 1200, "seq": 20}))) # TEST: out of range
wire.sort(key=lambda w: w[0])
# 2. relay + historian
db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE reading(st TEXT, tag TEXT, v REAL, ts INTEGER, seq INTEGER, PRIMARY KEY(st, seq))")
events, last = [], {}
for t, w in wire:
body, mac = w.rsplit(".", 1)
if not hmac.compare_digest(hmac.new(KEY, body.encode(), hashlib.sha256).hexdigest(), mac):
events.append((t, "mac_reject", "?")); continue
m = json.loads(body)
if m["seq"] <= last.get(m["st"], -1):
events.append((t, "replay_reject", m["st"])); continue
lo, hi = LIMITS[m["tag"]]
last[m["st"]] = m["seq"]
if not lo <= m["v"] <= hi:
events.append((t, "range_quarantine", m["st"])); continue
db.execute("INSERT INTO reading VALUES (?,?,?,?,?)", (m["st"], m["tag"], m["v"], m["ts"], m["seq"]))
# 3. monitoring: security events and silence > 15 min
alerts = [(t, kind, st) for t, kind, st in events]
for st, in db.execute("SELECT DISTINCT st FROM reading"):
ts = [r[0] for r in db.execute("SELECT ts FROM reading WHERE st=? ORDER BY ts", (st,))]
alerts += [(a + 900, "silence", st) for a, b in zip(ts, ts[1:]) if b - a > 900]
# 4. evidence record
stored = db.execute("SELECT COUNT(*) FROM reading").fetchone()[0]
record = {"period_utc_s": [now0, now0 + 7140], "messages_on_wire": len(wire), "stored": stored,
"alerts": sorted({(k, s) for _, k, s in alerts}), "injected_tests": 4,
"injected_tests_detected": len({k for _, k, _ in alerts} & {"replay_reject", "mac_reject", "range_quarantine", "silence"}),
"alert_count": len(alerts)}
blob = json.dumps(record, sort_keys=True).encode()
print(json.dumps(record, sort_keys=True))
print("evidence sha256:", hashlib.sha256(blob).hexdigest()[:24], "...")
print("PS-07 mean chlorine stored: %.3f mg/L" % db.execute("SELECT AVG(v) FROM reading WHERE st='PS-07'").fetchone()[0])
Expected output (Python 3.13.5):
{"alert_count": 4, "alerts": [["mac_reject", "?"], ["range_quarantine", "PS-07"], ["replay_reject", "PS-04"], ["silence", "PS-09"]], "injected_tests": 4, "injected_tests_detected": 4, "messages_on_wire": 327, "period_utc_s": [1790000000, 1790007140], "stored": 324}
evidence sha256: aa40e3905d178699f1460e1c ...
PS-07 mean chlorine stored: 0.606 mg/L
29.3 Reading the evidence record
Check that the numbers add up. Messages on the wire: 120 + 119 + 85 + 3 injected = 327. Stored: 324 (all genuine readings). Not stored: 3 (the replay, the forgery and the out-of-range reading). Alerts: four, one of each kind, and four of four injected tests detected. The digest lets anyone confirm later that the record has not been edited; in a real service the record would be stored off-host and the digest written into the change or test ticket.
If the stored count had read 325, one genuine reading would be unexplained.
29.4 A defect that only appears in combination
Change the injected out-of-range message's sequence number from 20 to 1000 — as a faulty station gateway might after a reset — and rerun. The result changes dramatically: stored readings fall from 324 to 225 and alerts rise from 4 to 103. Ninety-nine genuine PS-07 readings are rejected as replays. The cause is that the relay updates its last-seen sequence number after authentication but before the range check: the out-of-range message is authentic, so its sequence number 1000 is recorded, and every later genuine message (sequence 21 to 119) looks like a replay.
Each component was "correct" by its own chapter's tests. Chapter 3 warned against updating replay state before all other checks have passed; Chapter 15 put the update after authentication. The combination shows the remaining hole: an authentic but implausible message can poison the replay state. Fixes include: update the state only on accept or quarantine of a message that is plausible in sequence (reject sequence jumps larger than a bound, say 1,000 or one day of messages, unless in an explicit resynchronization mode); alert on large jumps; and give operators a reviewed resynchronization procedure. This is exactly why the capstone tests the integrated service, not just its parts.
29.5 Building the real capstone
The project version of the capstone, done in a team (Chapter 25), builds the same shape with real components in a lab: three relay virtual machines, a historian database, log forwarding to a collector, the normalizer, three monitoring rules plus self-monitoring, configuration as code with a drift check, a backup and timed restore, runbooks and a handover pack. Use only lab equipment or simulated stations. The deliverable is the service evidence record: for each requirement in "What the capstone must demonstrate" above, the test performed, its date, the raw output, the result against a pre-stated claim rule (Chapter 23), and the open items.
29.6 Common errors and limits
- Testing components separately and assuming the combination works.
- Evidence records whose counts do not add up.
- Injected tests that the team designed to pass.
- No digest or off-host copy of evidence.
- Omitting the "what this does not show" section: Lab 29 shows nothing about real field devices, carrier behaviour, load at scale, or attackers who adapt.
Team A's summary in Chapter 30 commits the fifth: it states no limits at all.
An injected forgery built with the station's real key would test nothing; the team must design tests that could fail.
Exercises — chapter 29
EX085 — Reproduce the defect. Objective: find and explain an integration defect. Inputs: Lab 29. Constraints: change only the sequence number of the out-of-range test. Steps: (1) set it to 1000 and run; (2) report stored count, alert count and which messages were lost; (3) explain the cause in terms of the order of checks. Expected evidence: output and explanation. Rubric: 3 = stored 225, alert_count 103, 99 genuine PS-07 readings rejected as replays, explanation that state was updated for an authentic but out-of-range message; 2 = numbers right, explanation thin; 1 = not run; 0 = none.
EX086 — Fix it. Objective: correct the defect without opening a replay hole. Inputs: Lab 29 with sequence 1000. Constraints: standard library only; replays must still be rejected. Steps: (1) implement a maximum sequence jump of 1,000 (reject larger jumps with a distinct event seq_jump_reject) or another defensible fix; (2) run with sequence 1000 and with sequence 20; (3) show that the replay test is still rejected in both. Expected evidence: code and both outputs. Rubric: 3 = jump of 981 (19→1000) is within 1,000 — so the learner must notice that a bound of 1,000 does not catch this case and choose a tighter or time-based bound (for example jump no more than the elapsed minutes plus a margin), with stored 324 restored and replay still rejected; 2 = fix works for this case but reasoning about the bound missing; 1 = removes replay protection; 0 = none.
EX087 — Evidence record review. Objective: check a record as an assessor. Inputs: the Lab 29 output with sequence 20. Constraints: checks only from the record and the lab source. Steps: (1) verify the counts add up; (2) recompute the digest from the printed JSON and compare its first 24 characters; (3) list three limits the record should state. Expected evidence: calculations and list. Rubric: 3 = 327 = 324 + 3 confirmed; digest recomputed by hashing the exact JSON string; limits such as synthetic data, no field devices or carrier, single run, no adaptive adversary, no load at scale; 2 = two parts; 1 = one; 0 = none.
Chapter synthesis
The capstone integrates the course into one monitored service and proves it with injected tests and a checkable evidence record. Counts must add up, the record must be protected by a digest and stored off-host, and its limits must be stated. Integration testing finds defects that component tests miss: here an authentic but implausible message poisoned replay state, a hole visible only when validation, authentication and ordering met.