Files
mpc/cc/attest/sev.go
zeekay 48744d2e82 cc/attest: canonical TEE attestation verifier (#222 stage 1)
Adds the canonical TEE attestation verifier package at cc/attest. This
is the home for chain validation + trust anchor management; the
luxcpp/crypto/attestation parser primitives feed bytes here BEFORE
their measurement output is trusted.

Stage 1 — SEV-SNP — PRODUCTION:
  - cc/attest/sev.go: SEVSNP{} verifier built on
    github.com/google/go-sev-guest v0.14.1. Live VCEK fetch from AMD
    KDS, ARK→ASK→VCEK chain validation, signature verify against the
    leaf, MEASUREMENT/REPORT_DATA/HOST_DATA/CHIP_ID extraction,
    caller-supplied policy gates (WithExpectedReportData,
    WithExpectedMeasurement). 197 LOC.
  - testdata/: real-vendor Milan attestation report + VCEK leaf
    committed verbatim from the upstream go-sev-guest test corpus.
    Tests replay the AMD KDS responses via SimpleGetter so CI never
    hits the network.
  - 9 SEV-SNP tests passing (happy path, dispatch, length reject,
    tampered signature, wrong report_data, wrong measurement,
    deterministic composite hash, unknown kind, stub-panic guard).

Stage 2 — TDX — STUB (#222 stage 2):
  - cc/attest/tdx.go: TDX{}.Verify panics with ErrNotImplemented so
    any caller routing TDX evidence during stage 1 fails LOUD. Plan
    documented inline: go-tdx-guest + Intel PCS + QE/TDX module
    identity pinning.

Stage 3 — NRAS — STUB (#222 stage 3):
  - cc/attest/nras.go: NRAS{}.Verify panics with ErrNotImplemented.
    Plan: lift the existing pkg/attestation/nvidia NRAS client into
    the cc/attest contract with a JWKS cache.

Wire-in:
  - pkg/attestation/composite.go gains VerifyEvidence(ctx, opts...)
    which dispatches each Evidence blob to cc/attest by Issuer.
    Callers (kms release gate, scheduler) MUST invoke this before
    accepting parser output.

Doc:
  - cc/attest/doc.go states the production/stub matrix and the
    layering contract between the C-ABI parser and this package.
2026-04-28 00:47:37 -07:00

198 lines
7.0 KiB
Go

// Copyright (c) 2026, Lux Industries Inc.
// SPDX-License-Identifier: BSD-3-Clause
package attest
import (
"context"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
"github.com/google/go-sev-guest/abi"
spb "github.com/google/go-sev-guest/proto/sevsnp"
"github.com/google/go-sev-guest/verify"
"github.com/google/go-sev-guest/verify/trust"
)
// SEVSNP is the production SEV-SNP verifier. It accepts a raw 0x4A0-byte
// AMD SEV-SNP attestation report (no embedded cert chain) and:
//
// 1. Parses the report via go-sev-guest/abi.ReportToProto.
// 2. Fetches the VCEK leaf from the AMD KDS at
// kdsintf.amd.com/vcek/v1/<product>/<hwid>?... using the report's
// CHIP_ID + reported TCB. The product CA bundle (ARK→ASK) is
// embedded in go-sev-guest/verify/trust.
// 3. Validates the chain to AMD's pinned root and checks the report
// signature against the VCEK public key.
// 4. Extracts MEASUREMENT, REPORT_DATA, HOST_DATA, CHIP_ID, TCB.
// 5. Enforces caller-supplied policy options (expected report data,
// expected measurement).
//
// Production path requires network reachability to kdsintf.amd.com. In
// tests the WithKDSGetter option installs a SimpleGetter that replays
// pre-fetched bytes from testdata/.
type SEVSNP struct{}
// Verify implements Verifier for SEV-SNP raw reports.
func (SEVSNP) Verify(ctx context.Context, evidence []byte, opts ...Option) (*VerifiedReport, error) {
if len(evidence) != abi.ReportSize {
return nil, fmt.Errorf("%w: sev-snp expects %d bytes, got %d",
ErrInvalidEvidence, abi.ReportSize, len(evidence))
}
cfg := applyOptions(opts...)
// Parse first so we fail fast on malformed framing.
report, err := abi.ReportToProto(evidence)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
}
// Build verify.Options. We rely on the embedded ARK/ASK trusted
// roots inside go-sev-guest; the only thing we override is the
// HTTPS getter (so tests can replay KDS responses) and Now.
vopts := &verify.Options{}
if cfg.kdsGetter != nil {
g, ok := cfg.kdsGetter.(trust.HTTPSGetter)
if !ok {
return nil, fmt.Errorf("%w: kds getter does not implement trust.HTTPSGetter",
ErrInvalidEvidence)
}
vopts.Getter = g
} else {
vopts.Getter = trust.DefaultHTTPSGetter()
}
if !cfg.now.IsZero() {
vopts.Now = cfg.now
} else {
vopts.Now = time.Now()
}
// Chain + signature verify. This:
// - fetches VCEK from KDS by CHIP_ID + TCB
// - validates ARK→ASK→VCEK
// - validates VCEK extensions match the report's TCB
// - validates the report signature against VCEK
if err := verify.RawSnpReportContext(ctx, evidence, vopts); err != nil {
// go-sev-guest reports a single error string. Classify by
// substring so callers can distinguish chain vs signature
// failures via errors.Is. This is conservative — when in
// doubt, treat as ChainInvalid (refuse).
msg := err.Error()
switch {
case strings.Contains(msg, "could not interpret report bytes"):
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
case strings.Contains(msg, "signature"):
return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
default:
return nil, fmt.Errorf("%w: %v", ErrChainInvalid, err)
}
}
// Apply caller policy on the verified report fields.
if cfg.expectedReportData != nil {
if subtle.ConstantTimeCompare(cfg.expectedReportData, report.GetReportData()) != 1 {
return nil, fmt.Errorf("%w: report_data mismatch (got %s)",
ErrPolicy, hex.EncodeToString(report.GetReportData()))
}
}
if cfg.expectedMeasurement != nil {
if subtle.ConstantTimeCompare(cfg.expectedMeasurement, report.GetMeasurement()) != 1 {
return nil, fmt.Errorf("%w: measurement mismatch (got %s)",
ErrPolicy, hex.EncodeToString(report.GetMeasurement()))
}
}
out := &VerifiedReport{
Kind: KindSEVSNP,
Vendor: "amd.sev.snp",
Measurement: cloneBytes(report.GetMeasurement()),
ReportData: cloneBytes(report.GetReportData()),
HostData: cloneBytes(report.GetHostData()),
ChipID: cloneBytes(report.GetChipId()),
IssuedAt: vopts.Now.UTC(),
Extra: buildSEVExtra(report),
}
// CompositeHash binds verifier kind + the canonical fields the
// chain validated. Re-deriving on the consumer side reproduces the
// same hash iff the same evidence was verified by an honest
// verifier with the same policy.
out.CompositeHash = computeCompositeHash(KindSEVSNP, canonicalSEVBytes(report))
return out, nil
}
// buildSEVExtra collects sev-snp-specific fields that don't fit the
// common shape. Keys are stable wire identifiers; values are hex.
func buildSEVExtra(report *spb.Report) map[string]string {
extra := make(map[string]string, 6)
extra["sev_snp.policy"] = fmt.Sprintf("%#x", report.GetPolicy())
extra["sev_snp.vmpl"] = fmt.Sprintf("%d", report.GetVmpl())
extra["sev_snp.current_tcb"] = fmt.Sprintf("%#x", report.GetCurrentTcb())
extra["sev_snp.committed_tcb"] = fmt.Sprintf("%#x", report.GetCommittedTcb())
extra["sev_snp.platform_info"] = fmt.Sprintf("%#x", report.GetPlatformInfo())
if id := report.GetFamilyId(); len(id) > 0 {
extra["sev_snp.family_id"] = hex.EncodeToString(id)
}
if id := report.GetImageId(); len(id) > 0 {
extra["sev_snp.image_id"] = hex.EncodeToString(id)
}
return extra
}
// canonicalSEVBytes returns the bytes that participate in CompositeHash.
// We hash the verifier-extracted fields (not the raw report) so that any
// pre-verify field shuffling at lower layers cannot change the root
// without changing what the verifier saw. Field order is fixed and
// matches the public ABI ordering of the report.
func canonicalSEVBytes(report *spb.Report) []byte {
// Length: 8(policy) + 8(current_tcb) + 8(committed_tcb)
// + 16(family) + 16(image) + 32(host) + 48(meas)
// + 64(report_data) + 64(chip_id) = 264 bytes max.
buf := make([]byte, 0, 264)
appendU64 := func(v uint64) {
var b [8]byte
for i := 0; i < 8; i++ {
b[i] = byte(v >> (8 * i))
}
buf = append(buf, b[:]...)
}
appendU64(report.GetPolicy())
appendU64(report.GetCurrentTcb())
appendU64(report.GetCommittedTcb())
buf = append(buf, fixedSize(report.GetFamilyId(), 16)...)
buf = append(buf, fixedSize(report.GetImageId(), 16)...)
buf = append(buf, fixedSize(report.GetHostData(), 32)...)
buf = append(buf, fixedSize(report.GetMeasurement(), 48)...)
buf = append(buf, fixedSize(report.GetReportData(), 64)...)
buf = append(buf, fixedSize(report.GetChipId(), 64)...)
return buf
}
// fixedSize returns b padded or truncated to size n. Zero-pads when
// short. This guarantees canonicalSEVBytes is a fixed-shape blob.
func fixedSize(b []byte, n int) []byte {
out := make([]byte, n)
copy(out, b)
return out
}
func cloneBytes(b []byte) []byte {
if len(b) == 0 {
return nil
}
out := make([]byte, len(b))
copy(out, b)
return out
}
// Compile-time guard: SEVSNP satisfies Verifier.
var _ Verifier = SEVSNP{}
// Sentinel for unused import resilience: keep the errors import live in
// case future SEV-specific sentinels are added here. (No-op at runtime.)
var _ = errors.New