diff --git a/.gitignore b/.gitignore index 770dc2fb..89bdf724 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ target/ # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html Cargo.lock +!tools/nvidia-attestation-helper/Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk @@ -28,4 +29,5 @@ Cargo.lock *.enc *.key -*.pub \ No newline at end of file +*.pub +.codex diff --git a/Makefile b/Makefile index 086376a1..8d8cf87a 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,24 @@ BUILD_DIR = build SERVICES = manager agent cli attestation-service log-forwarder computation-runner egress-proxy ingress-proxy +NVIDIA_ATTESTATION_HELPER = nvidia-attestation-helper +NVIDIA_ATTESTATION_HELPER_DIR = tools/$(NVIDIA_ATTESTATION_HELPER) +NVIDIA_ATTESTATION_HELPER_MANIFEST = $(NVIDIA_ATTESTATION_HELPER_DIR)/Cargo.toml +NVIDIA_ATTESTATION_HELPER_BINARY = $(BUILD_DIR)/$(NVIDIA_ATTESTATION_HELPER) +NVIDIA_ATTESTATION_HELPER_LIB_DIR = $(BUILD_DIR)/lib +NVAT_SDK_CPP_DIR ?= $(firstword $(wildcard $(HOME)/.cargo/git/checkouts/attestation-sdk-*/*/nv-attestation-sdk-cpp)) +NVAT_SDK_CPP_BUILD_DIR ?= $(NVAT_SDK_CPP_DIR)/build +NVAT_SDK_HEADER ?= $(NVAT_SDK_CPP_BUILD_DIR)/include/nvat.h +NVAT_SDK_SHARED_LIB ?= $(NVAT_SDK_CPP_BUILD_DIR)/libnvat.so.1 +NVAT_SYSTEM_HEADER ?= /usr/include/nvat.h +CARGO ?= cargo +CMAKE ?= cmake CGO_ENABLED ?= 0 GOARCH ?= amd64 VERSION ?= $(shell git describe --abbrev=0 --tags --always) COMMIT ?= $(shell git rev-parse HEAD) TIME ?= $(shell date +%F_%T) EMBED_ENABLED ?= 0 +NVAT_USE_SYSTEM_LIB ?= INSTALL_DIR ?= /usr/local/bin CONFIG_DIR ?= /etc/cocos SERVICE_NAME ?= cocos-manager @@ -23,14 +36,46 @@ define compile_service -o ${BUILD_DIR}/cocos-$(1) ./cmd/$(1) endef -.PHONY: all $(SERVICES) install clean +NVIDIA_ATTESTATION_HELPER_CARGO_ENV = $(if $(filter 1,$(NVAT_USE_SYSTEM_LIB)),NVAT_USE_SYSTEM_LIB=1,) +NVIDIA_ATTESTATION_HELPER_RUSTFLAGS = $(strip $(RUSTFLAGS) $(if $(filter 1,$(NVAT_USE_SYSTEM_LIB)),,-C link-arg=-Wl,-rpath,$$ORIGIN/lib)) + +.PHONY: all $(SERVICES) $(NVIDIA_ATTESTATION_HELPER) nvidia-attestation-helper-prereqs install clean all: $(SERVICES) -$(SERVICES): +$(BUILD_DIR): + mkdir -p $(BUILD_DIR) + +$(SERVICES): | $(BUILD_DIR) $(call compile_service,$@) @if [ "$@" = "cli" ] || [ "$@" = "manager" ]; then $(MAKE) build-igvm; fi +nvidia-attestation-helper-prereqs: +ifeq ($(filter 1,$(NVAT_USE_SYSTEM_LIB)),1) + @test -f $(NVAT_SYSTEM_HEADER) || \ + ( echo "Missing $(NVAT_SYSTEM_HEADER). Install the NVAT development package or run without NVAT_USE_SYSTEM_LIB=1."; exit 1 ) + @ldconfig -p | grep -q libnvat.so.1 || \ + ( echo "libnvat.so.1 not found in the dynamic linker cache. Install the NVAT runtime package or run without NVAT_USE_SYSTEM_LIB=1."; exit 1 ) +else + @if [ -z "$(NVAT_SDK_CPP_DIR)" ]; then \ + echo "Unable to locate nv-attestation-sdk-cpp under $$HOME/.cargo/git/checkouts."; \ + echo "Run 'cargo fetch --manifest-path $(NVIDIA_ATTESTATION_HELPER_MANIFEST)' first, or install NVAT and use 'make NVAT_USE_SYSTEM_LIB=1 $(NVIDIA_ATTESTATION_HELPER)'."; \ + exit 1; \ + fi + @if [ ! -f "$(NVAT_SDK_HEADER)" ] || [ ! -f "$(NVAT_SDK_SHARED_LIB)" ]; then \ + $(CMAKE) -S $(NVAT_SDK_CPP_DIR) -B $(NVAT_SDK_CPP_BUILD_DIR) && \ + $(CMAKE) --build $(NVAT_SDK_CPP_BUILD_DIR); \ + fi +endif + +$(NVIDIA_ATTESTATION_HELPER): nvidia-attestation-helper-prereqs | $(BUILD_DIR) + RUSTFLAGS='$(NVIDIA_ATTESTATION_HELPER_RUSTFLAGS)' $(NVIDIA_ATTESTATION_HELPER_CARGO_ENV) $(CARGO) build --manifest-path $(NVIDIA_ATTESTATION_HELPER_MANIFEST) --release + install -m 755 $(NVIDIA_ATTESTATION_HELPER_DIR)/target/release/$(NVIDIA_ATTESTATION_HELPER) $(NVIDIA_ATTESTATION_HELPER_BINARY) + @if [ "$(filter 1,$(NVAT_USE_SYSTEM_LIB))" != "1" ]; then \ + install -d $(NVIDIA_ATTESTATION_HELPER_LIB_DIR); \ + install -m 755 $(NVAT_SDK_SHARED_LIB) $(NVIDIA_ATTESTATION_HELPER_LIB_DIR)/libnvat.so.1; \ + fi + protoc: protoc -I. --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative agent/agent.proto protoc -I. --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative manager/manager.proto diff --git a/cmd/attestation-service/gpu.go b/cmd/attestation-service/gpu.go new file mode 100644 index 00000000..50bc4b20 --- /dev/null +++ b/cmd/attestation-service/gpu.go @@ -0,0 +1,76 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "strings" + + attestationpb "github.com/ultravioletrs/cocos/internal/proto/attestation/v1" + "github.com/ultravioletrs/cocos/pkg/attestation" + "github.com/ultravioletrs/cocos/pkg/attestation/eat" + attestationgpu "github.com/ultravioletrs/cocos/pkg/attestation/gpu" +) + +func newGPUCollector(cfg config) (attestationgpu.Collector, error) { + if strings.TrimSpace(cfg.GPUHelperPath) == "" { + return nil, nil + } + + return attestationgpu.NewCommandCollector(cfg.GPUHelperPath, cfg.GPUHelperTimeout) +} + +func (s *service) claimOptions(ctx context.Context, req *attestationpb.AttestationRequest, platformType attestation.PlatformType) ([]eat.ClaimsOption, error) { + var opts []eat.ClaimsOption + + if s.gpuCollector != nil && shouldCollectGPU(platformType) { + sessionNonce := requestNonce(req) + gpuNonce := deriveComponentNonce(sessionNonce, "gpu") + + evidence, err := s.gpuCollector.Collect(ctx, gpuNonce) + if err != nil { + // GPU evidence is opportunistic: if no supported CC-capable GPU is + // attached, or the helper cannot collect evidence, we continue with + // the root CPU/TEE attestation instead of failing the whole request. + s.logger.Warn(fmt.Sprintf("[ATTESTATION-SERVICE] Skipping optional GPU evidence collection: %s", err)) + return opts, nil + } + + s.logger.Info(fmt.Sprintf("[ATTESTATION-SERVICE] Collected GPU evidence: format=%s bytes=%d", + evidence.EvidenceFormat, len(evidence.RawEvidence))) + + opts = append(opts, eat.WithGPU(&eat.GPUExtensions{ + Vendor: evidence.Vendor, + EvidenceFormat: evidence.EvidenceFormat, + Nonce: gpuNonce, + EvidenceJSON: evidence.RawEvidence, + })) + } + + return opts, nil +} + +func shouldCollectGPU(platformType attestation.PlatformType) bool { + switch platformType { + case attestation.SNP, attestation.SNPvTPM, attestation.TDX, attestation.Azure: + return true + default: + return false + } +} + +func requestNonce(req *attestationpb.AttestationRequest) []byte { + if len(req.Nonce) > 0 { + return append([]byte(nil), req.Nonce...) + } + + return append([]byte(nil), req.ReportData...) +} + +func deriveComponentNonce(sessionNonce []byte, component string) []byte { + digest := sha256.Sum256(append(append([]byte(nil), sessionNonce...), []byte(":"+component)...)) + return digest[:] +} diff --git a/cmd/attestation-service/gpu_test.go b/cmd/attestation-service/gpu_test.go new file mode 100644 index 00000000..729c0e1b --- /dev/null +++ b/cmd/attestation-service/gpu_test.go @@ -0,0 +1,83 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "io" + "log/slog" + "testing" + + "github.com/stretchr/testify/assert" + attestationpb "github.com/ultravioletrs/cocos/internal/proto/attestation/v1" + "github.com/ultravioletrs/cocos/pkg/attestation" + attestationgpu "github.com/ultravioletrs/cocos/pkg/attestation/gpu" +) + +func TestRequestNonce(t *testing.T) { + req := &attestationpb.AttestationRequest{ + ReportData: []byte("report"), + Nonce: []byte("nonce"), + } + + assert.Equal(t, []byte("nonce"), requestNonce(req)) + + req.Nonce = nil + assert.Equal(t, []byte("report"), requestNonce(req)) +} + +func TestDeriveComponentNonce(t *testing.T) { + sessionNonce := []byte("session-nonce") + + gpuNonce := deriveComponentNonce(sessionNonce, "gpu") + gpuNonceAgain := deriveComponentNonce(sessionNonce, "gpu") + teeNonce := deriveComponentNonce(sessionNonce, "tee") + + assert.Len(t, gpuNonce, 32) + assert.Equal(t, gpuNonce, gpuNonceAgain) + assert.NotEqual(t, gpuNonce, teeNonce) +} + +func TestShouldCollectGPU(t *testing.T) { + assert.True(t, shouldCollectGPU(attestation.SNP)) + assert.True(t, shouldCollectGPU(attestation.SNPvTPM)) + assert.True(t, shouldCollectGPU(attestation.TDX)) + assert.False(t, shouldCollectGPU(attestation.VTPM)) + assert.False(t, shouldCollectGPU(attestation.NoCC)) +} + +func TestNewGPUCollector(t *testing.T) { + collector, err := newGPUCollector(config{}) + assert.NoError(t, err) + assert.Nil(t, collector) + + collector, err = newGPUCollector(config{ + GPUHelperPath: "/tmp/helper", + GPUHelperTimeout: 0, + }) + assert.NoError(t, err) + assert.NotNil(t, collector) +} + +func TestClaimOptions_SkipsOptionalGPUFailure(t *testing.T) { + svc := &service{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + gpuCollector: failingCollector{}, + } + + req := &attestationpb.AttestationRequest{ + ReportData: []byte("report-data"), + Nonce: []byte("nonce-data"), + } + + opts, err := svc.claimOptions(context.Background(), req, attestation.TDX) + assert.NoError(t, err) + assert.Empty(t, opts) +} + +type failingCollector struct{} + +func (failingCollector) Collect(context.Context, []byte) (*attestationgpu.Evidence, error) { + return nil, assert.AnError +} diff --git a/cmd/attestation-service/main.go b/cmd/attestation-service/main.go index 4c8782bd..ef1705c6 100644 --- a/cmd/attestation-service/main.go +++ b/cmd/attestation-service/main.go @@ -11,6 +11,7 @@ import ( "os" "os/signal" "syscall" + "time" mglog "github.com/absmach/supermq/logger" "github.com/caarlos0/env/v11" @@ -22,6 +23,7 @@ import ( "github.com/ultravioletrs/cocos/pkg/attestation/azure" "github.com/ultravioletrs/cocos/pkg/attestation/ccaa" "github.com/ultravioletrs/cocos/pkg/attestation/eat" + attestationgpu "github.com/ultravioletrs/cocos/pkg/attestation/gpu" "github.com/ultravioletrs/cocos/pkg/attestation/tdx" "github.com/ultravioletrs/cocos/pkg/attestation/vtpm" logclient "github.com/ultravioletrs/cocos/pkg/clients/grpc/log" @@ -35,16 +37,29 @@ const ( ) type config struct { - LogLevel string `env:"ATTESTATION_LOG_LEVEL" envDefault:"debug"` - Vmpl int `env:"ATTESTATION_VMPL" envDefault:"2"` - AgentMaaURL string `env:"AGENT_MAA_URL" envDefault:"https://sharedeus2.eus2.attest.azure.net"` - AgentOSBuild string `env:"AGENT_OS_BUILD" envDefault:"UVC"` - AgentOSDistro string `env:"AGENT_OS_DISTRO" envDefault:"UVC"` - AgentOSType string `env:"AGENT_OS_TYPE" envDefault:"UVC"` - EATFormat string `env:"ATTESTATION_EAT_FORMAT" envDefault:"CBOR"` // JWT or CBOR - EATIssuer string `env:"ATTESTATION_EAT_ISSUER" envDefault:"cocos-attestation-service"` - UseCCAttestationAgent bool `env:"USE_CC_ATTESTATION_AGENT" envDefault:"false"` - CCAgentAddress string `env:"CC_AGENT_ADDRESS" envDefault:"127.0.0.1:50002"` + LogLevel string `env:"ATTESTATION_LOG_LEVEL" envDefault:"debug"` + Vmpl int `env:"ATTESTATION_VMPL" envDefault:"2"` + AgentMaaURL string `env:"AGENT_MAA_URL" envDefault:"https://sharedeus2.eus2.attest.azure.net"` + AgentOSBuild string `env:"AGENT_OS_BUILD" envDefault:"UVC"` + AgentOSDistro string `env:"AGENT_OS_DISTRO" envDefault:"UVC"` + AgentOSType string `env:"AGENT_OS_TYPE" envDefault:"UVC"` + EATFormat string `env:"ATTESTATION_EAT_FORMAT" envDefault:"CBOR"` // JWT or CBOR + EATIssuer string `env:"ATTESTATION_EAT_ISSUER" envDefault:"cocos-attestation-service"` + UseCCAttestationAgent bool `env:"USE_CC_ATTESTATION_AGENT" envDefault:"false"` + CCAgentAddress string `env:"CC_AGENT_ADDRESS" envDefault:"127.0.0.1:50002"` + GPUHelperPath string `env:"ATTESTATION_GPU_HELPER_PATH" envDefault:""` + GPUHelperTimeout time.Duration `env:"ATTESTATION_GPU_HELPER_TIMEOUT" envDefault:"30s"` + + // Future KBS Integration Configuration + // When KBS support is added, these fields will enable: + // - Remote attestation verification via KBS + // - Encrypted algorithm/dataset retrieval + // - Per-computation secret provisioning + // + // Example future fields: + // KBSEndpoint string `env:"KBS_ENDPOINT" envDefault:""` // Optional KBS URL + // KBSEnabled bool `env:"KBS_ENABLED" envDefault:"false"` + // KBSTimeout int `env:"KBS_TIMEOUT_SECONDS" envDefault:"30"` } func main() { @@ -218,13 +233,24 @@ func main() { return } + gpuCollector, err := newGPUCollector(cfg) + if err != nil { + logger.Error(fmt.Sprintf("failed to configure GPU attestation collector: %s", err)) + exitCode = 1 + return + } + if gpuCollector != nil { + logger.Info(fmt.Sprintf("[ATTESTATION-SERVICE] GPU evidence collection enabled via helper %s", cfg.GPUHelperPath)) + } + grpcServer := grpc.NewServer() svc := &service{ - provider: provider, - logger: logger, - signingKey: signingKey, - eatFormat: cfg.EATFormat, - eatIssuer: cfg.EATIssuer, + provider: provider, + logger: logger, + signingKey: signingKey, + eatFormat: cfg.EATFormat, + eatIssuer: cfg.EATIssuer, + gpuCollector: gpuCollector, } attestationpb.RegisterAttestationServiceServer(grpcServer, svc) @@ -256,11 +282,12 @@ func main() { type service struct { attestationpb.UnimplementedAttestationServiceServer - provider attestation.Provider - logger *slog.Logger - signingKey *ecdsa.PrivateKey - eatFormat string - eatIssuer string + provider attestation.Provider + logger *slog.Logger + signingKey *ecdsa.PrivateKey + eatFormat string + eatIssuer string + gpuCollector attestationgpu.Collector } func (s *service) FetchAttestation(ctx context.Context, req *attestationpb.AttestationRequest) (*attestationpb.AttestationResponse, error) { @@ -319,12 +346,14 @@ func (s *service) FetchAttestation(ctx context.Context, req *attestationpb.Attes } // Create EAT claims from binary report - nonce := req.ReportData - if len(req.Nonce) > 0 { - nonce = req.Nonce + nonce := requestNonce(req) + + claimOpts, err := s.claimOptions(ctx, req, platformType) + if err != nil { + return nil, err } - claims, err := eat.NewEATClaims(binaryReport, nonce, platformType) + claims, err := eat.NewEATClaims(binaryReport, nonce, platformType, claimOpts...) if err != nil { s.logger.Error(fmt.Sprintf("failed to create EAT claims: %s", err)) return nil, fmt.Errorf("failed to create EAT claims: %w", err) diff --git a/pkg/atls/evidence_verifier.go b/pkg/atls/evidence_verifier.go index 1f8fb66b..f1e7bca3 100644 --- a/pkg/atls/evidence_verifier.go +++ b/pkg/atls/evidence_verifier.go @@ -4,24 +4,39 @@ package atls import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "os" + "strings" + "time" eaattestation "github.com/ultravioletrs/cocos/pkg/atls/eaattestation" cocosattestation "github.com/ultravioletrs/cocos/pkg/attestation" "github.com/ultravioletrs/cocos/pkg/attestation/azure" "github.com/ultravioletrs/cocos/pkg/attestation/eat" + "github.com/ultravioletrs/cocos/pkg/attestation/gpu" "github.com/ultravioletrs/cocos/pkg/attestation/tdx" "github.com/ultravioletrs/cocos/pkg/attestation/vtpm" "github.com/veraison/corim/corim" ) type policyEvidenceVerifier struct { - policyPath string + policyPath string + loadManifest func(string) (*corim.UnsignedCorim, error) + rootVerifier func(cocosattestation.PlatformType) (cocosattestation.Verifier, error) + newGPUVerifier func() (cocosattestation.Verifier, error) } func NewEvidenceVerifier(policyPath string) eaattestation.EvidenceVerifier { - return &policyEvidenceVerifier{policyPath: policyPath} + return &policyEvidenceVerifier{ + policyPath: policyPath, + loadManifest: loadCoRIM, + rootVerifier: platformVerifier, + newGPUVerifier: defaultGPUVerifier, + } } func (v *policyEvidenceVerifier) VerifyEvidence(evidence []byte) error { @@ -32,15 +47,25 @@ func (v *policyEvidenceVerifier) VerifyEvidence(evidence []byte) error { if err != nil { return fmt.Errorf("atls: failed to decode EAT evidence: %w", err) } - manifest, err := loadCoRIM(v.policyPath) + manifest, err := v.loadManifest(v.policyPath) if err != nil { return err } - verifier, err := platformVerifier(platformTypeFromClaims(claims.PlatformType)) + verifier, err := v.rootVerifier(platformTypeFromClaims(claims.PlatformType)) if err != nil { return err } - return verifier.VerifyWithCoRIM(claims.RawReport, manifest) + if err := verifier.VerifyWithCoRIM(claims.RawReport, manifest); err != nil { + return err + } + + if claims.GPUExtensions != nil { + if err := v.verifyGPUEvidence(claims, manifest); err != nil { + return err + } + } + + return nil } func loadCoRIM(path string) (*corim.UnsignedCorim, error) { @@ -90,3 +115,54 @@ func platformVerifier(platformType cocosattestation.PlatformType) (cocosattestat return nil, fmt.Errorf("atls: unsupported platform type: %d", platformType) } } + +func defaultGPUVerifier() (cocosattestation.Verifier, error) { + timeout := 30 * time.Second + if rawTimeout := os.Getenv("ATLS_GPU_VERIFIER_TIMEOUT"); rawTimeout != "" { + parsed, err := time.ParseDuration(rawTimeout) + if err != nil { + return nil, fmt.Errorf("atls: invalid ATLS_GPU_VERIFIER_TIMEOUT: %w", err) + } + timeout = parsed + } + + binaryPath := os.Getenv("ATLS_GPU_VERIFIER_PATH") + if binaryPath == "" { + binaryPath = os.Getenv("ATTESTATION_GPU_HELPER_PATH") + } + + return gpu.NewVerifier(binaryPath, timeout) +} + +func (v *policyEvidenceVerifier) verifyGPUEvidence(claims *eat.EATClaims, manifest *corim.UnsignedCorim) error { + if len(claims.GPUExtensions.EvidenceJSON) == 0 { + return fmt.Errorf("atls: gpu evidence is empty") + } + + expectedNonce := sha256.Sum256(append(append([]byte(nil), claims.Nonce...), []byte(":gpu")...)) + if !bytes.Equal(claims.GPUExtensions.Nonce, expectedNonce[:]) { + return fmt.Errorf("atls: gpu nonce binding mismatch") + } + + // Guard against replay: a stale self-consistent EvidenceJSON blob can be + // paired with a rewritten outer Nonce unless the inner nonce is also checked. + var envelopes []struct { + Nonce string `json:"nonce"` + } + if err := json.Unmarshal(claims.GPUExtensions.EvidenceJSON, &envelopes); err != nil { + return fmt.Errorf("atls: failed to parse GPU evidence JSON: %w", err) + } + if len(envelopes) == 0 || strings.TrimSpace(envelopes[0].Nonce) == "" { + return fmt.Errorf("atls: GPU evidence JSON missing nonce") + } + if envelopes[0].Nonce != hex.EncodeToString(expectedNonce[:]) { + return fmt.Errorf("atls: gpu evidence nonce mismatch") + } + + verifier, err := v.newGPUVerifier() + if err != nil { + return err + } + + return verifier.VerifyWithCoRIM(claims.GPUExtensions.EvidenceJSON, manifest) +} diff --git a/pkg/atls/evidence_verifier_test.go b/pkg/atls/evidence_verifier_test.go new file mode 100644 index 00000000..d85f0849 --- /dev/null +++ b/pkg/atls/evidence_verifier_test.go @@ -0,0 +1,165 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package atls + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "testing" + + "github.com/fxamacker/cbor/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + cocosattestation "github.com/ultravioletrs/cocos/pkg/attestation" + "github.com/ultravioletrs/cocos/pkg/attestation/eat" + "github.com/veraison/corim/corim" +) + +type stubVerifier struct { + reports [][]byte + err error +} + +func (s *stubVerifier) VerifyWithCoRIM(report []byte, _ *corim.UnsignedCorim) error { + s.reports = append(s.reports, append([]byte(nil), report...)) + return s.err +} + +func TestPolicyEvidenceVerifierVerifyEvidence_RootOnly(t *testing.T) { + root := &stubVerifier{} + gpu := &stubVerifier{} + + v := &policyEvidenceVerifier{ + policyPath: "/tmp/policy", + loadManifest: func(string) (*corim.UnsignedCorim, error) { + return &corim.UnsignedCorim{}, nil + }, + rootVerifier: func(cocosattestation.PlatformType) (cocosattestation.Verifier, error) { + return root, nil + }, + newGPUVerifier: func() (cocosattestation.Verifier, error) { + return gpu, nil + }, + } + + err := v.VerifyEvidence(encodeClaims(t, &eat.EATClaims{ + PlatformType: "TDX", + RawReport: []byte("root-report"), + Nonce: []byte("session-nonce"), + })) + require.NoError(t, err) + assert.Equal(t, [][]byte{[]byte("root-report")}, root.reports) + assert.Empty(t, gpu.reports) +} + +func TestPolicyEvidenceVerifierVerifyEvidence_RootAndGPU(t *testing.T) { + root := &stubVerifier{} + gpu := &stubVerifier{} + sessionNonce := []byte("session-nonce") + gpuNonce := deriveExpectedGPUNonce(sessionNonce) + gpuNonceHex := hex.EncodeToString(gpuNonce) + evidenceJSON := fmt.Appendf(nil, `[{"nonce":"%s","evidence":"abc","certificate":"def"}]`, gpuNonceHex) + + v := &policyEvidenceVerifier{ + policyPath: "/tmp/policy", + loadManifest: func(string) (*corim.UnsignedCorim, error) { + return &corim.UnsignedCorim{}, nil + }, + rootVerifier: func(cocosattestation.PlatformType) (cocosattestation.Verifier, error) { + return root, nil + }, + newGPUVerifier: func() (cocosattestation.Verifier, error) { + return gpu, nil + }, + } + + err := v.VerifyEvidence(encodeClaims(t, &eat.EATClaims{ + PlatformType: "TDX", + RawReport: []byte("root-report"), + Nonce: sessionNonce, + GPUExtensions: &eat.GPUExtensions{ + Nonce: gpuNonce, + EvidenceJSON: evidenceJSON, + }, + })) + require.NoError(t, err) + assert.Equal(t, [][]byte{[]byte("root-report")}, root.reports) + assert.Equal(t, [][]byte{evidenceJSON}, gpu.reports) +} + +func TestPolicyEvidenceVerifierVerifyEvidence_GPUNonceMismatch(t *testing.T) { + root := &stubVerifier{} + + v := &policyEvidenceVerifier{ + policyPath: "/tmp/policy", + loadManifest: func(string) (*corim.UnsignedCorim, error) { + return &corim.UnsignedCorim{}, nil + }, + rootVerifier: func(cocosattestation.PlatformType) (cocosattestation.Verifier, error) { + return root, nil + }, + } + + err := v.VerifyEvidence(encodeClaims(t, &eat.EATClaims{ + PlatformType: "TDX", + RawReport: []byte("root-report"), + Nonce: []byte("session-nonce"), + GPUExtensions: &eat.GPUExtensions{ + Nonce: []byte("wrong"), + EvidenceJSON: []byte(`[{"nonce":"aabbcc"}]`), + }, + })) + require.Error(t, err) + assert.ErrorContains(t, err, "gpu nonce binding mismatch") + assert.Equal(t, [][]byte{[]byte("root-report")}, root.reports) +} + +func TestPolicyEvidenceVerifierVerifyEvidence_GPUVerifierError(t *testing.T) { + expectedErr := errors.New("gpu verify failed") + root := &stubVerifier{} + gpu := &stubVerifier{err: expectedErr} + sessionNonce := []byte("session-nonce") + derivedNonce := deriveExpectedGPUNonce(sessionNonce) + gpuEvidenceJSON := fmt.Appendf(nil, `[{"nonce":"%s"}]`, hex.EncodeToString(derivedNonce)) + + v := &policyEvidenceVerifier{ + policyPath: "/tmp/policy", + loadManifest: func(string) (*corim.UnsignedCorim, error) { + return &corim.UnsignedCorim{}, nil + }, + rootVerifier: func(cocosattestation.PlatformType) (cocosattestation.Verifier, error) { + return root, nil + }, + newGPUVerifier: func() (cocosattestation.Verifier, error) { + return gpu, nil + }, + } + + err := v.VerifyEvidence(encodeClaims(t, &eat.EATClaims{ + PlatformType: "TDX", + RawReport: []byte("root-report"), + Nonce: sessionNonce, + GPUExtensions: &eat.GPUExtensions{ + Nonce: derivedNonce, + EvidenceJSON: gpuEvidenceJSON, + }, + })) + require.Error(t, err) + assert.ErrorIs(t, err, expectedErr) +} + +func encodeClaims(t *testing.T, claims *eat.EATClaims) []byte { + t.Helper() + + b, err := cbor.Marshal(claims) + require.NoError(t, err) + return b +} + +func deriveExpectedGPUNonce(sessionNonce []byte) []byte { + sum := sha256.Sum256(append(append([]byte(nil), sessionNonce...), []byte(":gpu")...)) + return sum[:] +} diff --git a/pkg/attestation/eat/eat.go b/pkg/attestation/eat/eat.go index a312ad86..10ffabb5 100644 --- a/pkg/attestation/eat/eat.go +++ b/pkg/attestation/eat/eat.go @@ -39,6 +39,7 @@ type EATClaims struct { SNPExtensions *SNPExtensions `json:"x-cocos-sevsnp,omitempty"` TDXExtensions *TDXExtensions `json:"x-cocos-tdx,omitempty"` VTPMExtensions *VTPMExtensions `json:"x-cocos-vtpm,omitempty"` + GPUExtensions *GPUExtensions `json:"x-cocos-gpu,omitempty"` // Original binary report (for verification) RawReport []byte `json:"raw_report,omitempty"` @@ -94,6 +95,36 @@ type VTPMExtensions struct { Quote []byte `json:"quote,omitempty"` // TPM quote } +// GPUExtensions contains optional GPU attestation evidence that is bound to the +// same attestation session as the root TEE evidence. +type GPUExtensions struct { + Vendor string `json:"vendor,omitempty" cbor:"vendor,omitempty"` + EvidenceFormat string `json:"evidence_format,omitempty" cbor:"evidence_format,omitempty"` + Nonce []byte `json:"nonce,omitempty" cbor:"nonce,omitempty"` + EvidenceJSON []byte `json:"evidence_json,omitempty" cbor:"evidence_json,omitempty"` +} + +// ClaimsOption customizes EAT claims after the root platform claims are +// extracted. +type ClaimsOption func(*EATClaims) error + +// WithGPU attaches GPU evidence both as a typed extension and as an EAT submod. +func WithGPU(gpu *GPUExtensions) ClaimsOption { + return func(claims *EATClaims) error { + if gpu == nil { + return nil + } + + claims.GPUExtensions = gpu + if claims.Submods == nil { + claims.Submods = map[string]interface{}{} + } + claims.Submods["gpu"] = gpu + + return nil + } +} + // DebugStatus constants (RFC 9711 Section 4.2.6). const ( DebugEnabled = 0 // Debug is enabled @@ -112,7 +143,7 @@ const ( const MinNonceLength = 8 // NewEATClaims creates EAT claims from binary attestation report. -func NewEATClaims(report []byte, nonce []byte, platformType attestation.PlatformType) (*EATClaims, error) { +func NewEATClaims(report []byte, nonce []byte, platformType attestation.PlatformType, opts ...ClaimsOption) (*EATClaims, error) { if len(nonce) < MinNonceLength { return nil, errors.New("eat_nonce must be at least 8 bytes long") } @@ -129,6 +160,15 @@ func NewEATClaims(report []byte, nonce []byte, platformType attestation.Platform return nil, err } + for _, opt := range opts { + if opt == nil { + continue + } + if err := opt(claims); err != nil { + return nil, err + } + } + return claims, nil } diff --git a/pkg/attestation/eat/eat_test.go b/pkg/attestation/eat/eat_test.go index a9f4a564..d176324a 100644 --- a/pkg/attestation/eat/eat_test.go +++ b/pkg/attestation/eat/eat_test.go @@ -202,3 +202,24 @@ func TestNewEATClaims_Platforms(t *testing.T) { }) } } + +func TestNewEATClaims_WithGPU(t *testing.T) { + gpuEvidence := &GPUExtensions{ + Vendor: "nvidia", + EvidenceFormat: "nvat-json", + Nonce: []byte("gpu-nonce"), + EvidenceJSON: []byte(`{"evidence":"gpu"}`), + } + + claims, err := NewEATClaims( + []byte("dummy report"), + []byte("12345678"), + attestation.NoCC, + WithGPU(gpuEvidence), + ) + assert.NoError(t, err) + assert.NotNil(t, claims.GPUExtensions) + assert.Equal(t, gpuEvidence, claims.GPUExtensions) + assert.Contains(t, claims.Submods, "gpu") + assert.Equal(t, gpuEvidence, claims.Submods["gpu"]) +} diff --git a/pkg/attestation/gpu/collector.go b/pkg/attestation/gpu/collector.go new file mode 100644 index 00000000..1a0b3e31 --- /dev/null +++ b/pkg/attestation/gpu/collector.go @@ -0,0 +1,138 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package gpu + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" +) + +const ( + DefaultVendor = "nvidia" + DefaultEvidenceFormat = "nvat-json" +) + +// Collector retrieves GPU evidence for the current attestation session. +type Collector interface { + Collect(ctx context.Context, nonce []byte) (*Evidence, error) +} + +// Evidence contains low-level GPU evidence collected out-of-process. +type Evidence struct { + Vendor string + EvidenceFormat string + Nonce []byte + RawEvidence []byte +} + +type commandCollector struct { + binaryPath string + timeout time.Duration + execCommandContext func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +type helperRequest struct { + Mode string `json:"mode,omitempty"` + NonceHex string `json:"nonce_hex"` + EvidenceJSON json.RawMessage `json:"evidence_json,omitempty"` +} + +type helperResponse struct { + Vendor string `json:"vendor,omitempty"` + EvidenceFormat string `json:"evidence_format,omitempty"` + EvidenceJSON json.RawMessage `json:"evidence_json,omitempty"` + ClaimsJSON json.RawMessage `json:"claims_json,omitempty"` + DetachedEATJSON json.RawMessage `json:"detached_eat_json,omitempty"` +} + +// NewCommandCollector creates a collector that shells out to a helper binary. +// The helper is expected to read a JSON request on stdin and emit a JSON +// response on stdout. See tools/nvidia-attestation-helper for the contract. +func NewCommandCollector(binaryPath string, timeout time.Duration) (Collector, error) { + if strings.TrimSpace(binaryPath) == "" { + return nil, fmt.Errorf("gpu helper path cannot be empty") + } + if timeout <= 0 { + timeout = 30 * time.Second + } + + return &commandCollector{ + binaryPath: binaryPath, + timeout: timeout, + execCommandContext: exec.CommandContext, + }, nil +} + +func (c *commandCollector) Collect(ctx context.Context, nonce []byte) (*Evidence, error) { + if len(nonce) == 0 { + return nil, fmt.Errorf("gpu nonce cannot be empty") + } + + reqBody, err := json.Marshal(helperRequest{ + Mode: "collect", + NonceHex: hex.EncodeToString(nonce), + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal GPU helper request: %w", err) + } + + runCtx := ctx + cancel := func() {} + if c.timeout > 0 { + runCtx, cancel = context.WithTimeout(ctx, c.timeout) + } + defer cancel() + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + cmd := c.execCommandContext(runCtx, c.binaryPath) + cmd.Stdin = bytes.NewReader(reqBody) + cmd.Stdout = stdout + cmd.Stderr = stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = err.Error() + } + return nil, fmt.Errorf("gpu helper failed: %s", errMsg) + } + + var resp helperResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("failed to decode GPU helper response: %w", err) + } + if len(resp.EvidenceJSON) == 0 { + return nil, fmt.Errorf("gpu helper response did not contain evidence_json") + } + + vendor := resp.Vendor + if vendor == "" { + vendor = DefaultVendor + } + + evidenceFormat := resp.EvidenceFormat + if evidenceFormat == "" { + evidenceFormat = DefaultEvidenceFormat + } + + return &Evidence{ + Vendor: vendor, + EvidenceFormat: evidenceFormat, + Nonce: append([]byte(nil), nonce...), + RawEvidence: append([]byte(nil), resp.EvidenceJSON...), + }, nil +} + +// SetExecCommandContext allows tests to inject a mock exec.CommandContext. +func (c *commandCollector) SetExecCommandContext(cmdFunc func(ctx context.Context, name string, arg ...string) *exec.Cmd) { + c.execCommandContext = cmdFunc +} diff --git a/pkg/attestation/gpu/collector_test.go b/pkg/attestation/gpu/collector_test.go new file mode 100644 index 00000000..6a50f52d --- /dev/null +++ b/pkg/attestation/gpu/collector_test.go @@ -0,0 +1,141 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package gpu + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func fakeExecCommandContext(_ context.Context, name string, arg ...string) *exec.Cmd { + args := append([]string{"-test.run=TestGPUHelperProcess", "--", name}, arg...) + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), "GO_WANT_GPU_HELPER_PROCESS=1") + return cmd +} + +func TestGPUHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_GPU_HELPER_PROCESS") != "1" { + return + } + + args := os.Args + for i := range args { + if args[i] == "--" { + args = args[i+1:] + break + } + } + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "missing helper name") + os.Exit(2) + } + + switch args[0] { + case "helper-error": + fmt.Fprintln(os.Stderr, "simulated helper failure") + os.Exit(1) + case "helper-invalid-json": + fmt.Fprintln(os.Stdout, "{not-json") + os.Exit(0) + case "helper-empty-evidence": + fmt.Fprintln(os.Stdout, `{"vendor":"nvidia","evidence_format":"nvat-json"}`) + os.Exit(0) + default: + var req helperRequest + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + if req.Mode != "collect" { + fmt.Fprintln(os.Stderr, "unexpected helper mode") + os.Exit(1) + } + + resp := helperResponse{ + Vendor: "nvidia", + EvidenceFormat: "nvat-json", + EvidenceJSON: json.RawMessage(fmt.Sprintf(`{"nonce_hex":"%s","evidence":"ok"}`, req.NonceHex)), + } + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + os.Exit(0) + } +} + +func TestNewCommandCollector(t *testing.T) { + collector, err := NewCommandCollector("helper", time.Second) + assert.NoError(t, err) + assert.NotNil(t, collector) + + collector, err = NewCommandCollector("", time.Second) + assert.Error(t, err) + assert.Nil(t, collector) +} + +func TestCommandCollectorCollect(t *testing.T) { + collector, err := NewCommandCollector("helper-success", time.Second) + require.NoError(t, err) + + cmdCollector, ok := collector.(*commandCollector) + require.True(t, ok) + cmdCollector.SetExecCommandContext(fakeExecCommandContext) + + evidence, err := collector.Collect(context.Background(), []byte{0xaa, 0xbb, 0xcc}) + require.NoError(t, err) + assert.Equal(t, DefaultVendor, evidence.Vendor) + assert.Equal(t, DefaultEvidenceFormat, evidence.EvidenceFormat) + assert.Equal(t, []byte{0xaa, 0xbb, 0xcc}, evidence.Nonce) + assert.JSONEq(t, `{"nonce_hex":"aabbcc","evidence":"ok"}`, string(evidence.RawEvidence)) +} + +func TestCommandCollectorCollectErrors(t *testing.T) { + tests := []struct { + name string + helperName string + wantErr string + }{ + { + name: "helper process failure", + helperName: "helper-error", + wantErr: "gpu helper failed: simulated helper failure", + }, + { + name: "invalid json response", + helperName: "helper-invalid-json", + wantErr: "failed to decode GPU helper response", + }, + { + name: "missing evidence payload", + helperName: "helper-empty-evidence", + wantErr: "gpu helper response did not contain evidence_json", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + collector, err := NewCommandCollector(tt.helperName, time.Second) + require.NoError(t, err) + + cmdCollector, ok := collector.(*commandCollector) + require.True(t, ok) + cmdCollector.SetExecCommandContext(fakeExecCommandContext) + + _, err = collector.Collect(context.Background(), []byte{0xaa}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} diff --git a/pkg/attestation/gpu/verifier.go b/pkg/attestation/gpu/verifier.go new file mode 100644 index 00000000..d76f8c8b --- /dev/null +++ b/pkg/attestation/gpu/verifier.go @@ -0,0 +1,231 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package gpu + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os/exec" + "strings" + "time" + + "github.com/ultravioletrs/cocos/pkg/attestation" + "github.com/veraison/corim/comid" + "github.com/veraison/corim/corim" +) + +const ( + DefaultVerifierBinary = "nvidia-attestation-helper" + defaultVerifierTimeout = 30 * time.Second +) + +var _ attestation.Verifier = (*verifier)(nil) + +type verifier struct { + binaryPath string + timeout time.Duration + execCommandContext func(ctx context.Context, name string, arg ...string) *exec.Cmd +} + +type evidenceEnvelope struct { + Nonce string `json:"nonce"` +} + +// gpuDeviceClaims mirrors the per-device object produced by the NVIDIA +// attestation helper's verify mode (e.g. "GPU-0": { ... }). +type gpuDeviceClaims struct { + HWModel string `json:"hwmodel"` + OEMID string `json:"oemid"` + DriverVersion string `json:"x-nvidia-gpu-driver-version"` + VBIOSVersion string `json:"x-nvidia-gpu-vbios-version"` + SecBoot bool `json:"secboot"` + DebugStatus string `json:"dbgstat"` + MeasurementResult string `json:"measres"` + NonceMatch bool `json:"x-nvidia-gpu-attestation-report-nonce-match"` + SigVerified bool `json:"x-nvidia-gpu-attestation-report-signature-verified"` + FWIDMatch bool `json:"x-nvidia-gpu-attestation-report-cert-chain-fwid-match"` + ArchCheck bool `json:"x-nvidia-gpu-arch-check"` + DriverRIMSigVerified bool `json:"x-nvidia-gpu-driver-rim-signature-verified"` + VBIOSRIMSigVerified bool `json:"x-nvidia-gpu-vbios-rim-signature-verified"` + DriverRIMVersionMatch bool `json:"x-nvidia-gpu-driver-rim-version-match"` + VBIOSRIMVersionMatch bool `json:"x-nvidia-gpu-vbios-rim-version-match"` + AttestationWarning *string `json:"x-nvidia-attestation-warning"` +} + +func NewVerifier(binaryPath string, timeout time.Duration) (attestation.Verifier, error) { + if strings.TrimSpace(binaryPath) == "" { + binaryPath = DefaultVerifierBinary + } + if timeout <= 0 { + timeout = defaultVerifierTimeout + } + + return &verifier{ + binaryPath: binaryPath, + timeout: timeout, + execCommandContext: exec.CommandContext, + }, nil +} + +func (v *verifier) VerifyWithCoRIM(report []byte, manifest *corim.UnsignedCorim) error { + if len(report) == 0 { + return fmt.Errorf("gpu evidence is empty") + } + + nonceHex, err := evidenceNonce(report) + if err != nil { + return err + } + + reqBody, err := json.Marshal(helperRequest{ + Mode: "verify", + NonceHex: nonceHex, + EvidenceJSON: append(json.RawMessage(nil), report...), + }) + if err != nil { + return fmt.Errorf("failed to marshal GPU verifier request: %w", err) + } + + runCtx := context.Background() + cancel := func() {} + if v.timeout > 0 { + runCtx, cancel = context.WithTimeout(runCtx, v.timeout) + } + defer cancel() + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + cmd := v.execCommandContext(runCtx, v.binaryPath) + cmd.Stdin = bytes.NewReader(reqBody) + cmd.Stdout = stdout + cmd.Stderr = stderr + + if err := cmd.Run(); err != nil { + errMsg := strings.TrimSpace(stderr.String()) + if errMsg == "" { + errMsg = err.Error() + } + return fmt.Errorf("gpu verifier helper failed: %s", errMsg) + } + + var resp helperResponse + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return fmt.Errorf("failed to decode GPU verifier response: %w", err) + } + if len(resp.ClaimsJSON) == 0 { + return fmt.Errorf("gpu verifier response did not contain claims_json") + } + + var deviceClaims map[string]gpuDeviceClaims + if err := json.Unmarshal(resp.ClaimsJSON, &deviceClaims); err != nil { + return fmt.Errorf("gpu: failed to parse claims JSON: %w", err) + } + if len(deviceClaims) == 0 { + return fmt.Errorf("gpu: verifier response contained no device claims") + } + + return appraiseGPUClaims(deviceClaims, manifest) +} + +// appraiseGPUClaims checks mandatory security flags on every device, then +// matches each device's identity against CoRIM reference values when a +// manifest is provided. +func appraiseGPUClaims(devices map[string]gpuDeviceClaims, manifest *corim.UnsignedCorim) error { + for id, c := range devices { + if !c.SecBoot { + return fmt.Errorf("gpu: %s: secure boot not enabled", id) + } + if c.DebugStatus != "disabled" { + return fmt.Errorf("gpu: %s: debug not disabled (got %q)", id, c.DebugStatus) + } + if c.MeasurementResult != "success" { + return fmt.Errorf("gpu: %s: measurement result not success (got %q)", id, c.MeasurementResult) + } + if !c.NonceMatch || !c.SigVerified || !c.FWIDMatch || !c.ArchCheck || + !c.DriverRIMSigVerified || !c.VBIOSRIMSigVerified || + !c.DriverRIMVersionMatch || !c.VBIOSRIMVersionMatch { + return fmt.Errorf("gpu: %s: one or more attestation verification flags are false", id) + } + if c.AttestationWarning != nil { + return fmt.Errorf("gpu: %s: attestation warning: %s", id, *c.AttestationWarning) + } + } + + if manifest == nil { + return nil + } + + // Match each device's identity (model|driver|vbios) against CoRIM digests. + // matchesCoRIM returns true when a digest matches OR when the manifest + // contains no digest entries at all (no GPU policy configured). + for id, c := range devices { + identity := c.HWModel + "|" + c.DriverVersion + "|" + c.VBIOSVersion + digest := sha256.Sum256([]byte(identity)) + if !matchesCoRIM(digest[:], manifest) { + return fmt.Errorf("gpu: %s: no CoRIM reference value matched (model=%q driver=%q vbios=%q)", + id, c.HWModel, c.DriverVersion, c.VBIOSVersion) + } + } + return nil +} + +// matchesCoRIM returns true when digest matches any reference value digest in +// the manifest, or when the manifest contains no digest entries (treating an +// empty manifest as "no GPU policy configured"). +func matchesCoRIM(digest []byte, manifest *corim.UnsignedCorim) bool { + hasAnyDigests := false + for _, tag := range manifest.Tags { + if !bytes.HasPrefix(tag, corim.ComidTag) { + continue + } + var c comid.Comid + if err := c.FromCBOR(tag[len(corim.ComidTag):]); err != nil { + continue + } + if c.Triples.ReferenceValues == nil { + continue + } + for _, rv := range *c.Triples.ReferenceValues { + if rv.Measurements.Valid() != nil { + continue + } + for _, m := range rv.Measurements { + if m.Val.Digests == nil { + continue + } + for _, d := range *m.Val.Digests { + hasAnyDigests = true + if bytes.Equal(d.HashValue, digest) { + return true + } + } + } + } + } + return !hasAnyDigests +} + +func evidenceNonce(report []byte) (string, error) { + var envelopes []evidenceEnvelope + if err := json.Unmarshal(report, &envelopes); err != nil { + return "", fmt.Errorf("failed to parse GPU evidence JSON: %w", err) + } + if len(envelopes) == 0 { + return "", fmt.Errorf("gpu evidence did not contain any devices") + } + if strings.TrimSpace(envelopes[0].Nonce) == "" { + return "", fmt.Errorf("gpu evidence nonce is missing") + } + + return envelopes[0].Nonce, nil +} + +// SetExecCommandContext allows tests to inject a mock exec.CommandContext. +func (v *verifier) SetExecCommandContext(cmdFunc func(ctx context.Context, name string, arg ...string) *exec.Cmd) { + v.execCommandContext = cmdFunc +} diff --git a/pkg/attestation/gpu/verifier_test.go b/pkg/attestation/gpu/verifier_test.go new file mode 100644 index 00000000..7e3e0a63 --- /dev/null +++ b/pkg/attestation/gpu/verifier_test.go @@ -0,0 +1,359 @@ +// Copyright (c) Ultraviolet +// SPDX-License-Identifier: Apache-2.0 + +package gpu + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/veraison/corim/corim" +) + +// validClaimsJSON is a well-formed helper response matching the real SDK output +// (hopperClaimsv3_decoded.json from the NVAT SDK test data). +const validClaimsJSON = `{ + "GPU-0": { + "hwmodel": "GH100 A01 GSP BROM", + "oemid": "5703", + "x-nvidia-gpu-driver-version": "550.90.07", + "x-nvidia-gpu-vbios-version": "96.00.9F.00.01", + "secboot": true, + "dbgstat": "disabled", + "measres": "success", + "x-nvidia-gpu-attestation-report-nonce-match": true, + "x-nvidia-gpu-attestation-report-signature-verified": true, + "x-nvidia-gpu-attestation-report-cert-chain-fwid-match": true, + "x-nvidia-gpu-arch-check": true, + "x-nvidia-gpu-driver-rim-signature-verified": true, + "x-nvidia-gpu-vbios-rim-signature-verified": true, + "x-nvidia-gpu-driver-rim-version-match": true, + "x-nvidia-gpu-vbios-rim-version-match": true, + "x-nvidia-attestation-warning": null + } +}` + +func fakeVerifierExecCommandContext(_ context.Context, name string, arg ...string) *exec.Cmd { + args := append([]string{"-test.run=TestGPUVerifierHelperProcess", "--", name}, arg...) + cmd := exec.Command(os.Args[0], args...) + cmd.Env = append(os.Environ(), "GO_WANT_GPU_VERIFIER_PROCESS=1") + return cmd +} + +func TestGPUVerifierHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_GPU_VERIFIER_PROCESS") != "1" { + return + } + + args := os.Args + for i := range args { + if args[i] == "--" { + args = args[i+1:] + break + } + } + + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "missing verifier binary name") + os.Exit(2) + } + + switch args[0] { + case "verifier-error": + fmt.Fprintln(os.Stderr, "simulated verifier failure") + os.Exit(1) + case "verifier-invalid-json": + fmt.Fprintln(os.Stdout, "{not-json") + os.Exit(0) + case "verifier-empty-claims": + fmt.Fprintln(os.Stdout, `{"detached_eat_json":{"overall_result":true}}`) + os.Exit(0) + case "verifier-invalid-claims-format": + fmt.Fprintln(os.Stdout, `{"claims_json":[1,2,3]}`) + os.Exit(0) + case "verifier-empty-device-claims": + fmt.Fprintln(os.Stdout, `{"claims_json":{}}`) + os.Exit(0) + default: + var req helperRequest + if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + if req.Mode != "verify" { + fmt.Fprintln(os.Stderr, "unexpected verifier mode") + os.Exit(1) + } + if req.NonceHex == "" { + fmt.Fprintln(os.Stderr, "nonce not propagated to verifier helper") + os.Exit(1) + } + if !json.Valid(req.EvidenceJSON) { + fmt.Fprintln(os.Stderr, "invalid evidence_json payload") + os.Exit(1) + } + if !containsNonce(req.EvidenceJSON, req.NonceHex) { + fmt.Fprintln(os.Stderr, "nonce not propagated to verifier") + os.Exit(1) + } + + resp := helperResponse{ + ClaimsJSON: json.RawMessage(validClaimsJSON), + DetachedEATJSON: json.RawMessage(`{"overall_result":true}`), + } + if err := json.NewEncoder(os.Stdout).Encode(resp); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + os.Exit(0) + } +} + +func TestEvidenceNonce(t *testing.T) { + nonce, err := evidenceNonce([]byte(`[{"nonce":"aabbcc"}]`)) + assert.NoError(t, err) + assert.Equal(t, "aabbcc", nonce) + + _, err = evidenceNonce([]byte(`[]`)) + assert.ErrorContains(t, err, "did not contain any devices") + + _, err = evidenceNonce([]byte(`[{}]`)) + assert.ErrorContains(t, err, "nonce is missing") +} + +func containsNonce(report json.RawMessage, nonce string) bool { + var envelopes []evidenceEnvelope + if err := json.Unmarshal(report, &envelopes); err != nil { + return false + } + if len(envelopes) == 0 { + return false + } + + return envelopes[0].Nonce == nonce +} + +func TestVerifierVerifyWithCoRIM(t *testing.T) { + v, err := NewVerifier("verifier-success", 0) + require.NoError(t, err) + + cmdVerifier, ok := v.(*verifier) + require.True(t, ok) + cmdVerifier.SetExecCommandContext(fakeVerifierExecCommandContext) + + report := []byte(`[{"nonce":"aabbcc","evidence":"abc","certificate":"def"}]`) + + // nil manifest: CoRIM phase skipped, only mandatory flags checked. + err = v.VerifyWithCoRIM(report, nil) + assert.NoError(t, err) + + // Empty manifest: no digest entries → matchesCoRIM returns true → pass. + err = v.VerifyWithCoRIM(report, &corim.UnsignedCorim{}) + assert.NoError(t, err) +} + +func TestVerifierVerifyWithCoRIMErrors(t *testing.T) { + tests := []struct { + name string + binary string + report []byte + wantError string + }{ + { + name: "empty report", + report: nil, + wantError: "gpu evidence is empty", + }, + { + name: "invalid json", + report: []byte(`{`), + wantError: "failed to parse GPU evidence JSON", + }, + { + name: "helper failure", + binary: "verifier-error", + report: []byte(`[{"nonce":"aabbcc"}]`), + wantError: "gpu verifier helper failed", + }, + { + name: "invalid verifier response", + binary: "verifier-invalid-json", + report: []byte(`[{"nonce":"aabbcc"}]`), + wantError: "failed to decode GPU verifier response", + }, + { + name: "missing claims", + binary: "verifier-empty-claims", + report: []byte(`[{"nonce":"aabbcc"}]`), + wantError: "gpu verifier response did not contain claims_json", + }, + { + name: "invalid claims format", + binary: "verifier-invalid-claims-format", + report: []byte(`[{"nonce":"aabbcc"}]`), + wantError: "gpu: failed to parse claims JSON", + }, + { + name: "empty device claims", + binary: "verifier-empty-device-claims", + report: []byte(`[{"nonce":"aabbcc"}]`), + wantError: "gpu: verifier response contained no device claims", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.name == "empty report" || tt.name == "invalid json" { + v, err := NewVerifier("verifier-success", 0) + require.NoError(t, err) + err = v.VerifyWithCoRIM(tt.report, nil) + assert.ErrorContains(t, err, tt.wantError) + return + } + + v, err := NewVerifier(tt.binary, 0) + require.NoError(t, err) + + cmdVerifier, ok := v.(*verifier) + require.True(t, ok) + cmdVerifier.SetExecCommandContext(fakeVerifierExecCommandContext) + + err = v.VerifyWithCoRIM(tt.report, nil) + assert.ErrorContains(t, err, tt.wantError) + }) + } +} + +func TestAppraiseGPUClaims(t *testing.T) { + warning := "some warning" + validDevice := gpuDeviceClaims{ + HWModel: "GH100 A01 GSP BROM", + OEMID: "5703", + DriverVersion: "550.90.07", + VBIOSVersion: "96.00.9F.00.01", + SecBoot: true, + DebugStatus: "disabled", + MeasurementResult: "success", + NonceMatch: true, + SigVerified: true, + FWIDMatch: true, + ArchCheck: true, + DriverRIMSigVerified: true, + VBIOSRIMSigVerified: true, + DriverRIMVersionMatch: true, + VBIOSRIMVersionMatch: true, + AttestationWarning: nil, + } + + tests := []struct { + name string + modify func(gpuDeviceClaims) gpuDeviceClaims + wantError string + }{ + { + name: "all valid", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { return c }, + }, + { + name: "secure boot disabled", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.SecBoot = false; return c }, + wantError: "secure boot not enabled", + }, + { + name: "debug not disabled", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.DebugStatus = "enabled"; return c }, + wantError: "debug not disabled", + }, + { + name: "measurement result failed", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.MeasurementResult = "failed"; return c }, + wantError: "measurement result not success", + }, + { + name: "nonce mismatch", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.NonceMatch = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "signature not verified", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.SigVerified = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "fwid mismatch", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.FWIDMatch = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "arch check failed", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.ArchCheck = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "driver RIM sig not verified", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.DriverRIMSigVerified = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "vbios RIM sig not verified", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.VBIOSRIMSigVerified = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "driver RIM version mismatch", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.DriverRIMVersionMatch = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "vbios RIM version mismatch", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.VBIOSRIMVersionMatch = false; return c }, + wantError: "one or more attestation verification flags are false", + }, + { + name: "attestation warning present", + modify: func(c gpuDeviceClaims) gpuDeviceClaims { c.AttestationWarning = &warning; return c }, + wantError: "attestation warning", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + devices := map[string]gpuDeviceClaims{ + "GPU-0": tt.modify(validDevice), + } + err := appraiseGPUClaims(devices, nil) + if tt.wantError == "" { + assert.NoError(t, err) + } else { + assert.ErrorContains(t, err, tt.wantError) + } + }) + } +} + +func TestMatchesCoRIM(t *testing.T) { + digest := []byte("some-32-byte-digest-padding-here") + + t.Run("nil tags returns true", func(t *testing.T) { + assert.True(t, matchesCoRIM(digest, &corim.UnsignedCorim{})) + }) + + t.Run("non-ComidTag prefix is skipped", func(t *testing.T) { + m := &corim.UnsignedCorim{ + Tags: []corim.Tag{[]byte{0x01, 0x02, 0x03}}, + } + assert.True(t, matchesCoRIM(digest, m)) + }) + + t.Run("unparseable ComidTag payload is skipped", func(t *testing.T) { + bad := append(append([]byte{}, corim.ComidTag...), 0xFF, 0xFE) + m := &corim.UnsignedCorim{Tags: []corim.Tag{bad}} + assert.True(t, matchesCoRIM(digest, m)) + }) +} diff --git a/tools/nvidia-attestation-helper/Cargo.lock b/tools/nvidia-attestation-helper/Cargo.lock new file mode 100644 index 00000000..7ecc1cdc --- /dev/null +++ b/tools/nvidia-attestation-helper/Cargo.lock @@ -0,0 +1,308 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nv-attestation-sdk" +version = "1.2.0" +source = "git+https://github.com/NVIDIA/attestation-sdk?branch=main#82d29930f5cbc9d689393844132ac0d733b1f499" +dependencies = [ + "nv-attestation-sdk-sys", +] + +[[package]] +name = "nv-attestation-sdk-sys" +version = "1.2.0" +source = "git+https://github.com/NVIDIA/attestation-sdk?branch=main#82d29930f5cbc9d689393844132ac0d733b1f499" +dependencies = [ + "bindgen", +] + +[[package]] +name = "nvidia-attestation-helper" +version = "0.1.0" +dependencies = [ + "anyhow", + "nv-attestation-sdk", + "serde", + "serde_json", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/nvidia-attestation-helper/Cargo.toml b/tools/nvidia-attestation-helper/Cargo.toml new file mode 100644 index 00000000..efdcdc33 --- /dev/null +++ b/tools/nvidia-attestation-helper/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "nvidia-attestation-helper" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" + +[dependencies] +anyhow = "1" +nv-attestation-sdk = { git = "https://github.com/NVIDIA/attestation-sdk", rev = "82d29930f5cbc9d689393844132ac0d733b1f499" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/tools/nvidia-attestation-helper/README.md b/tools/nvidia-attestation-helper/README.md new file mode 100644 index 00000000..d36dd6ca --- /dev/null +++ b/tools/nvidia-attestation-helper/README.md @@ -0,0 +1,92 @@ +# NVIDIA Attestation Helper + +This helper wraps NVIDIA's Rust attestation SDK low-level GPU evidence +collection and verification flows and exposes a tiny JSON stdin/stdout +protocol that the Go attestation service and ATLS verifier can call. + +## Request + +The helper reads a single JSON object from stdin: + +```json +{ + "mode": "collect", + "nonce_hex": "aabbccdd" +} +``` + +For verification, send: + +```json +{ + "mode": "verify", + "nonce_hex": "aabbccdd", + "evidence_json": [{ "...": "..." }] +} +``` + +## Response + +On success it writes: + +```json +{ + "vendor": "nvidia", + "evidence_format": "nvat-json", + "evidence_json": { "...": "..." } +} +``` + +`evidence_json` is the JSON emitted by `GpuEvidence::to_json()`. + +Verification responses contain the NVIDIA appraisal outputs: + +```json +{ + "claims_json": [{ "...": "..." }], + "detached_eat_json": { "...": "..." } +} +``` + +## Build + +Prerequisites: + +- Rust 1.80+ +- `libnvat.so.1` +- Clang/LLVM +- NVIDIA GPU driver with NVML support + +If you are using a system-installed NVAT library: + +```bash +export NVAT_USE_SYSTEM_LIB=1 +cargo build --release +``` + +If you built NVAT locally, make sure the C library is installed or on +`LD_LIBRARY_PATH` before building or running the helper. + +## Use With COCOS + +Point the attestation service at the compiled binary: + +```bash +export ATTESTATION_GPU_HELPER_PATH=/path/to/nvidia-attestation-helper +export ATTESTATION_GPU_HELPER_TIMEOUT=30s +``` + +When a helper path is configured, COCOS will attempt to collect GPU evidence +opportunistically. If the host does not expose a supported CC-capable NVIDIA +GPU, the attestation service skips GPU evidence and still returns the root +CPU/TEE attestation. + +ATLS can use the same helper during TLS-handshake verification: + +```bash +export ATLS_GPU_VERIFIER_PATH=/path/to/nvidia-attestation-helper +export ATLS_GPU_VERIFIER_TIMEOUT=30s +``` + +If `ATLS_GPU_VERIFIER_PATH` is unset, the verifier also falls back to +`ATTESTATION_GPU_HELPER_PATH`. diff --git a/tools/nvidia-attestation-helper/src/main.rs b/tools/nvidia-attestation-helper/src/main.rs new file mode 100644 index 00000000..62ee704f --- /dev/null +++ b/tools/nvidia-attestation-helper/src/main.rs @@ -0,0 +1,124 @@ +use anyhow::{bail, Context}; +use nv_attestation_sdk::{ + EvidencePolicy, GpuEvidenceSource, GpuLocalVerifier, HttpOptions, Nonce, NvatSdk, OcspClient, + RimStore, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::io::{stdin, stdout, Write}; + +#[derive(Debug, Deserialize)] +struct Request { + mode: Option, + nonce_hex: String, + #[serde(default)] + evidence_json: Option, +} + +#[derive(Debug, Serialize)] +struct Response { + #[serde(skip_serializing_if = "Option::is_none")] + vendor: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + evidence_format: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + evidence_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + claims_json: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detached_eat_json: Option, +} + +fn main() -> anyhow::Result<()> { + let _sdk = NvatSdk::init_default().context("failed to initialize NVIDIA attestation SDK")?; + + let req: Request = + serde_json::from_reader(stdin().lock()).context("failed to decode helper request")?; + let nonce = Nonce::from_hex(&req.nonce_hex).context("failed to parse nonce")?; + + let resp = match req.mode.as_deref().unwrap_or("collect") { + "collect" => collect_evidence(&nonce)?, + "verify" => verify_evidence(&nonce, req.evidence_json)?, + other => bail!("unsupported helper mode: {other}"), + }; + + let mut out = stdout().lock(); + serde_json::to_writer(&mut out, &resp).context("failed to write helper response")?; + let _ = out.write_all(b"\n"); + + Ok(()) +} + +fn collect_evidence(nonce: &Nonce) -> anyhow::Result { + let evidence_source = + GpuEvidenceSource::from_nvml().context("failed to create NVML evidence source")?; + let evidence = evidence_source + .collect(nonce) + .context("failed to collect evidence")?; + let evidence_json = evidence + .to_json() + .context("failed to serialize GPU evidence to JSON")?; + let evidence_json: Value = + serde_json::from_str(&evidence_json).context("failed to parse serialized evidence JSON")?; + + Ok(Response { + vendor: Some("nvidia"), + evidence_format: Some("nvat-json"), + evidence_json: Some(evidence_json), + claims_json: None, + detached_eat_json: None, + }) +} + +fn verify_evidence(nonce: &Nonce, evidence_json: Option) -> anyhow::Result { + let evidence_json = evidence_json.context("verify mode requires evidence_json")?; + let evidence_json = serde_json::to_string(&evidence_json) + .context("failed to serialize evidence_json request field")?; + + let evidence_source = GpuEvidenceSource::from_json_string(&evidence_json) + .context("failed to create JSON evidence source")?; + let evidence = evidence_source + .collect(nonce) + .context("failed to load GPU evidence from JSON")?; + + if evidence.is_empty() { + bail!("GPU evidence did not contain any devices"); + } + + let http_opts = HttpOptions::builder() + .max_retry_count(5) + .connection_timeout_ms(10000) + .request_timeout_ms(30000) + .build() + .context("failed to create HTTP options")?; + let rim_store = RimStore::create_remote(None, None, Some(&http_opts)) + .context("failed to create RIM store")?; + let ocsp_client = OcspClient::create_default(None, None, Some(&http_opts)) + .context("failed to create OCSP client")?; + let verifier = GpuLocalVerifier::new(&rim_store, &ocsp_client) + .context("failed to create GPU local verifier")?; + let policy = EvidencePolicy::builder() + .verify_rim_signature(true) + .verify_rim_cert_chain(true) + .build() + .context("failed to create evidence policy")?; + let result = verifier + .verify(&evidence, &policy) + .context("failed to verify GPU evidence")?; + + let claims_json = + serde_json::from_str(&result.claims_json()?).context("failed to parse claims JSON")?; + let detached_eat_json = result + .eat_json() + .ok() + .map(|raw| serde_json::from_str(&raw).context("failed to parse detached EAT JSON")) + .transpose()?; + + Ok(Response { + vendor: None, + evidence_format: None, + evidence_json: None, + claims_json: Some(claims_json), + detached_eat_json, + }) +}