# 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.