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.