Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.4"
go-version: "1.26.10"

- name: Download dependencies
run: go mod download

- name: Check reachable vulnerabilities
run: |
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags=nitro_enclave ./...

- name: Secret scan
run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --no-git --source . --redact --verbose

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ and pull requests are welcome.

## Development

The project requires Go 1.26.4 or newer.
The project requires Go 1.26.10 or newer.

```sh
go test ./...
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,21 @@ development workflow.

Licensed under [Apache-2.0](LICENSE). Bundled dependency notices are available
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).

## Operator security notes

Provider `api_key_secret_ref` values must name uppercase environment variables
ending in `_API_KEY` (for example `TINFOIL_API_KEY`). Other references fail closed;
internal credentials such as `METERING_TOKEN` cannot be used as provider keys.

The Nitro confidential endpoint authenticates before storing replay IDs. Each
account may retain at most 10,000 IDs during the three-minute replay window,
shared across its API keys; excess requests return 429 with
`confidential_request_limit_exceeded`. The process-wide limit remains 100,000.
Live entries are never evicted to admit new requests. Metrics bind to loopback
in Nitro builds and retain their internal-network listener in ordinary builds.

PII masking covers conversation text, tool results, assistant reasoning, tool-call
argument string values, and `user`. Tool definitions, names, IDs, schemas,
metadata, provider options, and image/audio content are not scanned. Keep
personal data out of those fields even when masking is enabled.
2 changes: 1 addition & 1 deletion apps/gateway/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ---- Build stage ----
FROM golang:1.26.4-alpine AS builder
FROM golang:1.26.10-alpine AS builder

WORKDIR /app

Expand Down
2 changes: 1 addition & 1 deletion apps/gateway/Dockerfile.enclave
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# deploy/nitro/verify-build.sh. Build this image with `docker buildx build`
# only -- a plain `docker build` skips the timestamp normalisation and silently
# produces different measurements.
FROM golang:1.26.4-alpine3.24@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder
FROM golang:1.26.10-alpine3.24@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder

ARG SOURCE_REVISION
ARG AWS_REGION
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package handlers

import (
"bytes"
"container/list"
"encoding/binary"
"encoding/hex"
"encoding/json"
Expand All @@ -19,14 +20,15 @@ import (
)

const (
confidentialEnvelopeSchema = 1
confidentialEnvelopeLimit = maxRequestBodySize + 64*1024
confidentialMaxAge = 2 * time.Minute
confidentialFutureSkew = 30 * time.Second
confidentialReplayTTL = 3 * time.Minute
confidentialReplayCapacity = 100_000
confidentialMaxEHBPFrame = 1*1024*1024 + 16
confidentialMaxEHBPBody = 16 * 1024 * 1024
confidentialEnvelopeSchema = 1
confidentialEnvelopeLimit = maxRequestBodySize + 64*1024
confidentialMaxAge = 2 * time.Minute
confidentialFutureSkew = 30 * time.Second
confidentialReplayTTL = 3 * time.Minute
confidentialReplayCapacity = 100_000
confidentialReplayAccountCapacity = 10_000
confidentialMaxEHBPFrame = 1*1024*1024 + 16
confidentialMaxEHBPBody = 16 * 1024 * 1024
)

// ConfidentialChatCompletionsHandler is the strict EHBP-only wrapper around
Expand All @@ -36,6 +38,7 @@ const (
type ConfidentialChatCompletionsHandler struct {
encrypted http.Handler
next http.Handler
auth ports.AuthService
logger ports.Logger
replays *confidentialReplayCache
now func() time.Time
Expand All @@ -53,18 +56,23 @@ type confidentialEnvelope struct {
// the enclave; a public-only identity is intentionally rejected.
func NewConfidentialChatCompletionsHandler(
serverIdentity *identity.Identity,
auth ports.AuthService,
next http.Handler,
logger ports.Logger,
) (*ConfidentialChatCompletionsHandler, error) {
if serverIdentity == nil || serverIdentity.PrivateKey() == nil {
return nil, errors.New("confidential handler requires a private EHBP identity")
}
if auth == nil {
return nil, errors.New("confidential handler requires authentication")
}
if next == nil {
return nil, errors.New("confidential handler requires a downstream handler")
}

h := &ConfidentialChatCompletionsHandler{
next: next,
auth: auth,
logger: logger,
replays: newConfidentialReplayCache(confidentialReplayTTL, confidentialReplayCapacity),
now: time.Now,
Expand Down Expand Up @@ -116,6 +124,21 @@ func isCanonicalEHBPKey(encoded string) bool {
// handleDecrypted executes only after the EHBP middleware authenticates the
// first encrypted frame and installs response encryption.
func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWriter, r *http.Request) {
token, err := ExtractBearerToken(r)
if err != nil {
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
return
}
auth, err := h.auth.AuthenticateAPIKey(r.Context(), token)
if err != nil {
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
return
}
if auth.Account.ID == "" {
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInternal("authentication returned no account"))
return
}

body, err := io.ReadAll(io.LimitReader(r.Body, confidentialEnvelopeLimit+1))
if err != nil {
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("failed to read confidential request"))
Expand All @@ -132,7 +155,7 @@ func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWrit
return
}

now := h.now().UTC()
now := h.now()
issuedAt, err := time.Parse(time.RFC3339, envelope.IssuedAt)
if err != nil {
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("issued_at must be an RFC3339 timestamp"))
Expand All @@ -153,18 +176,24 @@ func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWrit
return
}

switch h.replays.checkAndStore(envelope.RequestID, now) {
switch h.replays.checkAndStore(auth.Account.ID, envelope.RequestID, h.now) {
case replayDuplicate:
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrConflict("confidential request was already processed"))
return
case replayAccountCapacityReached:
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, &domain.GatewayError{
HTTPStatus: http.StatusTooManyRequests, Type: domain.ErrTypeRateLimit,
Code: domain.ErrCodeConfidentialLimit, Message: "Account confidential request limit reached; retry after replay entries expire",
})
return
case replayCapacityReached:
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrProviderUnavailable("confidential replay protection").WithMeta(
"fault", domain.FaultNexus, "dependency", "replay_store", "cause", domain.CauseReplayStoreFull))
return
}

// The existing handler remains the single implementation of bearer auth,
// OpenAI request validation, generation, and streaming. Only replace the
// The downstream handler rechecks authentication and remains responsible for
// OpenAI validation, generation, and streaming. Only replace the
// decrypted envelope with its exact nested OpenAI request object.
r.Body = io.NopCloser(bytes.NewReader(envelope.Request))
r.ContentLength = int64(len(envelope.Request))
Expand Down Expand Up @@ -201,46 +230,64 @@ const (
replayAccepted replayResult = iota
replayDuplicate
replayCapacityReached
replayAccountCapacityReached
)

type confidentialReplayKey struct{ accountID, requestID string }
type confidentialReplayEntry struct {
key confidentialReplayKey
expiresAt time.Time
}

type confidentialReplayCache struct {
mu sync.Mutex
ttl time.Duration
capacity int
entries map[string]time.Time
nextGC time.Time
// ponytail: one lock for a bounded cache; shard by account if contention is measured.
mu sync.Mutex
ttl time.Duration
capacity int
accountCapacity int
entries map[confidentialReplayKey]struct{}
accounts map[string]int
expirations *list.List
}

func newConfidentialReplayCache(ttl time.Duration, capacity int) *confidentialReplayCache {
return &confidentialReplayCache{
ttl: ttl,
capacity: capacity,
entries: make(map[string]time.Time),
ttl: ttl, capacity: capacity, accountCapacity: min(capacity, confidentialReplayAccountCapacity),
entries: make(map[confidentialReplayKey]struct{}), accounts: make(map[string]int), expirations: list.New(),
}
}

func (c *confidentialReplayCache) checkAndStore(requestID string, now time.Time) replayResult {
func (c *confidentialReplayCache) checkAndStore(accountID, requestID string, clock func() time.Time) replayResult {
c.mu.Lock()
defer c.mu.Unlock()

if expiresAt, ok := c.entries[requestID]; ok && now.Before(expiresAt) {
now := clock() // Sample under the lock so expiry order follows insertion order.
// Fixed TTLs preserve insertion order. Remove only expired entries, never
// live replay protection; full-cache rejection does not scan the map.
for front := c.expirations.Front(); front != nil; front = c.expirations.Front() {
entry := front.Value.(confidentialReplayEntry)
if now.Before(entry.expiresAt) {
break
}
delete(c.entries, entry.key)
c.accounts[entry.key.accountID]--
if c.accounts[entry.key.accountID] == 0 {
delete(c.accounts, entry.key.accountID)
}
c.expirations.Remove(front)
}
key := confidentialReplayKey{accountID: accountID, requestID: requestID}
if _, ok := c.entries[key]; ok {
return replayDuplicate
}

if c.nextGC.IsZero() || !now.Before(c.nextGC) || len(c.entries) >= c.capacity {
// Freshness validation happens before this call, so an entry can safely
// expire after the maximum request age.
for id, expiresAt := range c.entries {
if !now.Before(expiresAt) {
delete(c.entries, id)
}
}
c.nextGC = now.Add(time.Minute)
if c.accounts[accountID] >= c.accountCapacity {
return replayAccountCapacityReached
}
if len(c.entries) >= c.capacity {
return replayCapacityReached
}
c.entries[requestID] = now.Add(c.ttl)
c.entries[key] = struct{}{}
c.accounts[accountID]++
c.expirations.PushBack(confidentialReplayEntry{key: key, expiresAt: now.Add(c.ttl)})
return replayAccepted
}

Expand Down
Loading
Loading