COCOS-591: Add support for GPU CC attestation (#592)
CI / lint (push) Has been cancelled
CI / test (agent) (push) Has been cancelled
CI / test (cli) (push) Has been cancelled
CI / test (cmd) (push) Has been cancelled
CI / test (internal) (push) Has been cancelled
CI / test (manager, true) (push) Has been cancelled
CI / test (pkg) (push) Has been cancelled
CI / upload-coverage (push) Has been cancelled

* Added GPU evidence collection

* Added GPU evidence verification

* Added make command for nvattest helper

* Added command for installing all services

* changed attestion-service.service so it knows where the helper is

* Possible IGVM script bug

* Possible bug

* Bug

* bug

* Revert "bug"

This reverts commit d81d67e73d.

* Revert "Bug"

This reverts commit 5e566d53c1.

* Revert "Possible bug"

This reverts commit 47d13fe583.

* Revert "Possible IGVM script bug"

This reverts commit 3fb1b79537.

* Revert "changed attestion-service.service so it knows where the helper is"

This reverts commit f9f11ed183.

* Revert "Added command for installing all services"

This reverts commit 5dcf7a5c0a.

* NOISSUE - Enforce binding label check (#589)

* NOISSUE - Implement extensible resource downloader framework with support for S3, GCS, and OCI sources (#590)

* feat: implement extensible resource downloader framework with support for S3, GCS, and OCI sources

Signed-off-by: SammyOina <sammyoina@gmail.com>

* refactor: improve resource URL parsing and add support for bare OCI image references

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* fix: add empty string check and slash requirement for OCI image inference, and update python unit tests with event mock expectations

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* refactor: introduce OCIClient interface, add test coverage for decryption, and improve resource download error handling

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* chore: remove trailing whitespace in OCI downloader and HTTP tests

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

---------

Signed-off-by: SammyOina <sammyoina@gmail.com>
Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* Refactored baed on comments

* Added GPU evidence collection

* Added GPU evidence verification

* Added make command for nvattest helper

* Added command for installing all services

* changed attestion-service.service so it knows where the helper is

* Possible IGVM script bug

* Possible bug

* Bug

* bug

* Revert "bug"

This reverts commit d81d67e73d.

* Revert "Bug"

This reverts commit 5e566d53c1.

* Revert "Possible bug"

This reverts commit 47d13fe583.

* Revert "Possible IGVM script bug"

This reverts commit 3fb1b79537.

* Revert "changed attestion-service.service so it knows where the helper is"

This reverts commit f9f11ed183.

* Revert "Added command for installing all services"

This reverts commit 5dcf7a5c0a.

* Refactored baed on comments

* fixed lint error

* fixed tests

* Fixed according to comments

* COCOS-584 - Support multiple kbs (#587)

* feat: Implement per-resource KBS configuration, allowing algorithms and datasets to specify individual KBS URLs.

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* refactor: Encapsulate CLI error handling and CVM certificate paths within the CLI struct, and add algorithm type to agent's algorithm structure.

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* style: Remove blank lines and fix indentation in CLI commands.

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* refactor: Update downloadAndDecryptGenericResource to accept KBS URL as a parameter and adjust related tests

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* refactor: group CLI configuration into structured types and simplify skopeo decryption key handling

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

---------

Signed-off-by: Sammy Oina <sammyoina@gmail.com>

* Added GPU evidence collection

* Added GPU evidence verification

* Added make command for nvattest helper

* Added command for installing all services

* changed attestion-service.service so it knows where the helper is

* Possible IGVM script bug

* Possible bug

* Bug

* bug

* Revert "bug"

This reverts commit d81d67e73d.

* Revert "Bug"

This reverts commit 5e566d53c1.

* Revert "Possible bug"

This reverts commit 47d13fe583.

* Revert "Possible IGVM script bug"

This reverts commit 3fb1b79537.

* Revert "changed attestion-service.service so it knows where the helper is"

This reverts commit f9f11ed183.

* Revert "Added command for installing all services"

This reverts commit 5dcf7a5c0a.

* Refactored baed on comments

* Added GPU evidence collection

* Added GPU evidence verification

* Added make command for nvattest helper

* Added command for installing all services

* changed attestion-service.service so it knows where the helper is

* Possible IGVM script bug

* Possible bug

* Bug

* bug

* Revert "bug"

This reverts commit d81d67e73d.

* Revert "Bug"

This reverts commit 5e566d53c1.

* Revert "Possible bug"

This reverts commit 47d13fe583.

* Revert "Possible IGVM script bug"

This reverts commit 3fb1b79537.

* Revert "changed attestion-service.service so it knows where the helper is"

This reverts commit f9f11ed183.

* Revert "Added command for installing all services"

This reverts commit 5dcf7a5c0a.

* Refactored baed on comments

* fixed lint error

* fixed tests

* Fixed according to comments

---------

Signed-off-by: SammyOina <sammyoina@gmail.com>
Signed-off-by: Sammy Oina <sammyoina@gmail.com>
Co-authored-by: Danko Miladinovic <72250944+danko-miladinovic@users.noreply.github.com>
Co-authored-by: Sammy Kerata Oina <44265300+SammyOina@users.noreply.github.com>
This commit is contained in:
Jovan Djukic
2026-05-08 16:35:04 +02:00
committed by GitHub
parent 81fe0b11b5
commit 27db9b29eb
17 changed files with 1974 additions and 33 deletions
+81 -5
View File
@@ -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)
}
+165
View File
@@ -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[:]
}
+41 -1
View File
@@ -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
}
+21
View File
@@ -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"])
}
+138
View File
@@ -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
}
+141
View File
@@ -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)
})
}
}
+231
View File
@@ -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
}
+359
View File
@@ -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))
})
}