Files
vt1200/dwt/notes/CHALLENGE_ORACLE.md

182 lines
8.6 KiB
Markdown

# dwt_prf challenge transform — runtime oracle (ground truth)
Status: **DONE.** The firmware's `dwt_prf` transform has been driven directly on
the emulated VT1200 with **no TCP connection**, and its input→output behaviour
captured for 180 vector instances (177 distinct 16-byte inputs).
ROM MD5 `b46a3e442ccb86134d2356bd70d22fd2`; no driver edits; private NVRAM.
Deliverables:
| path | what |
|---|---|
| `dwt/oracle.lua` | MAME `-autoboot_script`; calls the firmware's own INIT / EXPAND / TRANSFORM / FREE |
| `dwt/oracle.py` | host driver: deterministic vector generation, two fresh-NVRAM boots, live-probe cross-check, characterisation, JSON writer |
| `dwt/challenge_vectors.json` | the vectors (top-level JSON **array**) |
| `dwt/evidence/oracle/` | per-boot inputs/outputs, full `stdout.log` with seed/key dumps, `characterisation.json` |
## 1. Method — direct invocation of the firmware's own code
`dwt_prf` (`0x3F59FDC0`) performs exactly this sequence around the transform
(disassembly in `harness/mame/hostport_dis34.txt:160152`):
```
0x3F5A9620(1) ; allocate/init (Ghidra 0x07EB52C4)
0x3F5AA2C0(0x1BE810) ; expand key schedule (Ghidra 0x07EB5458)
0x3F5A09C0(buf, 16) ; the transform (Ghidra 0x07EB4138)
0x3F5A9FA0() ; free (Ghidra 0x07EB53F4)
```
`dwt/oracle.lua` calls those same ROM routines in that same order at frame 3000
(OS up, `VT1200_SKIP_LEVEL1=1`), once per run for INIT/EXPAND, then once per
vector for the transform, and FREE at the end. No reply bytes are faked and no
firmware code is bypassed: everything between the calls is the stock firmware.
Calling convention (read from the firmware's call sites, e.g. `3f5a06c0`):
* caller pushes **A14**, writes arguments upward starting at A14, then `CALLA`
pushes the return address;
* a routine called with one or more args ends with `rets 2`, which pops the
return address and the saved A14; a zero-argument routine (`0x3F5A9FA0`)
uses a bare `CALL` and plain `rets`.
The harness supplies a scratch stack at bit `0x01840000` and argument block at
`0x01800000` (top 1 MiB of main RAM, also used by the hello-world payload),
disables ST.IE for the duration, and puts a breakpoint at a synthetic return
address (`0x018C0000`). The interrupted firmware PC, SP, A14 and ST are saved
and restored after every call. `MAME` keeps pumping frame callbacks while the
debugger is halted (`running_machine::run` → `video.frame_update` when paused),
which is what lets the Lua state machine advance one call per stop.
### Validation against live TCP captures
`dwt/evidence/run-live/render-probes.txt` has 11 chosen replies captured through
a **real TCP connection** and the actual `dwt_prf` path. The direct call
reproduces all 11, byte for byte, e.g.
```
live zero reply -> out 90 de 09 b4 72 85 58 17 (x2 records)
direct f(00*8) -> 90 de 09 b4 72 85 58 17 (x2 records)
```
`characterisation.json` records `live_probe_agreement: 11/11`.
## 2. Direction — what the buffer holds before vs after
From the `dwt_prf` disassembly (`3f59ff50`..`3f5a08f0`) and the live dumps
`chal_sent.bin` / `cmp_a9.bin`:
* the terminal picks four 32-bit words `W0..W3` and stores them **host order**
at `frame+0x100` bits — this is the **expected** value;
* it `ntohl`s each word into `frame+0` and sends **those** 16 bytes: the wire
challenge `C`, with `W = ntohl(C)` (per-word byte swap);
* it receives the reply `R`, `ntohl`s each word **in place** → buffer `X`;
* it applies the transform **in place** → `Y = f(X)`;
* it compares `Y` with `W`.
So the firmware transforms the reply, not the challenge. The pass condition is
```
f(ntohl(R)) == W <=> R = ntohl(f^-1(W)) = ntohl(f^-1(ntohl(C)))
```
The vectors' `input_hex` is `X` (post-`ntohl`); `output_hex` is `Y = f(X)`.
An echo `R = C` gives `X = W` and fails unless `f` is the identity; the live
memcmp returns were `0xFFFFFFFF` for echo and `1` for the word-swapped answer,
which is exactly what a non-identity `f` predicts. Live challenge used in the
vectors (one historical capture):
`f32cec8d0000ead00263a8c0f324ec8e`, whose `f` input is
`8dec2cf3d0ea0000c0a863028eec24f3`.
## 3. Characterisation
| property | result | evidence |
|---|---|---|
| **deterministic across boots** | **yes** — 177 distinct inputs identical on two fresh-NVRAM boots; a third boot that hijacks at frame 2500 is also identical | `runA/out.txt` vs `runB/out.txt` vs `runC_frame2500/out.txt` |
| key schedule fixed | yes — seed block `0x1BE810` and full 128-byte key schedule at `@0x1C0260` are identical in both boots (`seed sha256 dafc2461…`, `key sha256 4f897a57…`) | `runA/stdout.log` |
| **per-8-byte independence** | **yes** — records are processed separately; 0/32 independence-grid errors; 64 single-bit changes in record 0 changed record 1's output in **0** cases; a repeated record gives a repeated output | `characterisation.json` |
| **permutation on samples** | 152 distinct inputs → 152 distinct outputs; **0 fixed points** | `characterisation.json` |
| **avalanche** | flip one bit of the first record: **32.66 / 64** output bits change on average (min 23, max 42) | 64 flips of the ascending baseline |
| in-place | yes — the 16-byte buffer is overwritten with the result | `0x3F5A09C0` writes through its argument pointer |
### Intermediate states (`dwt/evidence/oracle/stages.txt`)
`dwt/evidence/scripts/oracle_stage.lua` calls the per-record stage
`0x3F5ABA40` alone and stops at two interior points:
```
0x3F5ABB30 after the first font render returns -> "feistel_in"
0x3F5AC620 before the final font render -> "pre_final_render"
```
For all six captured records `feistel_in == input` and
`pre_final_render == output`. That is not a broken probe: `dwt_prf` calls
INIT with argument **1**, and that mode leaves `@0x1C0220` / `@0x1C0240`
NULL (`0x3F5A9990: dec a9; jreq L3F5A99F0`), so both "font render" calls in
`0x3F5ABA40` take their **memcpy branch** (`0x3FE169C0`, via the
`jrne L3F5AC820` test on the second argument). On this path the advertised
font-render layers are identity copies, and the effective transform is the
16-round Feistel over the 128-byte key schedule expanded from `0x1BE810`
(plus the `0x1BED40/0x1BEF00/0x1BEF80` auxiliary blocks). This does not
contradict the static note that the calls are present; it says what they do
under `dwt_prf`'s mode.
## 4. Vector file
`dwt/challenge_vectors.json` is a **top-level JSON array**:
```json
{"input_hex": "00000000000000000000000000000000",
"output_hex": "90de09b47285581790de09b472855817",
"meta": {"stage": "full", "boot": 2, "kind": "zero", "index": 0}}
```
* `input_hex` — 16 bytes, the transform's input `X` (post-`ntohl` buffer).
* `output_hex` — 16 bytes, `Y = f(X)`.
* `stage` — `full` for every entry; the intermediate per-record values are in
`dwt/evidence/oracle/stages.txt` (columns:
`index input feistel_in pre_final_render output`).
* `boot` — number of independent fresh-NVRAM boots that produced the same
output (2 for every entry).
Load with:
```python
import json
vectors = json.load(open("dwt/challenge_vectors.json"))
for v in vectors:
x = bytes.fromhex(v["input_hex"]); y = bytes.fromhex(v["output_hex"])
```
Composition: 180 instances / 177 distinct inputs — 13 structured specials,
the live wire challenge and its word-swapped form, 11 live-probe inputs,
64 single-bit flips of the ascending baseline's first record, 8 flips of its
second record, a 32-vector independence grid, 48 random inputs, and two
deliberate duplicate inputs (identical outputs).
## 5. Reproduce
```sh
cd /home/arti/code/k-space/vt1200
python3 dwt/oracle.py # two boots + stage capture; ~35 s
# options: --quick (one boot), --no-run (rewrite JSON from saved outputs),
# --skip-stages
```
Each boot is `VT1200_SKIP_LEVEL1=1`, `-video none -sound none -nothrottle`,
`-debug -debugger none`, its own `-nvram_directory` under
`dwt/evidence/oracle/run*/nvram`.
## 6. Caveats
* `VT1200_SKIP_LEVEL1=1` is the documented harness divergence used to reach the
OS (and therefore the same firmware code the live TCP captures reached). It
does not change the transform: the direct outputs equal the live captures.
* The live challenge is time-dependent; the value in the file is one real
capture from `dwt/evidence/run-live/remote-attempt.txt`. Any 16-byte value is
a valid input to `f`.
* "Fixed key" is established for **this** ROM image and boot state: the seed
and auxiliary blocks are boot-constant (two fresh NVRAMs, plus a differently
timed hijack). Nothing here proves another firmware version shares them.
* No live TCP session was used during the vector runs; the live cross-check is
the 11 previously captured probe replies plus the challenge/memcmp dumps.