Skip to content

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:

Shell
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):

TypeScript
// 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

URIWhat it attests
https://pluck.run/ThermalAfterglow.Capture/v1Reader-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/v1Auditor-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/v1Bureau-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

Shell
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

Edit this page on GitHub
Previous
Ember

Ready to build?

Install Pluck and follow the Quick Start guide to wire MCP-first data pipelines into your agents and fleets in minutes.

Get started →