Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

PieceDevice (Rust)Reference (Python)Job
Endpoint loopendpoint/endpointpge/service.pyAdmits questions, forwards them, checks answers, decides when replies leave
Release rules and failure capendpoint/corepge/service.py, observer/cap_model.pyA deterministic state machine: arrival or fixed release, halt, cap
Message formatendpoint/wirepge/protocol.pyFixed-size binary frames; strict parsing; one accepted form
Binding and checkerendpoint/verifypge/binding.py, pge/bundle.py, pge/domain.py, pge/cli.pyEncodes questions like EZKL, pins the model, verifies with EZKL’s library, decodes proven outputs
Signed updatesendpoint/updatenoneAccepts a new model and policy only if signed, newer and for this device
Serverprover/server.pyProves each question with EZKL; can misbehave on purpose (tamper, replay, delay, hide bits)
Observerobserver/client.pySends 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.

StepCodeEvidence
Read and parse the questionserve_client and parse_requesttest_client_violation_closes_the_connection
Encode, check, admit or refusesubmittest_bad_requests_never_reach_the_server
Accept the first answer onlyon_answertest_duplicate_answer_gives_one_reply, test_late_answer_is_dropped_and_answered_once
Build instances, verify, decide_checktest_proof_for_another_request_is_blocked
Decide when the reply leaves_release and _pumpWhen 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

  1. One pinned model. The endpoint loads one bundle: settings.json, the verification key vk.key and the setup file kzg.srs, each pinned by SHA-256 and re-checked before every verify. The server can’t pick another model or key.
  2. The endpoint encodes the question. It computes the input values exactly as EZKL does, so a proof made for any other encoded question fails.
  3. 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).
  4. 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.
  5. Impossible questions are refused up front. A question that would overflow EZKL’s range checks can’t be proved, so it gets bad_request before 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

PitfallWhat happenedRule
Settings not pinnedChanging only the output scale from 26 to 13 kept the proof valid but decoded 0.9666 as 7918.61
Python roundingPython’s round() rounds half to even: 2.5/8192 encodes as 2, EZKL gives 32
Server-supplied answerThe server could state any answer next to a valid proof3
Padded proofsEZKL still accepts a valid proof with bytes appended4
Out-of-domain questionsA question no proof can exist for makes the server fail on demand5

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.

LimitValueWhy
Address space (Linux only)256 MiBStops a proof that makes the verifier allocate without bound
CPU timeup to the deadlineA second stop for a busy loop, besides the wall-clock deadline
File size0The check writes no files
Open files64Bounds file-handle exhaustion
Core dumpsoffNo memory dumps on disk
System calls (Linux)an allow-list of the calls a check was traced makingNo files, sockets, programs, new processes or executable memory; another architecture’s calls kill it
ThreadsRayon set to 1 (a setting, not an OS limit)Less memory, steadier timing
Output64 KiB, then SIGKILLA flooding child can’t exhaust the endpoint
Wall timethe time left until the deadlineThen 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 exactly reject; 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):

Pathms
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 proof2
Full endpoint check239

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.

MessageFrom, toPayload (test model)Contents
Questionuser to endpoint41 bytes (45 with the length)type 1, id (8 bytes), 4 float64 inputs
Forwarded questionendpoint to server41 bytestype 2, seq (8 bytes), the same inputs
Answerserver to endpoint105 bytes + prooftype 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)
Replyendpoint to user34 bytes (38 with the length), success or error aliketype 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.

RuleWhen an answer leavesStopsCosts
Immediate (v0)as soon as it is decidednothingnothing
Arrival (v1 default)after every earlier question’s answerthe server choosing the ordera slow answer holds up the ones behind it, up to its deadline
Fixedin arrival order, and not before admission + deadline (4 s in the tests)order, and the timing codes we triedevery answer waits the whole budget; a check not done by then is unavailable
Fixed + spin 5 msas fixed, but the timer wakes 5 ms early and busy-waits until the release timeno average sub-millisecond shift was detected; whether a weaker trace remains is not settleda 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 become unavailable, 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 answers unavailable straight away.

Bugs found by review in this part, all fixed with tests that fail on the old code:

RowBugFix
K30A held success could leave after a halt, or after its own deadlinere-checked when it leaves
K31Held answers didn’t count towards max_pending, so the queue could grow without boundcounted
K33With spin, a later answer could overtake an earlier one, letting the server order two error repliesone ordered queue; only the next answer may spin
K35With spin, a disconnect or halt that decided many answers at once chained one busy-wait per answer, stalling the endpoint for tens of msreleases 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 systemRaspberry Pi OS Lite (Debian 13)Buildroot 2025.02.18 LTS: kernel 6.6, BusyBox init, read-only root; the endpoint is one static binaryas v1, inside a signed boot image
EndpointPython, EZKL command-line toolRust, EZKL verifier as a library, unprivileged user, sandboxed checkeras v1
Networkone port, ssh and mDNS listeningone open port behind a firewall built into the kernel; the endpoint won’t start without its rulestwo physical ports, nothing forwarded
Updatescopy files over sshsigned model and policy in a store on a data partition; a new update comes as a new image or is applied to the card offlineas v1, plus a counter that can’t roll back
Loginssh, HDMI, serialnone (from the 2 Nov rebuild; until then a console login)none

What each one exposes, measured:

VersionSeen from outsideNotes
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 ports633 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 droppedModel 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 IPv6No 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 from raspberrypi4_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:

SignalDefenceStatus
What an answer saysproof + bindingblocked for the test model
Order of repliesarrival-order releasethe ordering code we tried no longer gets through (one connection)
Timingfixed release time, plus spinmuch reduced; see below
Which answers faila cap on failures per window (Rust endpoint)reduced at a large cost in availability; measured once on the Pi
Reply sizefixed-size repliesblocked at the message level; packet-level details on the outside link not measured
Logsfixed words onlythe number and timing of events still follow the server
Keeping the endpoint busyfixed 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 usesimmediate (v0)arrival (v1)fixed
Timing: delay an answer by 1 s for a 116/16 recovered16/16 recoverednot recovered (12/16 wrong); answer times spread over 2.0 ms
Ordering: answer 3 concurrent requests in a chosen order16/16 recovereddecoded bits constantdecoded bits constant
Failure position: fail one chosen answer in each 1616/16 recovered16/16 recovered16/16 recovered
No signal (honest server, timing decoder)8/16 wrong10/16 wrong7/16 wrong
Honest answer time, median0.81 s0.83 s4.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.