diff --git a/README.md b/README.md index 909adc1..04737df 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,11 @@ make[3]: Leaving directory '/builddir/build/BUILD/ratsd-1.0.3+la3/attesters/mock make[2]: Leaving directory '/builddir/build/BUILD/ratsd-1.0.3+la3/attesters/mocktsm' make[1]: Leaving directory '/builddir/build/BUILD/ratsd-1.0.3+la3/attesters' ``` + +### NVIDIA GPU attester prerequisites + +The `nvgpu` attester supports NVIDIA Hopper and Blackwell GPUs with confidential computing enabled. The attester host must have a compatible NVIDIA driver installed; the driver provides the `libnvidia-ml.so.1` NVML library that the plugin loads at runtime. Containerized deployments must expose the NVIDIA devices and driver libraries to the container. + # Query ratsd By default, ratsd core listens on port 8895. Use `POST /ratsd/chares` to retrieve a CMW collection containing evidence from each sub-attester. This API call requires the request body to be the JSON object `{"nonce": $(Base64 string of 64-byte data)}` replacing the placeholder with a proper base64 string. See the following example: diff --git a/attesters/Makefile b/attesters/Makefile index b1a2c83..0e371c5 100644 --- a/attesters/Makefile +++ b/attesters/Makefile @@ -3,6 +3,7 @@ SUBDIR := tsm SUBDIR += mocktsm +SUBDIR += nvgpu clean: ; $(RM) -rf ./bin diff --git a/attesters/nvgpu/Makefile b/attesters/nvgpu/Makefile new file mode 100644 index 0000000..7d2b92e --- /dev/null +++ b/attesters/nvgpu/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/nvgpu +SRCS := $(wildcard *.go) + +SUBDIR += plugin + +include ../../mk/subdir.mk diff --git a/attesters/nvgpu/nvgpu.go b/attesters/nvgpu/nvgpu.go new file mode 100644 index 0000000..2235a3e --- /dev/null +++ b/attesters/nvgpu/nvgpu.go @@ -0,0 +1,214 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package nvgpu + +import ( + "encoding/json" + "fmt" + + "github.com/NVIDIA/go-nvml/pkg/nvml" + nvtrustgpu "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/gpu" + "github.com/veraison/ratsd/proto/compositor" + "github.com/veraison/ratsd/tokens" +) + +const ( + ApplicationvndVeraisonNvGpuEvidenceJSON = tokens.GPUEvidenceMediaTypeJSON + nonceSize = nvml.CC_GPU_CEC_NONCE_SIZE +) + +var ( + sid = &compositor.SubAttesterID{ + Name: "nv-gpu-evidence", + Version: "1.0.0", + } + + supportedFormats = []*compositor.Format{ + { + ContentType: ApplicationvndVeraisonNvGpuEvidenceJSON, + NonceSize: nonceSize, + }, + } + + statusSucceeded = &compositor.Status{Result: true, Error: ""} +) + +type evidenceCollector interface { + CollectEvidence(nonce []byte) ([]nvtrustgpu.GPUDevice, error) + Shutdown() error +} + +type collectorFactory func() (evidenceCollector, error) + +type Plugin struct { + newCollector collectorFactory + availabilityErr error +} + +func NewPlugin() *Plugin { + return newPlugin(defaultCollectorFactory) +} + +func newPlugin(factory collectorFactory) *Plugin { + p := &Plugin{newCollector: factory} + p.availabilityErr = p.initialize() + return p +} + +func defaultCollectorFactory() (evidenceCollector, error) { + return nvtrustgpu.NewNvmlGPUAdmin(nil) +} + +// initialize probes the NVIDIA driver once when the plugin starts. Creating a +// collector loads and initializes NVML; shutting it down immediately leaves no +// resources open until evidence collection is requested. The plugin interface +// has no initialization hook, so GetSupportedFormats reports this probe's result +// and does not advertise a format that the host cannot produce. +func (p Plugin) initialize() error { + collector, err := p.newCollector() + if err != nil { + return err + } + + return collector.Shutdown() +} + +func getEvidenceError(e error) *compositor.EvidenceOut { + return &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: e.Error(), + }, + } +} + +func (p Plugin) GetOptions() *compositor.OptionsOut { + return &compositor.OptionsOut{ + Options: []*compositor.Option{}, + Status: statusSucceeded, + } +} + +func (p Plugin) GetSubAttesterID() *compositor.SubAttesterIDOut { + return &compositor.SubAttesterIDOut{ + SubAttesterID: sid, + Status: statusSucceeded, + } +} + +func (p Plugin) GetSupportedFormats() *compositor.SupportedFormatsOut { + if p.availabilityErr != nil { + return &compositor.SupportedFormatsOut{ + Status: &compositor.Status{ + Result: false, + Error: fmt.Sprintf("NVIDIA GPU evidence collection is not available: %s", p.availabilityErr), + }, + } + } + + return &compositor.SupportedFormatsOut{ + Status: statusSucceeded, + Formats: supportedFormats, + } +} + +func (p Plugin) GetEvidence(in *compositor.EvidenceIn) *compositor.EvidenceOut { + if uint32(len(in.Nonce)) != nonceSize { + errMsg := fmt.Errorf( + "nonce size of the NVIDIA GPU attester should be %d, got %d", + nonceSize, 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 nvgpu plugin matches the requested format")) + } + + collector, err := p.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("NVIDIA GPU attester does not support options") + } + + return nil +} + +func encodeEvidence(contentType string, nonce []byte, devices []nvtrustgpu.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 + default: + return nil, fmt.Errorf("no supported format in nvgpu plugin matches the requested format") + } +} diff --git a/attesters/nvgpu/nvgpu_test.go b/attesters/nvgpu/nvgpu_test.go new file mode 100644 index 0000000..532aed0 --- /dev/null +++ b/attesters/nvgpu/nvgpu_test.go @@ -0,0 +1,316 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package nvgpu + +import ( + "errors" + "fmt" + "testing" + + "github.com/NVIDIA/go-nvml/pkg/nvml" + "github.com/confidentsecurity/go-nvtrust/pkg/gonvtrust/certs" + nvtrustgpu "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 []nvtrustgpu.GPUDevice + collectErr error + shutdownErr error + collectedNonce []byte + shutdownInvoked bool +} + +func (f *fakeCollector) CollectEvidence(nonce []byte) ([]nvtrustgpu.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) *Plugin { + return newPlugin(factory) +} + +func availablePlugin() *Plugin { + return makePlugin(func() (evidenceCollector, error) { + return &fakeCollector{}, nil + }) +} + +func validGPUDevices(t *testing.T) []nvtrustgpu.GPUDevice { + t.Helper() + + certChain := certs.NewCertChainFromData(nvmocks.ValidCertChainData) + requireErr := certChain.Verify() + assert.NoError(t, requireErr) + + return []nvtrustgpu.GPUDevice{ + nvtrustgpu.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, availablePlugin().GetOptions()) +} + +func Test_GetSubAttesterID(t *testing.T) { + expected := &compositor.SubAttesterIDOut{ + SubAttesterID: sid, + Status: statusSucceeded, + } + + assert.Equal(t, expected, availablePlugin().GetSubAttesterID()) +} + +func Test_GetSupportedFormats(t *testing.T) { + collector := &fakeCollector{} + factoryCalls := 0 + p := makePlugin(func() (evidenceCollector, error) { + factoryCalls++ + return collector, nil + }) + + expected := &compositor.SupportedFormatsOut{ + Status: statusSucceeded, + Formats: supportedFormats, + } + + assert.Equal(t, expected, p.GetSupportedFormats()) + assert.Equal(t, 1, factoryCalls) + 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: "NVIDIA GPU evidence collection is not available: nvml unavailable", + }, + } + + assert.Equal(t, expected, p.GetSupportedFormats()) +} + +func Test_GetSupportedFormats_ShutdownFailure(t *testing.T) { + p := makePlugin(func() (evidenceCollector, error) { + return &fakeCollector{shutdownErr: errors.New("shutdown failed")}, nil + }) + + expected := &compositor.SupportedFormatsOut{ + Status: &compositor.Status{ + Result: false, + Error: "NVIDIA GPU evidence collection is not available: shutdown failed", + }, + } + + 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 NVIDIA GPU attester should be %d, got %d", + nonceSize, len(in.Nonce), + ) + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: errMsg, + }, + } + + assert.Equal(t, expected, availablePlugin().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 nvgpu plugin matches the requested format", + }, + } + + assert.Equal(t, expected, availablePlugin().GetEvidence(in)) +} + +func Test_GetEvidence_CBORMediaTypeUnsupported(t *testing.T) { + in := &compositor.EvidenceIn{ + ContentType: "application/vnd.veraison.nvidia-gpu-evidence+cbor", + Nonce: []byte("12345678901234567890123456789012"), + } + + expected := &compositor.EvidenceOut{ + Status: &compositor.Status{ + Result: false, + Error: "no supported format in nvgpu plugin matches the requested format", + }, + } + + assert.Equal(t, expected, availablePlugin().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: "NVIDIA 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, availablePlugin().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_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/nvgpu/plugin/Makefile b/attesters/nvgpu/plugin/Makefile new file mode 100644 index 0000000..38ab038 --- /dev/null +++ b/attesters/nvgpu/plugin/Makefile @@ -0,0 +1,8 @@ +# Copyright 2026 Contributors to the Veraison project. +# SPDX-License-Identifier: Apache-2.0 + +PLUGIN := ../../bin/nvgpu.plugin +GOPKG := github.com/veraison/ratsd/attesters/nvgpu +SRCS := main.go + +include ../../../mk/plugin.mk diff --git a/attesters/nvgpu/plugin/main.go b/attesters/nvgpu/plugin/main.go new file mode 100644 index 0000000..74e58a7 --- /dev/null +++ b/attesters/nvgpu/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/nvgpu" + "github.com/veraison/ratsd/plugin" +) + +func main() { + plugin.RegisterImplementation(nvgpu.NewPlugin()) + plugin.Serve() +} diff --git a/docs/nvidia-gpu-evidence.cddl b/docs/nvidia-gpu-evidence.cddl index 5a1d0c3..ec1d21b 100644 --- a/docs/nvidia-gpu-evidence.cddl +++ b/docs/nvidia-gpu-evidence.cddl @@ -10,4 +10,4 @@ nvidia-gpu-evidence = [ arch-type = "BLACKWELL" / "HOPPER" certificate-type = text .b64c bytes evidence-type = text .b64c bytes -nonce-type = text .hex (bytes .size 32) +nonce-type = text .b64c (bytes .size 32) 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..cad550b --- /dev/null +++ b/tokens/gpu-evidence.go @@ -0,0 +1,85 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package tokens + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + + "github.com/NVIDIA/go-nvml/pkg/nvml" +) + +const ( + GPUEvidenceMediaTypeJSON = "application/vnd.veraison.nvidia-gpu-evidence+json" + + gpuEvidenceNonceSize = nvml.CC_GPU_CEC_NONCE_SIZE +) + +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 GPU evidence device") + } + + for i, device := range g.Devices { + if len(device.Nonce) == 0 { + 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 "[%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 "[%d].evidence"`, i) + } + if device.CertificateChain == "" { + 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) + } + } + + 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.Devices) +} + +func (g *GPUEvidence) FromJSON(data []byte) error { + if g == nil { + return errors.New("JSON decoding failed: nil GPU evidence") + } + + if err := json.Unmarshal(data, &g.Devices); 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 +} diff --git a/tokens/gpu-evidence_test.go b/tokens/gpu-evidence_test.go new file mode 100644 index 0000000..2b603db --- /dev/null +++ b/tokens/gpu-evidence_test.go @@ -0,0 +1,119 @@ +// Copyright 2026 Contributors to the Veraison project. +// SPDX-License-Identifier: Apache-2.0 +package tokens + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "reflect" + "testing" + + "github.com/NVIDIA/go-nvml/pkg/nvml" + "github.com/stretchr/testify/assert" +) + +var ( + gpuNonce = []byte("12345678901234567890123456789012") + gpuReport = []byte{0xaa, 0xbb, 0xcc, 0xdd} + gpuCertificate = base64.StdEncoding.EncodeToString([]byte("certificate-chain")) +) + +func validGPUEvidence() *GPUEvidence { + return &GPUEvidence{ + Devices: []GPUDeviceEvidence{ + { + Nonce: gpuNonce, + Arch: "HOPPER", + AttestationReport: gpuReport, + CertificateChain: gpuCertificate, + }, + }, + } +} + +func Test_GPUEvidence_MediaTypes(t *testing.T) { + assert.Equal(t, "application/vnd.veraison.nvidia-gpu-evidence+json", GPUEvidenceMediaTypeJSON) +} + +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 "[0].nonce"`) +} + +func Test_GPUEvidence_Valid_Fail_WrongNonceSize(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices[0].Nonce = []byte("short") + + assert.EqualError(t, evidence.Valid(), fmt.Sprintf(`invalid field "[0].nonce": expected %d bytes, got 5`, nvml.CC_GPU_CEC_NONCE_SIZE)) +} + +func Test_GPUEvidence_Valid_Fail_MissingDevices(t *testing.T) { + evidence := validGPUEvidence() + evidence.Devices = nil + + assert.EqualError(t, evidence.Valid(), "missing mandatory GPU evidence device") +} + +func Test_GPUEvidence_FromJSON_Fail_NilEvidence(t *testing.T) { + var evidence *GPUEvidence + + assert.EqualError(t, evidence.FromJSON([]byte("[]")), "JSON decoding failed: nil GPU evidence") +} + +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 "[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 []map[string]string + assert.NoError(t, json.Unmarshal(encodedJSON, &wire)) + assert.Equal(t, []map[string]string{ + { + "arch": "HOPPER", + "certificate": gpuCertificate, + "evidence": base64.StdEncoding.EncodeToString(gpuReport), + "nonce": base64.StdEncoding.EncodeToString(gpuNonce), + }, + }, wire) +} + +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)) +}