A Go HTTP server for serving TEE (Trusted Execution Environment) attestation documents. The server runs behind an Envoy reverse proxy that terminates TLS — Envoy uses the private certificate for service-to-service mTLS (setting the XFCC header with the client cert hash) and optionally the public certificate for Internet-facing ingress without client certificates.
- CLI/config: spf13/cobra for CLI, spf13/viper for configuration
- HTTP: go-fiber v2 with
requestidmiddleware - Logging: standard
log/slog, JSON format on stdout
main.go # entry point
cmd/root.go # cobra root command; initializes config, logger, and starts server
internal/attestation.go # GET /api/v1/attestation handler and helpers (package app)
internal/config.go # Config struct and LoadConfig() (package app)
internal/dependencies.go # Transitive dependency attestation: parallel fetch, verify, cycle detection (package app)
internal/cosign.go # Cosign signature verification: bundle fetch, Sigstore/Rekor verification, Fulcio OID extraction + validation (package app)
internal/endorsements.go # Endorsement document fetching, DNSSEC, measurement validation, cosign integration (package app)
internal/fetch.go # Generic HTTP fetch with retry, per-attempt WARN logging, cache (ristretto), TTL parsing, cachedHTTPSGetter for TDX collateral — shared by endorsements, cosign, and TDX (package app)
internal/logging.go # NewLogger() (package app)
internal/server.go # Server, NewServer(), Run() (package app)
internal/tls.go # TLS certificate/CA loading, verification, and hot-reload (package app)
internal/types.go # BuildInfo, AttestationReport, AttestationReportData, and other shared structs (package app)
pkg/dnssec/dnssec.go # DNSSEC chain-of-trust validation: walks delegation chain from zone to root, verifies RRSIG signatures, embedded IANA root KSK trust anchors (package dnssec)
pkg/hexbytes/hexbytes.go # Shared HexBytes type: []byte that JSON-serializes as hex string (package hexbytes)
pkg/nitro/nitro.go # Shared Nitro attestation: COSE_Sign1 verification, cert chain validation, embedded AWS Nitro root CA (package nitro)
pkg/nitro/nsm.go # NSM device access and attestation via /dev/nsm (package nitro)
pkg/nitro/tpm.go # NitroTPM device access and attestation via raw TPM2 protocol over /dev/tpm0 (package nitro)
pkg/sevsnp/sevsnp.go # SEV-SNP device access, attestation via go-sev-guest, signature verification, report parsing (package sevsnp)
pkg/tdx/tdx.go # Intel TDX device access, attestation via go-tdx-guest, quote verification, report parsing (package tdx)
pkg/tpm/tpm.go # Generic TPM PCR reading via google/go-tpm over /dev/tpmrm0 (package tpm)
config/config.toml # default configuration file
flake.nix # Nix flake: reproducible hermetic build of the server binary and Docker image
flake.lock # pinned Nix input revisions (nixpkgs, flake-utils)
.github/workflows/ci.yml # CI: go fmt, go test (with DNSSEC live tests), go vet, go build on pushes to non-main branches
.github/workflows/nix-build.yml # Nix build: nix build .#docker-image (with offline tests) on PRs to main
.github/workflows/release.yml # Release: Nix build → Release Please → Docker push to GHCR + cosign
release-please-config.json # Release Please configuration (changelog sections, versioning)
.release-please-manifest.json # Release Please version manifest
Configuration is loaded via a TOML config file, environment variables, and CLI flags. Priority (highest to lowest): CLI flags > env vars > config file > defaults.
The config file is resolved in order:
--config-file/-cflagATTESTATION_SERVER_CONFIG_FILEenv var./config/config.toml(fallback)./config.toml(fallback)
See config/config.toml for the full structure:
[log]
format = "json"
level = "info"
[server]
host = "127.0.0.1"
port = 8187
[paths]
build_info = "/etc/build-info.json"
endorsements = "/etc/endorsements.json"
[report.evidence]
nitronsm = false
nitrotpm = false
sevsnp = false
sevsnp_vmpl = 0
tdx = false
[tpm]
enabled = false
algorithm = "sha384"
[report.user_data]
env = []
[ratelimit]
enabled = false
requests_per_second = 1
burst = 1
stall_timeout = "10s"
[revocation]
enabled = true
refresh_interval = "12h"
[secure_boot]
enforce = false
[endorsements]
dnssec = false
allowed_domains = []
[endorsements.client]
timeout = "10s"
[endorsements.cosign]
verify = true
url_suffix = ".sig"
tuf_cache_path = ""
[endorsements.cosign.build_signer]
uri = ""
uri_regex = ""
[http]
allow_proxy = false
[http.cache]
size = "100MiB"
default_ttl = "1h"
[dependencies]
endpoints = []
[tls.public]
cert_path = ""
key_path = ""
skip_verify = false
[tls.private]
cert_path = ""
key_path = ""
ca_path = "" # requiredOnly logging and config file settings have CLI flag equivalents:
| Flag | Default | Description |
|---|---|---|
--config-file, -c |
(see fallback above) | Path to TOML config file |
--log-format |
json |
Log format: json/text |
--log-level |
info |
Log level: debug/info/warn/error |
All settings can be configured via environment variables prefixed with ATTESTATION_SERVER_:
| Env var | TOML key | Default | Description |
|---|---|---|---|
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 |
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 |
ATTESTATION_SERVER_TLS_PUBLIC_CERT_PATH |
tls.public.cert_path |
— | Path to public TLS certificate (PEM) |
ATTESTATION_SERVER_TLS_PUBLIC_KEY_PATH |
tls.public.key_path |
— | Path to public TLS private key (PEM) |
ATTESTATION_SERVER_TLS_PUBLIC_SKIP_VERIFY |
tls.public.skip_verify |
false |
Skip system/Mozilla root CA chain verification for the public certificate |
ATTESTATION_SERVER_TLS_PRIVATE_CERT_PATH |
tls.private.cert_path |
— | Required. Path to private TLS certificate (PEM) |
ATTESTATION_SERVER_TLS_PRIVATE_KEY_PATH |
tls.private.key_path |
— | Required. Path to private TLS private key (PEM) |
ATTESTATION_SERVER_TLS_PRIVATE_CA_PATH |
tls.private.ca_path |
— | Required. PEM CA bundle — all private certs in the dependency chain must be issued by this CA |
ATTESTATION_SERVER_REPORT_EVIDENCE_NITRONSM |
report.evidence.nitronsm |
false |
Enable Nitro NSM evidence (exclusive: cannot combine with others) |
ATTESTATION_SERVER_REPORT_EVIDENCE_NITROTPM |
report.evidence.nitrotpm |
false |
Enable Nitro TPM evidence |
ATTESTATION_SERVER_REPORT_EVIDENCE_SEVSNP |
report.evidence.sevsnp |
false |
Enable SEV-SNP evidence |
ATTESTATION_SERVER_REPORT_EVIDENCE_SEVSNP_VMPL |
report.evidence.sevsnp_vmpl |
0 |
VMPL level for SEV-SNP attestation (0–3) |
ATTESTATION_SERVER_REPORT_EVIDENCE_TDX |
report.evidence.tdx |
false |
Enable Intel TDX evidence (exclusive: cannot combine with others) |
ATTESTATION_SERVER_TPM_ENABLED |
tpm.enabled |
false |
Enable generic TPM PCR reading via /dev/tpmrm0; auto-disabled if NitroNSM or NitroTPM evidence is enabled. Note: generic TPM PCR values are unattested (TPM2_PCR_Read) — they lack a hardware-signed quote. Integrity relies on the TEE's memory encryption protecting the OS. NitroNSM and NitroTPM PCRs are hardware-attested (embedded in the signed attestation document). A future revision may use TPM2_Quote for hardware-attested PCR values |
ATTESTATION_SERVER_TPM_ALGORITHM |
tpm.algorithm |
sha384 |
Hash algorithm for TPM PCR values: sha1/sha256/sha384/sha512 (case-insensitive) |
ATTESTATION_SERVER_REVOCATION_ENABLED |
revocation.enabled |
true |
Check TEE endorsement key CRLs. SEV-SNP CRLs are fetched from AMD KDS in the background when local SEV-SNP evidence is enabled or dependencies are configured; TDX uses go-tdx-guest's built-in Intel PCS collateral fetching |
ATTESTATION_SERVER_REVOCATION_REFRESH_INTERVAL |
revocation.refresh_interval |
12h |
How often to re-fetch CRLs in the background (SEV-SNP only; TDX checks are per-request via the library) |
ATTESTATION_SERVER_RATELIMIT_ENABLED |
ratelimit.enabled |
false |
Rate-limit edge requests (those without client certificate / XFCC header) |
ATTESTATION_SERVER_RATELIMIT_REQUESTS_PER_SECOND |
ratelimit.requests_per_second |
1 |
Per-IP request rate for edge traffic |
ATTESTATION_SERVER_RATELIMIT_BURST |
ratelimit.burst |
1 |
Burst allowance per IP |
ATTESTATION_SERVER_RATELIMIT_STALL_TIMEOUT |
ratelimit.stall_timeout |
10s |
Max time an over-limit request is stalled before receiving 429; IP extracted from X-Envoy-Original-IP > X-Forwarded-For > connection IP |
ATTESTATION_SERVER_SECURE_BOOT_ENFORCE |
secure_boot.enforce |
false |
Enforce UEFI Secure Boot; exit on startup if not enabled. UEFI secure boot detection is skipped when NitroNSM evidence is enabled (enclaves have no EFI firmware; boot integrity is proven by NSM PCR measurements) |
ATTESTATION_SERVER_REPORT_USER_DATA_ENV |
report.user_data.env |
[] |
Comma-separated environment variable names to include in report (unique) |
ATTESTATION_SERVER_DEPENDENCIES_ENDPOINTS |
dependencies.endpoints |
[] |
Comma-separated URLs of dependency attestation servers. HTTPS endpoints are verified against the private CA bundle (mTLS); HTTP endpoints are a design decision for transparent proxy configurations where Envoy diverts traffic through mTLS on non-loopback interfaces — the e2e encryption proof (XFCC fingerprint check) ensures the connection was mTLS-protected regardless of the URL scheme |
ATTESTATION_SERVER_ENDORSEMENTS_DNSSEC |
endorsements.dnssec |
false |
Require strict DNSSEC validation for endorsement URL hosts |
ATTESTATION_SERVER_ENDORSEMENTS_ALLOWED_DOMAINS |
endorsements.allowed_domains |
[] |
Comma-separated list of allowed endorsement hostnames (exact match). Empty = unrestricted. Applies to both own and dependency endorsement URLs |
ATTESTATION_SERVER_ENDORSEMENTS_CLIENT_TIMEOUT |
endorsements.client.timeout |
10s |
Overall timeout for fetching endorsement documents and cosign signatures (with retries) |
ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_VERIFY |
endorsements.cosign.verify |
true |
Verify cosign signatures on endorsement documents using Sigstore public-good infrastructure |
ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_URL_SUFFIX |
endorsements.cosign.url_suffix |
.sig |
Suffix appended to endorsement URL to fetch the cosign signature bundle |
ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_TUF_CACHE_PATH |
endorsements.cosign.tuf_cache_path |
— | Writable directory for Sigstore TUF metadata cache. Empty = in-memory only (no disk writes; background refresh every 24h). Set a path for disk-cached TUF root that survives restarts |
ATTESTATION_SERVER_ENDORSEMENTS_COSIGN_BUILD_SIGNER_URI |
endorsements.cosign.build_signer.uri |
— | Exact match override for BuildSignerURI Fulcio OID (takes precedence over uri_regex) |
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.
- Use
log/slogthroughout; never usefmt.Print*orlog.*for application logs. - Log messages are short single sentences, no initial capital, no trailing punctuation.
- All structured details (IDs, values, errors) go in slog fields, not in the message string.
- Access logs (via the fiber middleware in
server.go) include:method,path,status,duration_ms,request_id. Log level is INFO for 2xx/3xx, WARN for 4xx, ERROR for 5xx. - Errors in log fields use key
"error".
- All Go code must be
go fmt-conformant. Always rungo fmt ./...before committing. - Use
github.com/goccy/go-jsoneverywhere instead ofencoding/json. The attestation handler marshals report data withjson.MarshalWithOption(..., json.DisableHTMLEscape())for the nonce digest, then embeds those exact bytes (viajson.RawMessage) in the response to guarantee byte-for-byte consistency. - Fiber
UnsafeStringhazard: Fiber'sc.Get(),c.Query(),c.IP(),c.Method(),c.Path(), and similar methods return strings backed by fasthttp's reusableRequestCtxbuffer (UnsafeString). These strings are only valid within the handler. If stored in a long-lived data structure (map key, struct field on the server, channel, etc.), the backing bytes are silently corrupted when fasthttp recycles theRequestCtxviasync.Pool. Usestrings.Clone()before storing any Fiber context string beyond the handler lifetime. Operations that implicitly copy (JSON marshaling, string concatenation,net/http.Header.Set) are safe without cloning. - Fiber
c.UserContext()hazard: Fiber'sc.UserContext()returnscontext.Background()— it is never cancelled on graceful shutdown. Do not pass it to functions that perform blocking operations (HTTP fetches with retry, rate limiter waits, etc.) because those operations will not be interrupted when the server shuts down. Uses.shutdownCtx()instead, which returns the server's lifecycle context (set inRun()), falling back tocontext.Background()for pre-Runcallers and unit tests.
Each TEE package (pkg/nitro, pkg/sevsnp, pkg/tdx) exposes a consistent set of public functions:
| Function | Purpose |
|---|---|
GetEvidence |
Retrieve raw evidence from the device without verification |
VerifyEvidence |
Verify a raw evidence blob (standalone, no device needed) |
Attest |
Combined retrieval + verification (calls GetEvidence then VerifyEvidence) |
The sevsnp package additionally exports SplitEvidence (split a blob into raw report + certificate table), ReportSize (the raw report size constant), and the RevocationChecker function type.
Both sevsnp.VerifyEvidence and tdx.VerifyEvidence accept optional variadic parameters for revocation checking. These are omitted by standalone callers (backward-compatible) and provided by the attestation server when revocation is enabled:
sevsnp.VerifyEvidence(blob, reportData, now, checkers ...RevocationChecker)— optional callback checking the endorsement key (VCEK/VLEK) certificate against a CRLtdx.VerifyEvidence(rawQuote, reportData, now, opts ...VerifyOpt)—VerifyOptcontainsCheckRevocations bool(enables go-tdx-guest Intel PCS collateral fetching and CRL checking) andGetter trust.HTTPSGetter(overrides the HTTP client used for collateral fetching; the server provides a caching getter backed by the shared ristretto cache to avoid per-request Intel PCS round-trips)
VerifyEvidence implements its own verification flow instead of using verify.SnpAttestation from go-sev-guest (v0.14.1) to work around three library issues affecting cloud platforms like AWS Nitro. The workarounds are documented in the function's doc comment. Key constraints:
- Do not replace with
verify.SnpAttestation— it will fail on AWS due to unknown policy bits, malformed ASK/ARK certs in the certificate table, and proto round-trip breaking the signature. - Do not remove
reportToProto— it sanitises policy bits for parsing while preserving the original value for the API response. - Do not remove
trustedRoots— these pre-parsed AMD root certs bypass the malformed certificate table entries. - These workarounds can be revisited when go-sev-guest ships a release including PR #181 and fixes certificate table handling.
GetEvidence (and by extension Attest) caches the certificate table size after the first call. The go-sev-guest library's GetRawExtendedReportAtVmpl performs two ioctls per call (probe for cert buffer size + actual attestation), and the library's self-throttle inserts a ~2 s sleep between ioctls. By caching the cert size, subsequent calls use a single ioctl via getExtendedReportDirect, eliminating one PSP firmware round-trip and one throttle delay. The startup self-attestation Attest call primes this cache.
The handler calls Attest on each configured TEE device. Each Attest method retrieves evidence and verifies it internally using the same VerifyEvidence function that external verifiers would use, catching corrupted device output or driver bugs before they reach callers. The handler receives the verified parsed result alongside the raw blob and does not perform any additional verification.
The request_id (a crypto/rand-backed UUID) is included in the nonce-bound AttestationReportData for audit trail purposes. Since it is cryptographically random, an attacker cannot predict it to pre-compute attestation reports. Verifiers recompute the nonce from the response data (which includes the request_id), not from a pre-shared value.
During server initialization (NewServer), each opened TEE device is self-attested by calling Attest with random nonce/report data. The parsed results are captured in parsedSelfAttestation for endorsement validation. This catches environment issues early (tampered firmware, broken devices), primes the SEV-SNP certificate buffer cache, and provides the baseline measurements for endorsement checks. The server exits on any self-attestation failure.
When dependencies.endpoints is configured, the attestation handler fetches and verifies attestation reports from all dependency endpoints in parallel before collecting its own evidence. Each dependency receives the same nonce (x-attestation-nonce header) derived from the local AttestationReportData digest, and the same X-Request-Id for traceability.
Each dependency response is parsed as an AttestationReport, verified (nonce binding + cryptographic evidence verification for all known TEE types including NitroTPM→SEV-SNP chaining), and embedded as json.RawMessage in the dependencies field. Raw bytes are stored instead of re-marshaled structs to avoid goccy/go-json zero-copy string issues.
After cryptographic verification, the client certificate fingerprint check enforces end-to-end encryption: the dependency's data.tls.client must be present and match the SHA-256 fingerprint of our private certificate (which is used as the client cert for outgoing mTLS connections). If missing or mismatched, a descriptive error is logged and an opaque error is returned to the caller.
The dependency HTTP client verifies server certificates against the private CA bundle (tls.private.ca_path) and presents the private certificate as the TLS client cert. All private certificates in the dependency chain must be issued by the same CA — Envoy only populates the XFCC header (which provides the client cert fingerprint) when the client cert passes CA verification.
Every attestation response must prove end-to-end encryption via at least one of:
data.tls.client— XFCC-forwarded client cert fingerprint (service-to-service mTLS within the dependency chain)data.tls.public— public certificate fingerprint (external Internet clients at the first ingress hop, without client certificates)
If neither is present, the handler returns 400. This ensures the attestation evidence is always bound to a TLS channel that the verifier can reason about.
Dependency cycles are detected via the X-Attestation-Path header, which carries a comma-separated list of service identities visited along the dependency chain. Each server appends its own identity before forwarding to dependencies. If a server finds its identity already in the path, it returns 409 Conflict, which propagates up the chain.
The service identity is deterministic: SHA-256(json(build_info) || cert_subject || cert_SANs), using the private cert (or public cert as fallback). This ensures replicas of the same service share the same identity (cycles are between services, not processes), while different services produce distinct identities. SANs are included because SPIFFE SVIDs may have empty subjects.
The dependency HTTP client is hardened against slowloris-like attacks with per-phase timeouts (dial: 5s, TLS handshake: 10s, response headers: 15s, overall: 30s), a 4 MiB response body limit, and disabled keep-alives.
Certificate files (public cert/key, private cert/key, and private CA bundle) are hot-reloaded via fsnotify directory watchers. Since validateTLSConfig requires the CA bundle to be in the same directory as the private cert/key, a single watcher covers all three files. On reload, the private cert, CA bundle, and computed fingerprints are swapped atomically under the same sync.RWMutex (tlsCertificates.mu) that protects concurrent reads from request handlers and the dependency HTTP client.
The CA bundle loader (loadCABundle) cryptographically verifies self-signed certificates using x509.CheckSignatureFrom, rejecting certificates whose issuer matches subject but whose signature is invalid. SHA-1 CAs are hard-rejected (Go 1.18+ enforces this).
The dependency mTLS HTTP client enforces TLS 1.3 minimum. The endorsement/cosign fetch client uses TLS 1.2 minimum since public CDNs may not yet support TLS 1.3.
When ratelimit.enabled is true, a per-IP rate limiting handler is chained on the attestation endpoint (/api/v1/attestation) to protect the server from resource exhaustion by edge clients. It is scoped to this endpoint because attestation involves blocking TEE hardware operations; future lightweight endpoints should not inherit this restriction. The handler only applies to requests without an x-forwarded-client-cert (XFCC) header — service-to-service mTLS traffic is never rate-limited.
Client IP is extracted with priority: X-Envoy-Original-IP header > first entry in X-Forwarded-For > connection IP. Extracted values are validated as IP addresses to prevent header injection from creating unbounded map entries.
Over-limit requests are stalled (blocked in a FIFO queue) up to ratelimit.stall_timeout before receiving HTTP 429. This avoids immediately rejecting burst traffic while still bounding resource consumption. Per-IP rate limiter entries are cleaned up in a background goroutine when idle for 2× the stall timeout.
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). ThecrlCachestores parsedx509.RevocationListentries 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'sfetchHTTPClient()and honourhttp.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 acachedHTTPSGetter(viaVerifyOpt.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 responseCache-Controlheaders; Intel PCS currently returns no cache headers, sohttp.cache.default_ttlapplies. The go-tdx-guest library still validatesNextUpdateexpiry on all collateral, so stale cached data is rejected. The cached getter uses the server'sfetchHTTPClient()and honourshttp.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".
The server returns opaque "internal error" messages for all 5xx responses to prevent leaking device errors, file paths, and firmware codes to external callers. The real error is logged at ERROR level with request_id for debugging. 4xx error messages are preserved since they describe client-fixable problems (bad nonce, missing cert, etc.).
The server rejects requests with multiple comma-separated XFCC entries (HTTP 400). The design assumes a single forwarded client certificate entry per request, enforcing direct end-to-end encryption without proxy intermediaries that might strip or replace the client cert.
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. An empty allowlist logs a startup warning since dependency reports can contain attacker-controlled URLs.
When paths.endorsements is configured with endorsement URLs, the server fetches and validates endorsement documents containing golden measurement values for each configured evidence type.
A JSON object with evidence-type keys (nitronsm, nitrotpm, sevsnp, tdx, tpm):
- NitroNSM/NitroTPM/TPM:
{"PCR0": "hex", ...}or{"0": "hex", ...}— a flat map of PCR register indices to hex-encoded measurement values. Keys use either"PCRN"or"N"format (where N is 0–24). Values must be non-empty valid hex strings. These constraints are enforced at JSON parse time (PCRGoldenValues.UnmarshalJSON) so that downstream comparison functions (comparePCRs,validateTPMMeasurements) can trust the values are well-formed.PCRGoldenValuesismap[int]hexbytes.Bytes, matching the type used for TPM PCR values throughout the codebase. - SEV-SNP: a single hex string (96 chars = 384-bit launch measurement)
- TDX:
{"MRTD": "hex", "RTMR0": "hex", "RTMR1": "hex", "RTMR2": "hex"}(all optional)
During NewServer(), after self-attestation (which now captures parsed results instead of discarding them), the server:
- Fetches endorsement documents from all configured URLs in parallel with retry
- Verifies all documents are byte-for-byte identical
- Validates each configured evidence type against the golden measurements
- Exits on any failure (missing measurements, mismatches, fetch errors)
Before collecting own evidence in handleAttestation, the handler calls validateOwnEndorsements. On cache hit this is fast (pointer lookup + comparison). On cache miss (TTL expired) it re-fetches and revalidates. If revalidation fails, the handler returns 500 but the server stays up and self-heals when endorsements become available.
After cryptographically verifying a dependency's attestation report, the server also validates the dependency's endorsement URLs (from reportData.Endorsements) against the evidence in the dependency report. The shared ristretto cache is used across own and dependency endorsements.
Uses system/Mozilla root CAs (via golang.org/x/crypto/x509roots/fallback blank import). Hardened with per-phase timeouts (dial 3s, TLS 5s, headers 5s), 1 MiB body limit, disabled keep-alives. When endorsements.dnssec is enabled, the pkg/dnssec resolver performs cryptographic DNSSEC chain-of-trust validation for endorsement URL hosts before fetching. The resolver reads upstream nameservers from /etc/resolv.conf (falling back to 127.0.0.53:53 then 127.0.0.1:53), sets the CD bit to get raw RRSIG records from any resolver, and validates the full delegation chain from zone to root against embedded IANA root KSK trust anchors (KSK-2017 tag 20326, KSK-2024 tag 38696). It does not rely on the upstream resolver's AD flag.
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.
When endorsements.cosign.verify is enabled (default: true), the server verifies cosign signatures on endorsement documents using the Sigstore public-good infrastructure (Fulcio + Rekor). Only Cosign v3 protobuf bundles (from cosign sign-blob --bundle) are supported.
After fetching an endorsement document, a corresponding signature bundle is fetched from the same URL with the configured suffix appended (default .sig). The single endorsements.client.timeout covers both fetches (not extended for the signature). Signature bundles undergo the same multi-URL byte-for-byte identity check as endorsement documents.
Verification performs a full online Rekor inclusion proof check using an auto-updating Sigstore TUF client (root.NewLiveTrustedRoot) that refreshes roots in the background. Upon successful verification, Fulcio OID extensions are extracted from the signing certificate and validated against the server's BuildInfo:
- All OID fields except BuildSignerURI and BuildSignerDigest: exact match against corresponding BuildInfo field
- BuildSignerURI: matched against
endorsements.cosign.build_signer.uri(exact) or.uri_regex(regex) if configured;.uritakes precedence if both set (warning logged). When neither is configured, exact match againstBuildInfo.BuildSignerURI - BuildSignerDigest: exact match against
BuildInfo.BuildSignerDigestwhen nobuild_signerconfig is set; skipped when anybuild_signerconfig is set (digest changes per-commit) - DeploymentEnvironment: not checked (no standard Fulcio OID)
- Fulcio's
SourceRepositoryVisibilityAtSigningmaps to BuildInfo'sSourceRepositoryVisibility
When cosign verification is enabled, the server requires endorsement URLs to be configured (non-empty paths.endorsements); startup fails otherwise. Dependency attestation reports are also required to include non-empty endorsement URL lists — a dependency with no endorsement URLs is rejected.
Cosign verification is applied to both own endorsements and dependency endorsements. For dependencies, OIDs are validated against the dependency's BuildInfo from its attestation report.
Verified cosign results are cached alongside endorsement documents in the shared fetcherCache. On cache hit for both, zero network calls happen. On cache miss for either, both are re-fetched together.
Tests use the standard testing package (no testify), table-driven subtests with t.Run, and no mocking of hardware interfaces.
Security-sensitive parsers have Fuzz* tests (Go native fuzzing) that verify no panics on arbitrary input. Run seed corpus with go test ./...; run actual fuzzing with e.g. go test ./internal/ -fuzz=FuzzExtractXFCCHash -fuzztime=30s. Current fuzz targets:
FuzzExtractXFCCHash,FuzzIsValidHexFingerprint— untrusted XFCC header parsing (internal/attestation_test.go)FuzzParseCacheTTL,FuzzParseByteSize,FuzzPCRGoldenValues_UnmarshalJSON— untrusted HTTP headers, config input, endorsement JSON (internal/endorsements_test.go)FuzzBytes_UnmarshalJSON,FuzzBytes_RoundTrip— hex JSON deserialization and marshal↔unmarshal consistency (pkg/hexbytes/hexbytes_test.go)
pkg/dnssec/dnssec_test.go includes live tests that perform real DNSSEC chain-of-trust validation against public domains (ietf.org, internetsociety.org). Gated behind an environment variable:
DNSSEC_LIVE_TEST=1 go test ./pkg/dnssec/ -run TestLive -vEach TEE package has a testdata/ directory with JSON fixtures captured from real hardware. All fixtures are the raw AttestationReport JSON as returned by the attestation handler (pretty-printed):
{
"evidence": [{"kind": "...", "blob": "base64...", "data": {...}}],
"data": { ... AttestationReportData ... }
}The clock value for certificate validation is extracted from data.timestamp (RFC 3339, truncated to seconds). The nonce/report_data is derived as SHA-512(compact(data)). Each verification test also cross-checks that NewAttestationData produces JSON matching the fixture's evidence[0].data.
Chained (composite) attestation fixtures contain multiple evidence entries. For NitroTPM+SEV-SNP, the SEV-SNP report_data is SHA-512(nitroTPMBlob) instead of the raw digest, binding both proofs to the same request. The chained test in internal/attestation_test.go verifies both links and confirms the chain breaks if the unchained digest is used.
Fixture files:
pkg/nitro/testdata/nitronsm_attestation.jsonpkg/nitro/testdata/nitrotpm_attestation.jsonpkg/sevsnp/testdata/sevsnp_attestation_aws.jsonpkg/sevsnp/testdata/sevsnp_attestation_gcp.jsonpkg/tdx/testdata/tdx_attestation.jsoninternal/testdata/nitrotpm_sevsnp_attestation.json(chained NitroTPM → SEV-SNP)internal/testdata/dependencies_attestation.json(diamond dependency graph: A → {B, C}, B → C with NitroTPM+SEV-SNP, TDX, and SEV-SNP evidence across services; each dependency has client cert matching caller's private cert)
The project provides a Nix flake for reproducible, hermetic builds. Inputs are pinned to exact commit hashes in flake.nix and locked in flake.lock. The flake builds a statically linked binary (CGO_ENABLED=0, stripped with -s -w). Tests run during the build (live DNSSEC tests skip themselves in the sandbox since DNSSEC_LIVE_TEST is unset; all other tests use fixtures). The source filter includes *.go, *.json (test fixtures), go.mod, and go.sum — changes to docs or config do not trigger a rebuild.
The flake exposes two package targets:
default/attestation-server— the statically linked binarydocker-image— a minimal OCI image (streamLayeredImage) containing the binary (TLS root CAs are compiled in viax509roots/fallback) and a/usr/local/bin/attestation-serversymlink for use in multi-stage Docker builds, used by the release workflow and downstream Nitro TEE EIF builds
The flake is designed to be referenced as a GitHub source input from downstream TEE image repositories:
# In the downstream flake:
inputs.attestation-server.url = "github:eternisai/attestation-server";
# Binary: attestation-server.packages.x86_64-linux.default -> $out/bin/attestation-serverWhen go.mod or go.sum change, the vendorHash in flake.nix must be updated. Set it to lib.fakeHash, build, and use the hash from the error message.
- CI (
.github/workflows/ci.yml) — runs on pushes to non-main branches:go fmtcheck,go testwithDNSSEC_LIVE_TEST=1,go vet,go build. - Nix Build (
.github/workflows/nix-build.yml) — runs on PRs targeting main:nix build .#docker-image(runs offline test suite viadoCheck). Catches flake breakage before merge. - Branch protection on main should require both
TestandBuildstatus checks to pass. - Release (
.github/workflows/release.yml) — runs on push to main, three sequential jobs:- Build —
nix build .#docker-image(runs tests viadoCheck), uploads image tarball as artifact - Release — Release Please creates/updates a release PR; on merge, creates a GitHub Release + tag
- Docker — loads the pre-built image, pushes to
ghcr.io/eternisai/attestation-server:<tag>, cosigns with keyless Sigstore (Fulcio + Rekor via GitHub OIDC)
- Build —
Release Please is configured via release-please-config.json and .release-please-manifest.json. It parses Conventional Commit messages to determine version bumps and generate changelogs.
# build
go build ./...
# run locally (uses config/config.toml by default)
go run .
# run locally (with env vars)
ATTESTATION_SERVER_SERVER_PORT=8187 go run .
# run tests
go test ./...
# format
go fmt ./...Use Conventional Commits for commit messages:
feat:, fix:, chore:, refactor:, docs:, test:, etc.