Documentation

The server

The Eidola server is an OpenAI-compatible proxy that sits between the client and an upstream inference provider. It also runs the account and billing surface. Its design is dominated by one structural decision: the linked account surface and the unlinked inference surface are kept disjoint, in code and at runtime.

Linked vs. unlinked

The server’s HTTP surface splits into two layers with separate authentication mechanisms:

SurfaceAuthSees account identity?Sees inference content?
Publicnonen/ano
Linked (account, billing)HTTP Basic — account UUID + secretyesno
Unlinked (inference)Anonymous credentials (Privacy Pass ACT)noonly as opaque bytes in transit

The split is enforced at the type level. The BasicAuth extractor and the TokenAuth extractor are different types; an inference endpoint takes only TokenAuth, and an account endpoint takes only BasicAuth. There is no path through which Basic auth could be accepted on an inference endpoint without a code change visible in a diff.

This means a single Eidola server process, observing only its own inputs, cannot connect a particular inference request to the account that funded it. The most it can see on the inference path is that an anonymous credential was redeemed against a particular issuer key; it cannot map back to which account requested that credential from the issuance flow.

Unlinkability

The unlinkability property is achieved with Privacy Pass Anonymous Credentials Tokens (ACT), per draft-schlesinger-privacypass-act-01.

The flow:

  1. Issuance. The authenticated client (Basic auth, linked surface) requests credentials. It generates blinding factors locally and sends blinded token requests. The server signs the blinded requests with its issuer private key and returns blinded signatures. The server learns that this account requested N credentials, never which specific tokens it received.
  2. Redemption. When the client makes an inference request, it unblinds a token and presents it on the unlinked surface. The server verifies the token signature against the issuer public key. The token contains no account identifier, and the blind-signature construction guarantees the server cannot correlate this token with any individual issuance request from step 1.

The issuer key is stored encrypted at rest in Postgres using a CREDENTIAL_MASTER_KEY that is injected into the server enclave as a Tinfoil secret. If this key were compromised, new ACTs could be forged, but the unlinkability property remains.

Domain separation is baked into the credential construction (ACT-v1:eidola:inference:production:<date>) to prevent cross-deployment correlation if the issuer key were ever reused.

Anonymity set

The unlinkability invariants in privacy-guarantees.md §2 are meaningful only to the extent that each token’s anonymity set is large and the issuance/redemption policy doesn’t accidentally re-introduce a linkable identifier. The server’s issuance and key-rotation policies are tuned specifically for these properties.

Anonymity set = users sharing the same issuer key + domain separator. Every ACT token redeemed against a given issuer key is, by the math of the blind-signature scheme, indistinguishable from every other token issued under that key. The size of the set is the number of distinct accounts that received at least one token from that key during its issuance window.

Issuer keys rotate on a ~7-day epoch. Each key has an issue_from timestamp and an issue_until timestamp; while active, that key signs new credentials. After issue_until the key stops issuing, but tokens already issued under it remain redeemable until accept_until (a grace period beyond the issuance window). The dual-window design means at any moment multiple keys are concurrently spendable, but only one key is actively issuing. This is intentional:

The domain separator does not rotate on the same schedule — deliberately. The anonymity set is the intersection of “users sharing the same key” and “users sharing the same domain separator.” Rotating the domain separator would shrink the anonymity set without any compensating gain (nullifiers, which prevent double-spending, are partitioned by issuer key, not by domain separator). The domain separator only changes on protocol upgrades or deployment-identity changes.

Cross-device and batched issuance. Because tokens are spendable across the full accept_until window and don’t carry per-device binding, a user with multiple devices on a single account can have each device hold its own tokens issued under the same key — all contributing to the same anonymity set. The same flexibility covers JIT issuance scenarios where a device requests tokens on demand.

Why this prevents timing correlation between linked and unlinked requests. Issuance happens on the authenticated (linked) account surface. Redemption happens on the anonymous (unlinked) inference surface. If issuance and redemption were forced to be near-simultaneous, an observer of both surfaces could correlate “account X issued at time T, anonymous token redeemed at time T+ε” with high confidence. The batched-and-deferred-redemption policy decouples those timestamps by design: the issuance request is a separate, asynchronous event from any individual redemption.

What this policy does not defend against is a small total anonymity set during the early life of the deployment (when only a few users are issuing under a given key). That is named in gaps.md § Anonymity-set size as an early-stage residual.

What runs in confidential compute

The server runs inside a Tinfoil Container on confidential-compute hardware (AMD SEV-SNP; TDX values are recorded in the trust root but TDX presentations are refused by the verifier — see gaps.md). The relevant properties:

The server is FROM scratch, statically linked musl, runs as non-root, and ships no shell or package manager. The attack surface inside the enclave is limited to the server binary itself.

What the server is not doing

Several things that a typical AI proxy might do are deliberately absent:

Telemetry: scope and boundary

When OTEL_EXPORTER_OTLP_ENDPOINT is set, the server exports OpenTelemetry metrics and logs. It does not export traces unless directed by the client.

Spans are recorded everywhere and exported only on request. Tracing runs across the whole server: the HTTP request, each database round trip, each upstream call. Every span goes to a processor that converts it into histogram observations and drops it. A second, exporting processor is also registered, but it drops unsampled spans. The sampler returns RecordOnly for every trace that was not explicitly authorized, so in ordinary operation the export path carries nothing.

A span is a per-request record by construction — a trace id, a wall-clock start, a nanosecond duration — and a request’s duration is not independent of its content, since a response’s wall time varies with its length. Aggregating in process answers the operational question (“this route spends 300 ms in the database”) without retaining a record that would answer “what did this request do.”

No account identifier can reach the telemetry vendor, because nothing per-request does.

What is exported:

Bucket boundaries are set explicitly on every histogram, because the SDK default ladder is scaled for milliseconds while every duration instrument here records seconds — on the defaults, essentially all traffic lands in the first bucket and the resulting percentiles are meaningless.

One rule governs adding a span anywhere: #[instrument(skip_all)]. The attribute captures every function argument as a span field by default, which would put nullifiers, spend proofs and account ids into span attributes. Aggregation makes that survivable, not acceptable — and under client-directed tracing, an authorized trace would carry those fields all the way to the vendor.

Client-directed tracing

A request that carries a W3C traceparent with the sampled flag set is traced at full granularity: middleware installs it as a sampled remote parent, which promotes the root span and, through it, every child span in the request. Nothing else marks a request for export.

The sampled flag is honored rather than assumed. Per W3C Trace Context it reports whether the caller recorded the request; it is a hint about the caller’s own decision, not an instruction, and a receiver is free to sample independently. Honoring it costs nothing and means a client whose tracing is off does not cause an export here.

The trace id comes from the client rather than the server. The client knows it before it sends, so a request that times out, hangs, or dies mid-stream can still be found — and those are the failures most worth tracing.

A consequence to keep in view is that the id is caller-supplied: a caller reusing one across requests asserts that those requests are related, which on the anonymous surface is a self-linking primitive.

traceparent is parsed directly by Eidola code rather than by installing a TextMapPropagator. A global propagator would also inject context into the server’s outbound calls, putting the trace id on requests to the inference upstream, Stripe, and GitHub. As written, no outbound request carries trace context, and a traced request’s spans go only to the OTLP endpoint.

Inbound headers are counted by outcome (sampled, not_sampled, malformed).

Inference is proxied, not performed

The Eidola server is not the inference engine. Models run in a separate confidential-compute deployment operated by the upstream inference provider (Tinfoil), with its own attestation. The Eidola server’s role on the inference path is:

  1. Verify the anonymous credential.
  2. Open an attested HTTPS connection to the inference upstream.
  3. Stream the request through, stream the response back.
  4. Record the per-request token counts for accounting.

This means two layers of confidential compute protect the inference content: the Eidola server enclave (which sees the content only in transit, never logged) and the upstream provider’s confidential-compute enclaves that perform the inference. The upstream is itself a router enclave that forwards to a separate per-model enclave; the Eidola server attests the router it connects to on every handshake, and how far that verification reaches (and where it stops) is detailed in upstream.md and gaps.md § Inference upstream. The client verifies the attestation of the Eidola server directly on every handshake.

Where to read the code

SubsystemFile
Anonymous credentials (Privacy Pass ACT)crates/eidola-server/src/credentials.rs
Inference proxyingcrates/eidola-server/src/chat.rs
Auth extractorscrates/eidola-server/src/auth.rs
Linked/unlinked routingcrates/eidola-server/src/middleware.rs
Telemetry boundarycrates/eidola-server/src/telemetry.rs
OpenAPI surface (tags = linked / unlinked / public)crates/eidola-server/src/api_doc.rs

Edit this page on GitHub