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
12 changes: 7 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ format = "json"
level = "info"

[server]
host = "0.0.0.0"
host = "127.0.0.1"
port = 8187

[paths]
Expand Down Expand Up @@ -115,7 +115,8 @@ uri_regex = ""
allow_proxy = false

[http.cache]
size = "100MiB"
size = "100MiB"
default_ttl = "1h"

[dependencies]
endpoints = []
Expand Down Expand Up @@ -150,7 +151,7 @@ All settings can be configured via environment variables prefixed with `ATTESTAT
| `ATTESTATION_SERVER_CONFIG_FILE` | — | — | Path to TOML config file |
| `ATTESTATION_SERVER_LOG_FORMAT` | `log.format` | `json` | Log format: `json`/`text` |
| `ATTESTATION_SERVER_LOG_LEVEL` | `log.level` | `info` | Log level: `debug`/`info`/`warn`/`error` |
| `ATTESTATION_SERVER_SERVER_HOST` | `server.host` | `0.0.0.0` | HTTP bind host |
| `ATTESTATION_SERVER_SERVER_HOST` | `server.host` | `127.0.0.1` | HTTP bind host |
| `ATTESTATION_SERVER_SERVER_PORT` | `server.port` | `8187` | HTTP bind port |
| `ATTESTATION_SERVER_PATHS_BUILD_INFO` | `paths.build_info` | `/etc/build-info.json` | Path to build information file |
| `ATTESTATION_SERVER_PATHS_ENDORSEMENTS` | `paths.endorsements` | `/etc/endorsements.json` | Path to endorsements URL list file |
Expand Down Expand Up @@ -186,6 +187,7 @@ All settings can be configured via environment variables prefixed with `ATTESTAT
| `ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_BUILD_SIGNER_URI_REGEX` | `endorsements.cosign.build_signer.uri_regex` | — | Regex match override for BuildSignerURI Fulcio OID (ignored if `uri` is set) |
| `ATTESTATION_SERVER_HTTP_ALLOW_PROXY` | `http.allow_proxy` | `false` | Honour `HTTP_PROXY`/`HTTPS_PROXY`/`NO_PROXY` env vars for the server's outbound HTTP clients (endorsement/cosign fetches, SEV-SNP CRL fetches, dependency requests). Off by default; required in environments like AWS Nitro Enclaves where a vsock-proxy is the only egress path. TDX collateral fetching (go-tdx-guest) always honours proxy env vars via `http.DefaultTransport` regardless of this setting |
| `ATTESTATION_SERVER_HTTP_CACHE_SIZE` | `http.cache.size` | `100MiB` | Maximum memory for the shared HTTP fetch cache (endorsements + cosign signatures, ristretto) |
| `ATTESTATION_SERVER_HTTP_CACHE_DEFAULT_TTL` | `http.cache.default_ttl` | `1h` | Default cache TTL when response has no Cache-Control header (capped at 24h) |

List-typed environment variables (`ATTESTATION_SERVER_REPORT_USER_DATA_ENV`, `ATTESTATION_SERVER_DEPENDENCIES_ENDPOINTS`) support comma-separated values: `VAR=a,b,c`. Spaces around commas are trimmed.

Expand Down Expand Up @@ -297,7 +299,7 @@ Over-limit requests are **stalled** (blocked in a FIFO queue) up to `ratelimit.s
When `revocation.enabled` is true (the default), the server checks TEE endorsement key certificates against Certificate Revocation Lists. CRL fetching is conditional on configuration:

- **SEV-SNP**: A background goroutine fetches AMD KDS CRLs for all supported product lines (Milan, Genoa, Turin) at `revocation.refresh_interval` (default 12h). Both VCEK and VLEK CRLs are fetched. CRLs are initialized when local SEV-SNP evidence is enabled **or** when dependency endpoints are configured (dependencies may include SEV-SNP evidence requiring revocation checks). The `crlCache` stores parsed `x509.RevocationList` entries and checks endorsement key serial numbers during verification. Design is **fail-open**: if no CRL data is available yet (first fetch still pending or failed), certificates are accepted. CRL fetches use the server's `fetchHTTPClient()` and honour `http.allow_proxy`.
- **TDX**: Revocation checking is delegated to go-tdx-guest's built-in Intel PCS collateral fetching (`CheckRevocations: true, GetCollateral: true`). The server provides a `cachedHTTPSGetter` (via `VerifyOpt.Getter`) that caches Intel PCS responses (TCB info, QE identity, PCK CRL, Root CA CRL) in the shared ristretto cache. On cache hit, no network calls are made. TTL is derived from response `Cache-Control` headers; Intel PCS currently returns no cache headers, so the default 30-minute TTL applies. The go-tdx-guest library still validates `NextUpdate` expiry on all collateral, so stale cached data is rejected. The cached getter uses the server's `fetchHTTPClient()` and honours `http.allow_proxy`.
- **TDX**: Revocation checking is delegated to go-tdx-guest's built-in Intel PCS collateral fetching (`CheckRevocations: true, GetCollateral: true`). The server provides a `cachedHTTPSGetter` (via `VerifyOpt.Getter`) that caches Intel PCS responses (TCB info, QE identity, PCK CRL, Root CA CRL) in the shared ristretto cache. On cache hit, no network calls are made. TTL is derived from response `Cache-Control` headers; Intel PCS currently returns no cache headers, so `http.cache.default_ttl` applies. The go-tdx-guest library still validates `NextUpdate` expiry on all collateral, so stale cached data is rejected. The cached getter uses the server's `fetchHTTPClient()` and honours `http.allow_proxy`.
- **Nitro**: No CRL mechanism exists (ephemeral certificate chains per attestation; revocation is handled by AWS at the hypervisor level).

When disabled, a startup warning is logged: "certificate revocation checking is disabled, revoked TEE endorsement keys will be accepted".
Expand Down Expand Up @@ -347,7 +349,7 @@ Uses system/Mozilla root CAs (via `golang.org/x/crypto/x509roots/fallback` blank

### Endorsement cache

Uses `dgraph-io/ristretto/v2` with URL-string keys in a shared `fetcherCache` (stores both `*EndorsementDocument` and `*cosignResult` values — endorsement URLs and signature URLs don't collide). When multiple URLs resolve to the same document (verified byte-for-byte), the same pointer is stored under all URL keys (cost charged once). TTL is derived from Cache-Control `max-age` (capped at 24h, default 30m).
Uses `dgraph-io/ristretto/v2` with URL-string keys in a shared `fetcherCache` (stores both `*EndorsementDocument` and `*cosignResult` values — endorsement URLs and signature URLs don't collide). When multiple URLs resolve to the same document (verified byte-for-byte), the same pointer is stored under all URL keys (cost charged once). TTL is derived from Cache-Control `max-age` (capped at 24h, default `http.cache.default_ttl`).

Endorsement URLs are tied to CI commit hashes with immutable content. Extended caching (up to 24h) is by design — measurement changes require new commits and new URLs. The TTL cap and per-request revalidation on cache miss provide eventual consistency.

Expand Down
4 changes: 3 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func initConfig() {
// Defaults
viper.SetDefault("log.format", "json")
viper.SetDefault("log.level", "info")
viper.SetDefault("server.host", "0.0.0.0")
viper.SetDefault("server.host", "127.0.0.1")
viper.SetDefault("server.port", 8187)
viper.SetDefault("paths.build_info", "/etc/build-info.json")
viper.SetDefault("paths.endorsements", "/etc/endorsements.json")
Expand All @@ -76,6 +76,7 @@ func initConfig() {
viper.SetDefault("endorsements.client.timeout", "10s")
viper.SetDefault("http.allow_proxy", false)
viper.SetDefault("http.cache.size", "100MiB")
viper.SetDefault("http.cache.default_ttl", "1h")
viper.SetDefault("tls.public.skip_verify", false)
viper.SetDefault("endorsements.cosign.verify", true)
viper.SetDefault("endorsements.cosign.url_suffix", ".sig")
Expand Down Expand Up @@ -117,6 +118,7 @@ func initConfig() {
_ = viper.BindEnv("endorsements.client.timeout", "ATTESTATION_SERVER_ENDORSEMENTS_CLIENT_TIMEOUT")
_ = viper.BindEnv("http.allow_proxy", "ATTESTATION_SERVER_HTTP_ALLOW_PROXY")
_ = viper.BindEnv("http.cache.size", "ATTESTATION_SERVER_HTTP_CACHE_SIZE")
_ = viper.BindEnv("http.cache.default_ttl", "ATTESTATION_SERVER_HTTP_CACHE_DEFAULT_TTL")
_ = viper.BindEnv("endorsements.cosign.verify", "ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_VERIFY")
_ = viper.BindEnv("endorsements.cosign.url_suffix", "ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_URL_SUFFIX")
_ = viper.BindEnv("endorsements.cosign.tuf_cache_path", "ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_TUF_CACHE_PATH")
Expand Down
5 changes: 3 additions & 2 deletions config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ format = "json" # json, text
level = "info" # debug, info, warn, error

[server]
host = "0.0.0.0"
host = "127.0.0.1"
port = 8187

[paths]
Expand Down Expand Up @@ -59,7 +59,8 @@ uri_regex = "" # regex match override for BuildSignerURI OID (ignored if uri is
allow_proxy = false # honour HTTP_PROXY/HTTPS_PROXY/NO_PROXY env vars (off by default; needed in Nitro Enclaves with vsock-proxy)

[http.cache]
size = "100MiB" # max memory for shared HTTP fetch cache (endorsements + cosign signatures)
size = "100MiB" # max memory for shared HTTP fetch cache (endorsements + cosign signatures)
default_ttl = "1h" # default TTL when response has no Cache-Control header (capped at 24h)

[dependencies]
# URLs of dependency attestation servers whose reports are fetched,
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.26.1

require (
github.com/dgraph-io/ristretto/v2 v2.4.0
github.com/dustin/go-humanize v1.0.1
github.com/fsnotify/fsnotify v1.9.0
github.com/fxamacker/cbor/v2 v2.7.0
github.com/goccy/go-json v0.10.6
Expand All @@ -19,6 +20,7 @@ require (
github.com/spf13/viper v1.21.0
golang.org/x/crypto/x509roots/fallback v0.0.0-20260323153451-8400f4a93807
golang.org/x/sync v0.20.0
golang.org/x/time v0.15.0
)

require (
Expand All @@ -30,7 +32,6 @@ require (
github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.24.1 // indirect
Expand Down Expand Up @@ -105,7 +106,6 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.40.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -443,8 +443,6 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
Expand Down
90 changes: 46 additions & 44 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package app
import (
"fmt"
"log/slog"
"math"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"

"github.com/dustin/go-humanize"
"github.com/google/go-tpm/tpm2"
"github.com/spf13/viper"
)
Expand Down Expand Up @@ -47,6 +48,7 @@ type Config struct {
EndorsementClientTimeout time.Duration
HTTPAllowProxy bool
HTTPCacheSize int64
HTTPCacheDefaultTTL time.Duration
RevocationEnabled bool
RevocationRefreshInterval time.Duration
RateLimitEnabled bool
Expand Down Expand Up @@ -113,23 +115,34 @@ func LoadConfig() (*Config, error) {
return nil, err
}

endorsementTimeout, err := time.ParseDuration(viper.GetString("endorsements.client.timeout"))
endorsementTimeout, err := parseDuration("endorsements.client.timeout")
if err != nil {
endorsementTimeout = 10 * time.Second
return nil, err
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if endorsementTimeout == 0 {
return nil, fmt.Errorf("endorsements.client.timeout: must be positive")
}
httpCacheSize, err := parseByteSize(viper.GetString("http.cache.size"))
if err != nil {
httpCacheSize = 100 << 20
return nil, fmt.Errorf("http.cache.size: %w", err)
}

revocationRefreshInterval, err := time.ParseDuration(viper.GetString("revocation.refresh_interval"))
httpCacheDefaultTTL, err := parseDuration("http.cache.default_ttl")
if err != nil {
revocationRefreshInterval = 12 * time.Hour
return nil, err
}

rateLimitStallTimeout, err := time.ParseDuration(viper.GetString("ratelimit.stall_timeout"))
revocationRefreshInterval, err := parseDuration("revocation.refresh_interval")
if err != nil {
return nil, err
}
if revocationRefreshInterval == 0 {
return nil, fmt.Errorf("revocation.refresh_interval: must be positive")
}
rateLimitStallTimeout, err := parseDuration("ratelimit.stall_timeout")
if err != nil {
rateLimitStallTimeout = 10 * time.Second
return nil, err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if rateLimitStallTimeout == 0 {
return nil, fmt.Errorf("ratelimit.stall_timeout: must be positive")
}

cosignBuildSigner := CosignBuildSignerConfig{
Expand Down Expand Up @@ -172,6 +185,7 @@ func LoadConfig() (*Config, error) {
EndorsementClientTimeout: endorsementTimeout,
HTTPAllowProxy: viper.GetBool("http.allow_proxy"),
HTTPCacheSize: httpCacheSize,
HTTPCacheDefaultTTL: httpCacheDefaultTTL,
CosignVerify: viper.GetBool("endorsements.cosign.verify"),
CosignURLSuffix: viper.GetString("endorsements.cosign.url_suffix"),
CosignTUFCachePath: viper.GetString("endorsements.cosign.tuf_cache_path"),
Expand Down Expand Up @@ -308,49 +322,37 @@ func parseLogLevel(s string) slog.Level {
}
}

// parseByteSize parses a human-readable byte size string like "100MiB" or
// "1GiB" into a byte count. Supported suffixes: B, KiB, MiB, GiB, TiB
// (case-insensitive). A bare number without suffix is treated as bytes.
// parseDuration reads a viper string key and parses it as a time.Duration.
// Returns an error if the value is empty, unparseable, or negative.
func parseDuration(key string) (time.Duration, error) {
s := viper.GetString(key)
d, err := time.ParseDuration(s)
if err != nil {
return 0, fmt.Errorf("%s: invalid duration %q: %w", key, s, err)
}
if d < 0 {
return 0, fmt.Errorf("%s: negative duration %q", key, s)
}
return d, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// parseByteSize parses a human-readable byte size string into a byte count
// using github.com/dustin/go-humanize. Supports both SI (KB, MB, GB, TB) and
// IEC (KiB, MiB, GiB, TiB) suffixes, fractional values, and flexible
// whitespace.
func parseByteSize(s string) (int64, error) {
s = strings.TrimSpace(s)
if s == "" {
return 0, fmt.Errorf("empty byte size")
}

suffixes := []struct {
suffix string
multiplier int64
}{
{"TiB", 1 << 40},
{"GiB", 1 << 30},
{"MiB", 1 << 20},
{"KiB", 1 << 10},
{"B", 1},
}

lower := strings.ToLower(s)
for _, sf := range suffixes {
if strings.HasSuffix(lower, strings.ToLower(sf.suffix)) {
numStr := strings.TrimSpace(s[:len(s)-len(sf.suffix)])
n, err := strconv.ParseInt(numStr, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid byte size %q: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("negative byte size %q", s)
}
return n * sf.multiplier, nil
}
}

n, err := strconv.ParseInt(s, 10, 64)
n, err := humanize.ParseBytes(s)
if err != nil {
return 0, fmt.Errorf("invalid byte size %q: %w", s, err)
}
if n < 0 {
return 0, fmt.Errorf("negative byte size %q", s)
if n > uint64(math.MaxInt64) {
return 0, fmt.Errorf("byte size %q overflows int64", s)
}
return n, nil
return int64(n), nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// domainNameRe matches valid DNS domain names (no ports, no paths).
Expand Down
63 changes: 63 additions & 0 deletions internal/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/google/go-tpm/tpm2"
"github.com/spf13/viper"
)

func TestValidateEvidence(t *testing.T) {
Expand Down Expand Up @@ -745,6 +746,68 @@ func TestCheckEndorsementDomain(t *testing.T) {
}
}

func TestParseDuration(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "valid duration", value: "10s"},
{name: "zero duration", value: "0s"},
{name: "empty string", value: "", wantErr: "invalid duration"},
{name: "unparseable", value: "5xs", wantErr: "invalid duration"},
{name: "negative duration", value: "-5s", wantErr: "negative duration"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
key := "test.parse_duration." + tt.name
viper.Set(key, tt.value)
defer viper.Set(key, nil)

d, err := parseDuration(key)
if tt.wantErr != "" {
if err == nil {
t.Fatal("expected error, got nil")
}
if !contains(err.Error(), tt.wantErr) {
t.Fatalf("error %q does not contain %q", err.Error(), tt.wantErr)
}
if !contains(err.Error(), key) {
t.Fatalf("error %q does not contain key %q", err.Error(), key)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d < 0 {
t.Fatalf("parseDuration(%q) = %v, want non-negative", tt.value, d)
}
})
}
}

func TestParseByteSizeOverflow(t *testing.T) {
_, err := parseByteSize("9EiB")
if err == nil {
t.Fatal("expected error for int64 overflow, got nil")
}
if !contains(err.Error(), "overflows") {
t.Fatalf("error %q does not contain %q", err.Error(), "overflows")
}
}

func TestParseByteSizeEmpty(t *testing.T) {
_, err := parseByteSize("")
if err == nil {
t.Fatal("expected error for empty input, got nil")
}
if !contains(err.Error(), "empty") {
t.Fatalf("error %q does not contain %q", err.Error(), "empty")
}
}

func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
Expand Down
6 changes: 3 additions & 3 deletions internal/cosign.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,13 @@ func (s *Server) fetchCosignSignatures(ctx context.Context, urls []*url.URL, cli
// Use the most conservative (shortest) TTL across all responses
ttl := fetchMaxTTL
for _, r := range results {
t := parseCacheTTL(r.header)
t := parseCacheTTL(r.header, s.cfg.HTTPCacheDefaultTTL)
if t < ttl {
ttl = t
}
}
if ttl <= 0 {
ttl = fetchDefaultTTL
if ttl < 0 {
ttl = s.cfg.HTTPCacheDefaultTTL
}

return results[0].body, len(results[0].body), ttl, nil
Expand Down
6 changes: 3 additions & 3 deletions internal/endorsements.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ func (s *Server) fetchEndorsementDocumentsWithClient(ctx context.Context, urls [
// Use the most conservative (shortest) TTL across all responses
ttl := fetchMaxTTL
for _, r := range results {
t := parseCacheTTL(r.header)
t := parseCacheTTL(r.header, s.cfg.HTTPCacheDefaultTTL)
if t < ttl {
ttl = t
}
}
if ttl <= 0 {
ttl = fetchDefaultTTL
if ttl < 0 {
ttl = s.cfg.HTTPCacheDefaultTTL
}

return &doc, results[0].body, len(results[0].body), ttl, nil
Expand Down
Loading
Loading