The threat model for onion-routed LLM inference
Privacy-preserving inference sounds great in marketing copy. The honest engineering question is: what attacks does the 3-hop onion circuit actually stop, and what does it not?
SolanaLM ships a privacy-preserving inference mode where requests route through a 3-hop circuit of nodes. The marketing version is “no node sees both who you are and what you asked.” That sentence is approximately correct, but it is not a threat model. Anyone deploying this for sensitive workloads should understand what it actually defends against.
This post is the threat model.
The setup
A client wants to send an inference request without any operator being able to link the request content to the client identity. In SolanaLM’s private_inference mode, the request takes a 3-hop path:
Client → Entry Node → Middle Node → Exit Node → Inference Backend
Each hop is an independent operator. Each hop sees only:
- The previous hop’s address.
- The next hop’s address.
- A symmetrically encrypted blob of everything else.
The exit node decrypts the final blob and forwards the prompt to a backend (which may be the exit node itself or a proxied provider). The response travels back through the same circuit.
This is structurally Tor, adapted for LLM inference.
What we defend against
Attack 1: A single curious node operator.
If any one node in the circuit logs every request it sees, they learn nothing useful. The entry node sees the client’s IP but a meaningless ciphertext blob. The exit node sees the prompt but the client’s IP appears as the middle node, which is one of many operators. As long as one hop in the circuit is honest, the linkage breaks.
This is the core property: if all three nodes are compromised, you lose. If even one is honest, you are protected.
Attack 2: Provider-side data harvesting.
If the exit node forwards to OpenAI as a backend, OpenAI sees the prompt but cannot link it to the actual client. They see “the prompt came from exit-node-7 operated by wallet-X.” The actual client wallet is not in the request path the provider sees.
This is meaningful for workloads where the prompt itself reveals business information but the client identity is the sensitive bit.
Attack 3: Network-level traffic analysis (partially).
A passive observer between client and entry node sees that the client talked to an entry node. They do not see the content, the destination, or which backend served it. This is the same property Tor gives you, with the caveat that traffic analysis attacks against Tor are well-studied. We get the same partial defense and the same caveats.
Attack 4: Operator collusion with the gateway.
If the gateway and one node collude, they can correlate “request X went into the circuit at time T” with “exit node served request Y at time T+small.” The gateway does not know circuit composition (clients pick their own circuit), so this is still hard — the gateway sees only the entry node.
What we do not defend against
We should be honest about this part. Privacy theater is worse than no privacy claim.
Global passive adversary.
An adversary that can observe all network traffic everywhere can correlate request flow through the circuit by timing and packet size. This is the standard limitation of low-latency anonymity networks. There is no fix that is compatible with sub-second LLM responses. If your threat model includes a state-level actor watching everything, you need something stronger than this protocol.
Compromised client device.
If the client’s machine is compromised, the protocol does not help. The compromised device can capture the prompt before encryption.
Full circuit compromise.
If all three nodes happen to be controlled by the same adversary, they can fully de-anonymize the request. We mitigate by:
- Encouraging diverse operators (the registry surfaces operator reputation and geographic distribution).
- Letting clients pin circuit composition to specific operator wallets.
- Slashing operators whose nodes are caught collaborating.
None of these mitigations is a proof. They are economic and reputational pressure. If your threat model says “an adversary will burn millions of dollars to run a Sybil attack against this protocol,” we cannot help you. Most threat models do not say that.
Content-based reidentification.
If the prompt itself contains identifying information (“My name is Alice and my SSN is…”), the exit node and backend can identify the user from the content regardless of network anonymity. This is not a protocol failure; it is a user-input failure. We document this clearly in the SDK warnings.
Statistical fingerprinting of model use.
Different users have different prompt patterns. Over many requests, an exit node might statistically distinguish users by writing style, prompt structure, or topic. This is a real concern for high-volume single-user workloads. Workarounds include rotating circuits, batching, and cover traffic — but none is in the default protocol.
The crypto layer
For the curious: the encryption layer uses NaCl box (curve25519 + XSalsa20 + Poly1305) per hop. The client generates ephemeral keypairs, layers encryption from exit-inward, and discards the ephemeral keys after the request.
def build_circuit_payload(
prompt: str,
entry_pubkey: PublicKey,
middle_pubkey: PublicKey,
exit_pubkey: PublicKey,
backend_url: str,
) -> bytes:
client_sk = PrivateKey.generate()
# Innermost layer: exit-decryptable, contains the actual prompt
exit_payload = json.dumps({"prompt": prompt, "backend": backend_url}).encode()
exit_box = Box(client_sk, exit_pubkey)
exit_blob = exit_box.encrypt(exit_payload)
# Middle layer: middle-decryptable, contains exit address + exit blob
middle_payload = json.dumps({
"next_hop": exit_pubkey.encode(),
"blob": base64.b64encode(exit_blob).decode(),
}).encode()
middle_box = Box(client_sk, middle_pubkey)
middle_blob = middle_box.encrypt(middle_payload)
# Outermost layer: entry-decryptable
entry_payload = json.dumps({
"next_hop": middle_pubkey.encode(),
"blob": base64.b64encode(middle_blob).decode(),
}).encode()
entry_box = Box(client_sk, entry_pubkey)
return entry_box.encrypt(entry_payload)
Each hop decrypts its layer, finds the next hop and the next blob, and forwards. The response symmetric key was negotiated in the same exchange, so the response can be encrypted at the exit and decrypted in stages on the way back.
Performance cost
Three hops add latency. In our measurements:
- Direct inference: 50ms gateway overhead before backend.
- 3-hop circuit: 180–240ms gateway overhead before backend.
That extra ~150ms is the cost of privacy. For most LLM workloads where the backend itself takes 500ms+, it is a 20–30% overall slowdown. For latency-critical use cases, it is significant. For occasional sensitive requests where most queries do not need privacy, the cost is acceptable.
We charge a small premium for circuit-routed requests because each hop earns a fee. The economics are visible to the client before the request runs.
When to use it
Use private_inference when:
- The prompt contains data your organization classifies as confidential.
- The prompt destination is a backend you do not fully trust (e.g., an external proxy node).
- You need to defend against a compromised single operator.
Do not use it when:
- The prompt is public information.
- You need the absolute lowest latency.
- Your threat model requires defense against a global passive adversary (use offline inference instead).
What we are working on next
Cover traffic generation. Batch padding to defeat size-based correlation. Circuit rotation policies. Hardware-attested execution at the exit node (so the exit can prove it did not log the plaintext).
None of these are in v0.1.0. All of them are tracked in GitHub issues. The threat model we ship today is the threat model documented above — no more, no less.