From abc8dea06e2dda580e5a72a03b548f87cafbe3e3 Mon Sep 17 00:00:00 2001 From: Ian Chin Wang Date: Thu, 23 Apr 2026 16:22:52 -0400 Subject: [PATCH 1/2] attesters: add NVIDIA GPU evidence plugin Signed-off-by: Ian Chin Wang --- attesters/Makefile | 1 + attesters/gpu/Makefile | 10 ++ attesters/gpu/gpu.go | 214 +++++++++++++++++++++++ attesters/gpu/gpu_test.go | 311 ++++++++++++++++++++++++++++++++++ attesters/gpu/plugin/Makefile | 8 + attesters/gpu/plugin/main.go | 13 ++ go.mod | 4 +- go.sum | 7 +- tokens/gpu-evidence.go | 90 ++++++++++ tokens/gpu-evidence_test.go | 77 +++++++++ 10 files changed, 733 insertions(+), 2 deletions(-) create mode 100644 attesters/gpu/Makefile create mode 100644 attesters/gpu/gpu.go create mode 100644 attesters/gpu/gpu_test.go create mode 100644 attesters/gpu/plugin/Makefile create mode 100644 attesters/gpu/plugin/main.go create mode 100644 tokens/gpu-evidence.go create mode 100644 tokens/gpu-evidence_test.go diff --git a/attesters/Makefile b/attesters/Makefile index b1a2c83..47e6292 100644 --- a/attesters/Makefile +++ b/attesters/Makefile @@ -3,6 +3,7 @@ SUBDIR := tsm SUBDIR += mocktsm +SUBDIR += gpu clean: ; $(RM) -rf ./bin diff --git a/attesters/gpu/Makefile b/attesters/gpu/Makefile new file mode 100644 index 0000000..d536fe6 --- /dev/null +++ b/attesters/gpu/Makefile @@ -0,0 +1,10 @@ +# Copyright 2026 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 +.DEFAULT_GOAL := test + +GOPKG := github.com/veraison/ratsd/attesters/gpu +SRCS := $(wildcard *.go) + +SUBDIR += plugin + +include ../../mk/subdir.mk diff --git a/attesters/gpu/gpu.go b/attesters/gpu/gpu.go new file mode 100644 index 0000000..458f342 --- /dev/null +++ b/attesters/gpu/gpu.go @@ -0,0 +1,214 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package gpu + +import ( + "encoding/json" + "fmt" + + "github.com/NVIDIA/go-nvml/pkg/nvml" + nvgpu "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/gpu" + "github.com/veraison/ratsd/proto/compositor" + "github.com/veraison/ratsd/tokens" +) + +const ( + ApplicationvndVeraisonNvGpuEvidenceJSON = tokens.GPUEvidenceMediaTypeJSON + ApplicationvndVeraisonNvGpuEvidenceCBOR = tokens.GPUEvidenceMediaTypeCBOR + gpuNonceSize = nvml.CC_GPU_CEC_NONCE_SIZE +) + +var ( + sid = &compositor.SubAttesterID{ + Name: "nv-gpu-evidence", + Version: "1.0.0", + } + + supportedFormats = []*compositor.Format{ + { + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + NonceSize: gpuNonceSize, + }, + { + ContentType: ApplicationvndVeraisonNvGpuEvidenceCBOR, + NonceSize: gpuNonceSize, + }, + } + + statusSucceeded = &compositor.Status{Result: true, Error: ""} +) + +type evidenceCollector interface { + CollectEvidence(nonce []byte) ([]nvgpu.GPUDevice, error) + Shutdown() error +} + +type collectorFactory func() (evidenceCollector, error) + +type GPUPlugin struct { + newCollector collectorFactory +} + +func NewPlugin() *GPUPlugin { + return &GPUPlugin{newCollector: defaultCollectorFactory} +} + +func defaultCollectorFactory() (evidenceCollector, error) { + return nvgpu.NewNvmlGPUAdmin(nil) +} + +func getEvidenceError(e error) *compositor.EvidenceOut { + return &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: e.Error(), + }, + } +} + +func (g *GPUPlugin) GetOptions() *compositor.OptionsOut { + return &compositor.OptionsOut{ + Options: []*compositor.Option{}, + Status: statusSucceeded, + } +} + +func (g *GPUPlugin) GetSubAttesterID() *compositor.SubAttesterIDOut { + return &compositor.SubAttesterIDOut{ + SubAttesterID: sid, + Status: statusSucceeded, + } +} + +func (g *GPUPlugin) GetSupportedFormats() *compositor.SupportedFormatsOut { + collector, err := g.newCollector() + if err != nil { + return &compositor.SupportedFormatsOut{ + Status: &compositor.Status{ + Result: false, + Error: fmt.Sprintf("GPU evidence collection is not available: %s", err.Error()), + }, + } + } + + if err := collector.Shutdown(); err != nil { + return &compositor.SupportedFormatsOut{ + Status: &compositor.Status{ + Result: false, + Error: fmt.Sprintf("GPU evidence collection is not available: %s", err.Error()), + }, + } + } + + return &compositor.SupportedFormatsOut{ + Status: statusSucceeded, + Formats: supportedFormats, + } +} + +func (g *GPUPlugin) GetEvidence(in *compositor.EvidenceIn) *compositor.EvidenceOut { + if uint32(len(in.Nonce)) != gpuNonceSize { + errMsg := fmt.Errorf( + "nonce size of the GPU attester should be %d, got %d", + gpuNonceSize, uint32(len(in.Nonce)), + ) + return getEvidenceError(errMsg) + } + + if err := validateOptions(in.Options); err != nil { + return getEvidenceError(err) + } + + if !supportsFormat(in.ContentType) { + return getEvidenceError(fmt.Errorf("no supported format in gpu plugin matches the requested format")) + } + + collector, err := g.newCollector() + if err != nil { + return getEvidenceError(fmt.Errorf("failed to initialize GPU evidence collector: %v", err)) + } + + devices, collectErr := collector.CollectEvidence(in.Nonce) + shutdownErr := collector.Shutdown() + + if collectErr != nil { + return getEvidenceError(fmt.Errorf("failed to collect GPU evidence: %v", collectErr)) + } + if shutdownErr != nil { + return getEvidenceError(fmt.Errorf("failed to shutdown GPU evidence collector: %v", shutdownErr)) + } + + encodedEvidence, err := encodeEvidence(in.ContentType, in.Nonce, devices) + if err != nil { + return getEvidenceError(err) + } + + return &compositor.EvidenceOut{ + Status: statusSucceeded, + Evidence: encodedEvidence, + } +} + +func supportsFormat(contentType string) bool { + for _, format := range supportedFormats { + if format.ContentType == contentType { + return true + } + } + + return false +} + +func validateOptions(options []byte) error { + if len(options) == 0 || string(options) == "null" { + return nil + } + + var parsed map[string]json.RawMessage + if err := json.Unmarshal(options, &parsed); err != nil { + return fmt.Errorf("failed to parse %s: %v", options, err) + } + + if len(parsed) > 0 { + return fmt.Errorf("gpu attester does not support options") + } + + return nil +} + +func encodeEvidence(contentType string, nonce []byte, devices []nvgpu.GPUDevice) ([]byte, error) { + token := &tokens.GPUEvidence{ + Devices: make([]tokens.GPUDeviceEvidence, len(devices)), + } + + for i, device := range devices { + certChain, err := device.Certificate().EncodeBase64() + if err != nil { + return nil, fmt.Errorf("failed to encode GPU certificate chain for device %d: %v", i, err) + } + + token.Devices[i] = tokens.GPUDeviceEvidence{ + Nonce: nonce, + Arch: device.Arch(), + AttestationReport: device.AttestationReport(), + CertificateChain: certChain, + } + } + + switch contentType { + case ApplicationvndVeraisonNvGpuEvidenceJSON: + encodedEvidence, err := token.ToJSON() + if err != nil { + return nil, fmt.Errorf("failed to JSON encode GPU evidence: %v", err) + } + return encodedEvidence, nil + case ApplicationvndVeraisonNvGpuEvidenceCBOR: + encodedEvidence, err := token.ToCBOR() + if err != nil { + return nil, fmt.Errorf("failed to CBOR encode GPU evidence: %v", err) + } + return encodedEvidence, nil + default: + return nil, fmt.Errorf("no supported format in gpu plugin matches the requested format") + } +} diff --git a/attesters/gpu/gpu_test.go b/attesters/gpu/gpu_test.go new file mode 100644 index 0000000..dae6d51 --- /dev/null +++ b/attesters/gpu/gpu_test.go @@ -0,0 +1,311 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package gpu + +import ( + "errors" + "fmt" + "testing" + + "github.com/NVIDIA/go-nvml/pkg/nvml" + "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/certs" + nvgpu "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/gpu" + nvmocks "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/mocks" + "github.com/stretchr/testify/assert" + "github.com/veraison/ratsd/proto/compositor" + "github.com/veraison/ratsd/tokens" +) + +type fakeCollector struct { + devices []nvgpu.GPUDevice + collectErr error + shutdownErr error + collectedNonce []byte + shutdownInvoked bool +} + +func (f *fakeCollector) CollectEvidence(nonce []byte) ([]nvgpu.GPUDevice, error) { + f.collectedNonce = append([]byte(nil), nonce...) + if f.collectErr != nil { + return nil, f.collectErr + } + + return f.devices, nil +} + +func (f *fakeCollector) Shutdown() error { + f.shutdownInvoked = true + return f.shutdownErr +} + +func makePlugin(factory collectorFactory) *GPUPlugin { + return &GPUPlugin{newCollector: factory} +} + +func validGPUDevices(t *testing.T) []nvgpu.GPUDevice { + t.Helper() + + certChain := certs.NewCertChainFromData(nvmocks.ValidCertChainData) + requireErr := certChain.Verify() + assert.NoError(t, requireErr) + + return []nvgpu.GPUDevice{ + nvgpu.NewGPUDevice( + nvml.DEVICE_ARCH_HOPPER, + []byte("attestation-report"), + certChain, + ), + } +} + +func Test_GetOptions(t *testing.T) { + expected := &compositor.OptionsOut{ + Options: []*compositor.Option{}, + Status: statusSucceeded, + } + + assert.Equal(t, expected, NewPlugin().GetOptions()) +} + +func Test_GetSubAttesterID(t *testing.T) { + expected := &compositor.SubAttesterIDOut{ + SubAttesterID: sid, + Status: statusSucceeded, + } + + assert.Equal(t, expected, NewPlugin().GetSubAttesterID()) +} + +func Test_GetSupportedFormats(t *testing.T) { + collector := &fakeCollector{} + p := makePlugin(func() (evidenceCollector, error) { + return collector, nil + }) + + expected := &compositor.SupportedFormatsOut{ + Status: statusSucceeded, + Formats: supportedFormats, + } + + assert.Equal(t, expected, p.GetSupportedFormats()) + assert.True(t, collector.shutdownInvoked) +} + +func Test_GetSupportedFormats_InitFailure(t *testing.T) { + p := makePlugin(func() (evidenceCollector, error) { + return nil, errors.New("nvml unavailable") + }) + + expected := &compositor.SupportedFormatsOut{ + Status: &compositor.Status{ + Result: false, + Error: "GPU evidence collection is not available: nvml unavailable", + }, + } + + assert.Equal(t, expected, p.GetSupportedFormats()) +} + +func Test_GetEvidence_WrongNonceSize(t *testing.T) { + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + Nonce: []byte("short"), + } + + errMsg := fmt.Sprintf( + "nonce size of the GPU attester should be %d, got %d", + gpuNonceSize, len(in.Nonce), + ) + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: errMsg, + }, + } + + assert.Equal(t, expected, NewPlugin().GetEvidence(in)) +} + +func Test_GetEvidence_InvalidFormat(t *testing.T) { + in := &compositor.EvidenceIn{ + ContentType: "application/invalid", + Nonce: []byte("12345678901234567890123456789012"), + } + + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: "no supported format in gpu plugin matches the requested format", + }, + } + + assert.Equal(t, expected, NewPlugin().GetEvidence(in)) +} + +func Test_GetEvidence_InvalidOptions(t *testing.T) { + tests := []struct { + name string + opts string + msg string + }{ + { + name: "invalid json", + opts: `{"mode"}`, + msg: `failed to parse {"mode"}: invalid character '}' after object key`, + }, + { + name: "unsupported option", + opts: `{"mode":"full"}`, + msg: "gpu attester does not support options", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + Nonce: []byte("12345678901234567890123456789012"), + Options: []byte(tt.opts), + } + + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: tt.msg, + }, + } + + assert.Equal(t, expected, NewPlugin().GetEvidence(in)) + }) + } +} + +func Test_GetEvidence_CollectFailure(t *testing.T) { + collector := &fakeCollector{ + collectErr: errors.New("collection failed"), + } + p := makePlugin(func() (evidenceCollector, error) { + return collector, nil + }) + + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + Nonce: []byte("12345678901234567890123456789012"), + } + + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: "failed to collect GPU evidence: collection failed", + }, + } + + assert.Equal(t, expected, p.GetEvidence(in)) + assert.True(t, collector.shutdownInvoked) +} + +func Test_GetEvidence_JSON(t *testing.T) { + collector := &fakeCollector{ + devices: validGPUDevices(t), + } + p := makePlugin(func() (evidenceCollector, error) { + return collector, nil + }) + + nonce := []byte("12345678901234567890123456789012") + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + Nonce: nonce, + } + + expectedToken := &tokens.GPUEvidence{ + Devices: []tokens.GPUDeviceEvidence{ + { + Nonce: nonce, + Arch: "HOPPER", + AttestationReport: []byte("attestation-report"), + CertificateChain: mustCertChainBase64(t), + }, + }, + } + expectedEvidence, err := expectedToken.ToJSON() + assert.NoError(t, err) + + expected := &compositor.EvidenceOut{ + Status: statusSucceeded, + Evidence: expectedEvidence, + } + + assert.Equal(t, expected, p.GetEvidence(in)) + assert.Equal(t, nonce, collector.collectedNonce) + assert.True(t, collector.shutdownInvoked) +} + +func Test_GetEvidence_CBOR(t *testing.T) { + collector := &fakeCollector{ + devices: validGPUDevices(t), + } + p := makePlugin(func() (evidenceCollector, error) { + return collector, nil + }) + + nonce := []byte("12345678901234567890123456789012") + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceCBOR, + Nonce: nonce, + } + + expectedToken := &tokens.GPUEvidence{ + Devices: []tokens.GPUDeviceEvidence{ + { + Nonce: nonce, + Arch: "HOPPER", + AttestationReport: []byte("attestation-report"), + CertificateChain: mustCertChainBase64(t), + }, + }, + } + expectedEvidence, err := expectedToken.ToCBOR() + assert.NoError(t, err) + + expected := &compositor.EvidenceOut{ + Status: statusSucceeded, + Evidence: expectedEvidence, + } + + assert.Equal(t, expected, p.GetEvidence(in)) +} + +func Test_GetEvidence_ShutdownFailure(t *testing.T) { + collector := &fakeCollector{ + devices: validGPUDevices(t), + shutdownErr: errors.New("shutdown failed"), + } + p := makePlugin(func() (evidenceCollector, error) { + return collector, nil + }) + + in := &compositor.EvidenceIn{ + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + Nonce: []byte("12345678901234567890123456789012"), + } + + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: "failed to shutdown GPU evidence collector: shutdown failed", + }, + } + + assert.Equal(t, expected, p.GetEvidence(in)) +} + +func mustCertChainBase64(t *testing.T) string { + t.Helper() + + certChain := certs.NewCertChainFromData(nvmocks.ValidCertChainData) + encoded, err := certChain.EncodeBase64() + assert.NoError(t, err) + + return encoded +} diff --git a/attesters/gpu/plugin/Makefile b/attesters/gpu/plugin/Makefile new file mode 100644 index 0000000..c6d8aaa --- /dev/null +++ b/attesters/gpu/plugin/Makefile @@ -0,0 +1,8 @@ +# Copyright 2026 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 + +PLUGIN := ../../bin/gpu.plugin +GOPKG := github.com/veraison/ratsd/attesters/gpu +SRCS := main.go + +include ../../../mk/plugin.mk diff --git a/attesters/gpu/plugin/main.go b/attesters/gpu/plugin/main.go new file mode 100644 index 0000000..83f800a --- /dev/null +++ b/attesters/gpu/plugin/main.go @@ -0,0 +1,13 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "github.com/veraison/ratsd/attesters/gpu" + "github.com/veraison/ratsd/plugin" +) + +func main() { + plugin.RegisterImplementation(gpu.NewPlugin()) + plugin.Serve() +} diff --git a/go.mod b/go.mod index 09eeed1..0b67979 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module github.com/veraison/ratsd go 1.25.0 require ( + github.com/NVIDIA/go-nvml v0.13.0-1 + github.com/confidentsecurity/go-nvtrust v0.2.2 github.com/fxamacker/cbor/v2 v2.7.0 github.com/getkin/kin-openapi v0.131.0 github.com/golang/mock v1.6.0 @@ -11,7 +13,7 @@ require ( github.com/moogar0880/problems v0.1.1 github.com/oapi-codegen/runtime v1.1.1 github.com/spf13/viper v1.13.0 - github.com/stretchr/testify v1.9.0 + github.com/stretchr/testify v1.10.0 github.com/veraison/cmw v0.1.2-0.20250109140511-d907dcce0c61 github.com/veraison/eat v0.0.0-20220117140849-ddaf59d69f53 github.com/veraison/go-cose v1.3.0 diff --git a/go.sum b/go.sum index bf42f1b..cc6c5c7 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/NVIDIA/go-nvml v0.13.0-1 h1:OLX8Jq3dONuPOQPC7rndB6+iDmDakw0XTYgzMxObkEw= +github.com/NVIDIA/go-nvml v0.13.0-1/go.mod h1:+KNA7c7gIBH7SKSJ1ntlwkfN80zdx8ovl4hrK3LmPt4= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= @@ -66,6 +68,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/confidentsecurity/go-nvtrust v0.2.2 h1:3IcyLaJggudJQ7lXUWeW8kuWW7ICzAQBeOb0s2XAlaY= +github.com/confidentsecurity/go-nvtrust v0.2.2/go.mod h1:f0C83RCmNBwL0vT8bVZ/+sQYVAbjwLSsw5GuA/AYi2c= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -328,8 +332,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= diff --git a/tokens/gpu-evidence.go b/tokens/gpu-evidence.go new file mode 100644 index 0000000..291e82d --- /dev/null +++ b/tokens/gpu-evidence.go @@ -0,0 +1,90 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package tokens + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/fxamacker/cbor/v2" +) + +const ( + GPUEvidenceMediaTypeCBOR = "application/vnd.veraison.nv-gpu-evidence+cbor" + GPUEvidenceMediaTypeJSON = "application/vnd.veraison.nv-gpu-evidence+json" +) + +type GPUDeviceEvidence struct { + Nonce []byte `json:"nonce"` + Arch string `json:"arch"` + AttestationReport []byte `json:"evidence"` + CertificateChain string `json:"certificate"` +} + +type GPUEvidence struct { + Devices []GPUDeviceEvidence `json:"devices"` +} + +func (g *GPUEvidence) Valid() error { + if len(g.Devices) == 0 { + return errors.New(`missing mandatory field "devices"`) + } + + for i, device := range g.Devices { + if len(device.Nonce) == 0 { + return fmt.Errorf(`missing mandatory field "devices[%d].nonce"`, i) + } + if device.Arch == "" { + return fmt.Errorf(`missing mandatory field "devices[%d].arch"`, i) + } + if len(device.AttestationReport) == 0 { + return fmt.Errorf(`missing mandatory field "devices[%d].evidence"`, i) + } + if device.CertificateChain == "" { + return fmt.Errorf(`missing mandatory field "devices[%d].certificate"`, i) + } + } + + return nil +} + +func (g *GPUEvidence) ToJSON() ([]byte, error) { + if err := g.Valid(); err != nil { + return nil, fmt.Errorf("JSON encoding failed: %w", err) + } + + return json.Marshal(g) +} + +func (g *GPUEvidence) FromJSON(data []byte) error { + if err := json.Unmarshal(data, g); err != nil { + return fmt.Errorf("JSON decoding failed: %w", err) + } + + if err := g.Valid(); err != nil { + return fmt.Errorf("JSON decoding failed: %w", err) + } + + return nil +} + +func (g *GPUEvidence) ToCBOR() ([]byte, error) { + if err := g.Valid(); err != nil { + return nil, fmt.Errorf("CBOR encoding failed: %w", err) + } + + return cbor.Marshal(g) +} + +func (g *GPUEvidence) FromCBOR(data []byte) error { + if err := cbor.Unmarshal(data, g); err != nil { + return fmt.Errorf("CBOR decoding failed: %w", err) + } + + if err := g.Valid(); err != nil { + return fmt.Errorf("CBOR decoding failed: %w", err) + } + + return nil +} diff --git a/tokens/gpu-evidence_test.go b/tokens/gpu-evidence_test.go new file mode 100644 index 0000000..becbfd2 --- /dev/null +++ b/tokens/gpu-evidence_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package tokens + +import ( + "reflect" + "testing" + + "github.com/stretchr/testify/assert" +) + +var ( + gpuNonce = []byte("12345678901234567890123456789012") + gpuReport = []byte{0xaa, 0xbb, 0xcc, 0xdd} +) + +func validGPUEvidence() *GPUEvidence { + return &GPUEvidence{ + Devices: []GPUDeviceEvidence{ + { + Nonce: gpuNonce, + Arch: "HOPPER", + AttestationReport: gpuReport, + CertificateChain: "certificate-chain", + }, + }, + } +} + +func Test_GPUEvidence_Valid_Pass(t *testing.T) { + assert.NoError(t, validGPUEvidence().Valid()) +} + +func Test_GPUEvidence_Valid_Fail_MissingNonce(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].Nonce = nil + + assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices[0].nonce"`) +} + +func Test_GPUEvidence_Valid_Fail_MissingDevices(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices = nil + + assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices"`) +} + +func Test_GPUEvidence_Valid_Fail_MissingCertificateChain(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].CertificateChain = "" + + assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices[0].certificate"`) +} + +func Test_GPUEvidence_JSON_SerDes_Pass(t *testing.T) { + evidence := validGPUEvidence() + + encodedJSON, err := evidence.ToJSON() + assert.NoError(t, err) + + decodedEvidence := &GPUEvidence{} + assert.NoError(t, decodedEvidence.FromJSON(encodedJSON)) + + assert.True(t, reflect.DeepEqual(evidence, decodedEvidence)) +} + +func Test_GPUEvidence_CBOR_SerDes_Pass(t *testing.T) { + evidence := validGPUEvidence() + + encodedCBOR, err := evidence.ToCBOR() + assert.NoError(t, err) + + decodedEvidence := &GPUEvidence{} + assert.NoError(t, decodedEvidence.FromCBOR(encodedCBOR)) + + assert.True(t, reflect.DeepEqual(evidence, decodedEvidence)) +} From 4a8014f56b8cd1c78ccb5bf2cbdc4688bcfb21ea Mon Sep 17 00:00:00 2001 From: Ian Chin Wang Date: Tue, 16 Jun 2026 16:19:35 -0400 Subject: [PATCH 2/2] Align GPU evidence encoding with CDDL Signed-off-by: Ian Chin Wang --- tokens/gpu-evidence.go | 139 ++++++++++++++++++++++++++++++++++-- tokens/gpu-evidence_test.go | 71 ++++++++++++++++-- 2 files changed, 199 insertions(+), 11 deletions(-) diff --git a/tokens/gpu-evidence.go b/tokens/gpu-evidence.go index 291e82d..5cad2db 100644 --- a/tokens/gpu-evidence.go +++ b/tokens/gpu-evidence.go @@ -3,6 +3,8 @@ package tokens import ( + "encoding/base64" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -13,6 +15,8 @@ import ( const ( GPUEvidenceMediaTypeCBOR = "application/vnd.veraison.nv-gpu-evidence+cbor" GPUEvidenceMediaTypeJSON = "application/vnd.veraison.nv-gpu-evidence+json" + + gpuEvidenceNonceSize = 32 ) type GPUDeviceEvidence struct { @@ -26,23 +30,43 @@ type GPUEvidence struct { Devices []GPUDeviceEvidence `json:"devices"` } +type gpuDeviceEvidenceWire struct { + Arch string `json:"arch"` + CertificateChain string `json:"certificate"` + AttestationReport string `json:"evidence"` + Nonce string `json:"nonce"` +} + func (g *GPUEvidence) Valid() error { + if g == nil { + return errors.New("nil GPU evidence") + } + if len(g.Devices) == 0 { - return errors.New(`missing mandatory field "devices"`) + return errors.New("missing mandatory GPU evidence device") } for i, device := range g.Devices { if len(device.Nonce) == 0 { - return fmt.Errorf(`missing mandatory field "devices[%d].nonce"`, i) + return fmt.Errorf(`missing mandatory field "[%d].nonce"`, i) + } + if len(device.Nonce) != gpuEvidenceNonceSize { + return fmt.Errorf(`invalid field "[%d].nonce": expected %d bytes, got %d`, i, gpuEvidenceNonceSize, len(device.Nonce)) } if device.Arch == "" { - return fmt.Errorf(`missing mandatory field "devices[%d].arch"`, i) + return fmt.Errorf(`missing mandatory field "[%d].arch"`, i) + } + if device.Arch != "BLACKWELL" && device.Arch != "HOPPER" { + return fmt.Errorf(`invalid field "[%d].arch": expected "BLACKWELL" or "HOPPER", got %q`, i, device.Arch) } if len(device.AttestationReport) == 0 { - return fmt.Errorf(`missing mandatory field "devices[%d].evidence"`, i) + return fmt.Errorf(`missing mandatory field "[%d].evidence"`, i) } if device.CertificateChain == "" { - return fmt.Errorf(`missing mandatory field "devices[%d].certificate"`, i) + return fmt.Errorf(`missing mandatory field "[%d].certificate"`, i) + } + if _, err := base64.StdEncoding.DecodeString(device.CertificateChain); err != nil { + return fmt.Errorf(`invalid field "[%d].certificate": %w`, i, err) } } @@ -88,3 +112,108 @@ func (g *GPUEvidence) FromCBOR(data []byte) error { return nil } + +func (g GPUEvidence) MarshalJSON() ([]byte, error) { + wireDevices, err := g.toWireDevices() + if err != nil { + return nil, err + } + + return json.Marshal(wireDevices) +} + +func (g *GPUEvidence) UnmarshalJSON(data []byte) error { + if g == nil { + return errors.New("nil GPU evidence") + } + + var wireDevices []gpuDeviceEvidenceWire + if err := json.Unmarshal(data, &wireDevices); err != nil { + return err + } + + decoded, err := gpuEvidenceFromWireDevices(wireDevices) + if err != nil { + return err + } + + *g = decoded + return nil +} + +func (g GPUEvidence) MarshalCBOR() ([]byte, error) { + wireDevices, err := g.toWireDevices() + if err != nil { + return nil, err + } + + return cbor.Marshal(wireDevices) +} + +func (g *GPUEvidence) UnmarshalCBOR(data []byte) error { + if g == nil { + return errors.New("nil GPU evidence") + } + + var wireDevices []gpuDeviceEvidenceWire + if err := cbor.Unmarshal(data, &wireDevices); err != nil { + return err + } + + decoded, err := gpuEvidenceFromWireDevices(wireDevices) + if err != nil { + return err + } + + *g = decoded + return nil +} + +func (g GPUEvidence) toWireDevices() ([]gpuDeviceEvidenceWire, error) { + if err := (&g).Valid(); err != nil { + return nil, err + } + + wireDevices := make([]gpuDeviceEvidenceWire, len(g.Devices)) + for i, device := range g.Devices { + wireDevices[i] = gpuDeviceEvidenceWire{ + Arch: device.Arch, + CertificateChain: device.CertificateChain, + AttestationReport: base64.StdEncoding.EncodeToString(device.AttestationReport), + Nonce: hex.EncodeToString(device.Nonce), + } + } + + return wireDevices, nil +} + +func gpuEvidenceFromWireDevices(wireDevices []gpuDeviceEvidenceWire) (GPUEvidence, error) { + evidence := GPUEvidence{ + Devices: make([]GPUDeviceEvidence, len(wireDevices)), + } + + for i, wireDevice := range wireDevices { + nonce, err := hex.DecodeString(wireDevice.Nonce) + if err != nil { + return GPUEvidence{}, fmt.Errorf(`invalid field "[%d].nonce": %w`, i, err) + } + + report, err := base64.StdEncoding.DecodeString(wireDevice.AttestationReport) + if err != nil { + return GPUEvidence{}, fmt.Errorf(`invalid field "[%d].evidence": %w`, i, err) + } + + evidence.Devices[i] = GPUDeviceEvidence{ + Nonce: nonce, + Arch: wireDevice.Arch, + AttestationReport: report, + CertificateChain: wireDevice.CertificateChain, + } + } + + if err := evidence.Valid(); err != nil { + return GPUEvidence{}, err + } + + return evidence, nil +} diff --git a/tokens/gpu-evidence_test.go b/tokens/gpu-evidence_test.go index becbfd2..7655ebd 100644 --- a/tokens/gpu-evidence_test.go +++ b/tokens/gpu-evidence_test.go @@ -3,15 +3,20 @@ package tokens import ( + "encoding/base64" + "encoding/hex" + "encoding/json" "reflect" "testing" + "github.com/fxamacker/cbor/v2" "github.com/stretchr/testify/assert" ) var ( - gpuNonce = []byte("12345678901234567890123456789012") - gpuReport = []byte{0xaa, 0xbb, 0xcc, 0xdd} + gpuNonce = []byte("12345678901234567890123456789012") + gpuReport = []byte{0xaa, 0xbb, 0xcc, 0xdd} + gpuCertificate = base64.StdEncoding.EncodeToString([]byte("certificate-chain")) ) func validGPUEvidence() *GPUEvidence { @@ -21,12 +26,23 @@ func validGPUEvidence() *GPUEvidence { Nonce: gpuNonce, Arch: "HOPPER", AttestationReport: gpuReport, - CertificateChain: "certificate-chain", + CertificateChain: gpuCertificate, }, }, } } +func validGPUWireEvidence() []gpuDeviceEvidenceWire { + return []gpuDeviceEvidenceWire{ + { + Arch: "HOPPER", + CertificateChain: gpuCertificate, + AttestationReport: base64.StdEncoding.EncodeToString(gpuReport), + Nonce: hex.EncodeToString(gpuNonce), + }, + } +} + func Test_GPUEvidence_Valid_Pass(t *testing.T) { assert.NoError(t, validGPUEvidence().Valid()) } @@ -35,21 +51,53 @@ func Test_GPUEvidence_Valid_Fail_MissingNonce(t *testing.T) { evidence := validGPUEvidence() evidence.Devices[0].Nonce = nil - assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices[0].nonce"`) + assert.EqualError(t, evidence.Valid(), `missing mandatory field "[0].nonce"`) +} + +func Test_GPUEvidence_Valid_Fail_WrongNonceSize(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].Nonce = []byte("short") + + assert.EqualError(t, evidence.Valid(), `invalid field "[0].nonce": expected 32 bytes, got 5`) } func Test_GPUEvidence_Valid_Fail_MissingDevices(t *testing.T) { evidence := validGPUEvidence() evidence.Devices = nil - assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices"`) + assert.EqualError(t, evidence.Valid(), "missing mandatory GPU evidence device") +} + +func Test_GPUEvidence_Valid_Fail_InvalidArch(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].Arch = "AMPERE" + + assert.EqualError(t, evidence.Valid(), `invalid field "[0].arch": expected "BLACKWELL" or "HOPPER", got "AMPERE"`) } func Test_GPUEvidence_Valid_Fail_MissingCertificateChain(t *testing.T) { evidence := validGPUEvidence() evidence.Devices[0].CertificateChain = "" - assert.EqualError(t, evidence.Valid(), `missing mandatory field "devices[0].certificate"`) + assert.EqualError(t, evidence.Valid(), `missing mandatory field "[0].certificate"`) +} + +func Test_GPUEvidence_Valid_Fail_InvalidCertificateChain(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].CertificateChain = "%" + + assert.ErrorContains(t, evidence.Valid(), `invalid field "[0].certificate"`) +} + +func Test_GPUEvidence_JSON_WireShape(t *testing.T) { + evidence := validGPUEvidence() + + encodedJSON, err := evidence.ToJSON() + assert.NoError(t, err) + + var wire []gpuDeviceEvidenceWire + assert.NoError(t, json.Unmarshal(encodedJSON, &wire)) + assert.Equal(t, validGPUWireEvidence(), wire) } func Test_GPUEvidence_JSON_SerDes_Pass(t *testing.T) { @@ -64,6 +112,17 @@ func Test_GPUEvidence_JSON_SerDes_Pass(t *testing.T) { assert.True(t, reflect.DeepEqual(evidence, decodedEvidence)) } +func Test_GPUEvidence_CBOR_WireShape(t *testing.T) { + evidence := validGPUEvidence() + + encodedCBOR, err := evidence.ToCBOR() + assert.NoError(t, err) + + var wire []gpuDeviceEvidenceWire + assert.NoError(t, cbor.Unmarshal(encodedCBOR, &wire)) + assert.Equal(t, validGPUWireEvidence(), wire) +} + func Test_GPUEvidence_CBOR_SerDes_Pass(t *testing.T) { evidence := validGPUEvidence()