Proof-gated endpoint
In short
- A small computer (a Raspberry Pi) sits between an AI server and the outside world. It lets an answer out only if a cryptographic proof shows the approved model produced it from that exact question.
- That stops a hacked server from changing what an answer says. It doesn’t stop it from signalling through when answers leave, in what order, or which ones fail.
- We measured those three signals on the real device, one client at a time. Release rules stopped the ordering and timing codes we tried (timing at a cost in speed, with a small trace whose status is in the table below); deliberate failures still get through, fewer of them with a cap that costs availability.
This work was supported by ERA’s Frontier AI Security Residency. It is work in progress (November 2026), not production security.
Why this matters
The most valuable thing an AI lab owns is its model’s weights. A hacked server, or a model acting against its owners, could try to smuggle weights out a little at a time, hidden inside ordinary answers. If every answer has to pass through a checkpoint that verifies a proof, the server loses the freedom to hide data in the content of its answers. The checkpoint is small and simple, so it is much easier to secure than the datacenter behind it.
+---------+
user ---- question --> | endpoint | ---- question ----> AI server
| (Pi) | (may be hacked)
<--- answer ----- | | <--- answer + proof
or "unavailable"+---------+
We call the Pi running the checking software the endpoint.
What we found
| Way to smuggle data out | Basic endpoint | Best endpoint so far | What it costs |
|---|---|---|---|
| Change what an answer says | Blocked by the proof | Blocked | Each check takes about 0.24 s on the Pi |
| Reorder answers | Leaks | The ordering code we tried no longer gets through: answers leave in the order questions arrived | A slow answer delays the ones behind it |
| Delay answers | Leaks | Much reduced: each answer has a set release time and can’t leave before it; a small trace remained without spin, and with spin a 512-answer test was inconclusive on the stock system and found nothing on the stripped image (one run) | Answers take 4.0 s instead of about 0.8 s |
| Fail chosen answers on purpose | Leaks | Still leaks, less: with a failure cap, 4 of 16 planted bits got out instead of 16/16 (one run) | 18 of 64 answers ok instead of 60 of 64 |
These are results for one tiny, deterministic test model and the encoders we tried, not limits on what an attacker could do. Details and numbers are in What a hacked server can still leak.
Status
| Version | What it is | Status |
|---|---|---|
| v0 | The checking software on the Pi’s standard operating system | Done and measured |
| v1 | Stricter software in Rust (fixed message format, release in arrival order or at a fixed time, a failure cap, a sandboxed checker, fixed-word logs, signed updates) on a stripped-down, read-only system with one open port | Done and measured on the Pi; a rebuild with a locked-down kernel and no login is awaiting its Pi check |
| v2 | Secure boot, two physical ports, a counter against restoring an old update store, signed images | Planned |
Where to start
- 5 minutes: this page, then What a hacked server can still leak.
- The whole system with diagrams: How the endpoint works.
- Assessing the security: How hard the endpoint is to attack, then the Attack catalogue and Threat model.
- Checking the numbers: Measurements and Reproduce the results.
- New to the terms: the Glossary.
The code is at github.com/0x471/fasr-proof-gated-endpoint.
How the endpoint works
In short
- The endpoint checks each answer’s proof against the question it received, never against what the server says the question was.
- It sends back either the proven answer or a fixed error, never anything the server wrote, and it decides itself when and in what order answers leave.
- This page is the whole system on one page: every part with a diagram, the code that implements it and the evidence behind it. Every number here comes from Measurements, which is the reference.
The pieces
Two machines and two network links. Today the laptop plays both the AI server and the outside observer, and the Pi runs the endpoint. The endpoint listens only on the outside link and dials out to the server itself, so it has no listener for the server to connect to.
The device runs the Rust endpoint in endpoint/, one static binary with no Python and no EZKL command-line tool. The Python endpoint in pge/ is the reference it is tested against, reply for reply; the function names in the tables below are the Python ones, and the Rust crates mirror them.
| Piece | Device (Rust) | Reference (Python) | Job |
|---|---|---|---|
| Endpoint loop | endpoint/endpoint | pge/service.py | Admits questions, forwards them, checks answers, decides when replies leave |
| Release rules and failure cap | endpoint/core | pge/service.py, observer/cap_model.py | A deterministic state machine: arrival or fixed release, halt, cap |
| Message format | endpoint/wire | pge/protocol.py | Fixed-size binary frames; strict parsing; one accepted form |
| Binding and checker | endpoint/verify | pge/binding.py, pge/bundle.py, pge/domain.py, pge/cli.py | Encodes questions like EZKL, pins the model, verifies with EZKL’s library, decodes proven outputs |
| Signed updates | endpoint/update | none | Accepts a new model and policy only if signed, newer and for this device |
| Server | prover/server.py | Proves each question with EZKL; can misbehave on purpose (tamper, replay, delay, hide bits) | |
| Observer | observer/client.py | Sends questions, records each reply and its arrival time, decodes hidden bits |
Today both links share one Ethernet cable, so the separation between the server side and the outside side is only by address. A board with a second wired port (planned) makes the two sides physically separate, with nothing forwarded between them.
The life of one question
The server never gets to say what the question was. The endpoint rebuilds that part of the proof itself, so the server only supplies outputs and a proof, and they count only if the proof over the endpoint’s question and those outputs verifies.
| Step | Code | Evidence |
|---|---|---|
| Read and parse the question | serve_client and parse_request | test_client_violation_closes_the_connection |
| Encode, check, admit or refuse | submit | test_bad_requests_never_reach_the_server |
| Accept the first answer only | on_answer | test_duplicate_answer_gives_one_reply, test_late_answer_is_dropped_and_answered_once |
| Build instances, verify, decide | _check | test_proof_for_another_request_is_blocked |
| Decide when the reply leaves | _release and _pump | When answers leave |
- Malformed frames: a bad frame from a user closes that user’s connection; a bad frame from the server drops the server link and fails every pending question, because a broken message can’t be tied to one question.
- Deadline: checked before a check starts and again when the result comes back, so a late result can’t slip out.
- One decision per question: late, duplicate and unknown answers from the server are counted and dropped, never sent to a user.
How a proof is tied to the question
A valid EZKL proof only says “this circuit turned these public input values into these public output values”. Binding makes it a proof about this question, this model and this reading of the output, by never taking any of the three from the server.
The five rules
- One pinned model. The endpoint loads one bundle:
settings.json, the verification keyvk.keyand the setup filekzg.srs, each pinned by SHA-256 and re-checked before every verify. The server can’t pick another model or key. - The endpoint encodes the question. It computes the input values exactly as EZKL does, so a proof made for any other encoded question fails.
- Outputs are read only from verified values. The server’s outputs are released only if the proof over the endpoint’s inputs and those exact outputs verifies. The endpoint never uses an answer the server states, and never reads the proof file’s unbound fields (
pretty_*,timestamp,hex_proof). - The proof length is pinned. EZKL accepts a valid proof with extra bytes appended, which would be a free channel, so any length other than 3,072 bytes is rejected.
- Impossible questions are refused up front. A question that would overflow EZKL’s range checks can’t be proved, so it gets
bad_requestbefore it reaches the server; otherwise anyone could make the server fail on demand. The limits come from a domain file, which is trusted configuration: it is not among the bundle’s pinned hashes.
What went wrong without each rule
| Pitfall | What happened | Rule |
|---|---|---|
| Settings not pinned | Changing only the output scale from 26 to 13 kept the proof valid but decoded 0.9666 as 7918.6 | 1 |
| Python rounding | Python’s round() rounds half to even: 2.5/8192 encodes as 2, EZKL gives 3 | 2 |
| Server-supplied answer | The server could state any answer next to a valid proof | 3 |
| Padded proofs | EZKL still accepts a valid proof with bytes appended | 4 |
| Out-of-domain questions | A question no proof can exist for makes the server fail on demand | 5 |
Worked example (the tiny test model, y = ReLU(xW + b), 4 inputs and 3 outputs): x = [0.5, -0.2, 0.9, 0.1] encodes to inputs [4096, -1638, 7373, 819] at scale 13; the proven outputs are [64869479, 0, 0] at scale 26, which decode to y = [0.9666305631399155, 0, 0].
Limits
- It binds the encoded value, not the raw number: 0.5 and 0.50001 both encode to 4096, so one proof serves both.
- Replay: the proof has no nonce. The endpoint answers each pending question once and drops duplicates, so nothing is delivered twice, but an old proof for the same encoded question still verifies for a new request. For one deterministic model that gives the server no choice of output; it isn’t proof of fresh computation.
- The rules are specific to EZKL 23.0.5 and these test models. The test model is tiny (4 inputs, 3 outputs) and deterministic because the research question is about the endpoint, not the prover. A production proof system would take the same place in the design, but it needs its own binding rules (what exactly the proof states, how inputs and outputs are encoded, any randomness) and its own review.
Full rules and test vectors: Request binding specification.
The checker
The cryptography is EZKL’s verifier (23.0.5), compiled into the endpoint as a library. The endpoint has to trust its verdict, since it can’t tell if the verifier wrongly says yes, but it contains the verifier’s failures: each check runs in a fresh, short-lived child process (the same binary started with --verify-child), boxed in by limits, whose only way to say yes is a strict exit code and one word.
| Limit | Value | Why |
|---|---|---|
| Address space (Linux only) | 256 MiB | Stops a proof that makes the verifier allocate without bound |
| CPU time | up to the deadline | A second stop for a busy loop, besides the wall-clock deadline |
| File size | 0 | The check writes no files |
| Open files | 64 | Bounds file-handle exhaustion |
| Core dumps | off | No memory dumps on disk |
| System calls (Linux) | an allow-list of the calls a check was traced making | No files, sockets, programs, new processes or executable memory; another architecture’s calls kill it |
| Threads | Rayon set to 1 (a setting, not an OS limit) | Less memory, steadier timing |
| Output | 64 KiB, then SIGKILL | A flooding child can’t exhaust the endpoint |
| Wall time | the time left until the deadline | Then SIGKILL of the whole process group |
- Why a child process: a crash, a runaway allocation or a hang in the verifier ends only that child. The child reads the model files and the proof from its pipe and re-checks the files’ hashes, so it needs no file access at all.
- Verdicts: ACCEPT needs exit 0 and exactly
accept; REJECT needs exit 1 and exactlyreject; anything else, including a killed child, is ERROR. Only ACCEPT releases an answer, and ERROR counts as a server failure, never as a pass. - Memory: Near the memory limit the outcome is random (address-space randomisation): 128 MiB passed 20 of 20 runs, 144 and 160 MiB only about half; at the endpoint’s setting of 256 MiB every recorded check passed, 231 of 231.
- What it isn’t: a check of the verifier’s correctness. A bug that makes it say ACCEPT for a bad proof is not contained; the allow-list only stops a taken-over checker from reaching anything outside itself.
The Python reference endpoint does the same with EZKL’s command-line tool instead of the library.
What a check costs on the Pi (medians of 20, 19 October):
| Path | ms |
|---|---|
| Raw CLI, default threads (4 cores) | 97-98 |
Raw CLI, one thread (RAYON_NUM_THREADS=1) | 163 |
| With the endpoint’s resource limits (single-threaded launcher) | 207 |
| Re-checking the bundle’s hashes (includes a 4 MB setup file) | 28 |
| Reading and canonicalising the proof | 2 |
| Full endpoint check | 239 |
The message format
Every message is a 4-byte length followed by its payload. Questions and replies have one fixed size for the pinned model, and an answer is a fixed part plus a proof that must have the pinned length, so nothing about a reply’s size is the server’s choice and a parser only has to accept one shape.
| Message | From, to | Payload (test model) | Contents |
|---|---|---|---|
| Question | user to endpoint | 41 bytes (45 with the length) | type 1, id (8 bytes), 4 float64 inputs |
| Forwarded question | endpoint to server | 41 bytes | type 2, seq (8 bytes), the same inputs |
| Answer | server to endpoint | 105 bytes + proof | type 3, seq, 3 output field elements of 32 bytes, then the proof (up to 64 KiB on the wire, must be exactly 3,072 bytes) |
| Reply | endpoint to user | 34 bytes (38 with the length), success or error alike | type 4, id, status (0 ok, 1 bad_request, 2 unavailable), 3 float64 outputs (all zero unless ok) |
- Rejected: a frame of the wrong size, an unknown type byte, an id or seq of 2^53 or more, a NaN or infinite number, a frame that doesn’t arrive in full within the deadline, and ids that don’t strictly increase on one connection.
- Tested by: a fuzz test of 10,500 random and mutated messages per parser (each must be refused, or re-encode to exactly the same bytes), frames split at random points, and boundary tests (a 65,536-byte proof field accepted, 65,537 refused).
Details: Endpoint service specification.
When answers leave
Once the proof fixes what an answer says, the server’s remaining levers are when and in what order replies leave, so the endpoint decides that itself. Every admitted question goes through the same states; the release rule only changes when a decided answer leaves.
| Rule | When an answer leaves | Stops | Costs |
|---|---|---|---|
| Immediate (v0) | as soon as it is decided | nothing | nothing |
| Arrival (v1 default) | after every earlier question’s answer | the server choosing the order | a slow answer holds up the ones behind it, up to its deadline |
| Fixed | in arrival order, and not before admission + deadline (4 s in the tests) | order, and the timing codes we tried | every answer waits the whole budget; a check not done by then is unavailable |
| Fixed + spin 5 ms | as fixed, but the timer wakes 5 ms early and busy-waits until the release time | no average sub-millisecond shift was detected; whether a weaker trace remains is not settled | a little CPU; no callback blocks the loop for more than the spin (K35) |
- Halt (
--halt-after N, off by default): after N server-caused failures the endpoint stops every successful answer: pending and held ones becomeunavailable, and so do later requests. Error replies keep flowing. It caps damage but lets the server switch the endpoint off, and where the halting failure fell, and the timing of the errors, still leak. - Capacity: at most
max_pending(default 32) admitted questions wait at once, held answers included; beyond that the endpoint answersunavailablestraight away.
Bugs found by review in this part, all fixed with tests that fail on the old code:
| Row | Bug | Fix |
|---|---|---|
| K30 | A held success could leave after a halt, or after its own deadline | re-checked when it leaves |
| K31 | Held answers didn’t count towards max_pending, so the queue could grow without bound | counted |
| K33 | With spin, a later answer could overtake an earlier one, letting the server order two error replies | one ordered queue; only the next answer may spin |
| K35 | With spin, a disconnect or halt that decided many answers at once chained one busy-wait per answer, stalling the endpoint for tens of ms | releases only schedule the pump, which busy-waits at most once per run |
From v0 to v2
The same endpoint is built three times, each version removing a way in, and measured the same way each time. v0 and v1 are measured on the Pi; v2 is planned.
| v0 (measured) | v1 (measured) | v2 (planned) | |
|---|---|---|---|
| Operating system | Raspberry Pi OS Lite (Debian 13) | Buildroot 2025.02.18 LTS: kernel 6.6, BusyBox init, read-only root; the endpoint is one static binary | as v1, inside a signed boot image |
| Endpoint | Python, EZKL command-line tool | Rust, EZKL verifier as a library, unprivileged user, sandboxed checker | as v1 |
| Network | one port, ssh and mDNS listening | one open port behind a firewall built into the kernel; the endpoint won’t start without its rules | two physical ports, nothing forwarded |
| Updates | copy files over ssh | signed model and policy in a store on a data partition; a new update comes as a new image or is applied to the card offline | as v1, plus a counter that can’t roll back |
| Login | ssh, HDMI, serial | none (from the 2 Nov rebuild; until then a console login) | none |
What each one exposes, measured:
| Version | Seen from outside | Notes |
|---|---|---|
| v0 (stock OS, 16 Oct) | TCP 22 (ssh) and 8700 (the endpoint); UDP 5353 (mDNS) | 633 packages, 13 running services |
| v0 (stock OS, 29 Oct, endpoint not running) | TCP 22 only (nmap -p-); 691 TCP ports showed as filtered, most likely an artefact of the scan (on the v1 image the same pattern re-probed one by one came back closed); UDP not scanned. On the device: UDP 5353 (mDNS) and two unattributed UDP ports | 633 packages, 13 running services, 66 kernel modules (Wi-Fi and Bluetooth drivers loaded); per-port software in docs/ATTACK_SURFACE.md |
| v1 image with the update store and firewall (30 Oct, booted on the Pi) | TCP 8700 only; all 65,534 other TCP ports dropped (no reply at all, where the image without the firewall refused them); ping dropped | Model and release policy from the signed update store on a writable data partition; nftables rules built into the kernel; the endpoint won’t start without the rules |
| v1 image with the Rust endpoint (30 Oct, booted on the Pi) | TCP 8700 only (connect scan of all 65,535 ports from the laptop; every other port refused) | One static endpoint binary as user pge, fixed release 4 s, read-only root, no Python, no SSH; UDP not scanned |
v1 development image (16 Oct, sdcard.img 159 MB) | 0 of 65,535 TCP ports open; top 100 UDP ports closed; no IPv6 | No endpoint was running on this image yet, so this shows a quiet base system, not a working hardened endpoint |
- How the v1 image is built (
image/build.sh, on a Linux VM because Buildroot doesn’t build on macOS): download the Buildroot release and check its SHA-256, start fromraspberrypi4_64_defconfig, add the static endpoint, the firewall and a fixed address (image/v1.fragment), apply the signed update into the store exactly as the device would, and check that every added option, kernel option included, survived the build. - The 2 Nov rebuild adds a locked-down kernel (no kernel modules on the image and module loading switched off at boot, kernel lockdown, no raw memory, debugger or tracing interfaces, no IPv6, radios, sound or USB;
image/kernel.fragment) and removes every login prompt. Its check on the Pi is pending. - What v1 still lacks: the second port, and a way to deliver an update on the device itself.
Secure boot (v2, planned):
- The key hash in the chip is permanent, so this is done on a spare Pi, not the development one.
- Updates: versioned packages signed with update keys (in the style of The Update Framework), a rule that refuses an older version, and an install that survives power loss at every step. Boot signing, update signing and model approval use separate keys.
- Known gap: the Pi has no battery-backed clock, so after a reboot it can’t tell how old its version is, and an attacker who blocks updates can keep it on an old one.
- This part is planned from Raspberry Pi’s documentation; nothing about secure boot has been run yet.
More: How hard the endpoint is to attack.
Who attacks it
The main attacker is the AI server itself (hacked, or a model working against its owners) together with an accomplice outside who reads the signals. The endpoint’s job is to give that pair as little to work with as possible.
- Protected: the weights on the server, and everything that decides what gets out: the pinned bundle, the endpoint’s code and settings, when replies are released, and the endpoint’s own logs.
- Assumed: every path from the server to the outside goes through the endpoint (not yet enforced on v0); the proof system is sound; physical attacks are out of scope.
Signals the server controls even with valid proofs:
| Signal | Defence | Status |
|---|---|---|
| What an answer says | proof + binding | blocked for the test model |
| Order of replies | arrival-order release | the ordering code we tried no longer gets through (one connection) |
| Timing | fixed release time, plus spin | much reduced; see below |
| Which answers fail | a cap on failures per window (Rust endpoint) | reduced at a large cost in availability; measured once on the Pi |
| Reply size | fixed-size replies | blocked at the message level; packet-level details on the outside link not measured |
| Logs | fixed words only | the number and timing of events still follow the server |
| Keeping the endpoint busy | fixed time slots (planned) | open |
The catalogue has 49 scenarios: 37 stopped, 2 allowed on purpose, 8 that still work, are only partly stopped or are untested, and 2 out of scope. Some were shown on the Pi, some only in tests, and some are only proposed; the counts describe coverage, not a security score. Every row is in the Attack catalogue; the full model is in Threat model.
Measuring what still leaks
We made the server hide a known message in its behaviour and had an outside observer try to read it back. If the observer recovers the planted bits, that channel is open; if a fixed decoder fails, it didn’t recover that message in that run (a smarter one might still).
Three encoders (prover/server.py):
timing_bits: delay an answer by 1 s for each 1 bit.order_bits: take 3 questions at once and answer them in an order whose permutation index carries 2 bits.fail_position: in each block of 16 answers, make exactly one fail (a tampered output); its position carries 4 bits.
The statistics in plain words:
- Permutation test: shuffle the planted labels thousands of times and see how often a difference as big as the real one appears by chance. That fraction is the p-value.
- Held-out decoder: fit a simple threshold on half the answers and score it on the other half, both ways round, so the decoder never sees the answers it’s scored on. Its null refits the decoder on shuffled labels each time.
- Mutual information: how many bits per answer the binned arrival time tells about the hidden bit, with a shuffled-label baseline. It describes this one statistic, not a limit on what leaks.
- Honest control: the same analysis on a run where the server hides nothing; any “signal” there is noise.
Results on the Pi (19 October, 4 s budget, one client at a time, 16 bits per cell):
| Channel the server uses | immediate (v0) | arrival (v1) | fixed |
|---|---|---|---|
| Timing: delay an answer by 1 s for a 1 | 16/16 recovered | 16/16 recovered | not recovered (12/16 wrong); answer times spread over 2.0 ms |
| Ordering: answer 3 concurrent requests in a chosen order | 16/16 recovered | decoded bits constant | decoded bits constant |
| Failure position: fail one chosen answer in each 16 | 16/16 recovered | 16/16 recovered | 16/16 recovered |
| No signal (honest server, timing decoder) | 8/16 wrong | 10/16 wrong | 7/16 wrong |
| Honest answer time, median | 0.81 s | 0.83 s | 4.0 s |
The leftover timing trace. Without spin, answers for 1 bits left 0.66 ms earlier than answers for 0 bits (128 answers, p < 0.0001; the honest control showed nothing), and a decoder fitted on one half and tested on the other got about three bits in four right. The spin was re-tested on 20 October under a protocol written before the data (docs/EXPERIMENT_PROTOCOL.md; 512 answers, the corrected spin code, microsecond timestamps). With a 5 ms spin no mean shift was detected, and a 512-answer test with its decision rule fixed in advance was inconclusive: the held-out decoder got 143 of 256 right on its better half (about 56 %, p = 0.041, where the rule needed p < 0.01 to call it a leak and p > 0.05 to call it not detected). What this decoder finds is small, but the trace isn’t shown to be gone, and a cleverer decoder might find more. On the stripped v1 image (30 Oct) the same test found nothing (p = 0.50, one run), so the residue may depend on the system around the endpoint; that hasn’t been isolated.
What it doesn’t show: a limit on what a cleverer attacker could do, several clients at once, or packet-level signals. All numbers and caveats: Measurements and What a hacked server can still leak.
What a hacked server can still leak
In short
- A proof fixes what an answer says, but a hacked server still controls when it answers, in what order, and which answers fail. Each of these can carry hidden data.
- On the real device, with one client at a time: releasing answers in arrival order stopped the ordering code we tried, and releasing every answer at a fixed time stopped the timing code we tried, at the cost of speed. A small leftover timing trace is described below.
- Deliberate failures leaked under every release rule we tried. A cap on failures limits that channel, at a large cost in availability; it remains the main open problem.
Why a proof isn’t enough
Think of the endpoint as a strict customs officer who checks that every parcel’s contents match the paperwork. A smuggler who can’t change the contents can still send a message by choosing when parcels arrive, in what order, or which ones get rejected. Anyone watching from outside who knows the code can read the message back.
How we tested it
We made the server hide a known message (16 bits) in its behaviour, and had an observer outside the endpoint try to read it back with a fixed decoder. If the observer recovers all 16 bits, that channel is open. If the decoder fails, it didn’t recover that message in that run; a different encoder or a smarter observer might still get something through, which is why the leftover trace was tested separately (below).
We did this on the real Raspberry Pi for three ways of signalling and three release rules, with a 4-second budget for the fixed-time rule, one client at a time.
Results
| Channel the server uses | immediate (v0) | arrival (v1) | fixed |
|---|---|---|---|
| Timing: delay an answer by 1 s for a 1 | 16/16 recovered | 16/16 recovered | not recovered (12/16 wrong); answer times spread over 2.0 ms |
| Ordering: answer 3 concurrent requests in a chosen order | 16/16 recovered | decoded bits constant | decoded bits constant |
| Failure position: fail one chosen answer in each 16 | 16/16 recovered | 16/16 recovered | 16/16 recovered |
| No signal (honest server, timing decoder) | 8/16 wrong | 10/16 wrong | 7/16 wrong |
| Honest answer time, median | 0.81 s | 0.83 s | 4.0 s |
What this means:
- Order is easy to protect. Sending answers back in the order the questions arrived removes the server’s choice: the observer decoded the same bits whatever the server planted.
- Timing costs speed to protect. The endpoint aims to send every answer 4 seconds after its question, so a delay chosen by the server no longer shows in a simple way. But every user now waits the whole budget, even when the answer was ready much sooner (the last row above). Random delays are not a cheaper fix: the server’s own delay still shifts when answers leave, an observer with enough answers can read it, and the leak per answer only shrinks as the added wait grows (a model in Measurements).
- A small timing trace was left. Without spin, answers for 1 bits left 0.66 ms earlier than answers for 0 bits (128 answers, p < 0.0001; the honest control showed nothing), and a decoder fitted on one half and tested on the other got about three bits in four right. With a 5 ms spin no mean shift was detected, and a 512-answer test with its decision rule fixed in advance was inconclusive: the held-out decoder got 143 of 256 right on its better half (about 56 %, p = 0.041, where the rule needed p < 0.01 to call it a leak and p > 0.05 to call it not detected). What this decoder finds is small, but the trace isn’t shown to be gone, and a cleverer decoder might find more. On the stripped v1 image (30 Oct) the same test found nothing (p = 0.50, one run), so the residue may depend on the system around the endpoint; that hasn’t been isolated.
- Failures stay open. The server can always make an answer fail by sending a bad proof, and which answers fail is itself the message: choosing one failure in every 16 answers sends 4 bits per 16 answers. Only 4 of 64 answers failed (one chosen position in each block of 16), yet all 16 bits got through: 0.25 bit per request with this encoder.
Closing the failure channel
No release rule can stop deliberate failures, because a failure looks like a real fault. What the endpoint can do is limit how many failures it tolerates, and count every failure, including answers the server lets miss their deadline:
- Over a window of N answers, if the observer only sees which answers failed and the endpoint allows at most F failures, the server can choose among at most C(N,0) + C(N,1) + … + C(N,F) patterns, so it can send at most log2 of that many bits per window.
- Stopping the endpoint at the first failure in a window of N answers leaves N + 1 outcomes (no failure, or a failure at one of N positions), so at most log2(N + 1) bits per window, but only if someone decides when the endpoint restarts; and it lets the server switch the endpoint off whenever it wants.
These bounds hold only for that observation (failures only) and that policy; timing, logs and restarts need their own accounting. The Rust endpoint now has such a cap, and one run on the Pi showed the trade-off: far fewer hidden bits got out, but many honest answers were refused too (numbers in Measurements). How the failure code scales with longer messages:
| What the server may do | Bits it can carry |
|---|---|
| Fail any answers it likes, no cap | up to 1 bit per answer (512 answers carry 512 bits) |
| Fail exactly k of n answers | log2 C(n, k), e.g. 168.9 bits for 32 failures among 512 answers |
Any pattern the failure cap allows (W = 64, F = 1, C = 64, observer/cap_bound.py) | at most 11.02 bits in 64 replies, 63.05 in 512, 472.71 in 4,096 (about 0.12 bit per reply) |
Questions people ask
Why not just release every answer exactly 4 seconds after its question? That is what the fixed rule does. The clock starts when the question arrives at the endpoint, and the answer leaves at that moment plus the budget, whatever the server did; if the server’s answer isn’t checked by then, the reply is “unavailable” instead, which is the failure channel. The leftover trace was not in that schedule but in how precisely the computer acts on it: waking a sleeping program and pushing a packet out takes slightly different time depending on what the processor was doing just before. When the server answered late, the endpoint had just been busy checking that proof; when it answered early, the endpoint had been idle for seconds. That is our leading explanation, not yet isolated. Waking a few milliseconds early and busy-waiting until the exact moment (the “spin”) removed the difference we could see, and on the stripped image one run of the same test showed nothing.
How can a few failures carry many bits? The message is in which answer fails, not in how many. If one answer in each batch of 16 fails, its position (one of 16) carries 4 bits. Longer messages need proportionally more failures or longer batches; the table above shows the trade-off, and a cap limits any such code.
Can the server hide bits in the network packets themselves (packet sizes, TCP header fields)? Not directly. The endpoint keeps separate connections: its own connection to the server, and one with each outside client. Nothing the server sends is forwarded as packets. It reads each message, keeps only the checked output values, and writes a new reply of a fixed size. So the server never touches the outside packets’ headers or sizes. What it still controls is when it answers, which answers fail, and how busy it keeps the endpoint. Whether those choices show up in packet-level details on the outside link hasn’t been measured yet (see below).
What was the fuzzing for? A parser is the code that reads an incoming message (a question from a client, an answer from the server). The fuzz test throws thousands of random and corrupted messages at each one and checks that every one is either refused or, if accepted, writes back to exactly the same bytes. That makes sure malformed input can’t crash or confuse the endpoint, and that a message has only one accepted form, so extra bytes or alternative encodings can’t carry hidden data. It is not a timing test.
Does this depend on EZKL or on the Raspberry Pi? Partly. How a proof is tied to its question (the binding rules) is specific to EZKL and must be redone for any other proof system. The side-channel defences (fixed-size messages, release in arrival order or at a fixed time, the failure cap) don’t depend on the proof system. What the timing defence achieves does depend on the hardware and operating system, which is why it was measured on the stock system and on the stripped image separately.
What about physical attacks? Out of scope: the device is assumed to sit inside the data centre’s physical security. Basic measures (secure boot, a TPM, a counter that can’t be rolled back) are planned as a design, not as a defence against someone with the board in hand.
What this doesn’t show
- These are the rates one particular hiding method achieved, not the most any method could achieve.
- One device, one network, one client at a time (the ordering test used several questions at once on one connection). With several clients, the fixed-time rule and its spin interact in ways not yet measured.
- Packet-level signals (packet sizes, network timing details) haven’t been measured yet.
All numbers and their conditions are in Measurements.
How hard the endpoint is to attack
In short
- The endpoint is built in layers: check only what it computed itself, accept only one exact message format, contain the proof checker, and run on a system with almost nothing else on it.
- The catalogue has 49 scenarios: 37 stopped, 2 allowed on purpose, 8 that still work, are only partly stopped or are untested, and 2 out of scope. Some were shown on the Pi, some only in tests, and some are only proposed; the counts describe coverage, not a security score.
- Code reviews found five real bugs in the code that holds answers back until their turn; all fixed, each now covered by a test that fails on the old code.
The layers
1. Check only what the endpoint computed itself. The endpoint never trusts the server’s account of the question or the model. It encodes the question itself, pins the approved model’s files by hash, fixes the proof length, and reads the output only from the proven values. See How the endpoint works.
2. Accept one exact message format. Questions, forwarded questions and replies have one fixed size and layout for the model in use. The server’s answer is a fixed part plus the proof, which may be up to 64 KiB on the wire and must then be exactly the pinned length. Anything else is rejected: oversized or truncated messages, wrong types, numbers that aren’t finite. A fuzz test throws 10,500 random and mutated messages at each parser.
3. Contain the proof checker. The checker is a separate program with hard limits: 256 MiB of memory (enforced on Linux, which the Pi runs), CPU time up to the deadline, no file growth, 64 open files, no crash dumps. It is killed if it runs past its deadline or prints too much. A checker that hits a limit counts as a failed check, never as a pass. In the Rust endpoint on Linux, the checker also runs under a system-call allow-list once its limits are set: it can’t open files or network connections, run programs, start processes or map executable memory, so a bug in the proof code can’t reach anything outside it (it could still print a wrong verdict).
4. Say nothing the server chose. Users only ever see the proven output or a fixed error (unavailable, or bad_request for a well-formed question the model can’t answer); a malformed message just closes the connection. The endpoint’s log uses a fixed vocabulary, never text from the server; it still records a counter, a hash of the question and the time taken, and which events happen and when still follow the server.
5. Run on a minimal system (v1). The standard Pi system has ssh and other services listening. The v1 image is built for the endpoint alone: one static Rust binary running as an unprivileged user, a read-only system, one open port behind a firewall built into the kernel (the endpoint won’t start without its rules), no ssh and no scheduled jobs. The 2 Nov rebuild also locks the kernel down (no kernel modules on the image and module loading switched off at boot, kernel lockdown, no raw memory, debugger or tracing interfaces, nothing for IPv6, radios, sound or USB) and removes every login prompt; its check on the Pi is pending.
6. Accept only signed changes. The model files and the release policy come from a signed update: the endpoint refuses rollbacks, replays, updates for another device and unknown or revoked keys (catalogue rows K36-K48). Still planned (v2): secure boot, so the board starts only a system we signed, and a counter in a TPM so a whole older copy of the store can’t be restored (K43).
Catalogued scenarios
| Outcome | Scenarios | Examples |
|---|---|---|
| Stopped | 37 | Edited answers, a proof for a different question, a proof from another model, padded proofs, garbage or oversized messages, a taken-over checker reaching files or the network, answering twice or late, malicious proofs that try to exhaust the checker, using a disconnect to stall the endpoint, rolled-back, replayed or substituted updates, a changed or old release policy, updates for another device or from an unlisted key, overlapping or live installs, a key revocation that didn’t stick, a hand-edited state |
| Allowed on purpose | 2 | The same question gets the same answer again (the server has no choice there); an endpoint set to stop after failures can be stopped by the server |
| Still works, partly stopped or untested | 8 | Timing (much reduced), failing on purpose, never answering, keeping the endpoint busy, loading the endpoint to delay releases, other services on the standard system, probing the endpoint’s rules, restoring a whole older copy of the update store |
| Out of scope | 2 | Learning the model from many correct answers; physical attacks |
Every scenario has a row in the Attack catalogue with its result and its evidence, or says that there is none yet.
Bugs found by review
Independent reviews of the code (run with AI tools) found five real bugs, all in the code that holds answers back until it is their turn:
- an answer that was already checked could still leave after the endpoint had decided to stop answering;
- in arrival order, under a stalled event loop, a checked answer could leave after its own deadline;
- answers waiting their turn didn’t count towards the endpoint’s capacity limit, so the queue could grow without bound;
- with fixed-time release and the spin option, a later answer could overtake an earlier one whose deadline had nearly passed, so the server could choose the order of two failures again;
- with spin, a disconnect or halt that decided many answers at once chained one busy-wait per answer, stalling the endpoint for tens of milliseconds.
Each now has a test that fails on the old code (catalogue rows K30, K31, K33 and K35).
What isn’t hardened yet
Both sides still share one cable, so nothing physically forces the server’s traffic through the endpoint; there is no secure boot; a whole older copy of the update store is accepted (K43); a new update needs a new image or an offline step; the verifier’s own verdict is trusted; and the Linux kernel, though trimmed, is still large. See Limitations and open questions.
Limitations and open questions
In short
- Not built yet: two physically separate ports, secure boot, a counter that stops a whole older store being restored, update delivery on the device, a hash-chained log.
- Not measured yet: a real upper bound on leakage, several users at once, packet-level signals, the speed-versus-leakage trade-off.
- The results assume all of the server’s traffic goes through the endpoint, one approved model, and a sound proof system.
Not built yet
- Two physical ports. The v1 image runs the endpoint, but the server side and the outside still share one cable; a board with a second wired port is planned.
- v2: secure boot (which permanently writes a key into the board, so it needs a spare board); a TPM counter so a whole older copy of the update store can’t be restored (K43); a power-loss test of update installation on the Pi’s file system; a hash-chained log sent to a separate collector.
- Update delivery on the device. The image has no login; a new update comes as a new image or is applied to the card’s store on another machine, where the same rollback and replay rules apply because the state lives on the card.
- The failure channel is only reduced. The Rust endpoint caps failures per window, which bounds what the failure pattern alone can carry, and one Pi run showed far fewer hidden bits getting out at a large cost in availability (Measurements). Timing, logs and restarts need their own accounting.
Not measured yet
- A capacity bound. The numbers are achieved rates for planted bits and one binned mutual-information statistic (
observer/estimate.py, checked on synthetic channels with known values). Checking an estimator on known distributions is not a bound on what an arbitrary encoder and observer can achieve; no such bound is claimed. - Several clients at once, other loads, and a packet capture on the outside link.
- The
busychannel: a server that holds requests open makes other clients’ requests fail; it depends on load and is not measured. - The budget trade-off: fixed release was run with one budget (4 s); leakage and lost answers against the budget are not measured.
- The residual timing trace under fixed release: its mechanism (two candidates: how long the processor takes to wake from idle, and its speed changing with load; the v1 image’s kernel defaults to the lowest processor speed, which may be why its run showed nothing), and whether the redesigned spin removes it. With a 5 ms spin no mean shift was detected, and a 512-answer test with its decision rule fixed in advance was inconclusive: the held-out decoder got 143 of 256 right on its better half (about 56 %, p = 0.041, where the rule needed p < 0.01 to call it a leak and p > 0.05 to call it not detected). What this decoder finds is small, but the trace isn’t shown to be gone, and a cleverer decoder might find more. On the stripped v1 image (30 Oct) the same test found nothing (p = 0.50, one run), so the residue may depend on the system around the endpoint; that hasn’t been isolated. Settling it needs more answers, and several clients are untested.
Assumptions the results rest on
- Every path from the server to the outside goes through the device. With one shared cable this is not enforced physically.
- One pinned model and circuit; the binding rules are specific to EZKL 23.0.5.
- The endpoint binary, with EZKL’s verifier inside it, is trusted, with its failures contained: if the verifier wrongly said ACCEPT, the endpoint couldn’t tell. On the v1 image the model files, the domain file and the release policy come from the signed store and are checked at start; the bundle’s hashes are checked again before every check. The binary itself relies on the read-only image (and, in v2, secure boot). The Python reference endpoint additionally trusts the EZKL command-line tool and the interpreter.
- The proof system is sound. Its soundness is out of scope.
Out of scope
Physical attacks and physical side channels (power, electromagnetic, acoustic, thermal), what ordinary correct outputs reveal about a model (extraction from many answers), and the soundness of the proof system itself.
Open questions
- After a failed check, should the device keep answering (and count failures) or stop until reset? Halting is a denial-of-service lever and still reveals where the halting failure was.
- Which replay property matters: never delivering an answer twice, or proving each answer was freshly computed?
- Should answers be ordered across all clients (a silent request then delays everyone) or per client (the server can then order answers across clients)?
- Who may read the device’s logs? The log still carries the number and timing of events.
- How much latency and how many lost answers are acceptable in exchange for less leakage?
Measurements
In short
- Checking one proof on the Pi takes about 0.1 s with the proof tool alone, 0.19 s in the Rust endpoint and 0.24 s in the Python reference endpoint, with all the safety limits.
- At a 256 MiB memory cap every recorded check passed (231 of 231); smaller caps fail unpredictably.
- The leakage experiment, one client at a time: releasing in arrival order stopped the ordering code we tried; a fixed release time stopped the 1 s timing code, at the cost of 4 s per answer, and a pre-registered 512-answer test of what remained was inconclusive on the stock system and found nothing on the stripped image (one run); deliberate failures leaked under every policy, and a failure cap cut them at a large cost in availability. A plain explanation is in What a hacked server can still leak.
- In a model, random sleeps instead of a fixed release time keep leaking: the leak per answer shrinks only in proportion to the added wait, and enough answers reveal it.
Everything here was measured on one Raspberry Pi 4 (2 GB, Raspberry Pi OS Lite, 64-bit) with the AI server (Python EZKL prover) and the observer on a laptop (Apple M4 Pro) over a direct Ethernet link, unless stated otherwise. Dates are 2026.
Summary
| Way to smuggle data out | Basic endpoint | Best endpoint so far | What it costs |
|---|---|---|---|
| Change what an answer says | Blocked by the proof | Blocked | Each check takes about 0.24 s on the Pi |
| Reorder answers | Leaks | The ordering code we tried no longer gets through: answers leave in the order questions arrived | A slow answer delays the ones behind it |
| Delay answers | Leaks | Much reduced: each answer has a set release time and can’t leave before it; a small trace remained without spin, and with spin a 512-answer test was inconclusive on the stock system and found nothing on the stripped image (one run) | Answers take 4.0 s instead of about 0.8 s |
| Fail chosen answers on purpose | Leaks | Still leaks, less: with a failure cap, 4 of 16 planted bits got out instead of 16/16 (one run) | 18 of 64 answers ok instead of 60 of 64 |
How to read the leakage numbers. The server hides known bits and an observer tries to read them back with a fixed decoder. “16/16” means every planted bit was recovered. When the decoder fails, the table says how many bits it got wrong; that means this decoder did not recover that message in that run, not that the encoding carries no information or that nothing leaks. These are achieved rates for one encoder each, from one sequential client unless stated; they are not capacity bounds.
Checking one proof
Whole EZKL CLI process per check (start-up, loading, verify), tiny circuit:
| Date | Runs | Result |
|---|---|---|
| 13 Oct | 20 | 99-103 ms |
| 16 Oct | 20 | min 101, median 103, max 106 ms |
| 19 Oct | 20 | min 101, median 103, max 280 ms (one cold run just after boot) |
What the full endpoint path costs (19 Oct, medians of 20, device/verify_cost.py):
| Path | ms |
|---|---|
| Raw CLI, default threads (4 cores) | 97-98 |
Raw CLI, one thread (RAYON_NUM_THREADS=1) | 163 |
| With the endpoint’s resource limits (single-threaded launcher) | 207 |
| Re-checking the bundle’s hashes (includes a 4 MB setup file) | 28 |
| Reading and canonicalising the proof | 2 |
| Full endpoint check | 239 |
A raw proof for the test model is 3,072 bytes. The EZKL CLI binary (23.0.5, 64-bit ARM) is 36 MB (36,091,376 bytes); it also contains the prover and setup code. One thread costs 1.7x; the launcher’s Python start-up is about 45 ms. Both could be cut (a static launcher; hashing the bundle once when it lives in a read-only image). On the laptop, proving takes about 0.5-0.8 s and an in-process verify 8-14 ms.
Near the memory limit the outcome is random (address-space randomisation): 128 MiB passed 20 of 20 runs, 144 and 160 MiB only about half; at the endpoint’s setting of 256 MiB every recorded check passed, 231 of 231.
The memory limit is not a clean floor. Address-space limit vs. how often a valid proof is accepted, in one sweep of 20 runs per limit (19 Oct; the rest abort, which the endpoint treats as an error):
| Limit | Accepted |
|---|---|
| 96 MiB | 0/20 |
| 112 MiB | 18/20 |
| 128 MiB | 20/20 |
| 144 MiB | 11/20 |
| 160 MiB | 9/20 |
| 192 MiB | 20/20 |
| 224 MiB | 20/20 |
| 256 MiB (the endpoint’s setting) | 20/20 |
| 320 MiB | 20/20 |
Other runs the same day at 256 MiB: one first check, then 200/200 and 10/10, so 231 of 231 in all with the sweep; at 192 MiB 200/200; at 160 MiB 4/10 in a later sweep. The outcome near the limit varies from run to run and doesn’t improve steadily with more memory (address-space layout randomisation is on). At 256 MiB, 0 failures in 231 runs means below about 1.3 % at 95 % confidence if the runs are independent; not proven zero, and only for this circuit on this Pi. A spurious error would count as a server failure, so the margin matters. The memory cap is enforced on Linux only.
Attack surface
| Version | Seen from outside | Notes |
|---|---|---|
| v0 (stock OS, 16 Oct) | TCP 22 (ssh) and 8700 (the endpoint); UDP 5353 (mDNS) | 633 packages, 13 running services |
| v0 (stock OS, 29 Oct, endpoint not running) | TCP 22 only (nmap -p-); 691 TCP ports showed as filtered, most likely an artefact of the scan (on the v1 image the same pattern re-probed one by one came back closed); UDP not scanned. On the device: UDP 5353 (mDNS) and two unattributed UDP ports | 633 packages, 13 running services, 66 kernel modules (Wi-Fi and Bluetooth drivers loaded); per-port software in docs/ATTACK_SURFACE.md |
| v1 image with the update store and firewall (30 Oct, booted on the Pi) | TCP 8700 only; all 65,534 other TCP ports dropped (no reply at all, where the image without the firewall refused them); ping dropped | Model and release policy from the signed update store on a writable data partition; nftables rules built into the kernel; the endpoint won’t start without the rules |
| v1 image with the Rust endpoint (30 Oct, booted on the Pi) | TCP 8700 only (connect scan of all 65,535 ports from the laptop; every other port refused) | One static endpoint binary as user pge, fixed release 4 s, read-only root, no Python, no SSH; UDP not scanned |
v1 development image (16 Oct, sdcard.img 159 MB) | 0 of 65,535 TCP ports open; top 100 UDP ports closed; no IPv6 | No endpoint was running on this image yet, so this shows a quiet base system, not a working hardened endpoint |
The Rust endpoint on the Pi (29 Oct)
The device now runs the Rust endpoint (static 64-bit ARM binary from CI, no Python or EZKL CLI on the device). Same Pi and setup as above; raw data in the git-ignored runs/2026-10-29_rust/.
| Check | Result |
|---|---|
Checker alone (device/rust_verify_cost.py, one thread, n = 20) | valid accepted, changed output / changed input / garbage proof rejected, truncated job an error; 189 ms per check (Python endpoint path: 239 ms) |
Endpoint alone, local stub server (device/endpoint_latency.py, n = 30, two runs each, interleaved) | Rust 192 / 193 ms, Python 241 / 242 ms per answer |
| End to end with the laptop prover (honest, arrival, n = 16, two runs each, interleaved) | Rust 1008 / 1023 ms, Python 1065 / 1074 ms |
| Misbehaving server (the table above, plus wrong id and dropped answers) | same outcomes as the Python endpoint; a proof made for another question is rejected |
| Leak matrix (arrival and fixed release, the bits in the next section) | same as the Python endpoint: timing leaks 16/16 under arrival release; order blocked; failure position leaks 16/16 under both |
| Failure cap on (W = 64, F = 1, C = 64; fixed release, 64 answers) | all 64 replies match observer/cap_model.py; 4 of 16 planted bits got out (16/16 without the cap); 18 of 64 answers ok (60 of 64 without it) |
| Three connections at once (arrival and fixed, honest and order code) | all answers ok; the order code decoded to a constant |
| 5 ms spin, 512 answers, the same pre-registered protocol | inconclusive again: held-out decoder 0.44 / 0.48 errors, p = 0.0325; received 0.8-2.8 ms after the release time |
Two fixes made the Rust endpoint faster than the Python one: each check had hashed the 4 MB bundle three times (this Pi’s CPU has no SHA instructions, about 25-28 ms per pass), and the supervisor slept 1 ms after every 64 KiB written to the checker, about 66 ms per check. Before them the Rust endpoint was about 65 ms slower per answer end to end.
The v1 image on the Pi (30 Oct). The laptop as the AI server, the image’s own settings (fixed release, 4 s, 5 ms spin): honest answers 9/9 ok, each received 4001-4003 ms after it was sent; changed outputs 3/3 and garbage frames 3/3 refused (unavailable); the endpoint reconnected to a restarted server within about 2 s. Leakage, one run each with the bits of the matrix below: the 1 s timing code gave 11 of 16 bits wrong (answers 4000.8-4001.6 ms); the failure-position code still leaked 16/16 (no cap on this image). These runs overlapped a port scan, so their timing is a smoke check only.
The image with the update store and firewall (30 Oct): port 8700 back about 21 s after power-on; honest answers 9/9 ok at 4001-4005 ms (fixed release from the signed manifest); changed outputs 3/3 and garbage 3/3 refused; replayed answers for the same question 3/3 accepted (by design).
The same pre-registered 512-answer test with a 5 ms spin (docs/EXPERIMENT_PROTOCOL.md), repeated on the v1 image (30 Oct): the held-out decoder got 0.49 and 0.52 of answers wrong (p = 0.50, honest control p = 0.69), so under the rule the result is not detected by this decoder at n = 512; signal answers were received 0.8-2.3 ms after the release time. The same test was inconclusive on the stock system (Python 20 Oct, Rust 29 Oct). One run each, on different days: this doesn’t show the image removed the trace, and a better decoder might still find one. The honest control had six late answers (one 28.6 ms late), cause unknown.
The endpoint end to end
| Date | Software | Result |
|---|---|---|
| 16 Oct | v0 (JSON, immediate release) | about 0.9-1.05 s per answer (mostly proving on the laptop); y matches the float model within quantisation |
| 19 Oct | v1 (binary, arrival order, limits, fixed-word logs) | 5/5 ok, 0.79-0.89 s per answer |
Misbehaving server against the v1 endpoint on the Pi (19 Oct):
| Server behaviour | What the endpoint did |
|---|---|
| Changes an output value | proof rejected; client gets unavailable |
| Replays an earlier answer for the same request | accepted, by design: the same request gives the same output, so the server has no choice there |
| Answers twice | one answer to the client; the duplicate is dropped and counted |
| Sends garbage or an oversized frame | server link dropped; the pending request fails, the next gets unavailable in under 1 ms |
The first timing test (16 Oct, v0): the server delayed an answer by 1 s for each 1 bit; an observer decoded 16 of 16 bits, 0.69 bit/s, while honest answers varied by 81 ms.
Release policies on the Pi
The core experiment (19 Oct): the same workload under each of the endpoint’s three release policies, with a 4 s budget for every policy, 16 planted bits per channel, one run per cell (device/pi_matrix.sh, summarised by observer/analyze_matrix.py).
| Channel the server uses | immediate (v0) | arrival (v1) | fixed |
|---|---|---|---|
| Timing: delay an answer by 1 s for a 1 | 16/16 recovered | 16/16 recovered | not recovered (12/16 wrong); answer times spread over 2.0 ms |
| Ordering: answer 3 concurrent requests in a chosen order | 16/16 recovered | decoded bits constant | decoded bits constant |
| Failure position: fail one chosen answer in each 16 | 16/16 recovered | 16/16 recovered | 16/16 recovered |
| No signal (honest server, timing decoder) | 8/16 wrong | 10/16 wrong | 7/16 wrong |
| Honest answer time, median | 0.81 s | 0.83 s | 4.0 s |
- Ordering: with arrival or fixed release the decoded bits were the same whatever the server planted, so the server’s choice of order never reached the observer. On one connection that holds by construction: the endpoint sends answers in admission order and the client’s ids increase. A separate localhost run showed v0 leaking floor(log2 k!) bits per batch of k concurrent requests with no errors (16/16, 32/32 and 64/64 bits for k = 2, 3, 4).
- Timing: the 1 s code wasn’t recovered under fixed release, but a smaller timing trace remains (below). The cost is latency: every answer waits the whole budget, about 5 times the honest median in this sequential run.
- Failures were recovered under every policy (below).
Only 4 of 64 answers failed (one chosen position in each block of 16), yet all 16 bits got through: 0.25 bit per request with this encoder.
How the failure code scales. The position code fails one answer in each batch of B answers and hides log2(B) bits in which one fails, so n answers carry (n/B) log2(B) bits for n/B failures. Sending 512 bits takes 128 failures in 2,048 answers with B = 16, or 52 failures in 53,248 answers with B = 1,024: fewer failures cost many more answers. That is one encoder; the limits for any encoder that only uses which answers fail are:
| What the server may do | Bits it can carry |
|---|---|
| Fail any answers it likes, no cap | up to 1 bit per answer (512 answers carry 512 bits) |
| Fail exactly k of n answers | log2 C(n, k), e.g. 168.9 bits for 32 failures among 512 answers |
Any pattern the failure cap allows (W = 64, F = 1, C = 64, observer/cap_bound.py) | at most 11.02 bits in 64 replies, 63.05 in 512, 472.71 in 4,096 (about 0.12 bit per reply) |
These count only which answers fail; timing, busy replies and logs are separate (see the cap_bound.py docstring).
What fixed release leaves
A longer run (128 random bits, plus 128 honest requests as a control) asked whether the 2 ms of remaining jitter still follows the server:
| Run | Bit-1 minus bit-0 answer time | Permutation test |
|---|---|---|
| Server encoding bits | -0.66 ms (-0.73 and -0.60 in each half) | p < 0.0001 |
| Honest server, same labels | +0.19 ms | p = 0.13 |
| Server encoding bits, with a 5 ms spin before release (old spin code) | +0.08 ms | p = 0.31 |
| Honest server, with the spin (old spin code) | +0.09 ms | p = 0.20 |
| Server encoding bits, spin, fixed code, n = 512 (20 Oct) | -0.06 ms | p = 0.071 |
| Honest server, spin, fixed code, n = 512 (20 Oct) | -0.02 ms | p = 0.48 |
Without spin, answers for 1 bits left 0.66 ms earlier than answers for 0 bits (128 answers, p < 0.0001; the honest control showed nothing), and a decoder fitted on one half and tested on the other got about three bits in four right.
Without spin, the association is clear. A threshold decoder fitted on one half and tested on the other got 25 % and 28 % of the bits wrong (observer/jitter_test.py --heldout). If the channel were a symmetric binary channel with independent errors, that would correspond to about 0.14-0.19 bit per answer; it is a model-based figure, not a measured throughput, and the two error kinds were not symmetric in one half.
With a 5 ms spin (wake early and busy-wait until the release time, --spin-ms 5) no mean shift was detected (+0.08 ms, p = 0.31), but in that run the held-out decoder still got only 34 % wrong on one half (44 % on the other; honest controls 39-59 %), and a null that refits the decoder on shuffled labels does that well only 0.75 % of the time. That run used the spin code from before the K33 fix and 0.1 ms timestamps, so it was repeated.
The repeat (20 Oct) followed a protocol written before the data (docs/EXPERIMENT_PROTOCOL.md): 512 seeded random bits (249 ones), the fixed spin code, microsecond timestamps, and an honest run of 512 as the control. Both runs completed 512 of 512 answers, every one accepted, each received by the observer 4000.96-4003.84 ms after it was sent, that is 0.96-3.84 ms after its 4 s release time (network included). The primary test is the held-out decoder: it got 118 and 113 of 256 wrong (46 % and 44 %), and the refitting null does as well as the better half 4.1 % of the time (p = 0.041; honest control 131 and 134 of 256 wrong, p = 0.77). The protocol counts a residue as shown only below p = 0.01 and as not detected only above 0.05, so the result is inconclusive. Whatever this decoder finds is small: 143 of 256 right on its better half (about 56 %), against 72-75 % without spin (n = 128, 19 Oct). This does not show the channel is gone or bound what a cleverer observer could do, and separate runs on different days don’t show that spin caused the difference. Idle wake-up latency is one hypothesis for a residue; it hasn’t been isolated. The complete analysis output for both runs is in docs/results/2026-10-20_spin5_512.md.
In plain words:
With a 5 ms spin no mean shift was detected, and a 512-answer test with its decision rule fixed in advance was inconclusive: the held-out decoder got 143 of 256 right on its better half (about 56 %, p = 0.041, where the rule needed p < 0.01 to call it a leak and p > 0.05 to call it not detected). What this decoder finds is small, but the trace isn’t shown to be gone, and a cleverer decoder might find more. On the stripped v1 image (30 Oct) the same test found nothing (p = 0.50, one run), so the residue may depend on the system around the endpoint; that hasn’t been isolated.
Deviation from the protocol: the first attempt at both runs stopped at 149 of 512 answers because the observer’s time limit was fixed at 600 s. The limit now grows with the run’s length (commit cd0086c, which changes only device/pi_scenario.sh; the endpoint’s code hash on the Pi was the same), and both runs were repeated in full. That first attempt was also disturbed by an edit to the script while it ran; its signal run has no manifest or endpoint log, and its honest run stopped with 149 rows while the endpoint logged 150 accepts. The automatic analysis of the partial runs was printed and is not used; the registered rule was not changed after it.
A second measure on the same data (observer/estimate.py): the mutual information between the hidden bit and the answer time, with times cut into 4 bins at their quantiles, a small-sample bias correction, a shuffled-label null and a bootstrap interval, checked first on synthetic channels with known values (tests/test_estimate.py). It describes this one binned statistic; it is not a lower or upper bound on what the timing reveals.
| Run | n | Mutual information, bit per answer (4 bins) | p |
|---|---|---|---|
| Fixed release, server encoding bits | 128 | 0.17 | 0.0005 |
| Fixed release, honest server | 128 | about 0 | 0.68 |
| Fixed release with spin (old code), server encoding bits | 128 | 0.05 | 0.012 |
| Fixed release with spin (old code), honest server | 128 | about 0 | 0.73 |
| Spin, fixed code, server encoding bits | 512 | 0.006 (bootstrap interval -0.003 to 0.026) | 0.069 |
| Spin, fixed code, honest server | 512 | -0.004 (bootstrap interval -0.004 to 0.011) | 0.94 |
The bias correction can make the estimate negative; that is estimator noise, not negative information. The bootstrap intervals assume exchangeable answers within one run; they are not intervals over sessions, days or encoders.
With 2 or 8 bins the no-spin result holds (p = 0.0005) and the old spin result doesn’t reach significance (p = 0.46 and 0.09). Caveats on the 128-answer runs: the observer recorded times rounded to 0.1 ms (only 19-30 distinct values per 128 answers), and the spin runs used an earlier version of the spin code that a review later found could let a later answer overtake an earlier one when several were in flight. The 512-answer repeat fixes both; at 4 bins and n = 512 it is not significant either (p = 0.069).
Random sleeps instead of a fixed release time (a model, 2 Nov)
A model, not a device measurement (observer/sleep_vs_fixed.py, tests in tests/test_sleep_vs_fixed.py): the server sends each answer on time or a fixed amount late, and the endpoint either adds a sleep drawn uniformly from 0 to R before release or releases at a fixed time after the question arrived. With uniform sleeps alone, one answer of a two-level code that shifts by d carries exactly d/R bit (d up to R): only the first and last d of the window give the bit away, and extra noise only lowers this. A server allowed to delay by up to A can carry up to log2(1 + A/R) bit per answer (exact when A/R is a whole number), so the sleep has to grow with the server’s freedom. With a fixed release time the release moment doesn’t depend on the server at all; an answer that misses it is not released and becomes a failure, which is the failure channel above.
Simulated with honest-time noise of sd 20 ms, a 200 ms shift code and 4,096 answers, the binned estimate (observer/estimate.py, 4 bins, so it understates the exact value) was significant for every sleep tried, up to sleeps of 0-4 s that add 2 s on average; fixed release gave none:
| Release rule | Added wait on average | Upper bound, d/R | Binned estimate | p (500 shuffles) |
|---|---|---|---|---|
| Random sleep 0-0.5 s | 0.25 s | 0.4 bit | 0.26 bit | 0.002 |
| Random sleep 0-1 s | 0.5 s | 0.2 bit | 0.062 bit | 0.002 |
| Random sleep 0-2 s | 1 s | 0.1 bit | 0.016 bit | 0.002 |
| Random sleep 0-4 s | 2 s | 0.05 bit | 0.005 bit | 0.002 |
| Fixed release, 4 s | 3.2 s | 0 | 0.000 bit | 1.0 |
With a smaller 50 ms shift and 512 answers the same estimate no longer reached significance from sleeps of 0-0.5 s up (p = 0.076 to 0.53), although the d/R bound there is still 0.1 down to 0.0125 bit per answer: a test that finds nothing at one sample size is not evidence that the sleep closed the channel.
Several connections (smoke runs, 20 Oct)
Short checks of the new run script and the several-clients observer (CLIENTS=3, 4 s budget, one run each, not a registered experiment):
| Run | Result |
|---|---|
| One client, fixed release, 5 ms spin, 3 answers | 3/3 ok; received 1.39-2.22 ms after the 1 s release time; the manifest records the commit and the Pi’s file hashes |
| 3 connections, arrival release, honest server, 2 rounds | 6/6 ok, replies in client order every round |
| 3 connections, server answers each round in a chosen order (8 bits), immediate release | 8/8 bits read back from the order replies arrived across connections |
| The same, arrival release | decoded bits constant (00000000): the order across connections didn’t follow the server |
So the order a server chooses does reach an outside observer across separate connections when answers leave as they are checked, and arrival-order release stopped this code in this run. It is 8 bits in one run per rule, on one machine running all three clients; the planned two-client deadline-pressure experiment is the real test.
What these results don’t show
- One device, one network, one sequential client (ordering used concurrent requests on one connection). Several clients, other loads and packet-level observation (sizes, TCP behaviour) are not measured yet.
- Every leakage cell is one run of 16 bits, except the 128-bit residual tests and the 512-bit spin repeat.
- The release budget (4 s) was chosen by hand; the trade-off between budget, leakage and lost answers is not measured yet.
- Nothing here is a bound on what a cleverer encoder could achieve.
Attack catalogue
In short
- Every attack tried so far, by us or by reviewers, with its result and the test that now covers it.
- The catalogue has 49 scenarios: 37 stopped, 2 allowed on purpose, 8 that still work, are only partly stopped or are untested, and 2 out of scope. Some were shown on the Pi, some only in tests, and some are only proposed; the counts describe coverage, not a security score.
- The ones that still work are all about signalling (timing, failures, keeping the endpoint busy) or about the standard Pi system, which v1 replaces.
What the outcomes mean: stopped means the endpoint refuses or neutralises the attack (for example it rejects the answer, or releases nothing the server chose) and a test or a Pi run shows it; it doesn’t mean the device stopped working. Allowed on purpose means the behaviour is accepted by design. Still works means the attack gets something through, with the measured rate where there is one.
How to read a row: Version is the endpoint version the attack was tried against; Level says how it was shown (on the real Pi, in automated tests, or only proposed); Result says what happened; the last column names the test.
Every scenario catalogued against the device, by us, by reviewers or by testers: what it targets, how far it got, and its evidence (a test, a Pi run, or none yet). Living document; final in W7.
The catalogue has 49 scenarios: 37 stopped, 2 allowed on purpose, 8 that still work, are only partly stopped or are untested, and 2 out of scope. Some were shown on the Pi, some only in tests, and some are only proposed; the counts describe coverage, not a security score.
| Outcome | Scenarios | Examples |
|---|---|---|
| Stopped | 37 | Edited answers, a proof for a different question, a proof from another model, padded proofs, garbage or oversized messages, a taken-over checker reaching files or the network, answering twice or late, malicious proofs that try to exhaust the checker, using a disconnect to stall the endpoint, rolled-back, replayed or substituted updates, a changed or old release policy, updates for another device or from an unlisted key, overlapping or live installs, a key revocation that didn’t stick, a hand-edited state |
| Allowed on purpose | 2 | The same question gets the same answer again (the server has no choice there); an endpoint set to stop after failures can be stopped by the server |
| Still works, partly stopped or untested | 8 | Timing (much reduced), failing on purpose, never answering, keeping the endpoint busy, loading the endpoint to delay releases, other services on the standard system, probing the endpoint’s rules, restoring a whole older copy of the update store |
| Out of scope | 2 | Learning the model from many correct answers; physical attacks |
Level: shown on the Pi; shown in tests (laptop and CI); proposed (not yet tried).
Result: blocked; leaked (with a measured rate); open (no defence yet); out of scope.
LoC: the closest technique in Gigurtsis’s loss-of-control threat matrix (github.com/bgigurtsis/AgenticLoCThreatMatrix), where one fits.
Pi results: 16 Oct 2026 (v0 software) and 19 Oct 2026 (v1 software), both on the stock Raspberry Pi OS, endpoint on the Pi, AI server and observer on the laptop; each row gives its date where it matters.
Answer content
| # | Attack | Version | Level | Result | Covered by |
|---|---|---|---|---|---|
| K1 | Change an output value and keep the proof | v0 | shown on the Pi | blocked: REJECT | test_bad_answers_are_blocked[tamper_output] |
| K2 | Send a valid proof made for a different request | v0 | shown on the Pi (replay mode, other x) | blocked: REJECT | test_proof_for_another_request_is_blocked |
| K3 | Send a proof made with another circuit’s key | v0 | shown in tests | blocked: REJECT | test_proof_from_another_circuit_is_blocked |
| K4 | Reuse an earlier valid answer for the same request | v0 | shown on the Pi | accepted by design: the same encoded x gives the same y, so the server gains no choice (SERVICE_SPEC.md section 6) | test_replayed_answer_only_passes_for_the_same_request |
| K5 | Append bytes to a valid proof (EZKL still accepts it) | v0 | shown in tests | blocked by pinning the proof length | test_trailing_proof_bytes_still_verify, test_padded_proof_is_blocked_when_the_length_is_pinned |
| K6 | Change the settings’ output scale so the same proof decodes differently | v0 | shown in tests | blocked by pinning settings, vk and SRS | test_scale_only_settings_change_still_verifies, test_bundle_pins_enforced |
| K7 | Release extra fields from EZKL’s proof file (pretty_*, hex_proof, timestamp) | v0 | shown in tests | blocked: only instances and proof are read, y is decoded from verified values | test_pretty_fields_not_authenticated |
Delivery: whether, when and how often an answer arrives
| # | Attack | Version | Level | Result | Covered by |
|---|---|---|---|---|---|
| K8 | Delay answers to encode bits (LoC 5.4.1) | v0 | shown on the Pi | Leaked on v0 and in arrival order. Under fixed release the 1 s code was not read back, but a sub-millisecond trace still followed the server without spin; with spin a pre-registered test was inconclusive. Numbers: the book’s Measurements page (release policies; what fixed release leaves) and docs/results/2026-10-20_spin5_512.md | test_v0_leaks_bits_through_timing, test_fixed_release_hides_the_timing_bits, observer/jitter_test.py, test_observer_recovers_the_timing_bits |
| K9 | Answer twice | v0 | shown on the Pi | blocked: one answer, the duplicate is counted | test_duplicate_answer_gives_one_reply |
| K10 | Answer late, after the deadline | v0 | shown in tests | blocked: the client already got unavailable; the late answer is dropped | test_late_answer_is_dropped_and_answered_once |
| K11 | Never answer | v0 | shown in tests | unavailable at the deadline; still 1 bit per request (answer or not) | test_silent_server_hits_the_deadline |
| K12 | Answer for a seq the device never issued | v0 | shown on the Pi | dropped, counted, and the seq is not logged | test_answer_for_unissued_seq_is_dropped, test_unissued_seq_is_not_logged |
| K13 | Send garbage or an oversized frame | v0 | shown on the Pi | link dropped, pending requests answered unavailable | test_bad_answers_are_blocked[garbage, oversize] |
| K14 | Start a frame and never finish it; stop reading requests | v0 | shown in tests | link dropped | test_stalled_server_frame_is_a_violation, SERVER_BUFFER_MAX |
| K15 | Make verification slow, hang it, or flood its output | v0, v1 | shown in tests | killed at the deadline or once its output passes 64 KiB (checked per read, so up to one more read is kept); unavailable. v1 adds CPU-seconds, address-space (Linux), file-size and open-file limits on the verifier (K29) | test_cli.py::test_deadline_kills_the_whole_process_group, test_continuous_writer_is_stopped_at_the_limit |
| K16 | Fail on purpose to trigger a halt (if halt_after is set) | v0 | shown in tests | works by design: a halted device stops all releases, held successes included. A denial-of-service lever; the policy choice is open question 2 | test_halt_answers_pending_requests_at_once, test_halt_revokes_held_successes |
| K17 | Hold requests open so other clients get unavailable | v0 | proposed | open: busy depends on load until fixed slots (v2); fixed release holds every answer until its deadline, so pending plus held fills max_pending sooner | test_too_many_pending_is_unavailable (mechanism only) |
| K18 | Answer concurrent requests in a chosen order (LoC 5.4.1) | v1 (software) | shown in tests | blocked for admitted answers on the endpoint: they leave in arrival order, failures included; busy/halted answers leave at once, and the log keeps event timing; with --release immediate (v0) the server’s order goes through | test_answers_leave_in_arrival_order, test_failures_are_released_in_order_too, test_held_answer_waits_only_for_the_earlier_deadline, test_held_answers_go_to_their_own_clients, test_order_bits_leak_only_without_arrival_order. Measured 19 Oct (observer/order_experiment.py, real prover, localhost): v0 leaks floor(log2(k!)) bits per batch of k with no errors; with arrival order the decoded bits are constant (Measurements, release policies) |
| K28 | Vary the size of what leaves: the number of output values, the reply length | v1 (software) | shown in tests | blocked: fixed-size binary frames; extra output values are read as proof bytes and the proof fails; every reply has one size | test_log_records_hold_no_server_text, test_sizes_are_fixed_by_the_circuit |
The device’s own outputs
| # | Attack | Version | Level | Result | Covered by |
|---|---|---|---|---|---|
| K19 | Put server-chosen values into the device’s log (seq numbers, error text, output counts, proof lengths) | v1 (software) | shown in tests | blocked: records hold fixed words, the device’s seq, the client’s request hash, ms and the halt failure count (v0 logged error text that could carry the output count and proof length); the number and timing of log events still follow the server; who reads the log is undefined | test_unissued_seq_is_not_logged, test_server_violation_detail_is_fixed_text, test_log_records_hold_no_server_text, test_proof_length_reason_hides_the_length |
| K20 | Get server text into the client’s error | v0 | shown in tests | blocked: two fixed error strings | test_only_two_error_strings |
The client side and the device itself
| # | Attack | Version | Level | Result | Covered by |
|---|---|---|---|---|---|
| K21 | Send requests the circuit can’t prove, so the server fails on demand | v0 | shown on the Pi | blocked: bad_request at once, never forwarded | test_bad_requests_never_reach_the_server |
| K22 | Malformed client frames (wrong size or type, NaN or infinity, oversize, ids of 2^53 or more, non-increasing ids) | v1 (software) | shown in tests | connection closed; re-encoding an accepted frame gives the same bytes (fuzzed) | test_client_violation_closes_the_connection, test_ids_must_increase, test_fuzzed_input_is_refused_or_canonical |
| K23 | Idle or slow clients holding connections | v0 | shown in tests | closed after idle; at most max_clients | test_idle_client_is_closed, test_too_many_clients_are_refused |
| K24 | Reach the device through other services (LoC 5.2.4) | v0 | shown on the Pi | open in v0: TCP 22 (sshd) and UDP 5353 (avahi) reachable; v1 removes them | book/src/results.md (16 Oct nmap and ss on the Pi), device/measure.sh |
| K25 | Learn the device’s policy by probing (LoC 5.1.4) | v0 | proposed | partly: generic errors; failure alerts planned | none yet |
| K29 | Exhaust the verifier with a crafted proof: memory, CPU, disk, file handles | v1 (software) | shown in tests | blocked: the CLI runs with Rayon set to one thread, 256 MiB of address space (Linux), CPU seconds up to the deadline, a file-size limit of 0 (no growth; truncation and deletion need the v1 image’s permissions) and 64 open files, per process, set before exec by a single-threaded process; a CLI over a limit is ERROR. Checked with the real EZKL CLI (19 Oct): under a too-small limit it aborts and the endpoint answers ERROR. On the Pi the outcome near the limit varies by run, so 256 MiB is used (Measurements, memory limit) | test_proc.py (limit tests), test_a_cli_that_writes_a_file_is_stopped, test_cli_limits |
| K30 | Get a held success out after a halt or past its deadline: stall the lowest seq, then trigger the halt or let timers run late (found by review, 19 Oct) | v1 (software) | shown in tests | blocked: a held ok is checked again when it leaves and sent as unavailable after a halt (both release rules) or, in arrival order, past its own deadline; the failure is counted before release. Fixed release sends an ok decided before its deadline at its release time even if the loop runs late, by design | test_halt_revokes_held_successes, test_halt_on_the_earliest_request_revokes_later_held_successes, test_held_success_past_its_deadline_is_not_released |
| K31 | Grow the held queue past max_pending: keep the lowest seq silent and fail later ones fast (found by review, 19 Oct) | v1 (software) | shown in tests | blocked: held answers count towards max_pending | test_held_answers_count_towards_max_pending |
| K32 | Fail one chosen answer in each block of N, so its position carries log2 N bits (sparse failures) | v1 (software) | shown on the Pi | leaked under every release policy, immediate, arrival and fixed alike (19 Oct, 4 s budget, N = 16; Measurements, release policies). Open: a rare failure still carries many bits; halting reveals the halting failure’s position. A failure cap now exists in the Rust endpoint (observer/cap_model.py), with a computed bound on the status sequence of admitted replies only (observer/cap_bound.py: busy replies, timing and logs are outside it); not yet measured on the Pi | test_failure_position_leaks_under_every_release_policy; device/pi_matrix.sh; test_cap_matches_reference_model |
| K33 | With fixed release and spin, answer a later request so it is due inside the spin window while an earlier one stays silent, so the later reply overtakes the earlier deadline error and the order of two unavailable replies follows the server (found by review, 19 Oct) | v1 (software) | shown in tests | blocked: fixed release sends held answers in admission order, each not before its own deadline; only the next answer in order may busy-wait | test_fixed_release_keeps_admission_order_whatever_the_server_does, test_spin_never_blocks_the_loop_for_long |
| K34 | Load the endpoint on purpose (answer many requests at once, flood stale answers) so releases leave late or checks run past their deadline: timing and failures the server controls through load (found by review, 19 Oct) | v1 (software) | proposed | open: not measured; all leakage runs so far used one client at a time. Each spin blocks the loop for at most spin (K35), but load can still delay the loop and the verifier | none yet; planned: the concurrent-client matrix |
| K35 | Disconnect, or trigger a halt, while many answers are due a few ms apart, so that releasing them chains one busy-wait per answer and stalls the endpoint’s event loop for tens of milliseconds, delaying every other client (found by review, 20 Oct; reproduced in tests with 20 answers and a 5 ms spin) | v1 (software) | shown in tests | blocked: releases only schedule the pump, which runs in its own callback and busy-waits at most once per run, so no callback blocks the loop for more than spin | test_failing_many_answers_at_once_never_spins_for_long |
| K49 | A crafted proof takes over the checker (a memory-safety bug in parsing or verifying it) and uses it to open a connection, open or write files, run a program, start a process or map executable memory | v1 (software) | shown in tests (Linux) | blocked: after its limits are set the checker runs under a seccomp allow-list of the calls it was traced making, so these fail with EPERM and a call from another architecture kills it; a taken-over checker can still print a wrong verdict, so the checker’s own correctness stays trusted | sandbox_blocks_files_sockets_exec_and_executable_memory (endpoint/endpoint/src/process.rs) |
Updates
Signed updates (docs/UPDATE_SPEC.md), added 29 Oct, revised 30 Oct after an outside review (K45-K48 were its findings). The AI server can’t send updates at all: they are applied only by pge-endpoint --apply-update, which the store lock keeps from running while the endpoint serves. These rows are about whoever can hand the device an update directory or edit its store; the rollback rows assume the store’s unsigned state is trusted (see K43 and K48).
| # | Attack | Version | Level | Result | Covered by |
|---|---|---|---|---|---|
| K36 | Roll back to an older signed update (lower version) | v1 (software) | shown in tests | blocked: refused rollback | test_rollback_and_replay_are_refused, an_older_version_is_a_rollback |
| K37 | Apply a different signed manifest with the current version | v1 (software) | shown in tests | blocked: refused replay; the same manifest again is a no-op | test_rollback_and_replay_are_refused, a_different_manifest_with_the_same_version_is_a_replay |
| K38 | Bring back an older key list that still lists a revoked key | v1 (software) | shown in tests | blocked: refused rollback, once the newer list was installed (see K47) | test_an_older_key_list_is_refused, an_older_key_list_is_a_rollback, a_revoked_key_stays_revoked_after_rotation |
| K39 | Key or device confusion: an update for another device, signed by a key the root doesn’t list or has removed, by the wrong listed key, or a key list not signed by the root | v1 (software) | shown in tests | blocked: refused device, key or signature, both when applying and at start-up | test_key_and_device_confusion_are_refused, test_start_up_refuses_another_device, start_up_checks_every_part_of_the_store |
| K40 | Substitute one file (settings, verification key, setup file, domain) in an otherwise valid update | v1 (software) | shown in tests | blocked: refused bundle | test_a_substituted_file_is_refused |
| K41 | Edit a signed manifest or key list, or write its signature another way | v1 (software) | shown in tests | blocked: refused signature or format | test_an_edited_manifest_is_refused, manifests_have_exactly_one_form, signature_files_have_one_form |
| K42 | Change one stored file, a signature or one field of the state after an update was applied | v1 (software) | shown in tests | blocked: the endpoint refuses to start (store refused: signature, state or bundle); a coordinated rewrite of state and files together is K43 | test_start_up_refuses_any_change_to_the_store, test_the_endpoint_refuses_a_changed_store, start_up_checks_every_part_of_the_store |
| K43 | Restore a whole older copy of the store (state and files together), e.g. from an old SD card image, or delete the store and apply an old update | v1 (software) | proposed | open: the state is unsigned and lives on the card, so a complete old copy (or an empty store) is self-consistent; needs a counter the card can’t roll back (TPM, v2) | none yet |
| K44 | Change the release policy (release rule, deadline, cap) without a new signed update, or bring back an older policy, e.g. a weaker cap | v1 (software) | shown in tests | blocked: in store mode the policy is read only from the signed manifest, policy flags are refused, and an older manifest is a rollback; out-of-range values are refused (policy) | test_policy_flags_are_refused_in_store_mode, test_the_release_policy_comes_from_the_manifest, test_an_out_of_range_policy_is_refused |
| K45 | Run two installers at once so a lower version commits after a higher one (found by review, 30 Oct) | v1 (software) | shown in tests | blocked: one exclusive store lock from reading the state to the last write; the second installer fails store busy | test_installs_never_overlap |
| K46 | Install a new update while the endpoint is serving, so “applied” doesn’t mean in use (found by review, 30 Oct) | v1 (software) | shown in tests | blocked: the serving endpoint holds the lock shared, so the installer fails store busy; an update takes effect at the next start | test_no_install_while_the_endpoint_serves |
| K47 | Revoke a key with a new key list but an unchanged manifest, so the device reports same and keeps the old list (found by review, 30 Oct) | v1 (software) | shown in tests | blocked: a newer key list with the same manifest is installed; a different list under the same version is refused (replay) | test_a_key_revocation_with_an_unchanged_manifest_takes_effect, a_newer_key_list_with_the_same_manifest_is_applied, test_a_different_key_list_under_the_same_version_is_refused |
| K48 | Edit only the state back to an older version, then apply an older signed update (found by review, 30 Oct) | v1 (software) | shown in tests | blocked: before installing, the stored files must match the state (except to finish an interrupted install of the same manifest), so refused state | test_an_edited_state_does_not_let_an_older_update_in, test_an_interrupted_install_can_be_finished |
Out of scope
| # | Attack | Why |
|---|---|---|
| K26 | Learn the model from many correct answers (extraction) | Every proof verifies; the plan puts it out of scope, to confirm with the mentor (THREAT_MODEL.md 4.1) |
| K27 | Physical attacks and physical side channels | Out of scope (mentor, 14 Oct) |
Threat model
In short
- What we protect: the model’s weights on the AI server, and everything that decides what gets out: the pinned model files on the endpoint, the endpoint’s code and settings, when answers are released, and the endpoint’s own logs.
- Who attacks: mainly the AI server itself (hacked, or a model acting against its owners) working with an outside accomplice who reads the signals; also anyone on the network, and later anyone holding a signing key.
- Key assumption: every path from the server to the outside goes through the endpoint. Physical attacks on the device are out of scope.
- Section 6 lists every way a server can signal around a valid proof and how far each is closed; section 8 maps the threats to MITRE ATLAS.
This is the full reference document. For a plain overview, read What a hacked server can still leak and How hard the endpoint is to attack first.
Living document: started in W2, updated after each version and each adversarial-testing session, final in W7. Status as of 20 Oct 2026: v0 measured on the Pi, the v1 software (including fixed release) measured on the Pi on the v0 OS; a v1 development image boots but doesn’t run the endpoint yet; v2 is not built.
1. What the device protects
| Asset | Why it matters |
|---|---|
| Model weights (and other secrets) on the AI server | The thing an attacker wants out. The device can’t see them; it can only limit what leaves |
| The pinned bundle on the device (settings, vk, SRS) and the domain file | Swapping any of them changes what counts as a valid answer |
| The device’s code and configuration | Whoever controls them controls what is released |
| The release decision and its timing | Content is pinned by the proof; when, whether and how an answer is released is the remaining channel |
| The device’s own outputs (logs, errors, alerts) | A second way out if they carry server-chosen data |
2. Attackers
| Attacker | Controls | Wants |
|---|---|---|
| A1 Compromised AI server | Everything on the server side of the device: outputs, proofs, timing, which requests fail, the TCP connection | Move secret bits to someone outside, through answers that still pass the endpoint |
| A2 The served model acting as a rogue agent | The same as A1, from inside the inference stack, and patient. Gigurtsis’s loss-of-control matrix lists weight exfiltration (technique 5.4.1) as a step a rogue agent takes, and for closed-weight models a prerequisite for replication. In scope: a model that compromises the inference stack or controls timing and failures. Not in scope: secret-bearing content that the approved model itself computes; the endpoint must release that, since it is a correct answer (see 4.1) | Its own weights out, a few bits at a time |
| A3 Outside receiver | One or more ordinary client connections | Decode what A1 or A2 hides; probe the device (invalid requests, timing of errors) |
| A4 Network attacker | Either link, if not physically protected | Inject, drop or reorder frames; impersonate the server or a client |
| A5 Holder of a signing key (from v2) | An update, boot or model-approval key | Install a device that releases more |
A1 and A3 working together is the main case: the server encodes, the receiver decodes.
3. Trust boundaries
outside clients (A3) | device | AI server (A1, A2)
----- link 1 ----- ----- link 2 -----
- The device trusts nothing it receives on either link.
- The device opens link 2 itself and listens only on link 1.
- v0 runs on stock Raspberry Pi OS, so the device’s own software is trusted without being minimal (see section 5).
4. Assumptions
- The proof system is sound and EZKL 23.0.5’s verifier implements it correctly. Not tested here; checked by others.
- The SRS file is the pinned one, and the ceremony behind it was sound. A pin proves byte identity only.
- The pinned vk and settings were built from an honest, complete circuit for the claimed model, on a build machine that wasn’t compromised. The device can check that a proof matches the vk, not that the vk constrains what we think it does.
- The EZKL CLI binary matches its pinned release digest; that trusts zkonduit’s release process.
- Complete mediation: every path from the AI server to the outside crosses the endpoint. In v0 this is not enforced: the stock OS has SSH, mDNS, Wi-Fi and its own outbound traffic, and nothing stops the server from having another route out. The v1 image (two wired ports, nothing forwarded, no other services) is the first version where this can be claimed, and it has to be tested from both sides.
- Logs: where the log goes and who reads it is not yet defined. If an outsider can read it, the log is an output channel (section 6).
- The proof binds the encoded request and the outputs (docs/BINDING_SPEC.md), with one pinned model and fixed parameters.
- Physical access is out of scope (agreed with the mentor, 14 Oct): no power, EM, acoustic or thermal channels, no tampering.
- What ordinary, correct outputs reveal about the model is out of scope (see 4.1).
4.1 Extraction through correct answers: out of scope, to confirm
A proof shows that y came from the pinned model on this x. It does nothing against an attacker who learns the model from many correct answers. For the tiny affine + ReLU test model, a handful of chosen requests that keep the ReLUs active can recover the quantised weights by linear algebra, and every proof verifies. The plan puts this out of scope; it is on the agenda for the mentor call (21 Oct) to confirm, or to add an endpoint policy such as query-rate limits.
For real LLMs, any freedom the proof doesn’t pin (sampling seed, temperature, unproven layers) becomes a channel with valid proofs. The test model is deterministic; who chooses the seed is an open question for the mentor.
5. v0 attack surface (measured on the Pi, 11, 13 and 16 Oct)
- Raspberry Pi OS Lite (Debian 13), 633 installed packages, 13 to 15 running system services (the count depends on what has run since boot: 15 on 11 Oct, 13 on 16 Oct).
- Listening: sshd (TCP 22) and avahi (mDNS on UDP 5353 plus two random UDP ports), on IPv4 and IPv6. The endpoint adds its own listener on link 1.
- Verifier: EZKL’s official CLI (it also contains the prover and setup code; size in Measurements), run by
pge/cli.py. - Updates: files copied over SSH.
device/measure.sh records the same local items for every version (packages, running services, processes, listening sockets, hashes of the boot config and of any files passed to it). A tool that fails makes the run invalid instead of reporting zero. It is a local snapshot only: what is reachable is measured separately, from outside. What v0 and the first v1 development image expose: the book’s Measurements page (attack surface).
6. Side channels a compromised server controls
Status as of 20 Oct in the last column. “Open” means the channel is not closed by any software version yet.
| Channel | How A1/A2 uses it | Planned defence (version) | v0 |
|---|---|---|---|
| Timing | Delay each answer by a chosen amount | Fixed release time with a verification budget (--release fixed, 19 Oct) | Reduced, not closed (K8): v0 and arrival order leak (the planted bits were read back on the Pi); under fixed release the 1 s-code decoder fails, but a sub-millisecond shift follows the server’s delays without spin; with a 5 ms spin no mean shift was detected and a 512-answer pre-registered test was inconclusive, so what that decoder finds is small but the residue isn’t shown to be gone. Costs every answer the full budget (4 s here). Numbers: the book’s Measurements page |
| Ordering | Answer concurrent requests in a chosen order | Release in arrival order (v1); time slots (v2) | Closed in the v1 software for the order admitted answers are written on the endpoint, failures included (SERVICE_SPEC.md section 4). Not covered: busy and halted answers leave at once, and log timing. Order across separate connections is only smoke-tested: one run of 8 bits each, read back under immediate release and constant under arrival order (the book’s Measurements page). v0 released answers as checks finished |
| Whether an answer arrives | Fail on purpose: bad proofs, drops, resets, missed deadlines | One generic error; failure threshold and response (v1, v2). With timing fixed, still up to 1 bit per request, and a sparse failure’s position carries log2 N bits per block of N | Open, shown on the Pi (K32): 4 failures in 64 answers carried 16 bits under every release policy. One server-independent error string; failures are counted, and halt_after stops every successful answer (pending and held ones become unavailable, later requests get unavailable), off by default; error replies keep flowing, so their timing still carries information |
| Sizes and packet metadata | Response length, TCP segmentation, header fields | The device builds every outgoing packet itself; fixed-size responses (v1, v2) | Partly: in the v1 software every reply frame has one size and the server can’t choose the number of output values; the OS’s TCP stack (segmentation, headers) is still stock |
| Answer content | Pick among valid answers or encodings | One pinned model, the device’s own encoding, canonical values, y decoded from verified values, proof never released | Closed for the test bundle: binding and service tests |
| Verification time | Proofs that are slow to verify or crash the verifier | Fixed proof size; verification budget; separate worker with a deadline | Partly: CLI killed at the deadline (pge/cli.py). With arrival or immediate release the answer time still depends on it; with fixed release it doesn’t, except for the residual shift (K8), and a check that runs out of budget is unavailable |
| Device outputs | Smuggle data into logs, alerts or errors | Logs hold hashes and results only, exported one way; one generic error text | Partly: clients only see two error strings. In the v1 software log records hold fixed words, the device’s seq, the client’s request hash, ms and (for a halt) the failure count (no error text, no proof hash), to stderr or a file for a log partition; the number and timing of log events still follow the server. Who reads the log is undefined |
| Busy answers | Hold requests open to fill max_pending, so other clients get unavailable | Fixed slots (v2) | Open: depends on load |
7. Other attacks to test
| Attack | Target | Current coverage |
|---|---|---|
| Replay an old answer | Pending table | One answer per seq; tested (test_late_answer_is_dropped_and_answered_once, test_replayed_answer_only_passes_for_the_same_request) |
| Proof for another request or model | Binding | Tested: other request, other circuit, tampered output; EZKL also refuses wrong instance layouts (test_instance_layout_is_enforced_by_ezkl) |
| Bytes appended to a valid proof | Proof format | EZKL accepts them (test_trailing_proof_bytes_still_verify), so the endpoint pins the exact proof length (Policy.proof_bytes); tested |
| Inject, replay or reorder frames on either link | Links (A4) | No transport security in v0; replay is limited to one answer per seq |
| A substituted verifier binary that accepts everything | Supply chain | Digest pin on download; v1 bakes the binary into the image |
| Make the device unreliable until operators bypass it | Availability | Failures are logged and counted; nothing yet makes a bypass visible |
| Malformed or oversized frames | Parser | Fixed-size binary frames (v1 software); re-encoding an accepted frame gives the same bytes (fuzzed, including split streams, in tests/test_protocol.py) |
| Exploit the verifier or the network stack | Device software | The verifier runs with Rayon set to one thread and per-process limits (memory on Linux, CPU, no file growth, few files; v1 software); the minimal image and a separate verifier user come with the v1 image |
| Swap the bundle on disk | Bundle | Pins checked at load and before every check; same-user swap between check and use still possible |
| Change the device’s software | Boot chain | Not addressed in v0; secure boot in v2 |
| Persuade an operator to approve a bad update or model (Gigurtsis 5.2.2, 5.6.8) | Update process | Not addressed in v0; separate keys and 2-of-3 update signing in v2 |
| Learn the device’s policy by probing (Gigurtsis 5.1.4) | Policy | Generic errors; failure alerts planned |
8. Mapping to MITRE ATLAS
ATLAS release 2026.09 (mitre-atlas/atlas-data, dist/v6/ATLAS-2026.09.yaml: 16 tactics, 208 techniques, 40 mitigations, 73 case studies). ATLAS describes adversaries attacking AI systems, mostly from outside or through the supply chain. Our main attacker is the inference host itself (A1, A2) trying to get data out through a device that only releases verified answers. We found no ATLAS technique for leaking through the timing, order or failure pattern of valid outputs from a compromised inference host (searched names and descriptions for covert, side channel, timing, latency, steganography). The closest are the generic Exfiltration via Cyber Means, which defers to ATT&CK, and the goal AI Intellectual Property Theft. Gigurtsis’s loss-of-control matrix (technique 5.4.1) is the closer fit and is cited per row in ATTACKS.md.
| ATLAS | Name | Our rows | Note |
|---|---|---|---|
| AML.T0048.004 | AI Intellectual Property Theft (Impact) | the goal of A1/A2 (section 1) | the asset is the weights |
| AML.T0025 | Exfiltration via Cyber Means | K8, K18, K28, K19, section 6 | generic; our channels are a gap in ATLAS |
| AML.T0024.002 | Extract AI Model | K26 | out of scope |
| AML.T0018 | Manipulate AI Model | K3 | a proof under another circuit’s key is rejected (pinned vk) |
| AML.T0010.001 / .003 | AI Supply Chain Compromise: AI Software / Model | section 7 (substituted verifier binary, swapped bundle) | digest pins; v1 bakes both into the image |
| AML.T0029 | Denial of AI Service | K16, K17, K23 | partly by design (halt, busy) |
| AML.T0034.001 | Resource-Intensive Queries | K15, K29 | analogous: crafted proofs rather than queries |
No dedicated technique found in this release (by the search above) for: request binding and proof-format attacks on the verifier (K1-K7, K30, K31, K33), release timing, order and failure position, and packet metadata. This is a statement about the taxonomy’s granularity, not proof that nobody has studied them.
ATLAS mitigations we use: AML.M0014 Verify AI Artifacts (bundle pins), AML.M0013 Code Signing (v2 signed updates, secure boot), AML.M0036 Limit AI Workload Resource Consumption (verifier limits, deadlines), AML.M0004 Limit AI Service Query Volume and Rate (max_pending, max_clients), AML.M0024 AI Telemetry Logging (fixed-word logs; here the log is itself a channel, section 6), AML.M0035 AI Red Team (W6 sessions).
9. References
- Project plan, side-channel table and three versions (shared with mentor and RM).
- Gigurtsis, From Discovery to Persistence: A Loss-of-Control Threat Matrix for Agentic AI (2026), https://github.com/bgigurtsis/AgenticLoCThreatMatrix
- Rinberg et al., Verifying LLM Inference to Detect Model Weight Exfiltration (2025), https://arxiv.org/abs/2511.02620
- Lampson, A Note on the Confinement Problem (1973); Murdoch and Lewis, Embedding Covert Channels into TCP/IP (2005).
- MITRE ATLAS, release 2026.09, https://atlas.mitre.org/ (data: https://github.com/mitre-atlas/atlas-data)
Endpoint service specification
In short
- The exact behaviour of the endpoint software: message formats and sizes, what happens to each question, the three release rules, failure handling, limits and logs.
- This is a reference for checking the code against. For a plain description, see How the endpoint works.
The device as a network service: clients send requests to the device, the device forwards them to the AI server (the prover), checks each answer’s proof against its own encoding of the request, and releases either the decoded output or an error. There are two error strings: bad_request, which depends only on the client’s own request, and unavailable for every failure that involves the server. Code: pge/service.py, pge/protocol.py. Laptop prover: prover/server.py. Tests: tests/test_service.py, tests/test_protocol.py.
From 26 Oct the device runs the Rust endpoint (endpoint/, see endpoint/README.md). It implements this specification for arrival and fixed release, and tests/test_endpoint_rust.py checks it against the Python endpoint reply for reply and log record for log record. The failure cap (section 5) and the accept_failed record exist only in the Rust endpoint; the Python endpoint stays as the reference for everything else.
This is the v1 software, still run on the v0 OS until the v1 image exists. Compared with v0 (commit f8e27e0): fixed-size binary frames instead of JSON, answers released in arrival order, log records with fixed words only, and resource limits on the verifier. --release fixed (19 Oct) sends admitted answers in admission order, each no earlier than admission + deadline, which hides most of the server’s timing; a small residue was measured, and the failure channel stays open (section 4, step 6). --release immediate restores v0’s release behaviour for comparisons.
1. Connections
client --(outside port, device listens)--> device --(device connects out)--> server
- The endpoint listens only on the address given with
--listen, which must be the outside interface’s own address, not0.0.0.0. It opens the connection to the server itself, so the endpoint has no listener on the server side. That connection still carries hostile data. - These are claims about the endpoint’s own sockets. On the v0 OS the system also runs SSH and mDNS, and nothing yet forces every path from the server to the outside through the endpoint (see
THREAT_MODEL.mdsection 4). - If the server connection drops, every pending request fails at once, and the device reconnects with a growing delay.
2. Framing
Every message is a frame: a 4-byte big-endian length, then that many bytes of payload. For one pinned circuit every payload has one size, except the server’s answer (section 3). With n input values and m output values in total:
| Direction | Payload size |
|---|---|
| client to device | 9 + 8n bytes (41 for the tiny bundle) |
| device to server | 9 + 8n bytes |
| server to device | 9 + 32m bytes plus the proof, at most 64 KiB of proof (3,177 bytes for the tiny bundle) |
| device to client | 10 + 8m bytes, for every reply (34 for the tiny bundle) |
A frame of any other size, an unknown type byte, a counter of 2^53 or more, or a number that isn’t finite is a protocol violation (section 5). A server frame that has started must arrive in full within the deadline, counted from its first byte, or it is a violation too. Re-encoding an accepted payload gives the same bytes, so no message has a second accepted form (checked on fuzzed inputs in tests/test_protocol.py, not proven). Different float64 inputs can still quantise to the same x; that is the binding’s concern (BINDING_SPEC.md).
3. Messages
All integers are big-endian. Numbers are IEEE-754 float64. Field elements are the 32 little-endian bytes EZKL uses.
| From | Layout |
|---|---|
| client | type 1 (1 byte), id (8 bytes), x (n float64, inputs in graph order, flattened) |
| device to server | type 2, seq (8 bytes), x (n float64) |
| server | type 3, seq (8 bytes), outputs (m field elements), proof (the raw EZKL proof bytes) |
| device to client | type 4, id (8 bytes), status (1 byte: 0 ok, 1 bad_request, 2 unavailable), y (m float64; all zero bytes unless ok) |
idis the client’s, below 2^53. On one connection ids must be strictly increasing, so an id is never reused.seqis the device’s own counter, strictly increasing over the whole run, starting at 1. The server never sees client ids, and clients never seeseq.- Every reply has the same size, so its length says nothing about the answer.
- The number of output values is fixed by the frame, so the server can’t choose it (in v0 it could, and that count reached the log). The device checks the proof has exactly the pinned length (
--proof-bytes; EZKL itself accepts bytes appended to a valid proof). A proof of another length is a REJECT for that request, as in v0, not a protocol violation, except an empty proof or one over 64 KiB, which doesn’t fit the frame and is a violation. The pin itself can’t exceed 64 KiB. - The device builds the proof object EZKL verifies from its own instances and the raw proof bytes; nothing else from the server reaches the verifier.
4. Request lifecycle
- The device parses the client frame. A request of the wrong size is a protocol violation; otherwise
xhas the circuit’s shape, and the device checks its encoding (pge/binding.py) and, if the bundle has one, the input domain (pge/domain.py). Any failure is answeredbad_requestat once and nothing is forwarded. This answer depends only onx, so it reveals nothing about the server. - Otherwise the device assigns the next
seq, records the request as pending with a deadline, and forwards(seq, x). - The first server message for a pending
seqis final. The device checks the proof length, builds the instances from its ownxand the server’s outputs, verifies, and removes theseqfrom the pending table:- ACCEPT: the answer is
okwithydecoded from the verified output instances at their pinned scales; - REJECT or ERROR: the answer is
unavailable.
- ACCEPT: the answer is
- If the deadline passes first, the request is removed and answered
unavailable. A check still running is left to finish (the verifier has its own deadline) and its result is dropped. The deadline is absolute: it is checked before a check starts, when the result is decided, and again when a heldokleaves (step 5), so a late timer can’t let an overdue answer out. The verifier gets only the time that is left. - Release in arrival order (default,
--release arrival): every admitted request’s answer,okorunavailable, is sent only after the answers to all requests admitted before it. So the server can’t choose the order in which admitted answers leave. Requests are admitted in order with the same deadline, so the earlier requests’ deadlines are no later than an answer’s own. Timers can run late, so a heldokis checked again when it leaves: past its own deadline or after a halt it is sent asunavailableinstead (loggeddeadlinewith reasonheld, orhalted). Held answers count towardsmax_pending. Answers that are never admitted (bad_request,busy, halted) are sent at once, so they can overtake held ones;busyand halted depend on the server through load and failures (section 5). Global order couples clients: a silent server delays every later answer, from any client, up to the silent request’s deadline. Per-client order would remove that coupling but let the server order answers across clients; which matters more is open. With--release immediateeach answer leaves as soon as it is decided (v0). - Fixed release (
--release fixed): admitted answers,okorunavailable, leave in admission order, each no earlier than its admission + deadline, whatever the server did and whenever the check finished. So the deadline is also the target release time and has to cover proving plus checking; an answer not decided by then isunavailable. Only the next answer in order waits, so a later answer can’t overtake an earlier one. With--spin-msthe wait’s last stretch (at mostspin) is a busy-wait, to make the send time less dependent on how long the device was idle. Deciding an answer only schedules the release step, which runs in its own event-loop callback and busy-waits at most once per run, so no callback blocks the loop for longer thanspin, even when a disconnect or halt decides many answers at once (K35). Fixed release promises “never early”, not a latest send time: a stalled loop sends late, and anokdecided before its deadline is still sent asok. A heldokafter a halt is sent asunavailableat its release time. What still varies: whether each answer isok(the failure channel: the position of one failure in a block of N requests carries log2 N bits),busyand halted answers (never admitted, sent at once), and how late each send is (K8; numbers in the book’s Measurements page). - Messages for a
seqthat is not pending (late, duplicate, never issued) are dropped and counted. They never produce a client answer.
So the device makes exactly one decision per request and sends at most one answer for it; each seq is consumed once. The answer reaches the client as long as its connection can still receive: a client that closes its sending side (or goes idle) still gets the answers to the requests it already sent. A client closed for a protocol violation doesn’t.
5. Failures and policy
- Client protocol violation (bad frame or message, non-increasing id): the device closes that client’s connection. Its pending requests still run, but their answers are dropped.
- Idle or slow clients: a client that sends nothing, or doesn’t finish a frame, for
idleseconds (default 60) stops being read; its outstanding answers are still sent, then the connection is closed. At mostmax_clientsconnections (default 64) are served; more are closed at once. - Server protocol violation (a frame or message that doesn’t match section 2-3): the device drops the server connection and answers every pending request
unavailable. A broken message can’t be tied to one request, so all of them fail. - Stalled server: if more than 1 MiB of requests is waiting for a server that has stopped reading, the device drops the connection, with the same effect.
- Failure count: every
unavailablecaused by the server (REJECT, ERROR, deadline, disconnect, no server connection) adds one to the failure count. Ifhalt_afteris set and the count reaches it, the device halts: every pending request is answeredunavailableat once (with fixed release, at its release time), heldokanswers becomeunavailable, and every later request is answeredunavailabletoo, until it is restarted. The failure is counted before its answer is released. A disconnect fails every pending request, so it adds one failure for each. The default is not to halt (an open question for the mentor, halt vs continue); every failure is logged either way. - Failure cap (Rust endpoint,
--cap-window W --cap-failures F --cap-cooldown C; off unless--cap-windowis given): replies are taken in the order they leave. If a server-caused failure would make more than F failures among the last W replies that weren’t capped, the cap trips (cap_trippedis logged before that reply), the history is cleared, and the next C replies areunavailablewhatever the server does (capped); after that the window starts empty. Answers that the halt turnedunavailabledon’t count. The rule isobserver/cap_model.py.observer/cap_bound.pycounts exactly how many status sequences (ok orunavailable) the replies to n admitted requests can show, in release order from an empty cap; that bounds what this sequence can carry, and nothing else: busy and halted answers at admission, release times, ids and interleaving across clients, missing replies and the logs are outside it (see the tool’s docstring for when busy answers depend on the server). C must be at least 1: with no cooldown a trip only clears the window, and every answer could fail. The cap changes only a reply’s content; replies keep the release policy’s order and schedule. - Accept errors (Rust endpoint): a failed
accept()(e.g. out of file descriptors) is logged asaccept_failedand accepting pauses for 1 s; the device keeps serving connected clients. - Load: at most
max_pendingadmitted requests are not yet answered (pending or held); more are answeredunavailable. This answer depends on other clients’ load. - Checks run one at a time. A request that expires while waiting for its turn is never checked.
- Verifier limits, Rust endpoint: each check runs in a fresh copy of the endpoint binary (a hidden subcommand, no EZKL CLI) in its own process group, with the same limits as below; it only lowers each limit, soft and hard, to at most what the endpoint itself was started with, so too few resources make the check ERROR rather than stopping the endpoint.
- Verifier limits (Python reference,
pge/cli.py,pge/proc.py): the EZKL CLI runs with Rayon set to one thread (RAYON_NUM_THREADS=1, a setting, not an OS limit), in its own process group, with an address-space limit of 256 MiB (Linux; near the limit the outcome varies by run, so 256 MiB is used; the book’s Measurements page), CPU seconds up to the deadline, a file-size limit of 0, at most 64 open files and no core dumps. The file-size limit stops a file growing; it doesn’t stop truncating, deleting or creating empty files, which needs permissions or read-only mounts (v1 image). Limits are per process, not for the process tree. A fresh single-threaded process sets the limits and then execs the CLI. A CLI that hits a limit is ERROR. A larger circuit needs a larger memory limit (CLI_MEMORY). - Bad configuration: a deadline or idle time that isn’t a number of seconds in (0, 3600], capacities outside [1, 4096], non-positive
halt_after, a--proof-bytesoutside [1, 65536] (the frame’s proof limit), an unknown--release, and a cap with W outside [1, 4096], F not below W, C outside [1, 2^20], or cap settings without--cap-windoware refused at start-up.
6. Replay
- A server message is matched only to a pending
seq, so an old answer can’t be delivered twice or to another request. - The proof binds the encoded
xandyonly, with no nonce (seeBINDING_SPEC.mdsection 8). A proof the server made earlier for the same encodedxis still a valid answer to a new request with thatx. With one pinned, deterministic model the releasedyis the same either way, so this doesn’t let the server choose the output. A nonce in the proved relation would make each proof fresh, but not the inference: an old witness can be re-proved with a new nonce (seeBINDING_SPEC.mdsection 8). Which property matters is open question 3.
7. Logs
JSON lines, one per event, on stderr or appended to the file given with --log (for a separate log partition on the image). A record holds only:
result: a fixed word (accept,reject,error,deadline,disconnect,no_server,busy,halted,bad_request,stale_answer,duplicate_answer,server_violation,server_stalled,client_violation,client_idle,client_refused,connect_failed,check_crashed; Rust endpoint only:capped,cap_tripped,accept_failed);reason: a fixed word fromREASONSinpge/service.py(request,proof_length,binding,proof,verifier,verifier_failed,reply,closed,violation,lost,protocol,ids,held); anything else is logged asother;- for an admitted request: the device’s
seq, sha256 of the encoded request (the client’s input, not the server’s) andmsfrom admission to release. The record is written when the answer leaves, so with arrival release records come inseqorder; forduplicate_answer, the device’sseq; forhalted, the failure count.
No error text is logged: in v0 it could carry server-chosen values (the number of output values, the proof length). The proof is not hashed into the log either: a proof is randomized, so the server can pick among valid proofs, and a logged proof hash would give it a channel into the log whose rate depends on how many proofs it can afford to make, up to the digest width. A hash of the decoded y alone has no such freedom for one pinned deterministic model, but it would put the output into the log. Commitments to answers belong in v2’s hash chain, which authenticates the log but doesn’t close channels in its contents. The number of events, their timing, ms, the result and reason words and the failure count still follow the server, and x_sha256 is a linkable tag for the client’s input (guessable for small input sets), so who can read the log matters (THREAT_MODEL.md section 6). A log that fails to write never stops an answer (it is counted as log_error).
8. Not yet
Writing outgoing packets itself, the separate log partition and a separate unprivileged user for the verifier (both on the v1 image), alerts with a response beyond halt_after, signed updates.
Request binding specification
In short
- The rules that make a valid proof a proof for this question, this model and this reading of the output: pinned model files, exact input encoding, strict checks on the server’s output values.
- Each rule comes with the failure it prevents and a test vector. For the plain version, see Why the endpoint encodes the question itself.
What the device checks so that a valid proof is a proof for the request the device itself received, and what it releases. Code: pge/binding.py, pge/bundle.py, pge/domain.py, pge/verdict.py, pge/worker.py. Tests: tests/.
Everything here is for EZKL 23.0.5 and the pinned test bundles. It is not a general claim about every EZKL model.
1. The bundle is pinned
Required of the device service (pge/service.py serves exactly one bundle): it picks the single bundle for each request (settings, verification key, SRS) and refuses to start if any file’s sha256 differs from its pin. The server never chooses between bundles: picking between two valid models with different outputs would let it choose the output. Bundle.load implements the check.
| Bundle | File | sha256 |
|---|---|---|
| tiny | settings.json | b4a95fc7c572816034708c4e5c079f0f91f82eb9681cfa069565aacc9887aefd |
| tiny | vk.key | 34ab48a8ac0946be4faf10d85699ac729adb20ec4c6251da1d1d0d4ea18a4d26 |
| multi | settings.json | 89f83ab3aace396b42aced55d3b482d33cb8a196f2d00259ddfa20fea92a3121 |
| multi | vk.key | 0bd4cec6c17051d478ac3cfd3bff3ca5f4d98184673c99949bdb838b208411c7 |
| both | kzg.srs (logrows 15) | 90807800a1c3b248a452e1732c45ee5099f38b737356f5542c0584ec9c3ebb45 |
Why the settings pin is mandatory: changing only model_output_scales from [26] to [13] leaves the proof valid under the same vk, but the decoded output changes from 0.9666 to 7918.6 (test_scale_only_settings_change_still_verifies).
Why pins name one build, not “the circuit”: EZKL fills required_range_checks from a hash set, so its order can change between runs. That order changes the circuit layout and therefore the vk. Reversing only [[-1,1],[0,16383]] gives vk d3c4f45c... instead of 34ab48a8.... Keygen itself is deterministic for a fixed ordered circuit, and the settings timestamp is not part of the vk. A regenerated bundle is a new build that needs a new pin.
A pin proves byte identity. It says nothing about whether the SRS ceremony was sound.
Bundle.load reads each file once, checks it and keeps a private read-only copy of exactly those bytes; Bundle.verify re-checks the copies before every call and returns ERROR if they changed. This matters because a wrong but well-formed SRS makes a valid proof fail with a genuine [halo2] error, which would otherwise look like a rejected proof. The copies are protected only from other users: the service’s own user can still change them between the re-check and EZKL’s read.
2. Settings are validated strictly
CircuitSpec.parse rejects: a version other than 23.0.5; inputs or outputs that are not public; parameters that are not fixed; missing or empty fields; input_types that don’t match the inputs one to one; types other than F32/F64; scales that are not plain integers in [0, 30] (no bools, floats or strings); non-positive dimensions; tensors over 65536 values; the wrong number of instance shapes.
3. Input encoding
- Field: BN254 scalar field,
q = 21888242871839275222246405745257275088548364400416034343698204186575808495617. - Convert to the declared input type first (F32 here), then multiply by
2^scaleand round half away from zero (Rustf64::round). Python’sround()(half to even) andfloor(v + 0.5)are both wrong.
| x | Python round(x*8192) | EZKL, F32 |
|---|---|---|
| 0.5/8192 | 0 | 1 |
| -0.5/8192 | 0 | -1 |
| 2.5/8192 | 2 | 3 |
| (0.5-1e-9)/8192 | 0 | 1 (0 if the type were F64) |
- A negative integer v is encoded as
q - |v|. - Instances are 32-byte little-endian, lower-case hex, no prefix. Only that canonical form is accepted (64 characters, value < q), for inputs and outputs alike.
- Inputs must be finite numbers that fit the input type; the scaled value must stay below 2^63. EZKL itself allows i128 and maps NaN to 0. The narrower range is deliberate and still far wider than any circuit domain below.
- All input tensors come first in graph order, then all output tensors, in one instance column (checked with a two-input, two-output bundle whose inputs are declared out of alphabetical order).
Binding is to the encoded request, not the raw float: 0.5 and 0.50001 both encode as 4096 at scale 13, so a proof for one is a proof for the other (test_tiny_accepts_same_encoding_class). If raw request identity matters, the device has to record it separately.
4. Input domain
A request with no possible proof should be refused before it reaches the server, otherwise out-of-domain requests let anyone make the server fail on demand.
For the tiny bundle (y = ReLU(xW + b), 4 inputs, 3 outputs) EZKL decomposes the quantized inputs and every pre-activation in base 16384 with 2 digits, so with N = 16384^2 - 1 = 268435455:
n_i = round_half_away(8192 * F32(x_i))
z_j = sum_i n_i * Wq[i][j] + bq[j]
admissible <=> |n_i| <= N for all i and |z_j| <= N for all j
Wq = [[ 2244, -3772, -7521], (scale 13)
[-7921, 5133, 6763],
[ 1747, 3760, 715],
[ 7128, 5175, -8147]]
bq = [23984994, -31300542, 15411916] (scale 26, quantized directly)
pge/domain.py derives these from the model’s F32 weights and checks requests against them. The tests compare its decisions with EZKL’s real witness generation on:
- random requests;
- each pre-activation at exactly
+Nand-N(admitted) and at+(N+1)and-(N+1)(refused), with every other constraint well inside. These inputs are small, so they are exact F32 requests; - input coordinate 2 at
+N,-N,+(N+1)and-(N+1)with small pre-activations. These are sent to EZKL as field elements, becauseNitself is not an F32 request at scale 13:F32(N/8192)quantizes toN + 1.
Every request in [-1, 1]^4 is inside. from_float only accepts finite F32 parameters and is only valid for this graph’s shape (MatMul + Add + ReLU, each constant used once, no rescaling before the output). EZKL requantizes a shared bias differently, so the domain of other models has to be derived from their own compiled operations.
5. Output decoding
- Each output tensor has its own scale (tiny: 26; multi: 13 and 26). Decode as a signed integer divided by
2^scale. - Release only values decoded from the same instances that were verified. Never release the server’s claimed y or the proof itself, and never read the proof file’s
pretty_*,timestamporhex_prooffields: changing them does not invalidate the proof, because EZKL verifies only theproofbyte list. EZKL also accepts a validprooflist with bytes appended (test_trailing_proof_bytes_still_verify), so the endpoint pins the exact proof length (Policy.proof_bytes, 3072 for the test bundles). It does enforce the instance layout: extra, missing, shorter or longer columns are REJECT.
6. Verify results
pge/verdict.py maps a verify call to ACCEPT, REJECT (invalid or malformed proof) or ERROR (the check did not happen properly). Only ACCEPT releases output. Tests treat ERROR as a failure, never as a rejection.
- The proof is read once, up to 1 MiB. A read failure is ERROR; a larger proof is REJECT.
- The device checks the proof’s shape in memory: a JSON object whose
proofis a list of integers in [0, 255] and whoseinstancesare lists of canonical field elements. Anything else is REJECT without calling EZKL. It then writes a private copy holding only those two fields and verifies that, so the other fields are never read. - After that, only a
[halo2] ...error is REJECT. Everything else is ERROR, including any error while loading the proof, settings, vk or SRS. EZKL’s load messages can’t tell bad data from failed I/O: serde can attachat line L column Cto a real I/O error. The device script also requires the CLI’sverified: falseline for a[halo2]rejection, andverified: trueplus exit code 0 for ACCEPT. - A
[halo2]failure only says the proof is bad if settings, vk and SRS were the pinned ones, so useBundle.verify, which re-checks them. verifyitself has no deadline (it can block on opening a slow file) and cannot survive a native crash.pge/worker.pyruns it in a child process that is killed (SIGKILL) at the deadline; a timeout, crash, non-zero exit, or anything but one[verdict, detail]line of two strings is ERROR. The child runs in its own process group, killed as a whole at the deadline, and output over 64 KiB is ERROR. Limit: the bundle check runs before the deadline starts. The device script usestimeout -k 5, so a verifier that ignores SIGTERM is killed 5 s later.pge/cli.pyis the same check with EZKL’s official CLI, which the Pi needs because there is no Python package for Pi OS. ACCEPT needs exit 0, no error line and oneverified: true; REJECT needs exit 1, oneverified: falseand exactly one error line, a[halo2] ...one. Anything else is ERROR, including duplicated or contradictoryverifiedlines, output over 64 KiB and output that isn’t UTF-8. The CLI runs with a minimal environment (soRUST_LOGcan’t change its output) in its own process group, killed with SIGKILL at the deadline or as soon as its output passes 64 KiB (pge/proc.pyreads it from a pipe).device/check_verifier.shuses the sameclassify_cli, so the endpoint and the device checks can’t disagree on a verdict.
7. Test vectors
tiny: x = [0.5, -0.2, 0.9, 0.1]
inputs = [4096, -1638, 7373, 819] (scale 13)
outputs = [64869479, 0, 0] (scale 26)
decoded = [0.9666305631399155, 0, 0]
multi: inputs z = [0.25, -0.5], a = [-0.75, 0.125] (scale 13)
d = a - z = [-1.0, 0.625] (scale 13)
p = z * a = [-0.1875, -0.0625] (scale 26)
8. Open
- Replay: the proof binds only the encoded x and y, with no request id or nonce. The device’s pending table must consume each request id once and ignore late or duplicate answers. Binding a nonce into the proved relation would stop an old proof being replayed, but it would not by itself prove the inference was freshly computed.
- The domain policy covers the affine + ReLU shape only.
Reproduce the results
All commands run from the repository root.
Tests
uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python "ezkl==23.0.5" onnx numpy pytest
.venv/bin/python -m pytest -q
The verification tests need the pinned setup file (SRS). They use prover/artifacts/kzg.srs or $PGE_SRS if present, otherwise download it with EZKL and check its hash. Linux-only tests (the address-space limit, the device script) are skipped on macOS; CI runs them on Linux with EZKL’s real CLI.
Run the endpoint yourself
Start the AI server, then the endpoint. The endpoint calls EZKL’s command-line tool ezkl (version 23.0.5); it has been tested on Linux, and on macOS the memory cap is skipped because macOS doesn’t support it.
.venv/bin/python prover/server.py --listen 127.0.0.1:8701
python3 -I -c 'import sys; sys.path.insert(0, "."); from pge.service import main; main()' \
--listen OUTSIDE_IP:8700 --server 127.0.0.1:8701 --bundle BUNDLE_DIR --pins device/tiny.sha256 \
--cli ./ezkl --domain tests/fixtures/tiny/domain.json --proof-bytes 3072 \
[--release arrival|immediate|fixed] [--deadline SECONDS] [--spin-ms MS] [--log endpoint.log]
OUTSIDE_IP is the outside interface’s own address; don’t use 0.0.0.0, which listens on every interface. BUNDLE_DIR holds settings.json, vk.key and kzg.srs. Send questions with observer/client.py.
The ordering channel on one machine
.venv/bin/python observer/order_experiment.py
Runs the real EZKL prover in order_bits mode against the endpoint on localhost, for k = 2, 3, 4 concurrent requests, with immediate and arrival release. Prints one JSON line per setting.
On a Raspberry Pi
The Pi scripts expect ssh p01 to reach the Pi, the repository copied to ~/p01-v1 on it with a bundle directory ~/p01-v1/bundle (settings.json, vk.key, kzg.srs matching device/tiny.sha256), EZKL’s aarch64 CLI 23.0.5 at ~/p01-v0/ezkl, this machine at 192.168.2.1 and the Pi at 192.168.2.2. Paths and addresses are those of the lab setup; change them in the scripts for another one.
./device/verify_on_pi.sh # device checks: accept and reject cases, 20 timed verifies
ssh p01 'cd ~/p01-v1 && python3 -I device/verify_cost.py --cli ~/p01-v0/ezkl --srs ~/p01-v0/kzg.srs --runs 20 --sweep 20'
OUT=runs/matrix device/pi_matrix.sh # release policies x channels, 4 s budget
python3 observer/analyze_matrix.py runs/matrix
device/pi_scenario.sh NAME RELEASE N BATCH -- SERVER_ARGS runs one scenario: it starts the prover in the given mode on this machine and the endpoint on the Pi, sends N requests (in batches of BATCH, or one per connection per round with CLIENTS), and writes the traces and both logs to OUT/NAME.*. DEADLINE sets the endpoint’s deadline, which with fixed release is also the release time; SPIN_MS sets the spin. It refuses to start if the checkout has uncommitted changes, if the Pi’s endpoint code differs from it, or if the Pi’s pins or domain file differ.
The residual-timing test (128 random bits plus an honest control; add SPIN_MS=5 to both scenario commands for the spin version). Every scenario writes a manifest (OUT/NAME.manifest.json: the commit taken before the run, the settings, the hashes of what ran on the Pi (endpoint code, bundle, pins, domain, EZKL CLI, Python), the server’s arguments, exit status, row count, whether the endpoint logs were copied) and exits non-zero if the observer failed, got fewer rows than asked for, or the logs weren’t copied. The analysis scripts refuse a trace whose rows don’t match the bits one for one. The pre-registered spin repeat and its exact commands are in docs/EXPERIMENT_PROTOCOL.md.
B=$(python3 -c "import random; r=random.Random(20261019); print(''.join(r.choice('01') for _ in range(128)))")
OUT=runs/fixed_long DEADLINE=4 device/pi_scenario.sh timing_fixed_long fixed 128 1 -- --mode timing_bits --bits $B --slot 1.0
OUT=runs/fixed_long DEADLINE=4 device/pi_scenario.sh honest_fixed_long fixed 128 1 -- --mode honest
python3 observer/jitter_test.py runs/fixed_long/timing_fixed_long.trace.jsonl $B --control runs/fixed_long/honest_fixed_long.trace.jsonl --heldout 2000
python3 observer/estimate.py runs/fixed_long/timing_fixed_long.trace.jsonl $B --control runs/fixed_long/honest_fixed_long.trace.jsonl
python3 observer/estimate.py --selftest # the estimator on synthetic channels with known values
Server modes
prover/server.py --mode makes the AI server misbehave: honest, drop, delay, tamper_output, replay, duplicate, wrong_seq, oversize, garbage, and the leakage encoders timing_bits, order_bits and fail_position (--bits, --slot, --batch). See the docstring in prover/server.py.
Repository map
The endpoint (pge/)
| File | What it does |
|---|---|
pge/service.py | The endpoint service: one approved model, at most one answer per question, three release rules (arrival order by default, immediate as in v0, fixed time); replies are the proven output or one fixed error; logs use fixed words only |
pge/protocol.py | The message format: fixed-size binary messages for the approved model, parsed strictly |
pge/binding.py | Encodes the question exactly as EZKL does, checks the server’s output values, decodes proven outputs |
pge/bundle.py | Loads the model’s files only if each matches its pinned hash |
pge/domain.py | The range of questions the test model can answer, so impossible ones are refused up front |
pge/verdict.py | Turns a proof check into ACCEPT, REJECT or ERROR |
pge/cli.py | Checks proofs with EZKL’s official command-line tool, under resource limits |
pge/proc.py | Runs a program with a deadline, an output cap and resource limits |
pge/worker.py | Runs one check in a separate process with a deadline (Python version) |
Documents (docs/)
| File | What it covers |
|---|---|
docs/THREAT_MODEL.md | Assets, attackers, trust boundaries, assumptions, attack surface, side channels and their status, the MITRE ATLAS mapping |
docs/ATTACKS.md | Every catalogued scenario, its result and its evidence |
docs/SERVICE_SPEC.md | The endpoint service’s exact behaviour |
docs/BINDING_SPEC.md | The request-binding rules, pitfalls and test vectors |
docs/EXPERIMENT_PROTOCOL.md | The spin repeat’s protocol, written before the data |
docs/results/ | Complete analysis output for registered experiments |
Tests and experiments
| Path | What it is |
|---|---|
tests/ | Unit tests, comparisons against EZKL’s own encoding, end-to-end checks, a parser fuzz test, service tests including the leakage encoders, and test_docs.py, which fails when a fact copied between pages drifts from its home |
tests/fixtures/ | Two pinned test models: tiny (4 inputs, 3 outputs, with its input range in domain.json) and multi (two inputs, two outputs) |
prover/server.py | The laptop AI server: honest, or misbehaving in one of several ways, including hiding bits in timing, order or failures |
observer/client.py | The outside observer: sends questions, records each answer and its time, decodes hidden bits |
observer/analyze_matrix.py | Summarises a release-rule experiment |
observer/jitter_test.py | Tests whether leftover timing differences follow the server’s hidden bits |
observer/estimate.py | Estimates leakage as mutual information (bits per answer), with a null and an interval; --selftest checks it on synthetic channels |
observer/order_experiment.py | The ordering experiment on one machine |
Running on the Pi (device/)
| File | What it does |
|---|---|
device/verify_on_pi.sh, device/check_verifier.sh | Copy the pinned model to the Pi and check accept and reject cases with the real tool, timed |
device/pi_scenario.sh | One experiment: the endpoint on the Pi, the server and observer on this machine |
device/pi_matrix.sh | Every release rule against every signalling method |
device/verify_cost.py | What one check costs on the Pi, and how reliable each memory limit is |
device/measure.sh | Snapshot of the device’s attack surface: packages, services, processes, open ports, file hashes |
Other
| Path | What it is |
|---|---|
image/ | Builds the stripped-down v1 system with Buildroot (work in progress) |
book/ | This book (mdBook); the reference pages include the files in docs/ |
book/diagrams/, book/render-diagrams.sh | Diagram sources (Mermaid) and the script that renders them to book/src/diagrams/*.svg; mdBook has no Mermaid support, so the SVGs are committed |
spike/rust-verifier/ | Side experiment: EZKL’s checker as a small Rust program |
spike/stwo-verifier/ | Side experiment: a pinned checker for S-two (StarkWare) proofs |
Glossary
Arrival order. A release rule: each answer leaves only after the answers to every earlier question. Stops the server from choosing the order.
Bit. The smallest unit of information: one yes/no. 16 bits is enough for two letters of text. Leak rates here are in bits per answer or per second.
Bundle. The approved model’s files the endpoint needs to check proofs: its settings, its verification key and the setup file (SRS). The endpoint pins each by its hash.
Channel. Any way a hacked server can get information to someone outside: timing, order, failures, packet sizes, the endpoint’s logs.
Deadline / budget. How long the endpoint waits for a valid answer before replying unavailable. With fixed-time release, every answer also leaves no earlier than its deadline.
Endpoint. The Raspberry Pi running the checking software, between the AI server and the outside world.
EZKL. The open-source tool used here to prove the outputs of a small machine-learning model and to check those proofs.
Fixed-time release. A release rule: answers leave in arrival order, each never earlier than a set time after its question, whatever the server did. It promises “never early”, not a latest time; how late answers actually left is in Measurements. It hides most of the server’s timing at the cost of speed; a small trace was measured without spin.
Hash / pin. A short fingerprint of a file. “Pinned by hash” means the endpoint refuses to run if the file’s fingerprint differs from the approved one.
Proof (zero-knowledge proof). A short piece of data showing that a specific computation produced a specific output, which anyone can check quickly without redoing the computation.
Release rule (release policy). When the endpoint sends an answer back: immediately, in arrival order, or at a fixed time.
Replay. Sending an old valid answer again. The endpoint matches each answer to exactly one pending question, so no question gets two answers. An old proof for the same question can still answer a new request; for one deterministic model that gives the server no choice of output, but it isn’t proof of fresh computation.
Request binding. Making sure a proof is about this question, this model and this reading of the output, not just “some valid computation”.
Residual. What still leaks after a defence. Here: the sub-millisecond timing trace left by fixed-time release.
Side channel. A channel that isn’t the content itself: when, in what order or whether something happens.
SRS. The public setup file the proof system needs. Pinned by hash like the rest of the bundle.
unavailable. The endpoint’s single error reply for anything involving the server. It never says why, so the server can’t use the error text to signal.
v0, v1, v2. The endpoint’s versions: v0, the Python endpoint on the standard Pi system; v1, the Rust endpoint (stricter release rules, a failure cap, a sandboxed checker, signed updates) on a stripped-down, read-only system; v2 adds secure boot, two physical ports, a counter against restoring an old update store, and signed images.
Verification key (vk). The approved model’s public key for checking its proofs.