Inspiration

Developers paste API keys, customer emails, card numbers and database passwords into AI assistants every day. Once that text reaches a hosted LLM provider, it can be logged, retained or exposed in a breach, and you cannot take it back. A hosted model has to read the text it answers, so true end-to-end encryption is not possible. We asked a more practical question: how do we make sure the provider only ever reads what it truly needs?

What it does

VeilProxy is a local gateway that sits between any OpenAI-compatible client and OmniRoute, which routes to hundreds of models. For every request it:

  • Detects secrets and personal data: API keys, JWTs, private keys, card numbers (Luhn-checked), IBANs, emails, phone numbers (including Nigerian formats), IPs, URL credentials and passwords.
  • Encrypts each value locally with AES-256-GCM and sends the model only placeholders such as [EMAIL_1] and [CARD_1].
  • Rehydrates the model's reply locally, including streamed replies, so you see the real values and the provider never does.
  • Blocks the request (fail-closed) if a detected secret somehow survives redaction.

ShardKey protects the vault key. The master key is generated inside a Rust core, split with Shamir's secret sharing (3-of-5) across separate custodian processes, and held only in zeroized memory after a threshold of custodians approve. Locking the vault, or an idle timeout, wipes it. With the vault locked, nothing is forwarded at all.

A three-pane terminal UI shows what you see, what the provider receives, and the vault status with detections, side by side.

How we built it

  • Rust core (PyO3 and maturin): AES-256-GCM, HKDF per-session subkeys, HMAC pseudonym tags, and our own Shamir implementation over GF(2^8) with a branch-free multiply and zeroize-on-drop key custody. The key never crosses into Python.
  • Python gateway (FastAPI and httpx): an OpenAI-compatible /v1/chat/completions endpoint with streaming, forwarding to OmniRoute.
  • Detection engine: regex rules with checksum validators, entropy scanning, optional Presidio/spaCy for names and places, and YAML custom rules and allowlists.
  • Vault: SQLite storing only ciphertext and keyed tags; custodian processes that release shards only on approval; idle auto-lock.
  • Textual TUI with /unlock, /lock, /demo, /copy and live detections.
  • Testing: 7 Rust unit tests, 67 Python tests, a randomized 400-prompt leak test, a Rust-to-Python interoperability test, and property-based tests that split streamed replies at arbitrary points.

Reconstruction uses Lagrange interpolation at zero over GF(2^8):

$$ s = f(0) = \sum_{i=1}^{k} y_i \prod_{j \ne i} \frac{x_j}{x_i \oplus x_j} $$

The key is rebuilt only when at least $k = 3$ of $n = 5$ shares are supplied, and a key-check value confirms it is correct.

Challenges we ran into

  • Streaming: a placeholder like [EMAIL_1] can arrive split across chunks. We built a buffering rehydrator and property-tested it against arbitrary chunking.
  • Our own safety net had a bug. The fail-closed guard flagged a model name as a "secret" because of its entropy and blocked a legitimate request. We fixed it so the guard scans exactly the fields the redactor handles, and tightened the entropy rule.
  • False positives that leave fragments: a card number that failed the Luhn check was being partially redacted as a phone number. We added context checks.
  • The terminal UI: the log widget's default minimum width caused sideways scrolling in narrow panes. We fixed wrapping, reserved scrollbar space, and re-wrap content on resize.
  • Honest scope: a hosted LLM must read the prompt, so we protect secrets and identifiers rather than claiming impossible end-to-end encryption.

Accomplishments that we're proud of

  • The master key never exists in Python memory, and nothing is forwarded while the vault is locked.
  • Real calls to real models through OmniRoute, with a leak check showing no secret reaching the provider.
  • Rust and Python backends that interoperate byte-for-byte, with tests proving it.
  • A fail-closed design that has already caught its own bugs.

What we learned

  • Safety checks must look at exactly the same data as the thing they guard.
  • Fail-closed is the right default for security tooling, and it needs tests for false positives too.
  • Threat models need to be stated plainly. Our limits: anything the detector misses is still sent, placeholders reveal the type of data, and custodian shards travel over localhost.

What's next for VeilProxy

  • An encrypted (FHE) sensitivity classifier to decide which prompts go to the cloud and which stay on a local model.
  • TLS or hardware-key custodians for multi-machine use.
  • Proactive share refresh, and name and organization detection enabled by default.
  • A browser extension and an IDE plugin on top of the same gateway.

Built With

  • aes-gcm
  • cryptography
  • fastapi
  • hkdf
  • httpx
  • hypothesis
  • maturin
  • mitmproxy
  • omniroute
  • openai-api
  • presidio
  • pyo3
  • pytest
  • python
  • rust
  • shamir-secret-sharing
  • spacy
  • sqlite
  • textual
  • uvicorn
  • yaml
  • zeroize
Share this project:

Updates

Submission history