Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ All TOML settings map to env vars prefixed with `ATTESTATION_SERVER_`, with `.`

List-typed variables (`REPORT_USER_DATA_ENV`, `DEPENDENCIES_ENDPOINTS`, `ENDORSEMENTS_ALLOWED_DOMAINS`) support comma-separated values with trimmed spaces: `VAR=a,b,c`.

**When adding a new config setting**, three places must be updated in addition to `internal/config.go`: add a `viper.SetDefault` and a `viper.BindEnv` call in `cmd/root.go`, and add the default value in `config/config.toml`. Without the explicit `BindEnv` call the environment variable will be silently ignored.

## Logging conventions

- Use `log/slog` throughout; never `fmt.Print*` or `log.*`.
Expand Down Expand Up @@ -156,9 +158,13 @@ When cosign is enabled, endorsement URLs are required (own and dependencies).

`/healthz/live` returns 200 once the HTTP listener is up. `/healthz/ready` returns 200 after `NewServer` (self-attestation, endorsement validation) and the initial CRL fetch (if configured) complete; 503 before that. Readiness is a one-way transition — no runtime condition (cert reload failure, CRL refresh failure) flips it back because all background processes use fail-safe/fail-open semantics. Health routes are not rate-limited.

### Endorsement skip validation

`endorsements.skip_validation` (default `false`) makes endorsement *retrieval* failures non-fatal — if endorsement documents cannot be fetched, errors are logged as warnings and attestation proceeds without endorsement verification. If endorsements are successfully retrieved, measurement comparison is always performed and mismatches always fail regardless of this flag. Intended for disaster recovery when endorsement infrastructure is unavailable. Logs a startup warning that security is weakened. The skip logic lives inside `validateOwnEndorsements` and `validateDependencyEndorsements`, at the `resolveEndorsements` call boundary.

### Startup self-attestation

`NewServer` calls `Attest` with random nonce on each TEE device. Parsed results captured in `parsedSelfAttestation` for endorsement validation. Exits on failure.
`NewServer` calls `Attest` with random nonce on each TEE device. Parsed results captured in `parsedSelfAttestation` for endorsement validation. Exits on failure (unless `endorsements.skip_validation` is enabled).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

### Endorsement document format

Expand Down
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func initConfig() {
viper.SetDefault("ratelimit.burst", 1)
viper.SetDefault("ratelimit.stall_timeout", "10s")
viper.SetDefault("dependencies.endpoints", []string{})
viper.SetDefault("endorsements.skip_validation", false)
viper.SetDefault("endorsements.dnssec", false)
viper.SetDefault("endorsements.allowed_domains", []string{})
viper.SetDefault("endorsements.client.timeout", "10s")
Expand Down Expand Up @@ -113,6 +114,7 @@ func initConfig() {
_ = viper.BindEnv("ratelimit.burst", "ATTESTATION_SERVER_RATELIMIT_BURST")
_ = viper.BindEnv("ratelimit.stall_timeout", "ATTESTATION_SERVER_RATELIMIT_STALL_TIMEOUT")
_ = viper.BindEnv("dependencies.endpoints", "ATTESTATION_SERVER_DEPENDENCIES_ENDPOINTS")
_ = viper.BindEnv("endorsements.skip_validation", "ATTESTATION_SERVER_ENDORSEMENTS_SKIP_VALIDATION")
_ = viper.BindEnv("endorsements.dnssec", "ATTESTATION_SERVER_ENDORSEMENTS_DNSSEC")
_ = viper.BindEnv("endorsements.allowed_domains", "ATTESTATION_SERVER_ENDORSEMENTS_ALLOWED_DOMAINS")
_ = viper.BindEnv("endorsements.client.timeout", "ATTESTATION_SERVER_ENDORSEMENTS_CLIENT_TIMEOUT")
Expand Down
1 change: 1 addition & 0 deletions config/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ stall_timeout = "10s" # max wait time before 429
enforce = false

[endorsements]
skip_validation = false # skip endorsement validation; for debugging or disaster recovery when endorsement infrastructure is unavailable
dnssec = false # require DNSSEC validation for endorsement URL hosts
allowed_domains = [] # empty = unrestricted; non-empty = exact hostname match only

Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ At startup, the server fetches endorsement documents from all configured URLs, v

Per-request, endorsements are re-validated from cache (ristretto, TTL from Cache-Control headers, capped at 24h). On cache miss, documents are re-fetched and revalidated. If revalidation fails, the handler returns 500 but the server stays up and self-heals when endorsements become available.

When `endorsements.skip_validation` is enabled (default `false`), endorsement *retrieval* failures are demoted to warnings — the server starts and serves attestation responses without endorsed measurement verification. If endorsements are successfully retrieved, measurement comparison is always enforced regardless of this flag. This is a disaster recovery mechanism for when the endorsement infrastructure is completely unavailable; it weakens security guarantees and should be disabled as soon as endorsement service is restored.

### Endorsement document format

```jsonc
Expand Down
8 changes: 8 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ Before collecting evidence for each request, the handler calls `validateOwnEndor
- **Cache miss** (TTL expired) — documents are re-fetched and revalidated
- **Failure** — handler returns 500, but the server stays up and self-heals when endorsements become available

### Skip validation mode

When `endorsements.skip_validation` is enabled (default `false`), endorsement *retrieval* failures are logged as warnings instead of causing errors. This is intended for disaster recovery when the endorsement serving infrastructure is completely unavailable but service operations must be restored. The server logs a startup warning that security is weakened.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

**Only retrieval failures are skipped.** If endorsement documents are successfully fetched, measurement comparison is always performed — a mismatch between the endorsed golden values and the actual TEE evidence is a hard error regardless of this flag. This ensures that a TEE running modified code cannot pass attestation when endorsements are available.

The skip boundary is the `resolveEndorsements` call inside `validateOwnEndorsements` and `validateDependencyEndorsements`. Errors from endorsement document fetching, cosign signature fetching, or cosign bundle verification are treated as retrieval failures (skippable). Errors from cosign OID validation and measurement comparison are never skipped.

### Endorsement domain allowlist

When `endorsements.allowed_domains` is configured (non-empty), endorsement document URLs are checked against the allowlist before fetching. Matching is exact hostname (case-insensitive) — subdomain matching is not supported; each host must be listed explicitly. The check applies to both own endorsement URLs and dependency endorsement URLs.
Expand Down
2 changes: 2 additions & 0 deletions internal/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type Config struct {
EndorsementDNSSEC bool
EndorsementAllowedDomains []string
EndorsementClientTimeout time.Duration
EndorsementSkipValidation bool
HTTPAllowProxy bool
HTTPCacheSize int64
HTTPCacheDefaultTTL time.Duration
Expand Down Expand Up @@ -183,6 +184,7 @@ func LoadConfig() (*Config, error) {
EndorsementDNSSEC: viper.GetBool("endorsements.dnssec"),
EndorsementAllowedDomains: endorsementDomains,
EndorsementClientTimeout: endorsementTimeout,
EndorsementSkipValidation: viper.GetBool("endorsements.skip_validation"),
HTTPAllowProxy: viper.GetBool("http.allow_proxy"),
HTTPCacheSize: httpCacheSize,
HTTPCacheDefaultTTL: httpCacheDefaultTTL,
Expand Down
10 changes: 10 additions & 0 deletions internal/endorsements.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,15 @@ func (s *Server) validateOwnEndorsements(ctx context.Context) error {

doc, cr, err := s.resolveEndorsements(ctx, s.endorsements)
if err != nil {
if s.cfg.EndorsementSkipValidation {
s.logger.Warn("endorsement retrieval failed, skipping validation because skip_validation is enabled", "error", err)
return nil
}
return err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Endorsements were successfully retrieved — measurement comparison
// errors are never skipped, even with skip_validation enabled.
if cr != nil {
if err := s.validateCosignOIDs(cr, s.buildInfo); err != nil {
return fmt.Errorf("cosign: %w", err)
Expand Down Expand Up @@ -290,6 +296,10 @@ func (s *Server) validateDependencyEndorsements(ctx context.Context, report *Att

edp, cr, err := s.resolveEndorsements(ctx, urls)
if err != nil {
if s.cfg.EndorsementSkipValidation {
s.logger.Warn("dependency endorsement retrieval failed, skipping validation because skip_validation is enabled", "error", err)
return nil
}
return err
}
doc := *edp
Expand Down
140 changes: 140 additions & 0 deletions internal/endorsements_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,69 @@ func TestValidateOwnEndorsements_CacheHitMismatch(t *testing.T) {
}
}

func TestValidateOwnEndorsements_SkipValidation_RetrievalFailure(t *testing.T) {
cache, err := newFetcherCache(100 << 20)
if err != nil {
t.Fatal(err)
}

u, _ := url.Parse("https://unreachable.example.com/e.json")
s := &Server{
cfg: &Config{
ReportEvidence: EvidenceConfig{SEVSNP: true},
EndorsementClientTimeout: time.Second,
EndorsementSkipValidation: true,
},
logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
endorsements: []*url.URL{u},
httpCache: cache,
selfAttestation: &parsedSelfAttestation{
sevSNPReport: &spb.Report{Measurement: bytes.Repeat([]byte{0xdd}, 48)},
},
}

ctx, cancel := context.WithCancel(context.Background())
cancel()

if err := s.validateOwnEndorsements(ctx); err != nil {
t.Fatalf("expected nil error with skip_validation on retrieval failure, got: %v", err)
}
}

func TestValidateOwnEndorsements_SkipValidation_MismatchStillFails(t *testing.T) {
cache, err := newFetcherCache(100 << 20)
if err != nil {
t.Fatal(err)
}

hex := strings.Repeat("00", 48) // wrong measurement
doc := &EndorsementDocument{SEVSNP: &hex}
cache.setGroup([]string{"https://example.com/e.json"}, doc, 100, time.Minute)

u, _ := url.Parse("https://example.com/e.json")
s := &Server{
cfg: &Config{
ReportEvidence: EvidenceConfig{SEVSNP: true},
EndorsementClientTimeout: 5 * time.Second,
EndorsementSkipValidation: true,
},
logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
endorsements: []*url.URL{u},
httpCache: cache,
selfAttestation: &parsedSelfAttestation{
sevSNPReport: &spb.Report{Measurement: bytes.Repeat([]byte{0xdd}, 48)},
},
}

err = s.validateOwnEndorsements(context.Background())
if err == nil {
t.Fatal("expected error: measurement mismatch must not be skipped even with skip_validation")
}
if !contains(err.Error(), "mismatch") {
t.Errorf("unexpected error: %v", err)
}
}

// --- resolveEndorsements ---

func TestResolveEndorsements_CacheHit(t *testing.T) {
Expand Down Expand Up @@ -1494,6 +1557,83 @@ func TestValidateDependencyEndorsements_TPMNoEndorsement(t *testing.T) {
}
}

// --- skip_validation: dependency endorsement tests ---

func TestValidateDependencyEndorsements_SkipValidation_RetrievalFailure(t *testing.T) {
cache, err := newFetcherCache(100 << 20)
if err != nil {
t.Fatal(err)
}

reportData := &AttestationReportData{
Endorsements: []string{"https://unreachable.example.com/e.json"},
}
dataJSON, _ := json.Marshal(reportData)
report := &AttestationReport{
Evidence: []*AttestationEvidence{{Kind: "sevsnp", Blob: []byte("fake")}},
Data: json.RawMessage(dataJSON),
}

s := &Server{
cfg: &Config{
EndorsementClientTimeout: time.Second,
EndorsementSkipValidation: true,
},
logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
httpCache: cache,
}
parsed := &parsedDependencyEvidence{
sevSNPReport: &spb.Report{Measurement: bytes.Repeat([]byte{0xdd}, 48)},
}

ctx, cancel := context.WithCancel(context.Background())
cancel()

if err := s.validateDependencyEndorsements(ctx, report, parsed); err != nil {
t.Fatalf("expected nil error with skip_validation on retrieval failure, got: %v", err)
}
}

func TestValidateDependencyEndorsements_SkipValidation_MismatchStillFails(t *testing.T) {
cache, err := newFetcherCache(100 << 20)
if err != nil {
t.Fatal(err)
}

hex := strings.Repeat("00", 48)
doc := &EndorsementDocument{SEVSNP: &hex}
cache.setGroup([]string{"https://dep.example.com/e.json"}, doc, 100, time.Minute)

reportData := &AttestationReportData{
Endorsements: []string{"https://dep.example.com/e.json"},
}
dataJSON, _ := json.Marshal(reportData)
report := &AttestationReport{
Evidence: []*AttestationEvidence{{Kind: "sevsnp", Blob: []byte("fake")}},
Data: json.RawMessage(dataJSON),
}

s := &Server{
cfg: &Config{
EndorsementClientTimeout: 5 * time.Second,
EndorsementSkipValidation: true,
},
logger: slog.New(slog.NewJSONHandler(io.Discard, nil)),
httpCache: cache,
}
parsed := &parsedDependencyEvidence{
sevSNPReport: &spb.Report{Measurement: bytes.Repeat([]byte{0xdd}, 48)},
}

err = s.validateDependencyEndorsements(context.Background(), report, parsed)
if err == nil {
t.Fatal("expected error: measurement mismatch must not be skipped even with skip_validation")
}
if !contains(err.Error(), "mismatch") {
t.Errorf("unexpected error: %v", err)
}
}

// --- Edge case tests for measurement comparison ---

func TestValidateSEVSNPMeasurement_Empty(t *testing.T) {
Expand Down
4 changes: 4 additions & 0 deletions internal/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ func NewServer(cfg *Config, logger *slog.Logger) (*Server, error) {
}
logger.Debug("loaded endorsements", "count", len(endorsements), "urls", strings.Join(endorsementStrs, ","))

if cfg.EndorsementSkipValidation {
logger.Warn("endorsement validation is disabled, attestation will proceed without endorsement verification — security is weakened")
}

if len(endorsements) > 0 {
if !cfg.CosignVerify {
logger.Warn("cosign verification is disabled, endorsement documents are not cryptographically authenticated")
Expand Down