Security & trust
Attestation, the trust boundary, privacy tiers, keys and BYOK.
Research pack for the "0-to-CTO" onboarding. Scope: how TrustedRouter's privacy claim is built, what it does and does not prove, where every control lives in
quill-router, and which test keeps it honest. Researched againstquill-router@778120494(2026-10-19) plus read-onlygh apireads ofLore-Hex/quill-cloud-proxy. Paths are relative toquill-router/unless prefixedquill-cloud-proxy:.
1. The one-paragraph model
TrustedRouter is split into two planes, and the split is the security architecture:
- Attested gateway / prompt path (
api.trustedrouter.com, aliasapi.quillrouter.com). A Go binary (quill-cloud-proxy:enclave-go/) runs inside GCP Confidential Space on Intel TDX. AWS Nitro Enclaves and Azure SEV-SNP Confidential Containers serve as additional planes. TLS terminates inside the workload. Prompts and outputs exist only here and at the upstream provider. - Control plane (
trustedrouter.com, this repo, FastAPI on Cloud Run). It handles accounts, keys, billing, routing policy, catalog, the trust page and metadata. In production it never sees prompt or output content. It does not even register inference routes (src/trusted_router/main.py:880,:912).
The gateway calls the control plane's /internal/gateway/* routes (authorize, settle, refund) with an internal bearer token. Those calls carry only metadata: key hash, model, token counts and cost. Customers get evidence by fetching a Google-signed attestation JWT bound to their nonce and to the TLS session. They compare the container image_digest against the published release on trust.trustedrouter.com.
2. Honest scope (read this before you say anything to a customer)
From README.md:109-114, docs/security.md, quill-cloud-proxy:tools/verify-attestation.py, and docs/compliance/soc2/README.md:
| What attestation does prove | What it does not prove |
|---|---|
The container serving this TLS session has image digest X, running on genuine TDX hardware with secure boot and debug disabled since boot (dbgstat: disabled-since-boot) |
That the open-source code is bug-free or free of a logging bug |
The response is fresh, because your random nonce is in eat_nonce |
Anything about the upstream provider (OpenAI, Anthropic, …). Provider handling is policy or contract, cited per provider in the catalog. Only the confidential tier adds provider-side TEE + E2EE |
The cert you are talking to is bound into the token: workload TLS cert + RFC 9266 tls-exporter hashes in eat_nonce |
Resistance to a nation-state with physical host access, or a break in Google, Intel or AMD roots of trust |
| Secret Manager / KMS access is gated on the image (change one line, get a new digest, and secret access fails) | The control plane. It is not attested. It is trusted for billing, metadata and BYOK ciphertext storage only |
On Azure, MAA re-attests the SNP report, so Microsoft is a trusted third party. Receipt keys from AWS and Azure are published verified=false (docs/client-receipts.md) |
Compliance status (docs/compliance/soc2/README.md): SOC 2 Type I and II are not obtained. HIPAA, ISO 27001 and privacy certifications are not obtained. The DPA and BAA are drafts. The repo contains a readiness binder (docs/compliance/soc2/, docs/compliance/hipaa/), not a report. Lore Hex Corp has one person. .github/CODEOWNERS says so explicitly and leans on attested measurements as the control that doesn't depend on headcount.
Batch API is the documented exception to "no retention". It stores per-artifact AES-256-GCM ciphertext with Cloud KMS-wrapped DEKs and deletes after 30 days (docs/security.md "Prompt And Output Data"; quill-cloud-proxy/docs/design/batch-api.md). This guarantee is weaker than zero-retention: it depends on deployed KMS/IAM policy.
3. Trust-boundary diagram
flowchart LR
subgraph Client["Customer side (untrusted to us, trusts nothing)"]
C[App / SDK<br/>trusted-router-py, @lore-hex/trusted-router]
V[verify-attestation.py<br/>or SDK verifier]
end
subgraph Edge["Edge"]
CA[GCP External ALB + Cloud Armor<br/>infra/cloud_armor.tf<br/>host allowlist, throttles, L7 DDoS]
L4[Regional L3/L4 LB<br/>TLS passthrough, DNS-only, no Cloudflare proxy]
end
subgraph TEE["ATTESTED BOUNDARY: Confidential Space (Intel TDX) / Nitro / SEV-SNP"]
GW[enclave-go gateway<br/>TLS terminates here<br/>/attestation, /receipt-key<br/>byokcache: KMS unwrap, mem-only]
end
subgraph CP["CONTROL PLANE: NOT attested (this repo, Cloud Run)"]
API[FastAPI trusted_router<br/>no /chat, /messages, /responses, /embeddings in prod]
INT[/internal/gateway/authorize,settle,refund<br/>TR_INTERNAL_GATEWAY_TOKEN/]
ST[(Spanner / Postgres<br/>salted key hashes, BYOK ciphertext,<br/>metadata-only generations)]
SEN[Sentry, scrubbed<br/>control-plane only]
end
subgraph GCP["Google-managed"]
KMS[Cloud KMS<br/>single BYOK envelope key]
SM[Secret Manager<br/>operator provider keys<br/>access gated by image attestation]
ISS[confidentialcomputing.googleapis.com<br/>attestation JWT issuer + JWKS]
end
subgraph Up["Upstream providers (policy/contract trust; TEE only on 'confidential' tier)"]
P[OpenAI / Anthropic / Tinfoil / Phala / ...]
end
TP[trust.trustedrouter.com<br/>GitHub Pages, signed per-plane bundles<br/>image-digest-gcp.txt, gcp-release.json]
C -- "prompt (TLS to enclave)" --> L4 --> GW
V -- "GET /attestation?nonce=..." --> L4
C -- "console, keys, billing" --> CA --> API
GW -- "metadata only + internal token" --> INT
INT --> ST
API --> ST
API -. "KMS Encrypt (wrap DEK)" .-> KMS
GW -. "KMS Decrypt (unwrap DEK)" .-> KMS
GW -. "attested access" .-> SM
GW -- "attestation request" --> ISS
GW -- "prompt" --> P
V -- "compare image_digest" --> TP
API --> SENDiagram source
flowchart LR
subgraph Client["Customer side (untrusted to us, trusts nothing)"]
C[App / SDK<br/>trusted-router-py, @lore-hex/trusted-router]
V[verify-attestation.py<br/>or SDK verifier]
end
subgraph Edge["Edge"]
CA[GCP External ALB + Cloud Armor<br/>infra/cloud_armor.tf<br/>host allowlist, throttles, L7 DDoS]
L4[Regional L3/L4 LB<br/>TLS passthrough, DNS-only, no Cloudflare proxy]
end
subgraph TEE["ATTESTED BOUNDARY: Confidential Space (Intel TDX) / Nitro / SEV-SNP"]
GW[enclave-go gateway<br/>TLS terminates here<br/>/attestation, /receipt-key<br/>byokcache: KMS unwrap, mem-only]
end
subgraph CP["CONTROL PLANE: NOT attested (this repo, Cloud Run)"]
API[FastAPI trusted_router<br/>no /chat, /messages, /responses, /embeddings in prod]
INT[/internal/gateway/authorize,settle,refund<br/>TR_INTERNAL_GATEWAY_TOKEN/]
ST[(Spanner / Postgres<br/>salted key hashes, BYOK ciphertext,<br/>metadata-only generations)]
SEN[Sentry, scrubbed<br/>control-plane only]
end
subgraph GCP["Google-managed"]
KMS[Cloud KMS<br/>single BYOK envelope key]
SM[Secret Manager<br/>operator provider keys<br/>access gated by image attestation]
ISS[confidentialcomputing.googleapis.com<br/>attestation JWT issuer + JWKS]
end
subgraph Up["Upstream providers (policy/contract trust; TEE only on 'confidential' tier)"]
P[OpenAI / Anthropic / Tinfoil / Phala / ...]
end
TP[trust.trustedrouter.com<br/>GitHub Pages, signed per-plane bundles<br/>image-digest-gcp.txt, gcp-release.json]
C -- "prompt (TLS to enclave)" --> L4 --> GW
V -- "GET /attestation?nonce=..." --> L4
C -- "console, keys, billing" --> CA --> API
GW -- "metadata only + internal token" --> INT
INT --> ST
API --> ST
API -. "KMS Encrypt (wrap DEK)" .-> KMS
GW -. "KMS Decrypt (unwrap DEK)" .-> KMS
GW -. "attested access" .-> SM
GW -- "attestation request" --> ISS
GW -- "prompt" --> P
V -- "compare image_digest" --> TP
API --> SENThe dashed/colored line that matters is TEE ↔ CP. Content crosses into the TEE and to providers, never into CP. CP crosses into the TEE only with ciphertext envelopes and policy.
4. Attestation: what a real response looks like
I fetched GET https://api.trustedrouter.com/attestation?nonce=<16 random bytes hex> on 2026-10-19. (Disclosure: called twice. The first output was truncated, so I re-fetched to save the token.) The body is a raw RS256 JWT, not JSON. Decoded claims, trimmed:
// header
{"alg":"RS256","kid":"1be6ff7287d640316ddc929e1a8b21fbca7e54e7","typ":"JWT"}
// payload
{
"aud": "quill-cloud",
"iss": "https://confidentialcomputing.googleapis.com",
"sub": "https://www.googleapis.com/compute/v1/projects/quill-cloud-proxy/zones/us-central1-b/instances/quill-enclave-mig-us-j71s",
"iat": 1792432251, "exp": 1792435851,
"eat_nonce": [
"680d068a…e63f", // binding hash (workload TLS cert / tls-exporter, per gcp-attestation-issuance.md)
"0a8633b2…ff00", // binding hash
"223d263e…5096", // binding hash (differs per TLS session)
"6794d51294742e4b2d85989c38d20dd6" // <- MY nonce
],
"hwmodel": "GCP_INTEL_TDX", "swname": "CONFIDENTIAL_SPACE", "swversion": ["260900"],
"secboot": true, "dbgstat": "disabled-since-boot",
"submods": {
"confidential_space": {"support_attributes": ["LATEST","STABLE","USABLE"], "monitoring_enabled": {"memory": false}},
"container": {
"image_reference": "us-central1-docker.pkg.dev/quill-cloud-proxy/quill/enclave-multi:gcp-release-8cad0a1e",
"image_digest": "sha256:28ada804dd8befa51e0bc98732e4fd6f211b175ef15bfe08b74afe34ebbf6e9f",
"image_id": "sha256:9f076a33…9939",
"restart_policy": "Always",
"env": { "QUILL_ANTHROPIC_SECRET": "trustedrouter-anthropic-api-key", "...": "131 entries; secret *names*, never values" }
}
}
}
Doc bug worth fixing: the README's "For the nerds" snippet (README.md:93-99) is wrong in three places.
1. It pipes to jq ., but the body is a JWT, so jq fails.
2. It refers to a top-level image_digest, but the claim lives at submods.container.image_digest.
3. It lists pcrs, but GCP Confidential Space tokens have no PCRs. PCR0 exists on the AWS Nitro COSE document.
The env block also tells you that the operator provider keys are Secret Manager references that the attested image resolves at boot.
4.1 Client verification sequence
sequenceDiagram
autonumber
participant C as Client / verifier
participant GW as api.trustedrouter.com (enclave, TLS inside TEE)
participant L as CS launcher (in TEE)
participant G as Google CC issuer + JWKS
participant T as trust.trustedrouter.com
C->>C: nonce = 16+ random bytes (hex)
C->>GW: TLS handshake (SNI api.trustedrouter.com)
Note over C,GW: Same TLS session MUST carry evidence AND prompt
C->>GW: GET /attestation?nonce=N
GW->>L: request token with eat_nonce = [cert hash, tls-exporter hash, ..., N]
L->>G: TDX quote + claims
G-->>L: RS256 JWT (iss=confidentialcomputing.googleapis.com)
L-->>GW: JWT
GW-->>C: JWT (connection kept alive)
C->>G: fetch JWKS, verify signature, iss, aud, exp/nbf
C->>C: N ∈ eat_nonce (freshness, anti-replay)
C->>C: own tls-exporter + peer cert hash ∈ eat_nonce (no relay / cert substitution)
C->>C: dbgstat == disabled-since-boot, secboot == true, hwmodel/swname as expected
C->>T: GET image-digest-gcp.txt / gcp-release.json (signed bundle)
C->>C: submods.container.image_digest ∈ accepted set
alt all checks pass
C->>GW: POST /v1/chat/completions (same TLS session)
else any check fails
C->>C: refuse to send prompt (503 from issuer means retry fresh, never reuse)
endDiagram source
sequenceDiagram
autonumber
participant C as Client / verifier
participant GW as api.trustedrouter.com (enclave, TLS inside TEE)
participant L as CS launcher (in TEE)
participant G as Google CC issuer + JWKS
participant T as trust.trustedrouter.com
C->>C: nonce = 16+ random bytes (hex)
C->>GW: TLS handshake (SNI api.trustedrouter.com)
Note over C,GW: Same TLS session MUST carry evidence AND prompt
C->>GW: GET /attestation?nonce=N
GW->>L: request token with eat_nonce = [cert hash, tls-exporter hash, ..., N]
L->>G: TDX quote + claims
G-->>L: RS256 JWT (iss=confidentialcomputing.googleapis.com)
L-->>GW: JWT
GW-->>C: JWT (connection kept alive)
C->>G: fetch JWKS, verify signature, iss, aud, exp/nbf
C->>C: N ∈ eat_nonce (freshness, anti-replay)
C->>C: own tls-exporter + peer cert hash ∈ eat_nonce (no relay / cert substitution)
C->>C: dbgstat == disabled-since-boot, secboot == true, hwmodel/swname as expected
C->>T: GET image-digest-gcp.txt / gcp-release.json (signed bundle)
C->>C: submods.container.image_digest ∈ accepted set
alt all checks pass
C->>GW: POST /v1/chat/completions (same TLS session)
else any check fails
C->>C: refuse to send prompt (503 from issuer means retry fresh, never reuse)
endKey subtleties, from quill-cloud-proxy:tools/verify-attestation.py and docs/gcp-attestation-issuance.md:
- Exporter binding alone is insufficient. A relay could launder the client's exporter through the nonce channel. The enclave accepts only one caller nonce, so you must send your own random nonce and check the exporter.
- A new TLS connection needs fresh evidence. No nonce-bound evidence is cached server-side. Issuance is bounded to 1 active mint, 4 waiters, a 5 s queue and a 30 s issuer timeout. Temporary failures return 503 and must not be bypassed.
4.2 Trust drift workflow
scripts/verify_trust_measurements.pycompares every published measurement on the control plane's trust records against a live attestation from GCPapi_base_url, AWS, and every Azureregions[].attestation_url. It exits non-zero on mismatch or on an un-contacted endpoint when run with--strict..github/workflows/trust-drift.ymlruns it daily at 07:23 UTC and after every successful "Deploy TR control plane". It uses--strictand never gates PRs on prod state. Offline proofs live intests/test_trust_measurement_drift.py(e.g.test_gcp_digest_drift_fails_and_prints_both_values,test_gcp_matches_the_accepted_set_during_a_roll).- Why it exists: the Quill trust bucket once published a PCR0 that matched no running enclave. A stale measurement teaches verifiers to ignore mismatches.
- Publishing happens in
quill-cloud-proxy.make gcp-releasewritestrust-page/image-digest-gcp.txtandgcp-release.json. Per-planepublish-trust-{gcp,aws,azure}.ymlsign bundles.publish-trust-page.ymldeploys GitHub Pages attrust.trustedrouter.com, with an S3 mirror attrust.quill.lorehex.co. Publishers refuse unsigned or mismatched copies (tools/check-trust-signatures.py,tools/check-trust-copies.py). - The control-plane side of the trust records is
src/trusted_router/trust.py(gcp_release:69,aws_release:149,azure_release:268,trust_html:338). - Inconsistency:
docs/security.mdsaystrust.trustedrouter.com"should point at the control-plane/trust hosting", but the gateway README says it is GitHub Pages. Confirm before you document it externally.
5. Client receipts (signed per-request evidence)
docs/client-receipts.md, src/trusted_router/receipt_keys.py, src/trusted_router/routes/public.py:2511.
- Each enclave generates an Ed25519 receipt signing key at boot. Receipts (compact JWS) go only to the requesting client and are never stored.
- The enclave publishes the public key at
/receipt-key, committed into its attestation: - commitment =
sha256(domain || 0x00 || pubkey)(receipt_keys.py:99) - A scheduled collector (
POST /v1/internal/gateway/receipt-keys/collect, internal token) connects to every gateway A-record by IP with correct SNI, then stores(kid, att_sha256)rows. Rows are append-only: a kid collision never overwrites key material. This was fixed after the 2026-09-10 version-retention defect. - Public discovery is at
GET /.well-known/inference-receipt-keysor/trust/receipt-keys.json, paginated (≤250 versions, ≤1 MiB). verified=truecurrently means the GCP CS JWT signature, issuer, validity, audience and non-debug state were checked (verify_gcp_attestation_chain,receipt_keys.py:437,dbgstatcheck at :497). AWS and Azure rows areverified=falseuntil in-package chain verifiers exist. Clients must not treatverified=falseas hardware-anchored.- Receipt JWS verification for clients is implemented in
quill-cloud-proxy:tools/verify-attestation.py(verify_receipt_*,verify_receipt_stream).
6. Privacy tiers & fail-closed routing
Vocabulary (src/trusted_router/catalog_data.py:61-67):
| Tier | Int | Meaning |
|---|---|---|
standard / any |
0 | No tracked posture. Assume stored. This is the default for every provider until a cited flag says otherwise |
no_store |
1 | Provider does not store request/response content |
zdr |
2 | Contractual/policy zero data retention |
confidential (aliases e2e, e2ee) |
3 | Verified confidential compute and provider E2EE and explicit ZDR and host is not the model's own vendor (currently Tinfoil, Phala) |
- Request surface:
provider.min_privacyis parsed atsrc/trusted_router/routing.py:694-708. Unknown values return 400. Meta-modelstrustedrouter/zdr,trustedrouter/e2eandtrustedrouter/euset floors. - Enforcement is
endpoint_meets_privacy_requirement(src/trusted_router/catalog_privacy.py:231). It is not a numeric comparison: retention and confidential compute are orthogonal, and unknown returnsFalse(fail closed). Applied in_apply_provider_filters(routing.py:1121), which also notes "A TEE route with no tracked retention promise must not satisfy deny/ZDR". - Floors compose: the resolved floor dominates everything requested, regardless of order. If no endpoint qualifies, the request fails before dispatch rather than degrading.
- Gateway replay of stored routes re-checks the floor (
tests/test_gateway_replay_privacy.py). - The TR gateway hop itself is always attested. These tiers describe only the upstream provider (
catalog_privacy.pymodule docstring). - Broadcast observability is metadata-only by default. Content export requires explicit
include_content, and content-enabled encrypted destinations are returned only to the attested gateway (README.md"Broadcast Observability").
7. "Never logged": how prompt/output privacy is enforced in this repo
Defense in depth, from structural to cosmetic:
- Structural: the control plane cannot receive prompts in prod.
register_inference_routesruns only when_control_plane_inference_enabled(main.py:880,:912-919), which returns true forenvironment in {local,test}only. An outage cannot silently fall back to a non-attested handler. - Contract: gateway↔control-plane calls carry metadata only. Authorize, settle, refund, activity and generation metadata carry no content (
docs/security.md"BYOK Secrets"). Generation rows are limited to id, workspace, key hash, model, provider, tokens, cost, usage type, speed, finish reason and status. - No content-storage switch.
content_storage_enabled: trueis rejected withcontent_storage_disabled.GET /generation/contentalways returnscontent_not_stored(src/trusted_router/types.py:118). - Sentry is control-plane-only and scrubbed (
src/trusted_router/sentry_config.py). -send_default_pii=False,max_request_body_size="never"(:240-241). -before_send,before_send_logandbefore_breadcrumb(:301, :315, :326) recursively_scrub(:563). -SENSITIVE_KEYS(:21) covers authorization, cookie, api_key, byok, secret, token, password, messages, prompt, completion, content, output, input, body, and more. - Also scrubbed: string fragments/prefixes such assk-tr-v1-(:47, :83). - A floodgate caps events at 3 per fingerprint and 50 per hour per process. - No Sentry in the enclave image. - Logging hygiene tests (
tests/test_application_logging.py,tests/test_scrub_totality_property.py). Hypothesis property tests check that no declared secret survives any container shape, scrubbing is idempotent, cycles terminate and the Axiom filter ships no canary. - The gateway retention table (
quill-cloud-proxyREADME "What gets retained"): no prompt, completion, bearer tokens or key hashes. Only per-device daily aggregate counts.
8. Credentials & secrets
8.1 API keys
src/trusted_router/security.py:
- Key format: sk-tr-v1-<token_urlsafe(32)> (:8). Key ID: key_<token_urlsafe(18)> (:12).
- Verifier: per-key random 32-byte salt, digest SHA-256(salt || key) (:16-21), compared constant-time (hmac.compare_digest, :28, :38).
- Lookup index: lookup_hash_api_key = SHA-256(key) unsalted (:24). This is acceptable because keys carry ~256 bits of entropy (no dictionary attack). It gives O(1) lookup, and the salted digest remains the verifier. Sessions use the same lookup-hash scheme (storage_auth_sessions.py:48).
- The public hash field is an opaque key ID, not the verifier (docs/security.md "API Keys"). Tested by tests/test_stubs_and_security.py:298 test_api_key_secrets_are_salted.
8.2 BYOK envelope encryption
src/trusted_router/byok_crypto.py, src/trusted_router/key_management.py, src/trusted_router/routes/byok.py.
encrypt_byok_secret(:52):- Generates a random 32-byte DEK and a 12-byte nonce.
- Encrypts the provider key with
AES-256-GCM(DEK). - Wraps the DEK through
KeyWrapper(Cloud KMS Encrypt in prod) with a separate 12-byte nonce. - Stores only
{algorithm, key_ref, encrypted_dek, dek_nonce, ciphertext, nonce}plus a first/last display hint. - AAD v2 (:285) is length-prefixed and injective:
"trustedrouter/byok/v2" | namespace | workspace_id | context. It binds ciphertext to the workspace and provider. Three namespaces keep secret families from opening as one another: provider: BYOK, opened only by the enclavecontrol: broadcast keys/headers, never shipped to the enclaveuser_model: opened on both sides, deliberately- It must stay byte-identical to
quill-cloud-proxy:enclave-go/internal/byokcache/cache.go aadV2. Both repos pin a hex vector (tests/test_byok_aad_namespace_property.py::test_the_v2_vector_matches_the_enclave). - V1 envelopes are rejected before any unwrap (:306-312).
- Wrapper selection (
key_management.py:180): an explicit local key, thenTR_BYOK_KMS_KEY_NAME(GcpKmsKeyWrapper, :113), then an HKDF-derived dev key only in local/test, else raise. There is no silent dev-key fallback in prod. - One KMS key wraps every BYOK DEK. BYOK keys are rows, not one Secret Manager secret each.
byok_cache_key(:215) ="byokcache:v1:" + sha256(workspace, provider, all envelope fields). The gateway uses it for short-TTL, memory-only plaintext caching. Rotation produces a new key. Delete means authorize stops returning the envelope, and the cache entry expires by TTL.- Returned to the gateway in
/internal/gateway/authorize(routes/internal/gateway.py:5528-5529).
sequenceDiagram
autonumber
participant U as User (console / mgmt API)
participant CP as Control plane (byok.py → byok_crypto)
participant K as Cloud KMS (single BYOK key)
participant DB as Spanner (BYOK row)
participant GW as Attested gateway (enclave-go byokcache)
participant P as Provider
rect rgba(120,120,255,0.12)
Note over U,DB: Encrypt (one-time raw input)
U->>CP: PUT /v1/byok/providers/{p} {api_key}
CP->>CP: DEK=rand(32), nonce=rand(12), dek_nonce=rand(12)
CP->>CP: AAD = len‖"trustedrouter/byok/v2"‖len‖"provider"‖len‖ws‖len‖p
CP->>CP: ct = AES-256-GCM(DEK, nonce, key, AAD)
CP->>K: Encrypt(DEK) - prod, LocalAes wrapper in local or test only
K-->>CP: encrypted_dek
CP->>DB: store {alg=V2, key_ref, encrypted_dek, dek_nonce, ct, nonce, hint}
CP-->>U: hint (first/last chars) + metadata, never plaintext
end
rect rgba(120,255,120,0.12)
Note over GW,P: Decrypt (inside TEE only)
GW->>CP: POST /internal/gateway/authorize (internal token, key hash, model)
CP->>DB: load envelope
CP-->>GW: byok_encrypted_secret + byok_cache_key (non-secret)
alt cache hit for byok_cache_key
GW->>GW: use in-memory provider key
else miss
GW->>K: Decrypt(encrypted_dek) (IAM: attested workload identity)
K-->>GW: DEK
GW->>GW: key = AES-GCM-open(DEK, nonce, ct, AAD_v2) → cache (short TTL, memory only)
end
GW->>P: request with user's provider key
Note over CP,DB: Delete BYOK → authorize stops returning envelope → fails closed
endDiagram source
sequenceDiagram
autonumber
participant U as User (console / mgmt API)
participant CP as Control plane (byok.py → byok_crypto)
participant K as Cloud KMS (single BYOK key)
participant DB as Spanner (BYOK row)
participant GW as Attested gateway (enclave-go byokcache)
participant P as Provider
rect rgba(120,120,255,0.12)
Note over U,DB: Encrypt (one-time raw input)
U->>CP: PUT /v1/byok/providers/{p} {api_key}
CP->>CP: DEK=rand(32), nonce=rand(12), dek_nonce=rand(12)
CP->>CP: AAD = len‖"trustedrouter/byok/v2"‖len‖"provider"‖len‖ws‖len‖p
CP->>CP: ct = AES-256-GCM(DEK, nonce, key, AAD)
CP->>K: Encrypt(DEK) - prod, LocalAes wrapper in local or test only
K-->>CP: encrypted_dek
CP->>DB: store {alg=V2, key_ref, encrypted_dek, dek_nonce, ct, nonce, hint}
CP-->>U: hint (first/last chars) + metadata, never plaintext
end
rect rgba(120,255,120,0.12)
Note over GW,P: Decrypt (inside TEE only)
GW->>CP: POST /internal/gateway/authorize (internal token, key hash, model)
CP->>DB: load envelope
CP-->>GW: byok_encrypted_secret + byok_cache_key (non-secret)
alt cache hit for byok_cache_key
GW->>GW: use in-memory provider key
else miss
GW->>K: Decrypt(encrypted_dek) (IAM: attested workload identity)
K-->>GW: DEK
GW->>GW: key = AES-GCM-open(DEK, nonce, ct, AAD_v2) → cache (short TTL, memory only)
end
GW->>P: request with user's provider key
Note over CP,DB: Delete BYOK → authorize stops returning envelope → fails closed
endOperators can prove the full contract with TR_BYOK_PROD_SMOKE=1 uv run python scripts/smoke_byok_prod.py. It is destructive and manual, for security releases only. It covers an isolated workspace, rotation, settlement, delete, and fail-closed afterwards.
8.3 Sessions, OAuth, Sign in with TrustedRouter
- Principal resolution (
src/trusted_router/auth.py:90) tries three sources in order: 1.x-trustedrouter-userdev header, rejected outside local/test (:185) 2.tr_sessioncookie 3. Bearer, dispatched strictly by prefix:sk-tr-means API key only,trsess-means session only, anything else returns 401. There is no cross-shape fallback. - Session cookie (
auth.py:378):HttpOnly,SameSite=Lax,Securein every deployed env. The companiontr_signed_in=1hint cookie is deliberately non-HttpOnly and carries no auth power. - Login OAuth (Google/GitHub) is in
src/trusted_router/oauth_provider.py. GitHub picks the primary verified email (:181). The OAuth state cookie is HttpOnly/Secure/Lax (tests/test_session_cookie_attributes.py:91). - TrustedRouter as OAuth 2.1 AS (
docs/sign-in-with-trustedrouter.md,docs/security.md): - Redirect URIs are exact-match.
- PKCE S256 is mandatory on
/v1/oauth/token. The verifier is 43–128 unreserved chars. - Codes and consent are short-lived and single-use.
- Consent is server-bound: approval posts only an opaque id plus a constant-time-compared CSRF token.
- Scopes:
inference profile balance:read activity:read. - The default app budget is $20/mo. A suggested budget is never auto-applied above $20.
- The token endpoint has a 30/min per-IP bucket.
- Nuance: the deprecated legacy
/v1/auth/keyspath still acceptsplainPKCE (tests/test_oauth_key_delegation.py:189,tests/test_oauth_increment_d.py:196). - Internal tokens (
src/trusted_router/routes/internal/_shared.py:30-67): path-scoped credential classesgateway,observer,operatorandlightning, each with its own secret, compared in constant time. - Prod returns 403 if the token is unset.
- Config refuses to start if a surface holds a secret it doesn't own (
SERVICE_SURFACE_SECRET_OWNERS,config.py:247-257, check at :1981). - Federation directory, settlement and credit secrets are valid only on their exact routes.
9. Edge, WAF, rate limits, body limits
- Cloud Armor (
infra/cloud_armor.tf, owner-applied Terraform, sole writer). Rules: - 900: deny non-canonical Host → 403
- 1000:
/chat-proxy/throttle 120/60 s - 1100: state-changing request throttle 300/60 s
- 1150: throttle 12000/60 s
- 1200: throttle 2400/60 s
- L7 DDoS defense is enabled
scripts/deploy/_edge_security.shreconciles non-Terraform policies. Inventory lives indocs/security/edge-surfaces.json. It has 7 surfaces across GCP, AWS and Azure, andlive_state_verified: false. Several arep0_externalorp0_integration. Treat the edge rollout as in progress.- AWS WAF / App Runner helpers are
scripts/deploy/_aws_waf.shand_aws_app_runner_security.sh(tests/test_ddos_edge_deploy.py). api.trustedrouter.commust be DNS-only (no Cloudflare proxy) so TLS reaches the TEE.- App limiter (
src/trusted_router/request_limits.py): - Process-local, never storage-backed.
- Defaults to
TR_RATE_LIMIT_CLIENT_IP_MODE=untrusted: one shareduntrusted_lbbucket. X-Forwarded-For,CF-Connecting-IPandX-TrustedRouter-Userare ignored.edge_headermode is allowed only behind the GCP ALB, which overwritesX-TrustedRouter-Client-IP.- Body limits (
src/trusted_router/request_body_limit.py:79): 4 MiB per request, 64 MiB in flight, 16 concurrent bodies, 30 s read deadline. CL+TE smuggling framing is rejected. - Headers (
src/trusted_router/middleware.py:345): HSTS,X-Frame-Options: SAMEORIGIN, and a nonce-based CSP that is still report-only (tests/test_security_response_headers.py:61).
10. Supply chain & security workflows
| Workflow | What |
|---|---|
.github/workflows/dependency-scan.yml |
OSV-Scanner. Full scan with SARIF to code-scanning on push/daily. PR mode diffs base vs head. Actions are pinned by SHA |
.github/dependabot.yml |
Weekly updates for uv, npm, docker and github-actions |
.github/workflows/trust-drift.yml |
Published measurement vs live attestation (§4.2) |
.github/workflows/cloud-security-baseline.yml |
Daily read-only GCP CIS-style drift check (scripts/deploy/gcp_security_baseline_check.sh). Opens an issue on drift. The checkers never mutate (tests/test_cloud_security_baseline.py) |
.github/workflows/check-repo-security-coverage.yml |
Weekly check of branch protection and security settings across public Lore-Hex repos (scripts/security/check_repo_security_coverage.py) |
.github/workflows/typed-audit.yml, ci.yml |
ruff + mypy + pytest (coverage ≥70%) gate every change |
SECURITY.md |
GitHub private vulnerability reporting. Ack within 72 h, assessment within 5 business days. No bounty. Safe harbour. Customer-content exposure is top severity and notification does not wait for RCA |
11. License (BUSL-1.1) implications
LICENSE: Business Source License 1.1. Licensor is Lore Hex Corp. Additional Use Grant: None. Change License is Apache-2.0, four years after each version's publication.
- Anyone may read, copy, modify, and make non-production use. Running it in production (e.g. a competing hosted router) requires a commercial license until the change date.
- The security relevance is that "source-available, not OSI open source" is the honest phrasing. It still gives verifiers what they need: the ability to read and rebuild the attested gateway (quill-cloud-proxy is also BUSL-1.1) and compare digests. SDKs are Apache-2.0 / MIT. Marketing copy in competitor_comparisons.py already says this plainly. Keep it that way.
12. Security controls → implementation → enforcing test
| # | Control | Implemented at | Test that enforces it |
|---|---|---|---|
| 1 | No inference routes on prod control plane | src/trusted_router/main.py:880, :912 |
tests/test_stubs_and_security.py:1101 test_production_control_plane_does_not_register_inference_routes; tests/test_contract_regressions.py:195 |
| 2 | Prompt/output never in metadata store | routes/internal/gateway.py (metadata-only contract) |
tests/test_stubs_and_security.py:1134 test_prompt_output_never_enter_metadata_store |
| 3 | Content storage cannot be enabled | workspace PATCH handler, types.py:118 |
tests/test_stubs_and_security.py:55, :69 |
| 4 | Sentry scrubbing (keys, fragments, prefixes, bodies) | src/trusted_router/sentry_config.py:21,47,83,240-241,301-332,563 |
tests/test_security_contracts.py:255, :281; tests/test_stubs_and_security.py:1150; tests/test_scrub_totality_property.py |
| 5 | No Sentry in enclave | policy (docs/security.md) |
tests/test_stubs_and_security.py:1181 (⚠ silently passes if sibling quill-cloud-proxy isn't checked out, likely a no-op in CI) |
| 6 | Prod config fails closed (internal token, Stripe secrets, Sentry DSN, storage) | src/trusted_router/config.py:1443, :1950-1990 |
tests/test_stubs_and_security.py:1018, :1049, :1076; tests/test_security_contracts.py:50, :238 |
| 7 | Surface-scoped secret ownership | config.py:247-257, :1981 |
config surface tests in tests/test_security_contracts.py / tests/test_internal_surface_deploy.py |
| 8 | Salted SHA-256 API keys, constant-time verify | src/trusted_router/security.py:16-38 |
tests/test_stubs_and_security.py:298 test_api_key_secrets_are_salted |
| 9 | Dev user header disabled in prod | src/trusted_router/auth.py:185 |
tests/test_security_contracts.py:142; tests/test_stubs_and_security.py:539 |
| 10 | Strict bearer-prefix dispatch | auth.py:90-177 |
tests/test_auth_store_collapse.py (trsess-/sk-tr- paths) |
| 11 | Session cookie HttpOnly/Secure/Lax | auth.py:378-410 |
tests/test_session_cookie_attributes.py:112, :177 |
| 12 | Internal token constant-time, required in prod | routes/internal/_shared.py:53-67 |
tests/test_security_contracts.py:50, :81; tests/test_contract_regressions.py:195 |
| 13 | OAuth PKCE S256, exact redirect, single-use server-bound consent | routes/oauth.py, oauth_app_policy.py |
tests/test_oauth_increment_d.py:48, :122; tests/test_oauth_key_delegation.py:176 |
| 14 | BYOK envelope: KMS-wrapped DEK, no plaintext | src/trusted_router/byok_crypto.py:52-97, key_management.py:113,180 |
tests/test_byok_crypto.py:39; tests/test_storage_gcp.py:221; tests/test_oauth_and_console.py:1410; tests/test_billing.py:855 |
| 15 | BYOK AAD v2 injective + namespace separation + enclave parity | byok_crypto.py:285-312 |
tests/test_byok_aad_namespace_property.py:198,219,271,289,348 |
| 16 | No dev wrapping key outside local/test | key_management.py:193-199 |
tests/test_byok_crypto.py; tests/test_byok_v1_removal_gate.py |
| 17 | Privacy floor fail-closed, order-independent | catalog_privacy.py:231, routing.py:694-708,1108-1135 |
tests/test_routing_privacy_floor_property.py; tests/test_auto_route_privacy_floor.py:191,207,222; tests/test_gateway_replay_privacy.py; tests/test_catalog_privacy_coherence_property.py |
| 18 | Receipt-key log append-only, GCP chain verified | receipt_keys.py:99,437,497 |
tests/test_receipt_key_log.py:65,322; tests/test_receipt_key_versions_migration.py |
| 19 | Published measurement == running enclave | scripts/verify_trust_measurements.py, .github/workflows/trust-drift.yml |
tests/test_trust_measurement_drift.py:345,382,395,407 |
| 20 | Synthetic-probe attestation evidence checks (nonce binding, COSE/JWT, cert binding, PCR0 pin) | src/trusted_router/synthetic/probes.py (_attestation_evidence) |
tests/test_attestation_evidence.py; tests/test_attested_transport.py:84,126,135 |
| 21 | Rate limiter ignores spoofable headers; local-only | src/trusted_router/request_limits.py:31,89 |
tests/test_stubs_and_security.py:876-1018, :1193, :1253 |
| 22 | Request body limits / smuggling | src/trusted_router/request_body_limit.py:79 |
tests/test_api_edge_contracts.py, request-body tests |
| 23 | Security headers / CSP nonce | src/trusted_router/middleware.py:345 |
tests/test_security_response_headers.py |
| 24 | Stripe webhook signature required | Stripe webhook route | tests/test_security_contracts.py:171,215,238 |
| 25 | Cloud Armor / WAF configuration | infra/cloud_armor.tf, scripts/deploy/_edge_security.sh, _aws_waf.sh |
tests/test_ddos_edge_deploy.py; tests/test_public_surface_edge.py; tests/test_internal_surface_edge.py |
| 26 | Cloud baseline checkers read-only | scripts/deploy/*_security_baseline*.sh |
tests/test_cloud_security_baseline.py:50,90,96 |
13. Threat model
| # | Threat / actor | Asset | Mitigation | Residual risk |
|---|---|---|---|---|
| T1 | TR operator or insider wants to read prompts | Prompt/output | Prompts only inside TEE. Control plane has no inference routes. Image change means a new digest, which drift check and customer verifiers detect. Secrets gated on image | Operator ships a malicious build and publishes its digest. Detection then relies on someone reading the source diff. Single-person org |
| T2 | Cloud host admin / hypervisor | Prompt in memory | Intel TDX memory encryption, dbgstat disabled-since-boot, monitoring_enabled.memory=false |
Physical/side-channel attacks. Trust in Google, Intel and AMD roots |
| T3 | Network MITM / malicious relay | Prompt in transit | TLS terminates in TEE. Cert hash + tls-exporter + caller nonce bound in eat_nonce. Same-session rule |
Clients that skip verification get only ordinary TLS |
| T4 | Replay of old attestation | Verifier trust | Caller nonce, short exp, no evidence caching |
None significant if verifier checks nonce |
| T5 | Stale or drifted published measurement | Verifier trust | trust-drift.yml daily + post-deploy, --strict. Signed per-plane bundles |
Up to ~24 h detection for enclave-side drift |
| T6 | Control plane compromise | Keys, BYOK, billing | Salted key digests. BYOK ciphertext only (KMS unwrap needs attested identity). Secrets split per surface | Attacker can mint keys, alter routing policy (e.g. privacy flags), or read metadata. Control plane is not attested |
| T7 | Leaked BYOK DB rows | User provider keys | AES-256-GCM + KMS-wrapped DEK + AAD bound to workspace/provider/namespace | KMS IAM misconfiguration. Single KMS key for all users |
| T8 | Secrets leaking into logs/Sentry | Keys, prompts | Scrubbers, max_request_body_size=never, property tests, no Sentry in enclave |
New field names not in SENSITIVE_KEYS. Third-party library logging |
| T9 | Upstream provider retains or trains on data | Prompt | Privacy tiers. Default "assume stored". zdr/confidential floors fail closed |
ZDR is contract/policy, unverifiable by TR. Catalog flags can be wrong |
| T10 | Spoofed client IP / auth headers | Rate limits, identity | Untrusted mode, ignored XFF/CF headers, dev header off in prod | Per-instance limits only. Fleet protection relies on Cloud Armor |
| T11 | DoS / volumetric | Availability | Cloud Armor throttles + L7 DDoS, body limits, Cloud Run max caps | Edge rollout incomplete on AWS/Azure (edge-surfaces.json) |
| T12 | OAuth app abuse / code interception | User credits | PKCE S256, exact redirect, server-bound consent, $20 default budget, verified owner | Legacy /v1/auth/keys still allows plain PKCE |
| T13 | Gateway → control plane impersonation | Billing ledger | Internal token (constant-time), surface ownership, dedicated federation secrets | Shared bearer secret (not mTLS/attestation-bound) |
| T14 | Dependency / supply-chain compromise | Everything | OSV-Scanner, Dependabot, SHA-pinned actions, attested image | A malicious dependency inside the attested image would be attested. Attestation proves identity, not benign behavior |
| T15 | Batch API data at rest | Batch prompts/results | Per-artifact AES-GCM, KMS-wrapped, 30-day deletion | Weaker than zero retention. Depends on KMS/IAM policy |
| T16 | Azure MAA / non-GCP planes | Verifier trust | Pinned MAA issuer + HOST_DATA, receipts verified=false |
Microsoft is a trusted third party |
14. "Never do this" list for contributors
- Never register inference routes on the production control plane, or add any non-attested fallback for
/chat/completions,/messages,/responsesor/embeddings. Don't touch_control_plane_inference_enabled. - Never log, store, or send to Sentry/Axiom/analytics any prompt, message, completion, or output content. That includes debug logs, exception messages that embed request bodies, and "temporary" diagnostics. When you add a new sensitive field name, add it to
SENSITIVE_KEYS/SENSITIVE_STRING_FRAGMENTS, not to a second list. - Never add Sentry (or any third-party telemetry SDK) to
quill-cloud-proxy/enclave-goor any attested image. - Never add content fields to gateway↔control-plane contracts (authorize, settle, refund, activity, generation).
- Never store a raw BYOK/provider key, return it in an API response, or log it. Use
encrypt_byok_secret. Never decryptprovider-namespace envelopes in the control plane. - Never change
_aad_v2without changingenclave-go/internal/byokcache/cache.goin lockstep and updating both pinned vectors. A mismatch breaks every BYOK key silently. - Never introduce a silent dev-key / memory-backend / missing-secret fallback for deployed environments. Production must fail closed at startup.
- Never compare secrets with
==. Useconstant_time_equal/hmac.compare_digest. - Never trust
X-Forwarded-For,CF-Connecting-IP,X-TrustedRouter-User, or Host for identity or rate-limit keys. Never enableedge_headermode behind a front door that doesn't overwrite the header. - Never compare privacy tiers numerically for enforcement. Use
endpoint_meets_privacy_requirement. Never flip a provider's ZDR/E2EE/confidential flag without a cited source. Unknown means "assume stored". - Never put
api.trustedrouter.combehind Cloudflare proxy or any TLS-terminating front door. - Never publish a measurement without the matching signed bundle, and never weaken
trust-drift.yml --strict, issuer queue limits or verifier checks to make a red run green. - Never reuse one internal secret across surfaces (gateway, observer, operator, federation, lightning), and never treat federation secrets as generic internal credentials.
- Never claim SOC 2, HIPAA or ISO certification, "open source" (it's BUSL-1.1 source-available), or that attestation covers upstream providers.
- Never run
scripts/smoke_byok_prod.pycasually. It is destructive and meant for security releases only. Never run unbounded "read-only" Spanner scans (seeAGENTS.md). - Never weaken a security test or conformance test to make a change pass.
15. Open issues / follow-ups spotted during research
README.md:93-99attestation snippet is inaccurate: raw JWT, not JSON;image_digestis nested; GCP has nopcrs.tests/test_stubs_and_security.py:1181(test_no_sentry_in_enclave_code) returns early when the sibling repo is absent, so it probably enforces nothing in CI.docs/security.mdvs thequill-cloud-proxyREADME disagree on wheretrust.trustedrouter.comis hosted.- CSP is still report-only.
- The edge-surface inventory is
live_state_verified: false, with several P0 items pending. - The legacy
/v1/auth/keyspath still acceptsplainPKCE. - A single KMS key wraps all BYOK DEKs, and the internal gateway token is a shared bearer secret rather than attestation-bound. Both are reasonable today and worth revisiting at scale.