preserve hardened USB smoke evidence

This commit is contained in:
Jay
2026-08-17 17:37:39 -04:00
parent 73e5680fc3
commit 00f52ecf0f
7 changed files with 93 additions and 22 deletions
+5 -1
View File
@@ -141,9 +141,13 @@ optional `--wire` path preserves every received byte, including startup text and
damaged or partial frames, for forensic comparison: damaged or partial frames, for forensic comparison:
```sh ```sh
python tools/capture_binary.py --wire captures/session.wire python tools/capture_binary.py --reset --wire captures/session.wire
``` ```
`--reset` normalizes the USB DTR/RTS state, clears bytes from the prior session,
and resets the C3 while the new capture is already open. Omit it when attaching
to an intentionally uninterrupted stream.
An existing `.trk` stream can be decoded again without hardware: An existing `.trk` stream can be decoded again without hardware:
```sh ```sh
@@ -99,3 +99,19 @@ sample gaps/resets, timestamp anomalies, sensor status, saturation flags, drops,
and acquisition overruns. Sequence classification is wrap-aware. The capture and acquisition overruns. Sequence classification is wrap-aware. The capture
tool also accepts `--wire PATH` to preserve every serial byte before parsing, tool also accepts `--wire PATH` to preserve every serial byte before parsing,
including startup text, corrupt frames, and trailing fragments. including startup text, corrupt frames, and trailing fragments.
For deterministic fresh-session validation, `capture_binary.py --reset` releases
DTR/RTS, clears the prior input session, then pulses the C3 reset line while the
same reader remains open. This follows ESP-IDF monitor's USB Serial/JTAG reset
ordering and avoids a flash-to-capture port-open race.
Commit `73e5680` was built, flashed, and then captured through this reset path.
The result contains 1,184 contiguous samples, sequences 0 through 1,183, with
zero packet/sample gaps, resets, CRC failures, reported drops, loop overruns,
trailing bytes, or timestamp saturation. Raw-wire offline decoding produced CSV
byte-for-byte identical to live rendering. Both artifacts are tracked:
- `tests/fixtures/direct_usb_73e5680.trk`, SHA-256
`fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821`
- `tests/fixtures/direct_usb_73e5680.wire`, SHA-256
`82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea`
+11
View File
@@ -43,6 +43,17 @@ removed before production firmware was built and flashed.
of startup text before the valid frames. That text includes the literal of startup text before the valid frames. That text includes the literal
`TRK1`, producing one rejected candidate header as designed. Extracting all `TRK1`, producing one rejected candidate header as designed. Extracting all
valid frames reproduces `direct_usb_3c95f3d.trk` byte-for-byte. valid frames reproduces `direct_usb_3c95f3d.trk` byte-for-byte.
- `direct_usb_73e5680.trk` — SHA-256
`fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821`.
This exact firmware-hardening capture contains 1,184 contiguous samples,
sequences 0 through 1,183, with zero packet/sample gaps, resets, CRC failures,
reported drops, loop overruns, trailing bytes, or timestamp saturation.
- `direct_usb_73e5680.wire` — SHA-256
`82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea`.
This is the byte-for-byte `--reset --wire` capture corresponding to the
validated file above. Its 3,589 skipped startup bytes and one candidate-header
rejection are deterministic, and extracting its 151 valid frames reproduces
`direct_usb_73e5680.trk` byte-for-byte.
`tests/test_trikke_protocol.py` verifies the hashes, parses the captures in `tests/test_trikke_protocol.py` verifies the hashes, parses the captures in
fragmented chunks, and asserts these signatures so the hardware evidence remains fragmented chunks, and asserts these signatures so the hardware evidence remains
Binary file not shown.
Binary file not shown.
+45 -21
View File
@@ -215,6 +215,15 @@ class ProtocolContractTest(unittest.TestCase):
"timing_anomalies": 8, "timing_anomalies": 8,
"gaps": [], "gaps": [],
}, },
"direct_usb_73e5680.trk": {
"sha256": "fd34bb3bf8f92a64960024f1287e553c03076ff3714fe629ec629bec81ddf821",
"sample_count": 1184,
"first_sequence": 0,
"last_sequence": 1183,
"max_dropped": 0,
"timing_anomalies": 2,
"gaps": [],
},
} }
for name, contract in expected.items(): for name, contract in expected.items():
@@ -285,29 +294,44 @@ class ProtocolContractTest(unittest.TestCase):
self.assertEqual(contract["gaps"], gaps) self.assertEqual(contract["gaps"], gaps)
def test_exact_usb_raw_wire_evidence(self) -> None: def test_exact_usb_raw_wire_evidence(self) -> None:
wire = ( expected = {
ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.wire" "direct_usb_3c95f3d": {
).read_bytes() "sha256": "3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03",
validated = ( "frames": 214,
ROOT / "tests" / "fixtures" / "direct_usb_3c95f3d.trk" "skipped": 563,
).read_bytes() },
self.assertEqual( "direct_usb_73e5680": {
"3bdaeadff7962c6eac48c4ebeda285c8eb359e439d5e1728104add2009122c03", "sha256": "82d6d17bbf0729e9bfc53f337ec9adf70bcb5e5898b039685eda5dfa19cf4eea",
hashlib.sha256(wire).hexdigest(), "frames": 151,
) "skipped": 3589,
},
}
for stem, contract in expected.items():
with self.subTest(fixture=stem):
wire = (
ROOT / "tests" / "fixtures" / f"{stem}.wire"
).read_bytes()
validated = (
ROOT / "tests" / "fixtures" / f"{stem}.trk"
).read_bytes()
self.assertEqual(
contract["sha256"], hashlib.sha256(wire).hexdigest()
)
parser = StreamParser() parser = StreamParser()
frames = [] frames = []
for offset in range(0, len(wire), 113): for offset in range(0, len(wire), 113):
frames.extend(parser.feed(wire[offset : offset + 113])) frames.extend(parser.feed(wire[offset : offset + 113]))
self.assertEqual(214, len(frames)) self.assertEqual(contract["frames"], len(frames))
self.assertEqual(validated, b"".join(frame.raw for frame in frames)) self.assertEqual(
self.assertEqual(0, parser.startup_crc_errors) validated, b"".join(frame.raw for frame in frames)
self.assertEqual(0, parser.crc_errors) )
self.assertEqual(1, parser.header_errors) self.assertEqual(0, parser.startup_crc_errors)
self.assertEqual(563, parser.skipped_bytes) self.assertEqual(0, parser.crc_errors)
self.assertEqual(0, parser.buffered_bytes) self.assertEqual(1, parser.header_errors)
self.assertEqual(contract["skipped"], parser.skipped_bytes)
self.assertEqual(0, parser.buffered_bytes)
if __name__ == "__main__": if __name__ == "__main__":
+16
View File
@@ -6,6 +6,7 @@ import csv
import glob import glob
import signal import signal
import sys import sys
import time
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -27,6 +28,11 @@ def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--port", help="serial port; auto-detected when omitted") parser.add_argument("--port", help="serial port; auto-detected when omitted")
parser.add_argument("--baud", type=int, default=115200) parser.add_argument("--baud", type=int, default=115200)
parser.add_argument(
"--reset",
action="store_true",
help="hard-reset the ESP32-C3 after opening the serial port",
)
parser.add_argument("--output", type=Path, help="validated binary .trk output") parser.add_argument("--output", type=Path, help="validated binary .trk output")
parser.add_argument("--csv", type=Path, help="decoded CSV output") parser.add_argument("--csv", type=Path, help="decoded CSV output")
parser.add_argument( parser.add_argument(
@@ -109,6 +115,16 @@ def main() -> int:
if args.wire is not None if args.wire is not None
else None else None
) )
if args.reset:
# Match ESP-IDF monitor's USB Serial/JTAG hard-reset state:
# release DTR/RTS first, discard the old session, pulse reset,
# and keep this same reader open for the new boot stream.
sensor.dtr = False
sensor.rts = False
sensor.reset_input_buffer()
sensor.rts = True
time.sleep(0.2)
sensor.rts = False
writer = csv.writer(decoded) writer = csv.writer(decoded)
writer.writerow(CSV_COLUMNS) writer.writerow(CSV_COLUMNS)
while not stop_requested: while not stop_requested: