Skip to content

Commit 22966e3

Browse files
authored
Improve confidential request accounting and refresh runtime dependencies (#17)
1 parent 8ce2b5e commit 22966e3

23 files changed

Lines changed: 561 additions & 326 deletions

‎.github/workflows/ci.yml‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,16 @@ jobs:
1717
- name: Set up Go
1818
uses: actions/setup-go@v5
1919
with:
20-
go-version: "1.26.4"
20+
go-version: "1.26.10"
2121

2222
- name: Download dependencies
2323
run: go mod download
2424

25+
- name: Check reachable vulnerabilities
26+
run: |
27+
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./...
28+
go run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -tags=nitro_enclave ./...
29+
2530
- name: Secret scan
2631
run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --no-git --source . --redact --verbose
2732

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ and pull requests are welcome.
1313

1414
## Development
1515

16-
The project requires Go 1.26.4 or newer.
16+
The project requires Go 1.26.10 or newer.
1717

1818
```sh
1919
go test ./...

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,21 @@ development workflow.
120120

121121
Licensed under [Apache-2.0](LICENSE). Bundled dependency notices are available
122122
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).
123+
124+
## Operator security notes
125+
126+
Provider `api_key_secret_ref` values must name uppercase environment variables
127+
ending in `_API_KEY` (for example `TINFOIL_API_KEY`). Other references fail closed;
128+
internal credentials such as `METERING_TOKEN` cannot be used as provider keys.
129+
130+
The Nitro confidential endpoint authenticates before storing replay IDs. Each
131+
account may retain at most 10,000 IDs during the three-minute replay window,
132+
shared across its API keys; excess requests return 429 with
133+
`confidential_request_limit_exceeded`. The process-wide limit remains 100,000.
134+
Live entries are never evicted to admit new requests. Metrics bind to loopback
135+
in Nitro builds and retain their internal-network listener in ordinary builds.
136+
137+
PII masking covers conversation text, tool results, assistant reasoning, tool-call
138+
argument string values, and `user`. Tool definitions, names, IDs, schemas,
139+
metadata, provider options, and image/audio content are not scanned. Keep
140+
personal data out of those fields even when masking is enabled.

‎apps/gateway/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# ---- Build stage ----
2-
FROM golang:1.26.4-alpine AS builder
2+
FROM golang:1.26.10-alpine AS builder
33

44
WORKDIR /app
55

‎apps/gateway/Dockerfile.enclave‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
# deploy/nitro/verify-build.sh. Build this image with `docker buildx build`
88
# only -- a plain `docker build` skips the timestamp normalisation and silently
99
# produces different measurements.
10-
FROM golang:1.26.4-alpine3.24@sha256:3ad57304ad93bbec8548a0437ad9e06a455660655d9af011d58b993f6f615648 AS builder
10+
FROM golang:1.26.10-alpine3.24@sha256:27f2d3b83cfc5803c8078f8e730af0f7c431bf067afaec8f60a36fb763187cba AS builder
1111

1212
ARG SOURCE_REVISION
1313
ARG AWS_REGION

‎apps/gateway/internal/adapters/http/handlers/confidential_chat_completions_handler.go‎

Lines changed: 81 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handlers
22

33
import (
44
"bytes"
5+
"container/list"
56
"encoding/binary"
67
"encoding/hex"
78
"encoding/json"
@@ -19,14 +20,15 @@ import (
1920
)
2021

2122
const (
22-
confidentialEnvelopeSchema = 1
23-
confidentialEnvelopeLimit = maxRequestBodySize + 64*1024
24-
confidentialMaxAge = 2 * time.Minute
25-
confidentialFutureSkew = 30 * time.Second
26-
confidentialReplayTTL = 3 * time.Minute
27-
confidentialReplayCapacity = 100_000
28-
confidentialMaxEHBPFrame = 1*1024*1024 + 16
29-
confidentialMaxEHBPBody = 16 * 1024 * 1024
23+
confidentialEnvelopeSchema = 1
24+
confidentialEnvelopeLimit = maxRequestBodySize + 64*1024
25+
confidentialMaxAge = 2 * time.Minute
26+
confidentialFutureSkew = 30 * time.Second
27+
confidentialReplayTTL = 3 * time.Minute
28+
confidentialReplayCapacity = 100_000
29+
confidentialReplayAccountCapacity = 10_000
30+
confidentialMaxEHBPFrame = 1*1024*1024 + 16
31+
confidentialMaxEHBPBody = 16 * 1024 * 1024
3032
)
3133

3234
// ConfidentialChatCompletionsHandler is the strict EHBP-only wrapper around
@@ -36,6 +38,7 @@ const (
3638
type ConfidentialChatCompletionsHandler struct {
3739
encrypted http.Handler
3840
next http.Handler
41+
auth ports.AuthService
3942
logger ports.Logger
4043
replays *confidentialReplayCache
4144
now func() time.Time
@@ -53,18 +56,23 @@ type confidentialEnvelope struct {
5356
// the enclave; a public-only identity is intentionally rejected.
5457
func NewConfidentialChatCompletionsHandler(
5558
serverIdentity *identity.Identity,
59+
auth ports.AuthService,
5660
next http.Handler,
5761
logger ports.Logger,
5862
) (*ConfidentialChatCompletionsHandler, error) {
5963
if serverIdentity == nil || serverIdentity.PrivateKey() == nil {
6064
return nil, errors.New("confidential handler requires a private EHBP identity")
6165
}
66+
if auth == nil {
67+
return nil, errors.New("confidential handler requires authentication")
68+
}
6269
if next == nil {
6370
return nil, errors.New("confidential handler requires a downstream handler")
6471
}
6572

6673
h := &ConfidentialChatCompletionsHandler{
6774
next: next,
75+
auth: auth,
6876
logger: logger,
6977
replays: newConfidentialReplayCache(confidentialReplayTTL, confidentialReplayCapacity),
7078
now: time.Now,
@@ -116,6 +124,21 @@ func isCanonicalEHBPKey(encoded string) bool {
116124
// handleDecrypted executes only after the EHBP middleware authenticates the
117125
// first encrypted frame and installs response encryption.
118126
func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWriter, r *http.Request) {
127+
token, err := ExtractBearerToken(r)
128+
if err != nil {
129+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
130+
return
131+
}
132+
auth, err := h.auth.AuthenticateAPIKey(r.Context(), token)
133+
if err != nil {
134+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, err)
135+
return
136+
}
137+
if auth.Account.ID == "" {
138+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInternal("authentication returned no account"))
139+
return
140+
}
141+
119142
body, err := io.ReadAll(io.LimitReader(r.Body, confidentialEnvelopeLimit+1))
120143
if err != nil {
121144
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrInvalidField("failed to read confidential request"))
@@ -132,7 +155,7 @@ func (h *ConfidentialChatCompletionsHandler) handleDecrypted(w http.ResponseWrit
132155
return
133156
}
134157

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

156-
switch h.replays.checkAndStore(envelope.RequestID, now) {
179+
switch h.replays.checkAndStore(auth.Account.ID, envelope.RequestID, h.now) {
157180
case replayDuplicate:
158181
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrConflict("confidential request was already processed"))
159182
return
183+
case replayAccountCapacityReached:
184+
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, &domain.GatewayError{
185+
HTTPStatus: http.StatusTooManyRequests, Type: domain.ErrTypeRateLimit,
186+
Code: domain.ErrCodeConfidentialLimit, Message: "Account confidential request limit reached; retry after replay entries expire",
187+
})
188+
return
160189
case replayCapacityReached:
161190
rejectRequest(w, r, h.logger, domain.EndpointChatCompletions, domain.ErrProviderUnavailable("confidential replay protection").WithMeta(
162191
"fault", domain.FaultNexus, "dependency", "replay_store", "cause", domain.CauseReplayStoreFull))
163192
return
164193
}
165194

166-
// The existing handler remains the single implementation of bearer auth,
167-
// OpenAI request validation, generation, and streaming. Only replace the
195+
// The downstream handler rechecks authentication and remains responsible for
196+
// OpenAI validation, generation, and streaming. Only replace the
168197
// decrypted envelope with its exact nested OpenAI request object.
169198
r.Body = io.NopCloser(bytes.NewReader(envelope.Request))
170199
r.ContentLength = int64(len(envelope.Request))
@@ -201,46 +230,64 @@ const (
201230
replayAccepted replayResult = iota
202231
replayDuplicate
203232
replayCapacityReached
233+
replayAccountCapacityReached
204234
)
205235

236+
type confidentialReplayKey struct{ accountID, requestID string }
237+
type confidentialReplayEntry struct {
238+
key confidentialReplayKey
239+
expiresAt time.Time
240+
}
241+
206242
type confidentialReplayCache struct {
207-
mu sync.Mutex
208-
ttl time.Duration
209-
capacity int
210-
entries map[string]time.Time
211-
nextGC time.Time
243+
// ponytail: one lock for a bounded cache; shard by account if contention is measured.
244+
mu sync.Mutex
245+
ttl time.Duration
246+
capacity int
247+
accountCapacity int
248+
entries map[confidentialReplayKey]struct{}
249+
accounts map[string]int
250+
expirations *list.List
212251
}
213252

214253
func newConfidentialReplayCache(ttl time.Duration, capacity int) *confidentialReplayCache {
215254
return &confidentialReplayCache{
216-
ttl: ttl,
217-
capacity: capacity,
218-
entries: make(map[string]time.Time),
255+
ttl: ttl, capacity: capacity, accountCapacity: min(capacity, confidentialReplayAccountCapacity),
256+
entries: make(map[confidentialReplayKey]struct{}), accounts: make(map[string]int), expirations: list.New(),
219257
}
220258
}
221259

222-
func (c *confidentialReplayCache) checkAndStore(requestID string, now time.Time) replayResult {
260+
func (c *confidentialReplayCache) checkAndStore(accountID, requestID string, clock func() time.Time) replayResult {
223261
c.mu.Lock()
224262
defer c.mu.Unlock()
225-
226-
if expiresAt, ok := c.entries[requestID]; ok && now.Before(expiresAt) {
263+
now := clock() // Sample under the lock so expiry order follows insertion order.
264+
// Fixed TTLs preserve insertion order. Remove only expired entries, never
265+
// live replay protection; full-cache rejection does not scan the map.
266+
for front := c.expirations.Front(); front != nil; front = c.expirations.Front() {
267+
entry := front.Value.(confidentialReplayEntry)
268+
if now.Before(entry.expiresAt) {
269+
break
270+
}
271+
delete(c.entries, entry.key)
272+
c.accounts[entry.key.accountID]--
273+
if c.accounts[entry.key.accountID] == 0 {
274+
delete(c.accounts, entry.key.accountID)
275+
}
276+
c.expirations.Remove(front)
277+
}
278+
key := confidentialReplayKey{accountID: accountID, requestID: requestID}
279+
if _, ok := c.entries[key]; ok {
227280
return replayDuplicate
228281
}
229-
230-
if c.nextGC.IsZero() || !now.Before(c.nextGC) || len(c.entries) >= c.capacity {
231-
// Freshness validation happens before this call, so an entry can safely
232-
// expire after the maximum request age.
233-
for id, expiresAt := range c.entries {
234-
if !now.Before(expiresAt) {
235-
delete(c.entries, id)
236-
}
237-
}
238-
c.nextGC = now.Add(time.Minute)
282+
if c.accounts[accountID] >= c.accountCapacity {
283+
return replayAccountCapacityReached
239284
}
240285
if len(c.entries) >= c.capacity {
241286
return replayCapacityReached
242287
}
243-
c.entries[requestID] = now.Add(c.ttl)
288+
c.entries[key] = struct{}{}
289+
c.accounts[accountID]++
290+
c.expirations.PushBack(confidentialReplayEntry{key: key, expiresAt: now.Add(c.ttl)})
244291
return replayAccepted
245292
}
246293

0 commit comments

Comments
 (0)