- Docs
- Bureau — Red & Blue (dual-use)
- Thermal-Afterglow
Bureau — Red & Blue (dual-use)
Thermal-Afterglow
A consumer-grade thermal camera can detect residual heat from recent keypresses, supporting recovery of short codes (ATM PINs, hotel safes, smart-lock keypads). The same hardware can sign a secure room's thermal baseline so subsequent anomalies are detectable. Thermal-Afterglow attests both directions of use.
Posture: 🟣 Red & Blue (dual-use) · Status: alpha
What it does
Heat doesn't disappear instantly when your finger leaves a keypad. It decays, and the order of decay is recoverable: the most recently pressed key is hottest, the oldest is coolest. A $200 FLIR-One that clips onto a phone reads this residual-heat pattern for roughly 30 seconds after the keypress. Press 1-2-3-4 on an ATM, walk away, and an attacker with a thermal camera has 30 seconds to recover the PIN by sorting four hot pixels by temperature.
This is a fifteen-year-old academic result. What Thermal-Afterglow does new is sign the observation chain so the recovery – or the absence of recovery – is cryptographically attested. Same camera, different deployment: pointed at a server-room rack instead of a keypad, the FLIR-One captures the rack's thermal signature. A signed clean-state baseline becomes the reference. Future captures whose feature vector deviates more than three sigma from baseline emit a signed datacenter-anomaly proof – unbilled compute, unauthorized hardware, rack-swap. SCIFs (Sensitive Compartmented Information Facilities) get continuous attested baselines this way.
Who would use it
- A red-team operator demonstrating that thermal selfies posted to social media leak PINs and combination-lock codes.
- A blue-team SCIF defender continuously attesting a secure room's clean-state thermal baseline.
- A datacenter operator detecting unbilled compute (rogue miners, unauthorized tenants) on shared infrastructure.
- A compliance auditor producing court-admissible evidence of unauthorized hardware in a regulated facility.
- A bank security team validating that ATM thermal-decay countermeasures (rubber keypads, PIN-pad heaters) actually work.
What you'll need
- The Pluck CLI (
npm install -g @sizls/pluck-cli). - A FLIR-One Pro thermal camera (about $400 for current-gen, about $200 used) that clips onto a USB-C phone, or a Seek Thermal Compact (about $250). Resolution 160×120 or higher recommended.
- For the defensive deployment, a tripod or wall-mount, plus a registered baseline. You generate the baseline by capturing the room or rack at clean idle during commissioning and signing the auditor commitment.
- For the offensive demonstration, just the FLIR-One on a phone is enough.
Step-by-step
The alpha runs the full constraint chain on synthetic thermal frames – there is no live FLIR reader integration yet. The production capture daemon ships in a follow-up. To exercise the system today:
pluck bureau thermal-afterglow demo
Expected output: the system registers a server-room baseline, ingests four signed thermal captures (one inside the commissioning window that is correctly skipped, plus three contradictions: a keypress-recovery, a datacenter-anomaly, and an afterglow-replay), and emits three signed observation proofs. Each proof carries the per-pixel temperature-delta feature vector digest, the deviation magnitude, and the bound location and camera fingerprint.
What to do with the output: in production a wall-mounted FLIR streams continuously into the SCIF defender daemon, and datacenter-anomaly proofs publish to Rekor. For the offensive use case, a one-shot capture against a thermal selfie published on social media produces a keypress-recovery proof you can hand to a security-conference audience or a journalist.
Run it yourself
Drop this into a Node 18+ project (npm install @sizls/pluck-bureau-thermal-afterglow @sizls/pluck-bureau-core tsx):
// index.ts
import { createHash } from "node:crypto";
import {
createThermalAfterglowSystem,
fingerprintPrivateKey,
signCanonicalBody,
type ThermalBaseline,
type ThermalCapture,
} from "@sizls/pluck-bureau-thermal-afterglow";
import { generateOperatorKey } from "@sizls/pluck-bureau-core";
async function main() {
const operator = generateOperatorKey();
const reader = generateOperatorKey();
const auditor = generateOperatorKey();
const readerFp = fingerprintPrivateKey(reader.privateKeyPem);
const auditorFp = fingerprintPrivateKey(auditor.privateKeyPem);
const keypadCamera = digest("camera:flir-keypad-001");
const atmLocation = digest("location:atm-lobby");
const serverRoomCamera = digest("camera:flir-rack-002");
const serverRoomLocation = digest("location:datacenter-rack-A");
const thermal = createThermalAfterglowSystem({
signingKey: operator.privateKeyPem,
disablePausePoll: true,
disableLogging: true,
});
try {
// Register the clean-state server-room baseline (commissioning window 00:00 → 00:05).
thermal.registerBaseline(buildBaseline(serverRoomLocation, baselineVec(), 0.08, 240, "2026-04-26T00:00:00.000Z", "2026-04-26T00:05:00.000Z", "2026-04-26T00:00:00.000Z", auditor.privateKeyPem, auditorFp));
// Server-room anomaly post-window – fires datacenter-anomaly.
thermal.observeCapture(buildCapture("2026-04-26T00:11:00.000Z", reader.privateKeyPem, readerFp, serverRoomCamera, serverRoomLocation, anomalousVec()));
// ATM keypad with 4 monotonically-decaying hot peaks.
thermal.observeCapture(buildCapture("2026-04-26T00:10:00.000Z", reader.privateKeyPem, readerFp, keypadCamera, atmLocation, keypressVec()));
// Same camera + same vector at a different minute = afterglow-replay.
thermal.observeCapture(buildCapture("2026-04-26T00:12:00.000Z", reader.privateKeyPem, readerFp, keypadCamera, atmLocation, keypressVec()));
for (let i = 0; i < 60; i++) await new Promise((r) => setImmediate(r));
const proofs = thermal.facts.proofs();
console.log(`thermal proofs = ${proofs.length}`);
for (const p of proofs) console.log(`kind=${p.kind} location=${p.capture.locationHash.slice(0, 12)}…`);
} finally {
await thermal.shutdown();
}
}
function digest(s: string): string { return createHash("sha256").update(s).digest("hex"); }
function baselineVec(): number[] {
const out = new Array<number>(64);
for (let i = 0; i < 64; i++) { const phase = (i / 64) * Math.PI * 2; out[i] = 0.05 * Math.sin(phase) + 0.02 * Math.cos(phase * 3); }
return out;
}
function anomalousVec(): number[] {
const out = new Array<number>(64);
for (let i = 0; i < 64; i++) { const phase = (i / 64) * Math.PI * 2; out[i] = 8 * Math.sin(phase * 5) + 4 * Math.cos(phase * 2 + 0.7); }
return out;
}
function keypressVec(): number[] {
const out = new Array<number>(64);
for (let i = 0; i < 64; i++) out[i] = 0.1 * Math.sin((i / 64) * Math.PI * 4);
out[4] = 7.0; out[18] = 5.0; out[33] = 3.0; out[50] = 1.0;
return out;
}
function buildCapture(timestamp: string, readerKey: string, readerFingerprint: string, cameraFingerprint: string, locationHash: string, thermalFeatureVector: number[]): ThermalCapture {
const seed = timestamp + locationHash + cameraFingerprint;
const frameDigest = createHash("sha256").update(`frame:${seed}`).digest("hex");
const skeleton = { schemaVersion: 1 as const, locationHash, cameraFingerprint, frameDigest, thermalFeatureVector, timestamp, readerFingerprint };
const captureId = createHash("sha256").update(JSON.stringify(skeleton)).digest("hex");
const signed = signCanonicalBody({ ...skeleton, captureId }, readerKey);
return { ...skeleton, captureId, signature: signed.signature };
}
function buildBaseline(locationHash: string, centroid: number[], stddev: number, sampleCount: number, windowStart: string, windowEnd: string, registeredAt: string, auditorKey: string, auditorFingerprint: string): ThermalBaseline {
const skeleton = { schemaVersion: 1 as const, locationHash, centroid, stddev, sampleCount, windowStart, windowEnd, registeredAt, auditorFingerprint };
const baselineId = createHash("sha256").update(JSON.stringify(skeleton)).digest("hex");
const signed = signCanonicalBody({ ...skeleton, baselineId }, auditorKey);
return { ...skeleton, baselineId, signature: signed.signature };
}
main().catch((err) => { console.error(err); process.exit(1); });
Run with tsx index.ts. Expected output:
thermal proofs = 3
kind=keypress-recovery location=…
kind=datacenter-anomaly location=…
kind=afterglow-replay location=…
Open in StackBlitz – runs in your browser, no install required.
What you get
A signed ThermalAfterglow.Proof with one of three kind values:
keypress-recovery– operator attests they recovered an ATM PIN or hotel-safe code from a thermal selfie. Proof carries the recovered key sequence, per-press confidence, and monotonicity score.datacenter-anomaly– server-room thermal signature deviates from the registered baseline. Possible unbilled compute or unauthorized hardware.afterglow-replay– identical residual-heat patterns captured at two distinct timestamps from the same camera. Afterglow should decay in 30 seconds, so identical feature vectors at different minutes are high-signal that one capture is a replayed image.
What it can't do
- Thermal-Afterglow does not read through walls. The FLIR sees only what's in line of sight.
- Anti-thermal countermeasures work: rubber keypads, ambient-heat PIN-pad heaters, and metal keys that conduct heat away in seconds. A keypress-recovery proof against properly-defended hardware will fail to find a usable signal.
- The reader's signing key is the trust anchor. A compromised FLIR with a stolen key can fabricate observations.
- For datacenter anomaly detection, normal load variation can produce sigma-3 deviations during traffic spikes. Tune the baseline window carefully.
A real-world example
A traveler posts an Instagram story that includes a thermal-camera frame of an AirBnB smart-lock keypad. The frame shows four warm spots on the keypad in decreasing temperature order, suggesting the most recent four key presses. A penetration tester captures the still, runs Thermal-Afterglow against it, and a keypress-recovery proof is emitted with 0.91 confidence on the recovered sequence. With the host's permission, the proof is presented at a security conference as a teaching example. The traveler removes the post; the host installs a rubberized keypad cover.
For developers
Predicate URIs
| URI | What it attests |
|---|---|
https://pluck.run/ThermalAfterglow.Capture/v1 | Reader-signed thermal frame: digest of the raw IR pixel data, reduced per-pixel temperature-delta feature vector, bound location and camera fingerprint hashes. Raw frames stay on the reader. |
https://pluck.run/ThermalAfterglow.Baseline/v1 | Auditor-signed clean-state baseline for a SCIF or rack location, including the commissioning window range that's excluded from anomaly detection. |
https://pluck.run/ThermalAfterglow.Proof/v1 | Bureau-signed observation proof with kind (keypress-recovery, datacenter-anomaly, afterglow-replay). |
Programs composed
- Ember – overlapping thermal-IR domain (one of Ember's 4 channels) but different detection class. Ember attests model identity from steady-state thermal hotspots; Thermal-Afterglow attests transient phenomena.
- Oath / Dragnet – vendor and facility identity binding upstream of location and camera fingerprints.
- Evidence-Locker – long-term storage for thermal capture corpora and dossier roots.
Threat model + adversary
Two adversary classes. Offensive: the casual social-media poster who unknowingly leaks a code in a thermal selfie. Defensive: a malicious tenant or rogue insider running unauthorized hardware in a shared datacenter or SCIF. See Threat Model.
Verify a published cassette
pluck bureau verify <bundle-dir>
cosign verify-blob --key <pubkey.pem> --signature <sig> --type https://pluck.run/ThermalAfterglow.Proof/v1 <body.json>
Every signed shape is a detached Ed25519 signature over canonical JSON, optionally notarized into Sigstore Rekor as a DSSE in-toto envelope.
See also
- Bureau Foundations
- Threat Model
- Verify a dossier
- Ember – multi-modal (4-channel including thermal) side-channel attestor
- Magneto-Air – air-gap covert-channel detection (same SCIF-defender market)
- Evidence-Locker – long-term storage for capture corpora