Skip to content

Latest commit

 

History

History
300 lines (234 loc) · 14.8 KB

File metadata and controls

300 lines (234 loc) · 14.8 KB

Blindfold Integration Stack

How Blindfold went from a 4-endpoint LLM-key proxy to a real, multi-industry secret proxy with in-enclave provider auth — what changed, why, and the impact.


Why this work exists

Product feedback (Terminal 3 PM, competitive review): Blindfold ranked just outside the top 10. The two named gaps:

  1. Integration coverage / stack score is lower than the rest.
  2. The problem being solved isn't as concrete or distinct as other projects.

Both traced to the same root cause in the code.

The root cause

The enclave's substitution was a blind string replace (contract/src/forward.rs):

.map(|(k, v)| (k.clone(), v.replace(SENTINEL, &secret)))

That only works for one auth scheme — Authorization: Bearer <token>. So the proxy could only ever route providers that share it. Unsurprisingly, all four supported upstreams were LLM APIs (OpenAI, Anthropic, xAI, Groq). Meanwhile the pitch claimed "your agent holds its OpenAI / Stripe / Anthropic key" — Stripe and every non-LLM provider were unbacked. A judge scoring "integration stack" saw four near-identical LLM endpoints, not a moat.

The instruction that shaped the fix: "I do not want generic — I want proper industry-based, not faking generic." A generic host-allowlist passthrough that keeps doing the dumb replace against more hostnames would look like breadth while adding none. Real breadth means teaching the enclave each provider's actual auth computation.


What changed

1. In-enclave provider auth schemes (contract/src/auth.rs, forward.rs)

The enclave now applies the real auth computation for a provider, selected by a typed, tagged AuthSpec:

Scheme Providers What the enclave computes (inside TDX)
bearer OpenAI, Anthropic, xAI, Groq, Stripe, GitHub, SendGrid, Slack, Gemini sentinel → secret swap (Gemini swaps in x-goog-api-key, not Authorization)
basic Twilio base64(username : secret) — the secret is joined then base64-encoded in the enclave
sigv4 AWS S3, AWS SES full AWS Signature V4 — the secret signs a canonical request via an HMAC chain and is never transmitted

The basic and sigv4 schemes are the proof that this is proper, not generic: their secret is consumed by a computation, not pasted into a header. A generic "swap the sentinel" proxy structurally cannot reach Twilio or AWS, because:

  • Twilio's base64(SID:token) must be computed after the secret is joined — you can't pre-place a sentinel inside a base64 blob.
  • AWS SigV4's signature is an HMAC over the request keyed by the secret; the secret appears nowhere in the outbound bytes.

So for a whole class of major APIs, the raw secret now never exists as a header value anywhere — it's only ever an input to a computation that runs in sealed TDX memory.

The auth logic lives in a dependency-isolated module (no wit/host imports) so it is unit-testable natively.

2. Concrete provider registry (packages/blindfold/src/providers.ts)

Not a generic router — a list of named, first-class integrations, each with its exact upstream host, its own sealed-secret name, and its auth scheme:

Provider Industry Host Sealed secret Auth
OpenAI / Anthropic / xAI / Groq LLM api.openai.com, … (default) bearer
Gemini LLM generativelanguage.googleapis.com gemini_api_key bearer via x-goog-api-key
Stripe Payments api.stripe.com stripe_secret_key bearer
GitHub Dev infra api.github.com github_token bearer
SendGrid Email api.sendgrid.com sendgrid_api_key bearer
Slack Comms slack.com/api slack_bot_token bearer
Twilio Telephony api.twilio.com twilio_auth_token basic
AWS SES Cloud email.<region>.amazonaws.com aws_secret_access_key sigv4
AWS S3 Cloud s3.<region>.amazonaws.com aws_secret_access_key sigv4

12 named integrations across 6 industries (LLM, payments, dev, email, comms, cloud) and all three auth schemes. Longest-prefix routing; non-secret config (Twilio Account SID, AWS access-key-id / region) comes from env, never sealed.

Closed and curated by design — no generic passthrough, and no generic-feeling entries. There is no catch-all route and no user-config/wildcard host; providers are added only in code. To make the Bearer providers genuinely first-class (not "a host with a bearer token"), each carries its real required headers, which the integration injects only when the agent didn't set them:

Provider Required headers the integration supplies
GitHub User-Agent (GitHub 403s without it), Accept: application/vnd.github+json, X-GitHub-Api-Version
Anthropic anthropic-version (required by the raw REST API)
Stripe Stripe-Version (pins API behaviour)
Slack Content-Type: application/json; charset=utf-8
SendGrid Content-Type: application/json

Proven live: GET /github/user through the proxy with no User-Agent from the agent returns 200 — the integration supplied GitHub's mandatory headers. An agent doesn't have to know each provider's quirks; the integration does. That's the difference between a real integration and a generic host map.

3. Proxy + type wiring

  • ForwardRequest gained an optional auth field that serialises 1:1 into the contract's AuthSpec (types.ts).
  • proxy.ts resolves the provider, sets the per-provider secret_key, and plants the sentinel in the right header (or omits it entirely for basic/sigv4, where the enclave builds the whole Authorization).
  • The old upstreamForPath switch was deleted; routing lives in providers.ts.
  • Backward compatible: auth defaults to bearer, and an older published contract simply ignores the field — existing LLM flows are unchanged.

Why it's correct (not hand-waving)

  • Native crypto vectorscontract/auth-tests runs auth.rs natively and passes 4/4, including:
    • AWS SigV4 "get-vanilla" full-signature vector (byte-exact Signature=5fa00fa3…fbf31).
    • AWS signing-key derivation vector (f4780e2d…db404d).
    • base64 RFC-4648 vectors and the Twilio Basic-auth shape.
  • Enclave rebuilds cleanblindfold_proxy.wasm, 227,364 bytes, with sha2 + hmac compiled in.
  • Provider resolution + full mock proxy run — all paths route to the right upstream with the right sealed secret and the right auth descriptor.

Live, real end-to-end runs

Both examples run against the live enclave (tenant did:t3n:58f5f5f9…, testnet) — no mock, no stub.

Gemini (examples/gemini/)

Sealed gemini_api_key, granted egress to generativelanguage.googleapis.com, and made a real generateContent call with the agent holding no key:

✅ Real Gemini answer (key never left the enclave):
   An Intel TDX enclave is a hardware-isolated execution environment that
   protects the confidentiality and integrity of code and data, even from
   the hypervisor.
🕵️  If a prompt-injection dumped this agent's credentials, it would get:
   • env vars containing a real Gemini key: (none)   ← scans ALL of process.env
   • auth header the agent sends:           x-goog-api-key: __BLINDFOLD__

Notable: Gemini validated that the auth model is genuinely provider-aware — its key rides in x-goog-api-key, not Authorization, and Blindfold handles that.

Prompt injection (examples/prompt-injection/)

Real, authenticated GitHub call through the enclave (agent authenticates as a real login), then an injected "issue" tries to exfiltrate the token:

✅ Legit call succeeded — agent is authenticated to GitHub as "FiscalMindset".
📤 If the agent dumped its credentials, the attacker would get:
   • env vars containing a real GitHub token: (none)   ← scans ALL of process.env
   • Authorization header the agent sends:    Bearer __BLINDFOLD__
🛡️  Attacker receives only the sentinel. Nothing usable.

Retargets to a Stripe-refund injection by sealing stripe_secret_key and changing one path — the resistance is structural, so it's identical.

Stripe — payments, read + write (examples/stripe/)

Sealed a real Stripe test key (sk_test_…) as stripe_secret_key, granted egress to api.stripe.com, and ran live through the enclave:

✅ Authenticated to a REAL Stripe account (test mode, livemode=false).   # GET /v1/balance → 200
✅ Real WRITE succeeded — created customer cus_UnzQbHl4Iv4zUN (livemode=false).  # POST /v1/customers → 200
📤 Exfil check (scans ALL of process.env): env vars with a real Stripe key: (none); auth header: Bearer __BLINDFOLD__
🛡️  Attacker receives only the sentinel.

The agent has genuine read and write power over a real payments account, yet the injection gets nothing. The demo asserts livemode === false and refuses to run on a live key, so it can never touch real money.

Two real host-egress findings (documented, not hidden)

Running Stripe live surfaced real constraints in the current T3 host egress (host:interfaces/http call) — worth recording so nobody re-diagnoses them:

  1. The host parses request bodies as JSON. A non-JSON payload fails with http.parse_payload: expected value at line 1 column 1. ✅ Solved client-side: the proxy now detects a application/x-www-form-urlencoded body, moves its params into the URL query string, and sends an empty body — so the host's JSON parser is never triggered and the agent's code is unchanged (it can POST a normal form). Form-encoded providers (Stripe, Twilio) accept params in the query string, so this is transparent. JSON APIs are untouched.
  2. On testnet, request headers aren't always forwarded (residual host bug). Even with the params in the query string, Stripe still requires a content-type header, and the testnet host intermittently drops it — so a form write succeeds ~4/5 of the time and fails the rest with "check that your POST content type…". Mitigations: the proxy always injects the correct content-type for form providers (so it's present when the host does forward it), and clients retry. The residual drop is genuinely host-side (reads are 100%); it's the one item worth raising with the T3 team. Auth and key protection are unaffected either way.

basic + sigv4 proven LIVE (contract 0.5.4)

The basic/sigv4 schemes were built and unit-tested but, at first, never published — the live enclave ran the old bearer-only wasm. That's now fixed: contract 0.5.4 (id 461) is published, and both schemes are proven end-to-end against the live enclave.

  • HTTP Basic (Twilio scheme) — definitive live proof, no Twilio account. A known secret is sealed; the agent sends no password; the enclave builds Authorization: Basic base64(user:secret) and calls httpbin.org/basic-auth/<user>/<secret>, which validates the credential:

    BASIC_STATUS 200  { "authenticated": true, "user": "blindfold" }
    

    httpbin returns 200 only if the enclave's base64 is exactly right. Twilio uses the identical mechanism.

  • AWS SigV4 — byte-exact math + live structural proof. The unit vectors already prove the signature is byte-for-byte correct. Live, against real S3 with AWS's example access key:

    SIGV4_STATUS 403  AWS_CODE InvalidAccessKeyId
    

    AWS parsed our SigV4 header (it echoed back AWSAccessKeyId=AKIDEXAMPLE) and reached credential lookup — it did not return AuthorizationHeaderMalformed / IncompleteSignature, which is what a malformed signature yields. So the enclave's SigV4 header is well-formed and reaches AWS's auth layer. A real IAM key turns this into a 200; the machinery is proven either way.

Publishing gotcha found here: a new contract id resets the secrets-map read ACL (readers: only:[id]), so grantContractReads(newId) must run after every publish or all forward calls fail with cannot read map "…:secrets".

Two operational gotchas (cost real diagnosis time — now fixed)

  • blindfold grant used to REPLACE the egress allowlist.Fixed. T3 replaces allowedHosts on every agent-auth update, so grant now unions new hosts with the previously-granted set (cached per tenant in .blindfold/egress-hosts.json) and sends the full list — grants are additive. grant prints the complete authorized set; grant --replace forces a reset.
  • The proxy hid real enclave errors behind internal proxy error.Fixed. The proxy now returns the real T3 error as JSON — status, code, detail, request_id, and an actionable hint (e.g. egress not authorized → the exact blindfold grant to run; fuel_per_minute → "rate limited, retry in ~60s"; secrets-ACL → "run blindfold init"; JSON-payload → "use query-string params").
  • Testnet tenants have a per-minute compute quota (fuel_per_minute). Hammering the enclave (tight reliability loops, retry storms, repeated demo runs) trips HTTP 500 too_many_requests: quota exceeded (fuel_per_minute), now surfaced clearly (see above) instead of a generic 500. It looks like an outage but resets within a minute — space calls out.

Honesty hardening of the demos

The demos originally deleted a few hardcoded env-var names and then checked only those names — which false-passed when the .env var was renamed (the real key was still in process.env under the new name). They now scan the entire process.env for the provider's real key pattern (sk_…, AIza…/AQ.…, ghp_…) and report a leftover as a genuine leak rather than hiding it. A clean pass therefore means the key truly isn't reachable, not that we looked away.


Impact on the two gaps

Integration coverage. 4 LLM endpoints → 12 named integrations across 6 industries and 3 auth schemes, with two schemes (basic, sigv4) that a generic proxy fundamentally cannot offer. The "integration stack" is now real depth, not a switch statement.

Problem concreteness. The harm is now demonstrable and watchable: a real, privileged credential, real untrusted content, a real exfiltration attempt that comes back empty. The distinct, defensible claim — the enclave performs the provider's real auth (base64, SigV4 signing) so the secret is never in the agent even for non-bearer APIs — is one no generic-proxy competitor can make.


Files

  • contract/src/auth.rs — base64 / Basic / SigV4, computed in-enclave.
  • contract/src/forward.rsAuthSpec enum + per-scheme header building.
  • contract/auth-tests/ — native crypto-vector tests (4/4).
  • packages/blindfold/src/providers.ts — the concrete provider registry.
  • packages/blindfold/src/proxy.ts, types.ts — routing + auth wiring.
  • examples/gemini/, examples/prompt-injection/ — live end-to-end demos.