mirror of
https://github.com/luxfi/cc.git
synced 2026-09-24 00:24:54 +00:00
Merge remote-tracking branch 'origin/feat/kind-nvtrust-real'
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// NVIDIA GPU device-identity certificate chain verification.
|
||||
//
|
||||
// The public key that signs the SPDM MEASUREMENTS response is the GPU's
|
||||
// attestation key. By itself a valid SPDM signature only proves "some key
|
||||
// signed these measurements". To prove the key belongs to a genuine NVIDIA
|
||||
// GPU we verify the GPU's certificate chain to an operator-pinned NVIDIA
|
||||
// device-identity root CA:
|
||||
//
|
||||
// leaf (GPU attestation cert, ECDSA P-384)
|
||||
// -> NVIDIA GPU attestation intermediate(s)
|
||||
// -> NVIDIA device-identity root CA <-- operator pins this
|
||||
//
|
||||
// The root is supplied by the operator (WithNVTrustDeviceRoots), never
|
||||
// taken from the evidence — exactly as AMD's ARK is pinned for SEV-SNP and
|
||||
// the RIM signing key is pinned for the RIM. There is no insecure mode: an
|
||||
// empty root pool means the local SPDM path is refused.
|
||||
//
|
||||
// Clean-room: standard X.509 path validation against an operator pool. No
|
||||
// NVIDIA root certificate is vendored; production wiring loads NVIDIA's
|
||||
// published device-identity root PEM into the pool at startup.
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors returned by chain verification.
|
||||
var (
|
||||
ErrChainEmpty = errors.New("nvidia/chain: empty certificate chain")
|
||||
ErrChainBadPEM = errors.New("nvidia/chain: certificate is not valid PEM/DER")
|
||||
ErrChainNoRoots = errors.New("nvidia/chain: no device-identity roots configured")
|
||||
ErrChainUntrusted = errors.New("nvidia/chain: leaf does not chain to a pinned root")
|
||||
ErrChainLeafKeyType = errors.New("nvidia/chain: leaf public key type unsupported")
|
||||
)
|
||||
|
||||
// ParseCertChainPEM decodes a leaf-first list of PEM certificate blocks
|
||||
// into parsed certificates. Each element may itself contain multiple
|
||||
// concatenated PEM blocks (the typical NVIDIA evidence packs the whole
|
||||
// chain in one string); all blocks are flattened, preserving order.
|
||||
func ParseCertChainPEM(pems []string) ([]*x509.Certificate, error) {
|
||||
if len(pems) == 0 {
|
||||
return nil, ErrChainEmpty
|
||||
}
|
||||
var certs []*x509.Certificate
|
||||
for _, p := range pems {
|
||||
rest := []byte(p)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
c, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrChainBadPEM, err)
|
||||
}
|
||||
certs = append(certs, c)
|
||||
}
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
return nil, ErrChainEmpty
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
// VerifyDeviceChain validates a leaf-first certificate chain to the pinned
|
||||
// device-identity roots and returns the verified leaf. chain[0] is the GPU
|
||||
// attestation leaf; chain[1:] are intermediates. The root is NOT expected
|
||||
// in the chain — it is supplied via roots and pinned by the operator.
|
||||
//
|
||||
// ExtKeyUsageAny is used because NVIDIA GPU attestation leaves do not
|
||||
// carry a TLS-server EKU; path, validity window, and signatures are still
|
||||
// fully checked against the pinned root.
|
||||
func VerifyDeviceChain(chain []*x509.Certificate, roots *x509.CertPool, now time.Time) (*x509.Certificate, error) {
|
||||
if len(chain) == 0 {
|
||||
return nil, ErrChainEmpty
|
||||
}
|
||||
if roots == nil {
|
||||
return nil, ErrChainNoRoots
|
||||
}
|
||||
// CertPool offers no public emptiness check; the operator-supplied pool
|
||||
// is assumed non-empty when non-nil. An empty pool simply fails the
|
||||
// Verify below with ErrChainUntrusted, which is the correct refusal.
|
||||
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, ic := range chain[1:] {
|
||||
intermediates.AddCert(ic)
|
||||
}
|
||||
leaf := chain[0]
|
||||
opts := x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
CurrentTime: now,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
if _, err := leaf.Verify(opts); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrChainUntrusted, err)
|
||||
}
|
||||
return leaf, nil
|
||||
}
|
||||
|
||||
// LeafSPDMKey returns the leaf's public key in the form the SPDM verifier
|
||||
// consumes, rejecting key types no NVIDIA GPU attestation key uses.
|
||||
func LeafSPDMKey(leaf *x509.Certificate) (crypto.PublicKey, error) {
|
||||
switch leaf.PublicKey.(type) {
|
||||
case interface{ Equal(crypto.PublicKey) bool }:
|
||||
// *ecdsa.PublicKey and *rsa.PublicKey both satisfy this; the SPDM
|
||||
// verifier does the final algo<->key-type binding.
|
||||
return leaf.PublicKey, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %T", ErrChainLeafKeyType, leaf.PublicKey)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Unit tests for device-chain parsing/validation and the EAT x5c branch.
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testNow() time.Time { return time.Date(2026, 6, 27, 12, 0, 0, 0, time.UTC) }
|
||||
|
||||
func mkTestCert(t *testing.T, tmpl, parent *x509.Certificate, pub, signer any) *x509.Certificate {
|
||||
t.Helper()
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, signer)
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
c, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func toPEM(c *x509.Certificate) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))
|
||||
}
|
||||
|
||||
// chain3 builds root -> intermediate -> leaf (P-384).
|
||||
func chain3(t *testing.T, now time.Time) (root, interm, leaf *x509.Certificate) {
|
||||
t.Helper()
|
||||
rk, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
ik, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
lk, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
rt := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "root"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true}
|
||||
root = mkTestCert(t, rt, rt, rk.Public(), rk)
|
||||
it := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "interm"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true}
|
||||
interm = mkTestCert(t, it, root, ik.Public(), rk)
|
||||
lt := &x509.Certificate{SerialNumber: big.NewInt(3), Subject: pkix.Name{CommonName: "leaf"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
leaf = mkTestCert(t, lt, interm, lk.Public(), ik)
|
||||
return
|
||||
}
|
||||
|
||||
func TestParseCertChainPEM_Errors(t *testing.T) {
|
||||
if _, err := ParseCertChainPEM(nil); !errors.Is(err, ErrChainEmpty) {
|
||||
t.Fatalf("nil: %v", err)
|
||||
}
|
||||
if _, err := ParseCertChainPEM([]string{"not pem"}); !errors.Is(err, ErrChainEmpty) {
|
||||
t.Fatalf("garbage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeviceChain_Valid(t *testing.T) {
|
||||
now := testNow()
|
||||
root, interm, leaf := chain3(t, now)
|
||||
chain, err := ParseCertChainPEM([]string{toPEM(leaf), toPEM(interm)})
|
||||
if err != nil {
|
||||
t.Fatalf("parse chain: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(root)
|
||||
got, err := VerifyDeviceChain(chain, pool, now)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if got.Subject.CommonName != "leaf" {
|
||||
t.Fatalf("leaf = %q", got.Subject.CommonName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeviceChain_Untrusted(t *testing.T) {
|
||||
now := testNow()
|
||||
_, interm, leaf := chain3(t, now)
|
||||
chain, _ := ParseCertChainPEM([]string{toPEM(leaf), toPEM(interm)})
|
||||
// Empty pool -> leaf cannot chain to any pinned root.
|
||||
if _, err := VerifyDeviceChain(chain, x509.NewCertPool(), now); !errors.Is(err, ErrChainUntrusted) {
|
||||
t.Fatalf("err = %v, want ErrChainUntrusted", err)
|
||||
}
|
||||
if _, err := VerifyDeviceChain(chain, nil, now); !errors.Is(err, ErrChainNoRoots) {
|
||||
t.Fatalf("nil roots: err = %v, want ErrChainNoRoots", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDeviceChain_Expired(t *testing.T) {
|
||||
now := testNow()
|
||||
root, interm, leaf := chain3(t, now)
|
||||
chain, _ := ParseCertChainPEM([]string{toPEM(leaf), toPEM(interm)})
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(root)
|
||||
// Two hours later, the 1-hour-validity certs are expired.
|
||||
if _, err := VerifyDeviceChain(chain, pool, now.Add(2*time.Hour)); !errors.Is(err, ErrChainUntrusted) {
|
||||
t.Fatalf("err = %v, want ErrChainUntrusted (expired)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// EAT x5c branch: a token whose x5c chains to the pinned NRAS root pool is
|
||||
// accepted via the leaf key (no kid pin needed).
|
||||
func TestVerifyEAT_X5CChain(t *testing.T) {
|
||||
now := testNow()
|
||||
// The leaf key signs the JWS; build a dedicated NRAS chain with a known
|
||||
// leaf key (root -> interm -> leaf) and pin the NRAS root.
|
||||
lk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
lt := &x509.Certificate{SerialNumber: big.NewInt(4), Subject: pkix.Name{CommonName: "nras-leaf"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), KeyUsage: x509.KeyUsageDigitalSignature}
|
||||
ik, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
it := &x509.Certificate{SerialNumber: big.NewInt(5), Subject: pkix.Name{CommonName: "nras-interm"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true}
|
||||
rk, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
rt := &x509.Certificate{SerialNumber: big.NewInt(6), Subject: pkix.Name{CommonName: "nras-root"},
|
||||
NotBefore: now.Add(-time.Hour), NotAfter: now.Add(time.Hour), IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign, BasicConstraintsValid: true}
|
||||
nrasRoot := mkTestCert(t, rt, rt, rk.Public(), rk)
|
||||
nrasInterm := mkTestCert(t, it, nrasRoot, ik.Public(), rk)
|
||||
nrasLeaf := mkTestCert(t, lt, nrasInterm, lk.Public(), ik)
|
||||
|
||||
hdr := map[string]any{
|
||||
"alg": "ES256",
|
||||
"x5c": []string{
|
||||
base64.StdEncoding.EncodeToString(nrasLeaf.Raw),
|
||||
base64.StdEncoding.EncodeToString(nrasInterm.Raw),
|
||||
},
|
||||
}
|
||||
claims := map[string]any{"exp": now.Add(time.Hour).Unix(), "x-nvidia-overall-att-result": true}
|
||||
hb, _ := json.Marshal(hdr)
|
||||
cb, _ := json.Marshal(claims)
|
||||
signedInput := base64.RawURLEncoding.EncodeToString(hb) + "." + base64.RawURLEncoding.EncodeToString(cb)
|
||||
sum := sha256.Sum256([]byte(signedInput))
|
||||
r, s, _ := ecdsa.Sign(rand.Reader, lk, sum[:])
|
||||
token := signedInput + "." + base64.RawURLEncoding.EncodeToString(joseConcat(r, s, 32))
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(nrasRoot)
|
||||
res, err := VerifyEAT(token, nil, pool, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("eat x5c verify: %v", err)
|
||||
}
|
||||
if res.OverallResult != "true" {
|
||||
t.Fatalf("result = %q", res.OverallResult)
|
||||
}
|
||||
|
||||
// A token whose x5c does NOT chain to the pinned pool is refused.
|
||||
if _, err := VerifyEAT(token, nil, x509.NewCertPool(), nil, now); !errors.Is(err, ErrEATChainUntrusted) {
|
||||
t.Fatalf("untrusted x5c: err = %v, want ErrEATChainUntrusted", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
// NRAS EAT (Entity Attestation Token) verification — the remote NVIDIA
|
||||
// trust path.
|
||||
//
|
||||
// In the NRAS deployment model the host POSTs GPU evidence to the NVIDIA
|
||||
// Remote Attestation Service, which performs the SPDM + device-chain
|
||||
// verification on its side and returns a signed EAT: a JWS-compact JWT
|
||||
// whose claims are NVIDIA's attestation verdict (overall result, per-
|
||||
// measurement results, GPU identity, a freshness nonce). The relying party
|
||||
// trusts NVIDIA's cloud and only has to verify the token.
|
||||
//
|
||||
// VerifyEAT performs exactly that, with no network call (the HTTP fetch is
|
||||
// NRASClient's job — separation of concerns):
|
||||
//
|
||||
// - signature: JWS verified against operator-pinned NRAS signer keys
|
||||
// (by kid) with a strict alg<->key-type binding; OR, when the token
|
||||
// carries an x5c header and an NRAS root pool is pinned, the x5c chain
|
||||
// is validated to that root and the leaf key verifies the JWS.
|
||||
// - chain: the x5c path anchors the signer to NVIDIA's NRAS root CA.
|
||||
// - claims: exp/nbf freshness, eat_nonce binding to the caller's
|
||||
// challenge, and (when present) the overall attestation result must
|
||||
// be positive.
|
||||
//
|
||||
// Clean-room: no NVIDIA keys are vendored; the NRAS signer key / root CA
|
||||
// is operator-pinned, exactly like the local device root.
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Errors returned by EAT verification.
|
||||
var (
|
||||
ErrEATBadJWT = errors.New("nvidia/eat: token is not a valid JWS compact serialization")
|
||||
ErrEATBadSignature = errors.New("nvidia/eat: token signature verification failed")
|
||||
ErrEATNoSigner = errors.New("nvidia/eat: no signer key (kid not pinned and no verifiable x5c)")
|
||||
ErrEATExpired = errors.New("nvidia/eat: token expired")
|
||||
ErrEATNotYetValid = errors.New("nvidia/eat: token not yet valid (nbf in the future)")
|
||||
ErrEATNonceMismatch = errors.New("nvidia/eat: eat_nonce does not match challenge")
|
||||
ErrEATResultNegative = errors.New("nvidia/eat: NVIDIA overall attestation result is negative")
|
||||
ErrEATChainUntrusted = errors.New("nvidia/eat: x5c chain does not anchor to a pinned NRAS root")
|
||||
)
|
||||
|
||||
// EATResult is the verified projection of an NRAS EAT.
|
||||
type EATResult struct {
|
||||
SignerKeyID string // kid that verified the token (or x5c leaf subject)
|
||||
Nonce [32]byte // eat_nonce, when present
|
||||
HasNonce bool // whether a 32-byte nonce claim was present
|
||||
Measurement []byte // measurement root (see note in VerifyEAT)
|
||||
OverallResult string // x-nvidia-overall-att-result / measres, when present
|
||||
IssuedAt time.Time // iat
|
||||
NotBefore time.Time // nbf
|
||||
ExpiresAt time.Time // exp
|
||||
Claims map[string]json.RawMessage // full claim set, for audit/policy
|
||||
}
|
||||
|
||||
// VerifyEAT verifies an NRAS-issued EAT token.
|
||||
//
|
||||
// roots are the pinned NRAS signer public keys (looked up by the JWS kid).
|
||||
// rootPool, when non-nil, pins the NRAS root CA so a token carrying an x5c
|
||||
// header is anchored by chain validation (the leaf key then verifies the
|
||||
// JWS). challenge, when non-nil, must equal the token's eat_nonce. now is
|
||||
// the verification clock.
|
||||
//
|
||||
// Measurement mapping: NVIDIA's EAT expresses measurement results across
|
||||
// many submodule claims rather than a single root. VerifyEAT surfaces an
|
||||
// explicit measurement-root claim when present ("x-nvidia-gpu-measurements-
|
||||
// root" hex) and otherwise binds Measurement to sha256(token) — a stable,
|
||||
// signature-covered root the caller can pin. The exact production claim
|
||||
// name is documented NVIDIA collateral; callers needing a specific
|
||||
// submeasurement read it from Claims.
|
||||
func VerifyEAT(token string, roots []TrustRoot, rootPool *x509.CertPool, challenge *[32]byte, now time.Time) (*EATResult, error) {
|
||||
hdrBytes, payload, sig, signedInput, err := splitJWS(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrEATBadJWT, err)
|
||||
}
|
||||
var hdr struct {
|
||||
Alg string `json:"alg"`
|
||||
Kid string `json:"kid"`
|
||||
X5c []string `json:"x5c"`
|
||||
}
|
||||
if err := json.Unmarshal(hdrBytes, &hdr); err != nil {
|
||||
return nil, fmt.Errorf("%w: header: %v", ErrEATBadJWT, err)
|
||||
}
|
||||
|
||||
signerPub, signerID, err := eatSignerKey(hdr.Kid, hdr.X5c, roots, rootPool, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := verifyJWS(signerPub, hdr.Alg, signedInput, sig); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrEATBadSignature, err)
|
||||
}
|
||||
|
||||
// Decode claims twice: once into known fields, once as a raw map for
|
||||
// audit / policy on top.
|
||||
var claims map[string]json.RawMessage
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return nil, fmt.Errorf("%w: claims: %v", ErrEATBadJWT, err)
|
||||
}
|
||||
res := &EATResult{SignerKeyID: signerID, Claims: claims}
|
||||
|
||||
if t, ok := eatUnixClaim(claims, "iat"); ok {
|
||||
res.IssuedAt = t
|
||||
}
|
||||
if t, ok := eatUnixClaim(claims, "nbf"); ok {
|
||||
res.NotBefore = t
|
||||
if now.Before(t) {
|
||||
return nil, fmt.Errorf("%w: nbf=%s now=%s", ErrEATNotYetValid, t, now.UTC())
|
||||
}
|
||||
}
|
||||
if t, ok := eatUnixClaim(claims, "exp"); ok {
|
||||
res.ExpiresAt = t
|
||||
if now.After(t) {
|
||||
return nil, fmt.Errorf("%w: exp=%s now=%s", ErrEATExpired, t, now.UTC())
|
||||
}
|
||||
}
|
||||
|
||||
// Nonce binding. NVIDIA uses "eat_nonce"; accept "nonce" as an alias.
|
||||
if nb, ok := eatHexClaim(claims, "eat_nonce", "nonce"); ok {
|
||||
if len(nb) == 32 {
|
||||
copy(res.Nonce[:], nb)
|
||||
res.HasNonce = true
|
||||
}
|
||||
if challenge != nil {
|
||||
if len(nb) != 32 || !bytesEqual(nb, challenge[:]) {
|
||||
return nil, ErrEATNonceMismatch
|
||||
}
|
||||
}
|
||||
} else if challenge != nil {
|
||||
// A challenge was demanded but the token carries no nonce.
|
||||
return nil, ErrEATNonceMismatch
|
||||
}
|
||||
|
||||
// Overall result, when present, must be positive.
|
||||
if r, ok := eatResultClaim(claims); ok {
|
||||
res.OverallResult = r
|
||||
if !eatResultPositive(r) {
|
||||
return nil, fmt.Errorf("%w: %q", ErrEATResultNegative, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Measurement root: explicit claim if present, else token-bound.
|
||||
if mb, ok := eatHexClaim(claims, "x-nvidia-gpu-measurements-root", "measurement_root"); ok {
|
||||
res.Measurement = mb
|
||||
} else {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
res.Measurement = h[:]
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// eatSignerKey resolves the public key that must verify the JWS: by pinned
|
||||
// kid, or by validating an x5c chain to the pinned NRAS root pool.
|
||||
func eatSignerKey(kid string, x5c []string, roots []TrustRoot, rootPool *x509.CertPool, now time.Time) (crypto.PublicKey, string, error) {
|
||||
// Prefer the pinned-kid path: deterministic, no chain processing.
|
||||
if kid != "" {
|
||||
if root, ok := findTrustRoot(roots, kid); ok {
|
||||
return root.Public, root.KeyID, nil
|
||||
}
|
||||
}
|
||||
// x5c path: anchor to the NRAS root pool, use the leaf key.
|
||||
if len(x5c) > 0 && rootPool != nil {
|
||||
chain, err := parseX5C(x5c)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, ic := range chain[1:] {
|
||||
intermediates.AddCert(ic)
|
||||
}
|
||||
leaf := chain[0]
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: rootPool,
|
||||
Intermediates: intermediates,
|
||||
CurrentTime: now,
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
return nil, "", fmt.Errorf("%w: %v", ErrEATChainUntrusted, err)
|
||||
}
|
||||
return leaf.PublicKey, leaf.Subject.String(), nil
|
||||
}
|
||||
return nil, "", ErrEATNoSigner
|
||||
}
|
||||
|
||||
// parseX5C parses an RFC 7515 x5c header (array of base64-STD DER certs,
|
||||
// leaf first).
|
||||
func parseX5C(x5c []string) ([]*x509.Certificate, error) {
|
||||
out := make([]*x509.Certificate, 0, len(x5c))
|
||||
for i, b64 := range x5c {
|
||||
der, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: x5c[%d]: %v", ErrEATChainUntrusted, i, err)
|
||||
}
|
||||
c, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: x5c[%d]: %v", ErrEATChainUntrusted, i, err)
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, ErrEATChainUntrusted
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func eatUnixClaim(claims map[string]json.RawMessage, name string) (time.Time, bool) {
|
||||
raw, ok := claims[name]
|
||||
if !ok {
|
||||
return time.Time{}, false
|
||||
}
|
||||
var n int64
|
||||
if err := json.Unmarshal(raw, &n); err != nil || n <= 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(n, 0).UTC(), true
|
||||
}
|
||||
|
||||
// eatHexClaim returns the first present claim among names, hex-decoded.
|
||||
func eatHexClaim(claims map[string]json.RawMessage, names ...string) ([]byte, bool) {
|
||||
for _, name := range names {
|
||||
raw, ok := claims[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return b, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// eatResultClaim extracts NVIDIA's overall attestation result, accepting a
|
||||
// boolean or a string verdict under a few documented claim names.
|
||||
func eatResultClaim(claims map[string]json.RawMessage) (string, bool) {
|
||||
for _, name := range []string{"x-nvidia-overall-att-result", "measres", "overall_result"} {
|
||||
raw, ok := claims[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var b bool
|
||||
if err := json.Unmarshal(raw, &b); err == nil {
|
||||
if b {
|
||||
return "true", true
|
||||
}
|
||||
return "false", true
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err == nil {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func eatResultPositive(r string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(r)) {
|
||||
case "true", "success", "comparison-successful", "pass", "passed":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -28,6 +29,7 @@ var (
|
||||
ErrReportMissingArch = errors.New("nvidia/report: missing architecture")
|
||||
ErrReportUnknownArch = errors.New("nvidia/report: unknown architecture")
|
||||
ErrReportNoMeasurements = errors.New("nvidia/report: no measurements present")
|
||||
ErrReportBadSPDM = errors.New("nvidia/report: malformed SPDM request/response encoding")
|
||||
)
|
||||
|
||||
// SupportedEvidenceVersions enumerates the report-envelope versions
|
||||
@@ -64,8 +66,11 @@ type rawReport struct {
|
||||
VBIOSVersion string `json:"vbios_version"`
|
||||
NonceHex string `json:"nonce"`
|
||||
Measurements []Measurement `json:"measurements"`
|
||||
CertChain []string `json:"cert_chain"` // PEM blocks
|
||||
Quote string `json:"attestation_quote"` // base64 binary
|
||||
CertChain []string `json:"cert_chain"` // PEM blocks, leaf first
|
||||
Quote string `json:"attestation_quote"` // legacy alias for spdm_response
|
||||
SPDMRequest string `json:"spdm_request"` // base64: GET_MEASUREMENTS (0xE0)
|
||||
SPDMResponse string `json:"spdm_response"` // base64: signed MEASUREMENTS (0x60)
|
||||
SPDMAsymAlgo string `json:"spdm_asym_algo"` // e.g. "ecdsa-p384-sha384"
|
||||
NVSwitchPresent bool `json:"nvswitch_present"`
|
||||
}
|
||||
|
||||
@@ -78,9 +83,19 @@ type GPUReport struct {
|
||||
DriverVersion string
|
||||
VBIOSVersion string
|
||||
Nonce [32]byte
|
||||
Measurements []Measurement
|
||||
CertChain []string // PEM blocks (verified at NRAS, kept for audit)
|
||||
QuoteB64 string
|
||||
Measurements []Measurement // CLAIMED measurements (untrusted labels); the
|
||||
// trusted values come from the signed SPDM record, not from here.
|
||||
CertChain []string // leaf-first PEM blocks; chained to the pinned NVIDIA root
|
||||
|
||||
// SPDMRequest / SPDMResponse are the binary SPDM GET_MEASUREMENTS (0xE0)
|
||||
// request and signed MEASUREMENTS (0x60) response. These ARE the GPU's
|
||||
// cryptographic evidence — the local verifier checks the response
|
||||
// signature with the leaf key and extracts measurements from the signed
|
||||
// record. Empty for an NRAS-only envelope.
|
||||
SPDMRequest []byte
|
||||
SPDMResponse []byte
|
||||
SPDMAsymAlgo string // negotiated signature algorithm name (may be empty -> P-384)
|
||||
|
||||
NVSwitchPresent bool
|
||||
}
|
||||
|
||||
@@ -123,6 +138,23 @@ func ParseGPUReport(data []byte) (*GPUReport, error) {
|
||||
r.Measurements[i].Value = raw
|
||||
}
|
||||
|
||||
// The signed SPDM response is carried under "spdm_response"; the legacy
|
||||
// "attestation_quote" key is accepted as an alias. Decode both SPDM
|
||||
// fields if present (they are optional at parse time — the local
|
||||
// verifier enforces their presence, the NRAS path ignores them).
|
||||
respB64 := r.SPDMResponse
|
||||
if respB64 == "" {
|
||||
respB64 = r.Quote
|
||||
}
|
||||
spdmResp, err := decodeOptionalB64(respB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: spdm_response: %v", ErrReportBadSPDM, err)
|
||||
}
|
||||
spdmReq, err := decodeOptionalB64(r.SPDMRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: spdm_request: %v", ErrReportBadSPDM, err)
|
||||
}
|
||||
|
||||
rep := &GPUReport{
|
||||
EvidenceVersion: r.EvidenceVersion,
|
||||
UUID: r.GPUUUID,
|
||||
@@ -131,13 +163,27 @@ func ParseGPUReport(data []byte) (*GPUReport, error) {
|
||||
VBIOSVersion: r.VBIOSVersion,
|
||||
Measurements: r.Measurements,
|
||||
CertChain: r.CertChain,
|
||||
QuoteB64: r.Quote,
|
||||
SPDMRequest: spdmReq,
|
||||
SPDMResponse: spdmResp,
|
||||
SPDMAsymAlgo: r.SPDMAsymAlgo,
|
||||
NVSwitchPresent: r.NVSwitchPresent,
|
||||
}
|
||||
copy(rep.Nonce[:], nonceBytes)
|
||||
return rep, nil
|
||||
}
|
||||
|
||||
// decodeOptionalB64 decodes a base64 field that may be empty. It accepts
|
||||
// both standard and raw (unpadded) base64, returning nil for empty input.
|
||||
func decodeOptionalB64(s string) ([]byte, error) {
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
return b, nil
|
||||
}
|
||||
return base64.RawStdEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
// MeasurementMap returns measurements indexed by Name for fast lookup
|
||||
// during RIM matching. Names are case-sensitive per spec.
|
||||
func (r *GPUReport) MeasurementMap() map[string]Measurement {
|
||||
|
||||
+59
-2
@@ -36,11 +36,17 @@ var (
|
||||
ErrRIMVersionMismatch = errors.New("nvidia/rim: driver/vbios version mismatch")
|
||||
)
|
||||
|
||||
// RIMEntry is one row in the manifest: an expected measurement keyed
|
||||
// by name, with its expected hex-encoded value.
|
||||
// RIMEntry is one row in the manifest: an expected measurement value.
|
||||
// Name is a human/audit label. Index, when present, is the SPDM
|
||||
// measurement index this golden value pins — the binding the signed-record
|
||||
// path (MatchSPDM) uses, since SPDM measurement blocks are keyed by index,
|
||||
// not name. Index is a pointer so that index 0 is distinguishable from
|
||||
// "no index", and omitempty keeps name-only manifests byte-identical on
|
||||
// the wire (their detached signatures are unaffected).
|
||||
type RIMEntry struct {
|
||||
Name string `json:"name"`
|
||||
ValueHex string `json:"value"`
|
||||
Index *uint8 `json:"index,omitempty"`
|
||||
}
|
||||
|
||||
// rawRIM is the on-the-wire JSON. The signature block is detachable:
|
||||
@@ -175,6 +181,57 @@ func (rim *RIM) Match(report *GPUReport) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrRIMEntryNoIndex is returned by MatchSPDM when a RIM entry lacks the
|
||||
// index needed to bind it to a signed SPDM measurement block.
|
||||
var ErrRIMEntryNoIndex = errors.New("nvidia/rim: entry has no index for SPDM matching")
|
||||
|
||||
// MatchSPDM validates that every golden value in the manifest is present
|
||||
// — by SPDM measurement index — among the cryptographically signed blocks
|
||||
// extracted from a verified MEASUREMENTS response. This is the trusted
|
||||
// match: blocks come from VerifySPDMMeasurementSignature, so a passing
|
||||
// match means "the GPU's signed measurements include exactly NVIDIA's
|
||||
// golden values for this driver/VBIOS".
|
||||
//
|
||||
// arch/driver/vbios are the envelope's claimed preconditions and must
|
||||
// equal the manifest's; the cryptographic gate, however, is value
|
||||
// equality against the SIGNED blocks. Extra signed blocks not named by the
|
||||
// manifest are allowed (NVIDIA reports more indices than any single RIM
|
||||
// pins); every manifest entry MUST be covered (completeness), which closes
|
||||
// the drop-a-bad-measurement attack.
|
||||
func (rim *RIM) MatchSPDM(arch, driver, vbios string, blocks []SPDMMeasurement) error {
|
||||
if rim == nil {
|
||||
return errors.New("nvidia/rim: nil rim")
|
||||
}
|
||||
if rim.Architecture != arch {
|
||||
return fmt.Errorf("%w: rim=%s report=%s", ErrRIMArchMismatch, rim.Architecture, arch)
|
||||
}
|
||||
if rim.DriverVersion != driver || rim.VBIOSVersion != vbios {
|
||||
return fmt.Errorf("%w: rim=%s/%s report=%s/%s", ErrRIMVersionMismatch,
|
||||
rim.DriverVersion, rim.VBIOSVersion, driver, vbios)
|
||||
}
|
||||
signed := make(map[uint8][]byte, len(blocks))
|
||||
for _, b := range blocks {
|
||||
signed[b.Index] = b.Value
|
||||
}
|
||||
for _, e := range rim.Entries {
|
||||
if e.Index == nil {
|
||||
return fmt.Errorf("%w: name=%s", ErrRIMEntryNoIndex, e.Name)
|
||||
}
|
||||
want, err := hex.DecodeString(strings.TrimPrefix(e.ValueHex, "0x"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("nvidia/rim: bad entry hex %q: %w", e.Name, err)
|
||||
}
|
||||
got, ok := signed[*e.Index]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: name=%s index=%d (no signed block)", ErrRIMMissingEntry, e.Name, *e.Index)
|
||||
}
|
||||
if !bytesEqual(want, got) {
|
||||
return fmt.Errorf("%w: name=%s index=%d", ErrRIMValueMismatch, e.Name, *e.Index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MeasurementRoot returns sha256(name||value) folded into a single
|
||||
// root over a deterministic ordering. Used as the canonical
|
||||
// MeasurementRoot in GPUEvidence.
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
// SPDM (DMTF DSP0274) measurement-report parsing and signature
|
||||
// verification — the cryptographic core of NVIDIA GPU confidential-compute
|
||||
// local attestation.
|
||||
//
|
||||
// An NVIDIA CC-capable GPU (Hopper H100/H200, Blackwell B100/B200/GB200)
|
||||
// produces an attestation report that is a signed SPDM MEASUREMENTS
|
||||
// response (RequestResponseCode 0x60, DSP0274). The report carries the
|
||||
// DMTF measurement blocks the GPU firmware measured, a responder nonce,
|
||||
// optional opaque data, and a signature produced by the GPU's attestation
|
||||
// key. The matching GET_MEASUREMENTS request (0xE0) carries the requester
|
||||
// nonce (the host's freshness challenge). The signature covers the
|
||||
// transcript request || response[:-signature], so verifying it binds the
|
||||
// measurement record AND the requester nonce to the GPU attestation key.
|
||||
//
|
||||
// This file implements:
|
||||
// - the DSP0274 wire parse of the 0xE0 request and 0x60 response,
|
||||
// - extraction of DMTF measurement blocks (index + signed digest),
|
||||
// - the DSP0274 transcript / signing-context construction for SPDM 1.1
|
||||
// and 1.2, and
|
||||
// - signature verification with a strict algorithm <-> key-type binding
|
||||
// (the same algorithm-substitution defense the NRAS JWS path uses).
|
||||
//
|
||||
// What makes the result trustworthy is the COMBINATION with devicechain.go:
|
||||
// the public key that verifies the SPDM signature is the leaf of a cert
|
||||
// chain that VerifyDeviceChain anchors to an operator-pinned NVIDIA device-
|
||||
// identity root. SPDM signature alone proves "some key signed these
|
||||
// measurements"; the chain proves "that key is a genuine NVIDIA GPU key".
|
||||
//
|
||||
// Clean-room: this is an implementation of the publicly documented DMTF
|
||||
// DSP0274 schema. No NVIDIA proprietary code or keys are vendored.
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"math/big"
|
||||
)
|
||||
|
||||
// Errors returned by the SPDM parser / verifier.
|
||||
var (
|
||||
ErrSPDMShort = errors.New("nvidia/spdm: message shorter than header")
|
||||
ErrSPDMBadCode = errors.New("nvidia/spdm: unexpected RequestResponseCode")
|
||||
ErrSPDMBadVersion = errors.New("nvidia/spdm: unsupported SPDM version")
|
||||
ErrSPDMTruncated = errors.New("nvidia/spdm: message truncated mid-field")
|
||||
ErrSPDMNoSignature = errors.New("nvidia/spdm: response carries no signature")
|
||||
ErrSPDMBadMeasRecord = errors.New("nvidia/spdm: malformed measurement record")
|
||||
ErrSPDMUnsupportedAlgo = errors.New("nvidia/spdm: unsupported asym/hash algorithm")
|
||||
ErrSPDMAlgKeyMismatch = errors.New("nvidia/spdm: asym algo does not match leaf key type")
|
||||
ErrSPDMSignatureInvalid = errors.New("nvidia/spdm: measurement-report signature invalid")
|
||||
ErrSPDMNonceLength = errors.New("nvidia/spdm: request nonce not present / wrong length")
|
||||
)
|
||||
|
||||
// SPDM message opcodes (DSP0274).
|
||||
const (
|
||||
spdmGetMeasurements byte = 0xE0 // request
|
||||
spdmMeasurements byte = 0x60 // response
|
||||
)
|
||||
|
||||
// Supported SPDM version bytes. The version byte is encoded as
|
||||
// (major<<4)|minor: 0x11 = SPDM 1.1, 0x12 = SPDM 1.2. Hopper attestation
|
||||
// uses 1.1; Blackwell uses 1.2.
|
||||
const (
|
||||
spdmVersion11 byte = 0x11
|
||||
spdmVersion12 byte = 0x12
|
||||
)
|
||||
|
||||
// SPDMAsymAlgo names the asymmetric signature algorithm the GPU used to
|
||||
// sign the MEASUREMENTS response. It binds three things together —
|
||||
// signature length (needed to split the signed body from the trailing
|
||||
// signature), the hash used in the transcript, and the public-key type
|
||||
// the verifier must hold — so an attacker cannot substitute one for
|
||||
// another. NVIDIA Hopper/Blackwell attestation keys are ECDSA P-384.
|
||||
type SPDMAsymAlgo uint8
|
||||
|
||||
const (
|
||||
// SPDMAsymECDSAP384SHA384 — ECDSA on NIST P-384 with SHA-384. The
|
||||
// NVIDIA Hopper/Blackwell GPU attestation-key algorithm. Signature is
|
||||
// fixed-width r||s, 96 bytes.
|
||||
SPDMAsymECDSAP384SHA384 SPDMAsymAlgo = iota + 1
|
||||
// SPDMAsymECDSAP256SHA256 — ECDSA P-256 / SHA-256, 64-byte r||s.
|
||||
SPDMAsymECDSAP256SHA256
|
||||
// SPDMAsymRSAPSS3072SHA384 — RSASSA-PSS, 3072-bit, SHA-384, 384-byte sig.
|
||||
SPDMAsymRSAPSS3072SHA384
|
||||
)
|
||||
|
||||
// ParseSPDMAsymAlgo maps the envelope's wire string to an algo. Unknown
|
||||
// strings are refused (no silent default — the asym algo is a security
|
||||
// parameter).
|
||||
func ParseSPDMAsymAlgo(s string) (SPDMAsymAlgo, error) {
|
||||
switch s {
|
||||
case "ecdsa-p384-sha384", "ecdsa-p384", "ECDSA_P384", "":
|
||||
// Empty string defaults to the NVIDIA production algorithm
|
||||
// (ECDSA P-384), the only algo current CC GPUs emit. This is a
|
||||
// convenience for the common case, not an insecure fallback: the
|
||||
// signature still must verify under a P-384 leaf key, and a non-
|
||||
// P-384 leaf is refused by the alg<->key binding below.
|
||||
return SPDMAsymECDSAP384SHA384, nil
|
||||
case "ecdsa-p256-sha256", "ecdsa-p256", "ECDSA_P256":
|
||||
return SPDMAsymECDSAP256SHA256, nil
|
||||
case "rsa-pss-3072-sha384", "rsapss3072":
|
||||
return SPDMAsymRSAPSS3072SHA384, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("%w: %q", ErrSPDMUnsupportedAlgo, s)
|
||||
}
|
||||
}
|
||||
|
||||
// sigLen returns the fixed signature length in bytes for the algo.
|
||||
func (a SPDMAsymAlgo) sigLen() int {
|
||||
switch a {
|
||||
case SPDMAsymECDSAP384SHA384:
|
||||
return 96
|
||||
case SPDMAsymECDSAP256SHA256:
|
||||
return 64
|
||||
case SPDMAsymRSAPSS3072SHA384:
|
||||
return 384
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// newHash returns a fresh hash for the algo's transcript hash function.
|
||||
func (a SPDMAsymAlgo) newHash() hash.Hash {
|
||||
switch a {
|
||||
case SPDMAsymECDSAP256SHA256:
|
||||
return sha256.New()
|
||||
default: // P-384 and RSA-PSS-3072 use SHA-384
|
||||
return sha512.New384()
|
||||
}
|
||||
}
|
||||
|
||||
func (a SPDMAsymAlgo) String() string {
|
||||
switch a {
|
||||
case SPDMAsymECDSAP384SHA384:
|
||||
return "ecdsa-p384-sha384"
|
||||
case SPDMAsymECDSAP256SHA256:
|
||||
return "ecdsa-p256-sha256"
|
||||
case SPDMAsymRSAPSS3072SHA384:
|
||||
return "rsa-pss-3072-sha384"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// SPDMMeasurement is one DMTF measurement block extracted from a verified
|
||||
// MEASUREMENTS response. Index is the SPDM measurement index (the stable
|
||||
// identifier the RIM keys golden values by). Value is the raw measurement
|
||||
// value (a SHA-384 digest for the digest value-type, or raw bytes for the
|
||||
// raw value-type).
|
||||
type SPDMMeasurement struct {
|
||||
Index uint8
|
||||
ValueType uint8 // DMTFSpecMeasurementValueType (bit7 = raw, bits[6:0]=type)
|
||||
Value []byte
|
||||
}
|
||||
|
||||
// IsRaw reports whether the block carries a raw value (bit7 set) rather
|
||||
// than a digest.
|
||||
func (m SPDMMeasurement) IsRaw() bool { return m.ValueType&0x80 != 0 }
|
||||
|
||||
// SPDMResponse is a parsed (not yet signature-verified) MEASUREMENTS
|
||||
// response.
|
||||
type SPDMResponse struct {
|
||||
Version byte
|
||||
NumberOfBlocks uint8
|
||||
Measurements []SPDMMeasurement
|
||||
ResponderNonce [32]byte
|
||||
OpaqueData []byte
|
||||
Signature []byte
|
||||
|
||||
// signedBody is response[:len-sigLen] — the bytes the signature
|
||||
// covers. Retained so the verifier hashes exactly what was signed.
|
||||
signedBody []byte
|
||||
}
|
||||
|
||||
// ParseSPDMMeasurementResponse parses a MEASUREMENTS (0x60) response,
|
||||
// splitting off a trailing signature of sigLen bytes. It validates the
|
||||
// header, walks the measurement record into typed blocks, and locates the
|
||||
// nonce / opaque / signature fields by their DSP0274 offsets.
|
||||
//
|
||||
// sigLen must be the negotiated signature size; a response shorter than
|
||||
// header+record+nonce+opaque+sigLen is rejected as truncated.
|
||||
func ParseSPDMMeasurementResponse(msg []byte, sigLen int) (*SPDMResponse, error) {
|
||||
if sigLen <= 0 {
|
||||
return nil, ErrSPDMUnsupportedAlgo
|
||||
}
|
||||
// Fixed header: version, code, param1, param2, numblocks, recordlen(3).
|
||||
if len(msg) < 8 {
|
||||
return nil, ErrSPDMShort
|
||||
}
|
||||
version := msg[0]
|
||||
if version != spdmVersion11 && version != spdmVersion12 {
|
||||
return nil, fmt.Errorf("%w: 0x%02x", ErrSPDMBadVersion, version)
|
||||
}
|
||||
if msg[1] != spdmMeasurements {
|
||||
return nil, fmt.Errorf("%w: got 0x%02x want 0x60", ErrSPDMBadCode, msg[1])
|
||||
}
|
||||
numBlocks := msg[4]
|
||||
recordLen := int(msg[5]) | int(msg[6])<<8 | int(msg[7])<<16 // 3-byte LE
|
||||
|
||||
off := 8
|
||||
if off+recordLen+32+2+sigLen > len(msg) {
|
||||
return nil, fmt.Errorf("%w: record=%d nonce=32 sig=%d total=%d", ErrSPDMTruncated, recordLen, sigLen, len(msg))
|
||||
}
|
||||
record := msg[off : off+recordLen]
|
||||
off += recordLen
|
||||
|
||||
var responderNonce [32]byte
|
||||
copy(responderNonce[:], msg[off:off+32])
|
||||
off += 32
|
||||
|
||||
opaqueLen := int(binary.LittleEndian.Uint16(msg[off : off+2]))
|
||||
off += 2
|
||||
if off+opaqueLen+sigLen > len(msg) {
|
||||
return nil, fmt.Errorf("%w: opaque=%d sig=%d total=%d", ErrSPDMTruncated, opaqueLen, sigLen, len(msg))
|
||||
}
|
||||
opaque := msg[off : off+opaqueLen]
|
||||
off += opaqueLen
|
||||
|
||||
// The signature occupies the final sigLen bytes; anything after it is
|
||||
// trailing garbage and rejected.
|
||||
if off+sigLen != len(msg) {
|
||||
return nil, fmt.Errorf("%w: %d trailing bytes after signature", ErrSPDMTruncated, len(msg)-(off+sigLen))
|
||||
}
|
||||
sig := msg[off : off+sigLen]
|
||||
|
||||
blocks, err := parseMeasurementBlocks(record, int(numBlocks))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SPDMResponse{
|
||||
Version: version,
|
||||
NumberOfBlocks: numBlocks,
|
||||
Measurements: blocks,
|
||||
ResponderNonce: responderNonce,
|
||||
OpaqueData: append([]byte(nil), opaque...),
|
||||
Signature: append([]byte(nil), sig...),
|
||||
signedBody: append([]byte(nil), msg[:len(msg)-sigLen]...),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseMeasurementBlocks walks the DMTF measurement record into numBlocks
|
||||
// blocks. Each block: Index(1) MeasurementSpecification(1)
|
||||
// MeasurementSize(2 LE) Measurement(MeasurementSize). The DMTF Measurement
|
||||
// is itself ValueType(1) ValueSize(2 LE) Value(ValueSize).
|
||||
func parseMeasurementBlocks(record []byte, numBlocks int) ([]SPDMMeasurement, error) {
|
||||
out := make([]SPDMMeasurement, 0, numBlocks)
|
||||
off := 0
|
||||
for i := 0; i < numBlocks; i++ {
|
||||
if off+4 > len(record) {
|
||||
return nil, fmt.Errorf("%w: block %d header past end", ErrSPDMBadMeasRecord, i)
|
||||
}
|
||||
index := record[off]
|
||||
// record[off+1] = MeasurementSpecification; we only support DMTF
|
||||
// (bit0). We do not hard-fail on the spec byte: NVIDIA always sets
|
||||
// DMTF, and the value-type below is what we actually consume.
|
||||
measSize := int(binary.LittleEndian.Uint16(record[off+2 : off+4]))
|
||||
off += 4
|
||||
if off+measSize > len(record) {
|
||||
return nil, fmt.Errorf("%w: block %d value past end", ErrSPDMBadMeasRecord, i)
|
||||
}
|
||||
dmtf := record[off : off+measSize]
|
||||
off += measSize
|
||||
|
||||
if len(dmtf) < 3 {
|
||||
return nil, fmt.Errorf("%w: block %d dmtf shorter than 3", ErrSPDMBadMeasRecord, i)
|
||||
}
|
||||
valType := dmtf[0]
|
||||
valSize := int(binary.LittleEndian.Uint16(dmtf[1:3]))
|
||||
if 3+valSize != len(dmtf) {
|
||||
return nil, fmt.Errorf("%w: block %d dmtf value size %d != %d", ErrSPDMBadMeasRecord, i, valSize, len(dmtf)-3)
|
||||
}
|
||||
out = append(out, SPDMMeasurement{
|
||||
Index: index,
|
||||
ValueType: valType,
|
||||
Value: append([]byte(nil), dmtf[3:]...),
|
||||
})
|
||||
}
|
||||
if off != len(record) {
|
||||
return nil, fmt.Errorf("%w: %d trailing record bytes", ErrSPDMBadMeasRecord, len(record)-off)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SPDMRequestNonce extracts the requester nonce from a GET_MEASUREMENTS
|
||||
// (0xE0) request that requested a signature. The nonce is the host's
|
||||
// freshness challenge; it lives at offset 4..36 when Param1 bit0 (signature
|
||||
// requested) is set. Returns the nonce and the SPDM version.
|
||||
//
|
||||
// The request is part of the signed transcript, so a verified signature
|
||||
// proves this nonce was bound by the GPU. Callers compare it to the
|
||||
// challenge they issued (WithExpectedReportData).
|
||||
func SPDMRequestNonce(req []byte) (version byte, nonce [32]byte, err error) {
|
||||
if len(req) < 4 {
|
||||
return 0, nonce, ErrSPDMShort
|
||||
}
|
||||
version = req[0]
|
||||
if version != spdmVersion11 && version != spdmVersion12 {
|
||||
return 0, nonce, fmt.Errorf("%w: 0x%02x", ErrSPDMBadVersion, version)
|
||||
}
|
||||
if req[1] != spdmGetMeasurements {
|
||||
return 0, nonce, fmt.Errorf("%w: got 0x%02x want 0xE0", ErrSPDMBadCode, req[1])
|
||||
}
|
||||
sigRequested := req[2]&0x01 != 0
|
||||
if !sigRequested {
|
||||
return 0, nonce, fmt.Errorf("%w: request did not ask for a signature", ErrSPDMNoSignature)
|
||||
}
|
||||
if len(req) < 4+32 {
|
||||
return 0, nonce, ErrSPDMNonceLength
|
||||
}
|
||||
copy(nonce[:], req[4:4+32])
|
||||
return version, nonce, nil
|
||||
}
|
||||
|
||||
// VerifySPDMMeasurementSignature verifies the MEASUREMENTS response
|
||||
// signature over the DSP0274 transcript request || response[:-sig] using
|
||||
// leafPub, under the named algo. It returns the parsed, signature-verified
|
||||
// response on success.
|
||||
//
|
||||
// The algo binds the signature length, the hash, AND the leaf key type:
|
||||
// a P-384 algo against an RSA leaf (or vice versa) is refused before any
|
||||
// verification primitive runs (ErrSPDMAlgKeyMismatch) — the same
|
||||
// algorithm-substitution defense the NRAS JWS path applies.
|
||||
func VerifySPDMMeasurementSignature(leafPub crypto.PublicKey, algo SPDMAsymAlgo, requestMsg, responseMsg []byte) (*SPDMResponse, error) {
|
||||
sigLen := algo.sigLen()
|
||||
if sigLen == 0 {
|
||||
return nil, ErrSPDMUnsupportedAlgo
|
||||
}
|
||||
resp, err := ParseSPDMMeasurementResponse(responseMsg, sigLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The request version must match the response version (a transcript
|
||||
// spanning two SPDM versions is malformed).
|
||||
reqVersion, _, err := SPDMRequestNonce(requestMsg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if reqVersion != resp.Version {
|
||||
return nil, fmt.Errorf("%w: request 0x%02x response 0x%02x", ErrSPDMBadVersion, reqVersion, resp.Version)
|
||||
}
|
||||
|
||||
digest := spdmMeasurementDigest(algo, resp.Version, requestMsg, resp.signedBody)
|
||||
|
||||
switch algo {
|
||||
case SPDMAsymECDSAP384SHA384, SPDMAsymECDSAP256SHA256:
|
||||
pub, ok := leafPub.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: algo=%s leaf=%T", ErrSPDMAlgKeyMismatch, algo, leafPub)
|
||||
}
|
||||
wantCurve := "P-384"
|
||||
if algo == SPDMAsymECDSAP256SHA256 {
|
||||
wantCurve = "P-256"
|
||||
}
|
||||
if pub.Curve.Params().Name != wantCurve {
|
||||
return nil, fmt.Errorf("%w: algo=%s leaf curve=%s", ErrSPDMAlgKeyMismatch, algo, pub.Curve.Params().Name)
|
||||
}
|
||||
if err := ecdsaVerifyRaw(pub, digest, resp.Signature); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
|
||||
case SPDMAsymRSAPSS3072SHA384:
|
||||
pub, ok := leafPub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: algo=%s leaf=%T", ErrSPDMAlgKeyMismatch, algo, leafPub)
|
||||
}
|
||||
if err := rsa.VerifyPSS(pub, crypto.SHA384, digest, resp.Signature, &rsa.PSSOptions{
|
||||
SaltLength: rsa.PSSSaltLengthEqualsHash,
|
||||
Hash: crypto.SHA384,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrSPDMSignatureInvalid, err)
|
||||
}
|
||||
return resp, nil
|
||||
|
||||
default:
|
||||
return nil, ErrSPDMUnsupportedAlgo
|
||||
}
|
||||
}
|
||||
|
||||
// spdmMeasurementDigest returns the digest the asymmetric primitive
|
||||
// verifies against, per DSP0274:
|
||||
//
|
||||
// M = requestMsg || responseSignedBody (the transcript L)
|
||||
// 1.1: digest = Hash(M)
|
||||
// 1.2: digest = Hash( SPDMSignedContext || Hash(M) )
|
||||
//
|
||||
// where SPDMSignedContext is the 100-byte responder-measurements signing
|
||||
// context defined by DSP0274 1.2 (see spdmSigningContext).
|
||||
func spdmMeasurementDigest(algo SPDMAsymAlgo, version byte, requestMsg, responseSignedBody []byte) []byte {
|
||||
h := algo.newHash()
|
||||
h.Write(requestMsg)
|
||||
h.Write(responseSignedBody)
|
||||
th := h.Sum(nil)
|
||||
|
||||
ctx := spdmSigningContext(version)
|
||||
if ctx == nil {
|
||||
return th // SPDM 1.1: sign over Hash(M)
|
||||
}
|
||||
h2 := algo.newHash()
|
||||
h2.Write(ctx)
|
||||
h2.Write(th)
|
||||
return h2.Sum(nil) // SPDM 1.2: sign over Hash(ctx || Hash(M))
|
||||
}
|
||||
|
||||
// spdmSigningContext builds the DSP0274 1.2 "SPDMSignedContext" prefix for
|
||||
// a responder MEASUREMENTS signature. Returns nil for SPDM < 1.2 (which
|
||||
// signs Hash(transcript) directly, no prefix).
|
||||
//
|
||||
// Layout (100 bytes total, per DSP0274 1.2 and libspdm/spdm-rs):
|
||||
//
|
||||
// [0:64] four copies of the 16-byte ASCII "dmtf-spdm-v1.2.*"
|
||||
// [64:70] six zero bytes
|
||||
// [70:100] the 30-byte context "responder-measurements signing"
|
||||
//
|
||||
// NOTE (production integration): this prefix construction matches the DMTF
|
||||
// spec and reference implementations. Confirming byte-exact agreement with
|
||||
// a real Blackwell (SPDM 1.2) attestation report — including whether
|
||||
// NVIDIA's firmware folds the VCA (GET_VERSION/CAPABILITIES/ALGORITHMS)
|
||||
// transcript into M — requires a real B200/GB200 vector (documented
|
||||
// collateral). Hopper (SPDM 1.1, the current production target) uses the
|
||||
// no-prefix path and is fully exercised by the test vectors.
|
||||
func spdmSigningContext(version byte) []byte {
|
||||
if version < spdmVersion12 {
|
||||
return nil
|
||||
}
|
||||
const prefixUnit = "dmtf-spdm-v1.2.*" // exactly 16 bytes
|
||||
const context = "responder-measurements signing"
|
||||
ctx := make([]byte, 100)
|
||||
for i := 0; i < 4; i++ {
|
||||
copy(ctx[i*16:(i+1)*16], prefixUnit)
|
||||
}
|
||||
// ctx[64:70] left zero; context is right-aligned in the 100-byte buffer.
|
||||
copy(ctx[100-len(context):], context)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// ecdsaVerifyRaw verifies a fixed-width r||s ECDSA signature (big-endian,
|
||||
// equal halves — the SPDM/JOSE encoding, NOT DER) over a pre-computed
|
||||
// digest.
|
||||
func ecdsaVerifyRaw(pub *ecdsa.PublicKey, digest, sig []byte) error {
|
||||
coord := (pub.Curve.Params().BitSize + 7) / 8
|
||||
if len(sig) != 2*coord {
|
||||
return fmt.Errorf("%w: sig len %d != 2*%d", ErrSPDMSignatureInvalid, len(sig), coord)
|
||||
}
|
||||
r := new(big.Int).SetBytes(sig[:coord])
|
||||
s := new(big.Int).SetBytes(sig[coord:])
|
||||
if !ecdsa.Verify(pub, digest, r, s) {
|
||||
return ErrSPDMSignatureInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
// Unit tests for the DSP0274 SPDM measurement-report parser and signature
|
||||
// verifier. These exercise the primitive directly (the cc/attest end-to-end
|
||||
// tests drive it through the NVTrust Kind); the focus here is wire-parse
|
||||
// robustness and the algorithm <-> key-type substitution defense.
|
||||
package nvidia
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha512"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func le16b(v int) []byte {
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, uint16(v))
|
||||
return b
|
||||
}
|
||||
|
||||
func le24b(v int) []byte { return []byte{byte(v), byte(v >> 8), byte(v >> 16)} }
|
||||
|
||||
// buildSPDM builds a (request, response) pair: a single measurement block
|
||||
// at the given index/value, signed with leafPriv (ECDSA P-384, SPDM 1.1).
|
||||
func buildSPDM(t *testing.T, leafPriv *ecdsa.PrivateKey, idx uint8, value []byte) (req, resp []byte) {
|
||||
t.Helper()
|
||||
var nonce [32]byte
|
||||
for i := range nonce {
|
||||
nonce[i] = byte(i)
|
||||
}
|
||||
req = []byte{0x11, 0xE0, 0x01, 0xFF}
|
||||
req = append(req, nonce[:]...)
|
||||
req = append(req, 0x00)
|
||||
|
||||
dmtf := []byte{0x00}
|
||||
dmtf = append(dmtf, le16b(len(value))...)
|
||||
dmtf = append(dmtf, value...)
|
||||
blk := []byte{idx, 0x01}
|
||||
blk = append(blk, le16b(len(dmtf))...)
|
||||
blk = append(blk, dmtf...)
|
||||
|
||||
var respNonce [32]byte
|
||||
body := []byte{0x11, 0x60, 0x00, 0x00, 1}
|
||||
body = append(body, le24b(len(blk))...)
|
||||
body = append(body, blk...)
|
||||
body = append(body, respNonce[:]...)
|
||||
body = append(body, le16b(0)...)
|
||||
|
||||
h := sha512.New384()
|
||||
h.Write(req)
|
||||
h.Write(body)
|
||||
digest := h.Sum(nil)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, leafPriv, digest)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
sig := joseConcat(r, s, 48)
|
||||
return req, append(body, sig...)
|
||||
}
|
||||
|
||||
func TestSPDM_VerifyValid_ExtractsMeasurement(t *testing.T) {
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
val := bytes.Repeat([]byte{0xAB}, 48)
|
||||
req, resp := buildSPDM(t, priv, 7, val)
|
||||
|
||||
parsed, err := VerifySPDMMeasurementSignature(&priv.PublicKey, SPDMAsymECDSAP384SHA384, req, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if len(parsed.Measurements) != 1 || parsed.Measurements[0].Index != 7 {
|
||||
t.Fatalf("measurements = %+v", parsed.Measurements)
|
||||
}
|
||||
if !bytes.Equal(parsed.Measurements[0].Value, val) {
|
||||
t.Fatalf("value = %x want %x", parsed.Measurements[0].Value, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPDM_RejectsTamperedSignature(t *testing.T) {
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
req, resp := buildSPDM(t, priv, 0, bytes.Repeat([]byte{0x01}, 48))
|
||||
resp[len(resp)-1] ^= 0xFF
|
||||
_, err := VerifySPDMMeasurementSignature(&priv.PublicKey, SPDMAsymECDSAP384SHA384, req, resp)
|
||||
if !errors.Is(err, ErrSPDMSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSPDMSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPDM_RejectsWrongKey(t *testing.T) {
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
other, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
req, resp := buildSPDM(t, priv, 0, bytes.Repeat([]byte{0x01}, 48))
|
||||
_, err := VerifySPDMMeasurementSignature(&other.PublicKey, SPDMAsymECDSAP384SHA384, req, resp)
|
||||
if !errors.Is(err, ErrSPDMSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSPDMSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Algorithm-substitution defense: a P-384 algo paired with a non-P-384 key
|
||||
// is refused before verification (mirrors the JWS alg<->key binding).
|
||||
func TestSPDM_AlgKeyTypeMismatch(t *testing.T) {
|
||||
p384, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
req, resp := buildSPDM(t, p384, 0, bytes.Repeat([]byte{0x01}, 48))
|
||||
|
||||
// P-384 algo, P-256 key.
|
||||
p256, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if _, err := VerifySPDMMeasurementSignature(&p256.PublicKey, SPDMAsymECDSAP384SHA384, req, resp); !errors.Is(err, ErrSPDMAlgKeyMismatch) {
|
||||
t.Fatalf("p256 key: err = %v, want ErrSPDMAlgKeyMismatch", err)
|
||||
}
|
||||
// P-384 algo, RSA key.
|
||||
rsaKey, _ := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if _, err := VerifySPDMMeasurementSignature(&rsaKey.PublicKey, SPDMAsymECDSAP384SHA384, req, resp); !errors.Is(err, ErrSPDMAlgKeyMismatch) {
|
||||
t.Fatalf("rsa key: err = %v, want ErrSPDMAlgKeyMismatch", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPDM_RejectsTruncated(t *testing.T) {
|
||||
if _, err := ParseSPDMMeasurementResponse([]byte{0x11, 0x60, 0, 0}, 96); !errors.Is(err, ErrSPDMShort) {
|
||||
t.Fatalf("short header: err = %v, want ErrSPDMShort", err)
|
||||
}
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
_, resp := buildSPDM(t, priv, 0, bytes.Repeat([]byte{0x01}, 48))
|
||||
if _, err := ParseSPDMMeasurementResponse(resp[:len(resp)-10], 96); !errors.Is(err, ErrSPDMTruncated) {
|
||||
t.Fatalf("truncated body: err = %v, want ErrSPDMTruncated", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPDM_RejectsBadResponseCode(t *testing.T) {
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
_, resp := buildSPDM(t, priv, 0, bytes.Repeat([]byte{0x01}, 48))
|
||||
resp[1] = 0x61 // not MEASUREMENTS
|
||||
if _, err := ParseSPDMMeasurementResponse(resp, 96); !errors.Is(err, ErrSPDMBadCode) {
|
||||
t.Fatalf("err = %v, want ErrSPDMBadCode", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPDM_RequestNonce(t *testing.T) {
|
||||
var nonce [32]byte
|
||||
for i := range nonce {
|
||||
nonce[i] = byte(i + 100)
|
||||
}
|
||||
req := []byte{0x11, 0xE0, 0x01, 0xFF}
|
||||
req = append(req, nonce[:]...)
|
||||
req = append(req, 0x00)
|
||||
v, got, err := SPDMRequestNonce(req)
|
||||
if err != nil {
|
||||
t.Fatalf("nonce: %v", err)
|
||||
}
|
||||
if v != 0x11 || got != nonce {
|
||||
t.Fatalf("version=0x%02x nonce=%x", v, got)
|
||||
}
|
||||
// A request that did not ask for a signature is refused.
|
||||
req[2] = 0x00
|
||||
if _, _, err := SPDMRequestNonce(req); !errors.Is(err, ErrSPDMNoSignature) {
|
||||
t.Fatalf("no-sig: err = %v, want ErrSPDMNoSignature", err)
|
||||
}
|
||||
}
|
||||
+276
-92
@@ -5,160 +5,344 @@ package attest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/cc/attest/nvidia"
|
||||
)
|
||||
|
||||
// NVTrust is the NVIDIA GPU confidential-compute attestation verifier.
|
||||
// NVTrust is the NVIDIA GPU confidential-compute attestation verifier. It
|
||||
// implements both NVIDIA trust models behind one Kind, selected by Mode:
|
||||
//
|
||||
// NVTrust is NVIDIA's open attestation toolkit. It has two trust models:
|
||||
// - ModeLocal (default, cloud-free): full hardware verification. The GPU
|
||||
// evidence carries a signed SPDM MEASUREMENTS report plus the GPU's
|
||||
// device certificate chain. NVTrust (1) chains the leaf to an operator-
|
||||
// pinned NVIDIA device-identity root, (2) verifies the SPDM report
|
||||
// signature with that leaf key, (3) extracts measurements from the
|
||||
// SIGNED record and matches them by index to an NVIDIA-signed Reference
|
||||
// Integrity Manifest, and (4) binds the signed requester nonce to the
|
||||
// caller's freshness challenge. This is the mode a decentralized
|
||||
// control plane requires — the only trust anchors are the operator-
|
||||
// pinned device root and RIM signing key; no NVIDIA cloud is contacted.
|
||||
//
|
||||
// - LOCAL (this verifier, the canonical Kind): the GPU evidence report
|
||||
// is matched against a Reference Integrity Manifest (RIM) that NVIDIA
|
||||
// signs per driver+VBIOS. The operator pins the RIM signing key; the
|
||||
// verifier checks the RIM signature and that every measurement the GPU
|
||||
// reports equals the signed golden value. No network, no NVIDIA cloud
|
||||
// dependency — the only trust anchor is the operator-pinned signing key.
|
||||
// This is the mode a decentralized / blockchain control plane requires.
|
||||
// - ModeNRAS (remote): the evidence is an NVIDIA Remote Attestation
|
||||
// Service EAT/JWT. NVTrust verifies the token signature against pinned
|
||||
// NRAS signer keys (or an x5c chain to a pinned NRAS root), checks
|
||||
// freshness, and binds the eat_nonce. The relying party trusts NVIDIA's
|
||||
// cloud verdict. The live POST to NRAS is nvidia.NRASClient's job; this
|
||||
// Kind only verifies the resulting token (no network in Verify).
|
||||
//
|
||||
// - REMOTE (NVIDIA Remote Attestation Service): the evidence is POSTed to
|
||||
// NRAS, which returns a signed JWS token. That path is the lower-level
|
||||
// nvidia.NRASClient primitive; it is a distinct deployment choice (trust
|
||||
// NVIDIA's cloud) and is NOT what this Kind dispatches to. Callers that
|
||||
// accept a cloud dependency use nvidia.NRASClient directly.
|
||||
// Trust anchors are NEVER taken from the evidence. They are operator-pinned
|
||||
// at construction (NewNVTrust + options), exactly as AMD's ARK is pinned
|
||||
// for SEV-SNP. There is no insecure mode: ModeLocal with no device root, or
|
||||
// ModeNRAS with no NRAS trust, is refused.
|
||||
//
|
||||
// Evidence is the JSON GPU evidence envelope (see nvidia.ParseGPUReport).
|
||||
// The signed RIM and the trust roots that may sign it are supplied via
|
||||
// options — WithNVTrustRIM and WithNVTrustTrustRoots. There is no insecure
|
||||
// mode: a verify with no RIM or no trust roots is refused with ErrPolicy,
|
||||
// exactly as nvidia.ParseAndVerifyRIM refuses an empty trust-root set.
|
||||
// The zero value (NVTrust{}, used by Dispatch) is ModeLocal with no pinned
|
||||
// device root and therefore fail-closed: it refuses every input. Production
|
||||
// callers construct a configured verifier with NewNVTrust and use it as a
|
||||
// Verifier; see nvtrust_options.go.
|
||||
//
|
||||
// On success the VerifiedReport carries:
|
||||
//
|
||||
// - Measurement: the RIM measurement root (sha256 over the signed,
|
||||
// name-sorted golden measurement list the GPU was proven to match).
|
||||
// - ReportData: the 32-byte freshness nonce echoed in the GPU report.
|
||||
// - ChipID: the per-GPU UUID bytes.
|
||||
// - Extra: driver/VBIOS/architecture/evidence-version + RIM signer.
|
||||
//
|
||||
// Honest scope: local mode proves "this GPU reports measurements that match
|
||||
// an NVIDIA-signed RIM for its driver+VBIOS, bound to a fresh nonce." It
|
||||
// does not, on its own, verify the GPU device certificate's SPDM signature
|
||||
// against NVIDIA's device root CA — that anchor is what the REMOTE NRAS path
|
||||
// (or a future embedded device-root) provides. Local mode is a strict
|
||||
// upgrade over any unsigned/structural-only check and is the cloud-free
|
||||
// integrity gate; deployments needing device-cert chaining add the NRAS
|
||||
// primitive on top.
|
||||
type NVTrust struct{}
|
||||
// - Measurement: the matched RIM measurement root (ModeLocal) or the EAT
|
||||
// measurement root (ModeNRAS). Trust-score / tier is policy ON TOP of
|
||||
// this — the caller scores an already-verified measurement.
|
||||
// - ReportData: the 32-byte freshness nonce (the SIGNED SPDM requester
|
||||
// nonce in ModeLocal; the eat_nonce in ModeNRAS).
|
||||
// - ChipID: the device leaf-certificate serial (ModeLocal) — a
|
||||
// trusted GPU identity, not the untrusted envelope UUID.
|
||||
type NVTrust struct {
|
||||
mode NVTrustMode
|
||||
deviceRoots *x509.CertPool // ModeLocal: NVIDIA device-identity root CA
|
||||
nrasRoots []nvidia.TrustRoot // ModeNRAS: NRAS signer keys (by kid)
|
||||
nrasRootPool *x509.CertPool // ModeNRAS: NRAS root CA for x5c chaining (optional)
|
||||
now func() time.Time // injectable clock; nil = per-call WithNow / wall
|
||||
}
|
||||
|
||||
// Verify implements Verifier for the NVIDIA local-RIM attestation path.
|
||||
func (NVTrust) Verify(ctx context.Context, evidence []byte, opts ...Option) (*VerifiedReport, error) {
|
||||
// NVTrustMode selects the GPU attestation trust model.
|
||||
type NVTrustMode uint8
|
||||
|
||||
const (
|
||||
// ModeLocal performs full local hardware SPDM verification. This is
|
||||
// the zero value, so an unconfigured NVTrust defaults to the strict
|
||||
// (and, without a pinned device root, fail-closed) local path.
|
||||
ModeLocal NVTrustMode = iota
|
||||
// ModeNRAS verifies an NVIDIA Remote Attestation Service EAT token.
|
||||
ModeNRAS
|
||||
)
|
||||
|
||||
func (m NVTrustMode) String() string {
|
||||
switch m {
|
||||
case ModeLocal:
|
||||
return "local"
|
||||
case ModeNRAS:
|
||||
return "nras"
|
||||
default:
|
||||
return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
// Verify implements Verifier, dispatching on the configured mode.
|
||||
func (n NVTrust) Verify(ctx context.Context, evidence []byte, opts ...Option) (*VerifiedReport, error) {
|
||||
cfg := applyOptions(opts...)
|
||||
switch n.mode {
|
||||
case ModeLocal:
|
||||
return n.verifyLocal(evidence, cfg)
|
||||
case ModeNRAS:
|
||||
return n.verifyNRAS(evidence, cfg)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: nvtrust mode %d", ErrUnsupportedKind, n.mode)
|
||||
}
|
||||
}
|
||||
|
||||
// verifyLocal runs the full SPDM + device-chain + RIM verification.
|
||||
func (n NVTrust) verifyLocal(evidence []byte, cfg config) (*VerifiedReport, error) {
|
||||
now := n.clock(cfg)
|
||||
|
||||
// Parse the GPU evidence envelope. ParseGPUReport validates the
|
||||
// envelope version, architecture, nonce shape, and decodes every
|
||||
// measurement — structural problems fail here before any trust
|
||||
// decision is made.
|
||||
report, err := nvidia.ParseGPUReport(evidence)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
|
||||
}
|
||||
|
||||
// No insecure mode: a signed RIM and the keys that may sign it are
|
||||
// mandatory. Refuse rather than fall back to "trust the report".
|
||||
// No insecure mode: the device-identity root must be pinned.
|
||||
if n.deviceRoots == nil {
|
||||
return nil, fmt.Errorf("%w: nvtrust local mode requires a pinned NVIDIA device root (NewNVTrust + WithNVTrustDeviceRoots)", ErrPolicy)
|
||||
}
|
||||
// The signed SPDM evidence (request + response) is mandatory in local
|
||||
// mode — without it there is nothing cryptographic to verify.
|
||||
if len(report.SPDMResponse) == 0 || len(report.SPDMRequest) == 0 {
|
||||
return nil, fmt.Errorf("%w: nvtrust local mode requires spdm_request and spdm_response", ErrInvalidEvidence)
|
||||
}
|
||||
|
||||
// 1) Chain the GPU device cert to the pinned NVIDIA root, get the leaf.
|
||||
chain, err := nvidia.ParseCertChainPEM(report.CertChain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
|
||||
}
|
||||
leaf, err := nvidia.VerifyDeviceChain(chain, n.deviceRoots, now)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrChainInvalid, err)
|
||||
}
|
||||
leafKey, err := nvidia.LeafSPDMKey(leaf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrChainInvalid, err)
|
||||
}
|
||||
|
||||
// 2) Verify the SPDM measurement-report signature with the leaf key.
|
||||
algo, err := nvidia.ParseSPDMAsymAlgo(report.SPDMAsymAlgo)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
|
||||
}
|
||||
spdm, err := nvidia.VerifySPDMMeasurementSignature(leafKey, algo, report.SPDMRequest, report.SPDMResponse)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
|
||||
}
|
||||
|
||||
// 3) The SIGNED requester nonce is the freshness anchor. It must match
|
||||
// the envelope's claimed nonce (consistency) and is what policy binds.
|
||||
_, reqNonce, err := nvidia.SPDMRequestNonce(report.SPDMRequest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
|
||||
}
|
||||
if reqNonce != report.Nonce {
|
||||
return nil, fmt.Errorf("%w: envelope nonce %x != signed request nonce %x",
|
||||
ErrPolicy, report.Nonce[:], reqNonce[:])
|
||||
}
|
||||
|
||||
// 4) Match the SIGNED measurements against the NVIDIA-signed RIM.
|
||||
if len(cfg.nvtrustRIM) == 0 {
|
||||
return nil, fmt.Errorf("%w: nvtrust requires a signed RIM (WithNVTrustRIM)", ErrPolicy)
|
||||
}
|
||||
if len(cfg.nvtrustTrustRoots) == 0 {
|
||||
return nil, fmt.Errorf("%w: nvtrust requires RIM trust roots (WithNVTrustTrustRoots)", ErrPolicy)
|
||||
}
|
||||
|
||||
// Verify the RIM's detached signature against the operator-pinned
|
||||
// trust roots. A bad signature means the golden values are not vendor-
|
||||
// authentic — treat as a vendor-chain failure (refuse).
|
||||
rim, err := nvidia.ParseAndVerifyRIM(cfg.nvtrustRIM, cfg.nvtrustTrustRoots)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrChainInvalid, err)
|
||||
}
|
||||
|
||||
// Match every reported measurement against the signed RIM, and assert
|
||||
// the RIM's driver/VBIOS/architecture preconditions equal the report.
|
||||
// A cryptographically valid RIM that does not cover this GPU's actual
|
||||
// measurements is a policy refusal, not a chain failure.
|
||||
if err := rim.Match(report); err != nil {
|
||||
if err := rim.MatchSPDM(report.Architecture, report.DriverVersion, report.VBIOSVersion, spdm.Measurements); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrPolicy, err)
|
||||
}
|
||||
|
||||
measRoot := rim.MeasurementRoot()
|
||||
|
||||
// Caller-supplied policy: freshness nonce binding.
|
||||
// 5) Caller policy: freshness nonce and measurement-root pin.
|
||||
if cfg.expectedReportData != nil {
|
||||
if subtle.ConstantTimeCompare(cfg.expectedReportData, report.Nonce[:]) != 1 {
|
||||
return nil, fmt.Errorf("%w: nonce mismatch (got %s)",
|
||||
ErrPolicy, hex.EncodeToString(report.Nonce[:]))
|
||||
if subtle.ConstantTimeCompare(cfg.expectedReportData, reqNonce[:]) != 1 {
|
||||
return nil, fmt.Errorf("%w: nonce mismatch (got %s)", ErrPolicy, hex.EncodeToString(reqNonce[:]))
|
||||
}
|
||||
}
|
||||
// Caller-supplied policy: pin the expected measurement root.
|
||||
if cfg.expectedMeasurement != nil {
|
||||
if subtle.ConstantTimeCompare(cfg.expectedMeasurement, measRoot[:]) != 1 {
|
||||
return nil, fmt.Errorf("%w: measurement root mismatch (got %s)",
|
||||
ErrPolicy, hex.EncodeToString(measRoot[:]))
|
||||
return nil, fmt.Errorf("%w: measurement root mismatch (got %s)", ErrPolicy, hex.EncodeToString(measRoot[:]))
|
||||
}
|
||||
}
|
||||
|
||||
out := &VerifiedReport{
|
||||
Kind: KindNVTrust,
|
||||
Vendor: "nvidia.nvtrust",
|
||||
Vendor: "nvidia.nvtrust.local",
|
||||
Measurement: append([]byte(nil), measRoot[:]...),
|
||||
ReportData: append([]byte(nil), report.Nonce[:]...),
|
||||
ChipID: []byte(report.UUID),
|
||||
IssuedAt: cfg.nowOrWall().UTC(),
|
||||
Extra: buildNVTrustExtra(report, rim),
|
||||
ReportData: append([]byte(nil), reqNonce[:]...),
|
||||
ChipID: leaf.SerialNumber.Bytes(),
|
||||
IssuedAt: now.UTC(),
|
||||
Extra: buildNVTrustLocalExtra(report, rim, leaf, algo),
|
||||
}
|
||||
out.CompositeHash = computeCompositeHash(KindNVTrust, canonicalNVTrustBytes(report, rim))
|
||||
out.CompositeHash = computeCompositeHash(KindNVTrust, canonicalNVTrustLocalBytes(leaf, reqNonce, measRoot, rim.SignerKeyID, spdm.Signature))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// buildNVTrustExtra collects NVIDIA-specific fields that don't fit the
|
||||
// common shape. Keys are stable wire identifiers prefixed "nvtrust.".
|
||||
func buildNVTrustExtra(report *nvidia.GPUReport, rim *nvidia.RIM) map[string]string {
|
||||
extra := make(map[string]string, 6)
|
||||
extra["nvtrust.gpu_uuid"] = report.UUID
|
||||
extra["nvtrust.architecture"] = report.Architecture
|
||||
extra["nvtrust.driver_version"] = report.DriverVersion
|
||||
extra["nvtrust.vbios_version"] = report.VBIOSVersion
|
||||
extra["nvtrust.evidence_version"] = report.EvidenceVersion
|
||||
extra["nvtrust.rim_signer"] = rim.SignerKeyID
|
||||
// verifyNRAS verifies an NVIDIA Remote Attestation Service EAT token.
|
||||
func (n NVTrust) verifyNRAS(evidence []byte, cfg config) (*VerifiedReport, error) {
|
||||
now := n.clock(cfg)
|
||||
if len(n.nrasRoots) == 0 && n.nrasRootPool == nil {
|
||||
return nil, fmt.Errorf("%w: nvtrust NRAS mode requires pinned NRAS trust (WithNVTrustNRASRoots / WithNVTrustNRASRootCAs)", ErrPolicy)
|
||||
}
|
||||
if len(evidence) == 0 {
|
||||
return nil, fmt.Errorf("%w: empty NRAS token", ErrInvalidEvidence)
|
||||
}
|
||||
|
||||
// Freshness challenge from policy: must be 32 bytes if supplied.
|
||||
var challenge *[32]byte
|
||||
if cfg.expectedReportData != nil {
|
||||
if len(cfg.expectedReportData) != 32 {
|
||||
return nil, fmt.Errorf("%w: NRAS challenge must be 32 bytes, got %d", ErrPolicy, len(cfg.expectedReportData))
|
||||
}
|
||||
var c [32]byte
|
||||
copy(c[:], cfg.expectedReportData)
|
||||
challenge = &c
|
||||
}
|
||||
|
||||
eat, err := nvidia.VerifyEAT(string(evidence), n.nrasRoots, n.nrasRootPool, challenge, now)
|
||||
if err != nil {
|
||||
return nil, classifyEATError(err)
|
||||
}
|
||||
|
||||
if cfg.expectedMeasurement != nil {
|
||||
if subtle.ConstantTimeCompare(cfg.expectedMeasurement, eat.Measurement) != 1 {
|
||||
return nil, fmt.Errorf("%w: measurement mismatch (got %s)", ErrPolicy, hex.EncodeToString(eat.Measurement))
|
||||
}
|
||||
}
|
||||
|
||||
out := &VerifiedReport{
|
||||
Kind: KindNVTrust,
|
||||
Vendor: "nvidia.nvtrust.nras",
|
||||
Measurement: append([]byte(nil), eat.Measurement...),
|
||||
IssuedAt: now.UTC(),
|
||||
Extra: buildNVTrustNRASExtra(eat),
|
||||
}
|
||||
if eat.HasNonce {
|
||||
out.ReportData = append([]byte(nil), eat.Nonce[:]...)
|
||||
}
|
||||
tokenHash := sha256.Sum256(evidence)
|
||||
out.CompositeHash = computeCompositeHash(KindNVTrust, canonicalNVTrustNRASBytes(eat, tokenHash))
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// clock resolves the verification time: per-call WithNow wins (tests),
|
||||
// then the instance clock, then wall.
|
||||
func (n NVTrust) clock(cfg config) time.Time {
|
||||
if !cfg.now.IsZero() {
|
||||
return cfg.now
|
||||
}
|
||||
if n.now != nil {
|
||||
return n.now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// classifyEATError maps EAT verification failures onto the refusal
|
||||
// taxonomy: bad token/structure -> ErrInvalidEvidence, untrusted chain or
|
||||
// signature -> ErrChainInvalid/ErrSignatureInvalid, everything else
|
||||
// (freshness, nonce, negative verdict) -> ErrPolicy. Every path refuses.
|
||||
func classifyEATError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, nvidia.ErrEATBadJWT):
|
||||
return fmt.Errorf("%w: %v", ErrInvalidEvidence, err)
|
||||
case errors.Is(err, nvidia.ErrEATChainUntrusted):
|
||||
return fmt.Errorf("%w: %v", ErrChainInvalid, err)
|
||||
case errors.Is(err, nvidia.ErrEATBadSignature), errors.Is(err, nvidia.ErrEATNoSigner):
|
||||
return fmt.Errorf("%w: %v", ErrSignatureInvalid, err)
|
||||
default:
|
||||
return fmt.Errorf("%w: %v", ErrPolicy, err)
|
||||
}
|
||||
}
|
||||
|
||||
// buildNVTrustLocalExtra collects local-mode audit fields. The envelope
|
||||
// UUID/driver/VBIOS are CLAIMED (untrusted labels) and tagged as such; the
|
||||
// device identity is the verified leaf subject.
|
||||
func buildNVTrustLocalExtra(report *nvidia.GPUReport, rim *nvidia.RIM, leaf *x509.Certificate, algo nvidia.SPDMAsymAlgo) map[string]string {
|
||||
extra := map[string]string{
|
||||
"nvtrust.mode": "local",
|
||||
"nvtrust.claimed_uuid": report.UUID,
|
||||
"nvtrust.architecture": report.Architecture,
|
||||
"nvtrust.driver_version": report.DriverVersion,
|
||||
"nvtrust.vbios_version": report.VBIOSVersion,
|
||||
"nvtrust.evidence_ver": report.EvidenceVersion,
|
||||
"nvtrust.rim_signer": rim.SignerKeyID,
|
||||
"nvtrust.spdm_asym_algo": algo.String(),
|
||||
"nvtrust.device_subject": leaf.Subject.String(),
|
||||
"nvtrust.device_serial": leaf.SerialNumber.Text(16),
|
||||
"nvtrust.device_issuer": leaf.Issuer.String(),
|
||||
"nvtrust.device_notafter": leaf.NotAfter.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if report.NVSwitchPresent {
|
||||
extra["nvtrust.nvswitch_present"] = "true"
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
// canonicalNVTrustBytes returns the bytes that participate in
|
||||
// CompositeHash. We hash the verifier-extracted fields (UUID, arch,
|
||||
// driver, VBIOS, nonce, signed measurement root, RIM signer) in a fixed,
|
||||
// null-separated order so that re-deriving the hash on the consumer side
|
||||
// reproduces the same value iff the same evidence was matched against the
|
||||
// same signed RIM. UUID/version strings cannot contain NUL, so the
|
||||
// separator is unambiguous.
|
||||
func canonicalNVTrustBytes(report *nvidia.GPUReport, rim *nvidia.RIM) []byte {
|
||||
measRoot := rim.MeasurementRoot()
|
||||
buf := make([]byte, 0, 256)
|
||||
func buildNVTrustNRASExtra(eat *nvidia.EATResult) map[string]string {
|
||||
extra := map[string]string{
|
||||
"nvtrust.mode": "nras",
|
||||
"nvtrust.nras_signer": eat.SignerKeyID,
|
||||
}
|
||||
if eat.OverallResult != "" {
|
||||
extra["nvtrust.nras_result"] = eat.OverallResult
|
||||
}
|
||||
if !eat.ExpiresAt.IsZero() {
|
||||
extra["nvtrust.nras_exp"] = eat.ExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
// canonicalNVTrustLocalBytes is the deterministic, verified-fields blob
|
||||
// that CompositeHash commits to. It binds the device identity (leaf SPKI),
|
||||
// the freshness nonce, the matched RIM measurement root + signer, and the
|
||||
// SPDM signature (which uniquely commits to the exact signed measurement
|
||||
// record under the device key). NUL-separated; SPKI/sig are fixed-shape
|
||||
// DER/bytes so the separator is unambiguous across the string fields.
|
||||
func canonicalNVTrustLocalBytes(leaf *x509.Certificate, nonce [32]byte, measRoot [32]byte, rimSigner string, spdmSig []byte) []byte {
|
||||
buf := make([]byte, 0, 512)
|
||||
appendField := func(b []byte) {
|
||||
buf = append(buf, b...)
|
||||
buf = append(buf, 0x00)
|
||||
}
|
||||
appendField([]byte(report.UUID))
|
||||
appendField([]byte(report.Architecture))
|
||||
appendField([]byte(report.DriverVersion))
|
||||
appendField([]byte(report.VBIOSVersion))
|
||||
appendField(report.Nonce[:])
|
||||
appendField(leaf.RawSubjectPublicKeyInfo)
|
||||
appendField(nonce[:])
|
||||
appendField(measRoot[:])
|
||||
appendField([]byte(rim.SignerKeyID))
|
||||
appendField([]byte(rimSigner))
|
||||
appendField(spdmSig)
|
||||
return buf
|
||||
}
|
||||
|
||||
func canonicalNVTrustNRASBytes(eat *nvidia.EATResult, tokenHash [32]byte) []byte {
|
||||
buf := make([]byte, 0, 160)
|
||||
appendField := func(b []byte) {
|
||||
buf = append(buf, b...)
|
||||
buf = append(buf, 0x00)
|
||||
}
|
||||
appendField(tokenHash[:])
|
||||
appendField(eat.Measurement)
|
||||
appendField([]byte(eat.SignerKeyID))
|
||||
if eat.HasNonce {
|
||||
appendField(eat.Nonce[:])
|
||||
} else {
|
||||
appendField(nil)
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026, Lux Industries Inc.
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
package attest
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/cc/attest/nvidia"
|
||||
)
|
||||
|
||||
// NVTrustOption configures a NVTrust verifier at construction. These set
|
||||
// the per-DEPLOYMENT trust anchors and mode — distinct from the per-CALL
|
||||
// policy Options (WithNVTrustRIM, WithExpectedReportData, ...) passed to
|
||||
// Verify. Trust anchors live on the instance because they are pinned once
|
||||
// at startup and never vary per request; the RIM blob and freshness
|
||||
// challenge vary per request and stay per-call.
|
||||
type NVTrustOption func(*NVTrust)
|
||||
|
||||
// NewNVTrust builds a configured NVTrust verifier. The result implements
|
||||
// Verifier and is the production entry point; the zero value (used by
|
||||
// Dispatch) is intentionally fail-closed.
|
||||
//
|
||||
// Registration note: this module dispatches Kinds through the switch in
|
||||
// Dispatch (KindNVTrust -> NVTrust{}). There is no init()-based registry in
|
||||
// the base module, so a configured verifier is wired by the caller (the kms
|
||||
// release gate / ai trust-tier policy) constructing NewNVTrust and using it
|
||||
// as a Verifier. If the dispatch layer later becomes an init-registry, the
|
||||
// nvtrust registration is one line — register NewNVTrust(defaults) — and
|
||||
// belongs in this file.
|
||||
func NewNVTrust(opts ...NVTrustOption) NVTrust {
|
||||
var n NVTrust // zero value: ModeLocal, no roots (fail-closed)
|
||||
for _, o := range opts {
|
||||
o(&n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// WithNVTrustMode selects ModeLocal (default) or ModeNRAS.
|
||||
func WithNVTrustMode(m NVTrustMode) NVTrustOption {
|
||||
return func(n *NVTrust) { n.mode = m }
|
||||
}
|
||||
|
||||
// WithNVTrustDeviceRoots pins the NVIDIA device-identity root CA pool that
|
||||
// the GPU attestation leaf certificate must chain to in ModeLocal. The
|
||||
// operator loads NVIDIA's published device root PEM here at startup. An
|
||||
// unset pool makes ModeLocal refuse — there is no insecure mode.
|
||||
func WithNVTrustDeviceRoots(pool *x509.CertPool) NVTrustOption {
|
||||
return func(n *NVTrust) { n.deviceRoots = pool }
|
||||
}
|
||||
|
||||
// WithNVTrustNRASRoots pins the NRAS EAT signer public keys (looked up by
|
||||
// the token's JWS kid) used in ModeNRAS.
|
||||
func WithNVTrustNRASRoots(roots []nvidia.TrustRoot) NVTrustOption {
|
||||
return func(n *NVTrust) {
|
||||
n.nrasRoots = append([]nvidia.TrustRoot(nil), roots...)
|
||||
}
|
||||
}
|
||||
|
||||
// WithNVTrustNRASRootCAs pins the NRAS root CA pool used to validate an EAT
|
||||
// token's x5c certificate chain in ModeNRAS. Optional: when set and a token
|
||||
// carries x5c, the chain is anchored here and the leaf key verifies the
|
||||
// token; otherwise the kid-pinned WithNVTrustNRASRoots path is used.
|
||||
func WithNVTrustNRASRootCAs(pool *x509.CertPool) NVTrustOption {
|
||||
return func(n *NVTrust) { n.nrasRootPool = pool }
|
||||
}
|
||||
|
||||
// WithNVTrustClock sets the instance verification clock. Production leaves
|
||||
// this unset (wall clock); tests pin it. Per-call WithNow still overrides.
|
||||
func WithNVTrustClock(fn func() time.Time) NVTrustOption {
|
||||
return func(n *NVTrust) { n.now = fn }
|
||||
}
|
||||
|
||||
// ErrNVTrustNoRootPEM is returned when a device-root PEM contains no
|
||||
// usable certificate.
|
||||
var ErrNVTrustNoRootPEM = errors.New("nvtrust: no certificate found in device-root PEM")
|
||||
|
||||
// NVTrustDeviceRootsFromPEM is an operator convenience that builds a
|
||||
// device-root pool from one or more concatenated PEM certificates (NVIDIA's
|
||||
// published device-identity root). It refuses an empty/garbage PEM so a
|
||||
// misconfiguration fails loud at startup rather than silently producing an
|
||||
// empty pool.
|
||||
func NVTrustDeviceRootsFromPEM(pemBytes []byte) (*x509.CertPool, error) {
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pemBytes) {
|
||||
return nil, ErrNVTrustNoRootPEM
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
+588
-102
@@ -6,54 +6,302 @@ package attest
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/cc/attest/nvidia"
|
||||
)
|
||||
|
||||
// nvTrustFixture builds a matched (GPU report, signed RIM, trust roots)
|
||||
// triple. The RIM is signed with a fresh ed25519 key; the report's
|
||||
// measurements equal the RIM golden values so RIM.Match passes. Callers
|
||||
// mutate the returned bytes to drive negative cases.
|
||||
func nvTrustFixture(t *testing.T) (report, rim []byte, roots []nvidia.TrustRoot, pub ed25519.PublicKey, priv ed25519.PrivateKey) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("genkey: %v", err)
|
||||
}
|
||||
roots = []nvidia.TrustRoot{{KeyID: "nvidia-rim-1", Public: pub}}
|
||||
entries := []nvidia.RIMEntry{
|
||||
{Name: "FW_RUNTIME", ValueHex: "deadbeef00112233"},
|
||||
{Name: "VBIOS_RT", ValueHex: "cafebabe44556677"},
|
||||
}
|
||||
rim = signRIMEd25519(t, priv, "nvidia-rim-1", "Hopper", "535.104.05", "96.00.74.00.01", entries)
|
||||
report = []byte(`{
|
||||
"evidence_version": "2.1",
|
||||
"gpu_uuid": "GPU-1234-5678",
|
||||
"architecture": "Hopper",
|
||||
"driver_version": "535.104.05",
|
||||
"vbios_version": "96.00.74.00.01",
|
||||
"nonce": "` + hex.EncodeToString(make([]byte, 32)) + `",
|
||||
"measurements": [
|
||||
{"pcr_index": 0, "name": "FW_RUNTIME", "value": "deadbeef00112233"},
|
||||
{"pcr_index": 1, "name": "VBIOS_RT", "value": "cafebabe44556677"}
|
||||
],
|
||||
"attestation_quote": "AAAA",
|
||||
"nvswitch_present": false
|
||||
}`)
|
||||
return report, rim, roots, pub, priv
|
||||
// =============================================================================
|
||||
// Self-consistent ModeLocal vector: a real SPDM-signed measurement report,
|
||||
// a device cert chain (root -> intermediate -> leaf, ECDSA P-384) that the
|
||||
// leaf key signs the report under, and an NVIDIA-signed RIM whose index-
|
||||
// keyed golden values equal the signed measurements.
|
||||
//
|
||||
// The verification logic exercised is production-real (real ECDSA P-384
|
||||
// over the real DSP0274 transcript, real X.509 path validation, real RIM
|
||||
// signature + index match). Only the trust anchor is a test root, exactly
|
||||
// as go-sev-guest's tests use a test signer and production pins AMD's root.
|
||||
// =============================================================================
|
||||
|
||||
const (
|
||||
testFixedNow = "2026-06-27T12:00:00Z"
|
||||
testArch = "Hopper"
|
||||
testDriver = "535.104.05"
|
||||
testVBIOS = "96.00.74.00.01"
|
||||
testLeafSerial = 0x4e56494449413031 // "NVIDIA01" as an int serial
|
||||
)
|
||||
|
||||
// localVector is everything a ModeLocal Verify needs, plus the knobs to
|
||||
// tamper for negative cases.
|
||||
type localVector struct {
|
||||
evidence []byte // the GPU evidence envelope JSON
|
||||
rim []byte // signed RIM
|
||||
rimRoots []nvidia.TrustRoot
|
||||
devRoots *x509.CertPool
|
||||
nonce [32]byte
|
||||
leafCert *x509.Certificate
|
||||
leafPriv *ecdsa.PrivateKey
|
||||
intermPEM string
|
||||
now time.Time
|
||||
}
|
||||
|
||||
// signRIMEd25519 reproduces the nvidia RIM wire format: the signature is
|
||||
// over json.Marshal of the body (architecture, driver_version,
|
||||
// vbios_version, entries — in that field order, matching the package's
|
||||
// canonicalization), and the signed blob appends signer_key_id + signature.
|
||||
func mustTime(t *testing.T, s string) time.Time {
|
||||
t.Helper()
|
||||
tm, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
t.Fatalf("parse time: %v", err)
|
||||
}
|
||||
return tm
|
||||
}
|
||||
|
||||
func u8ptr(v uint8) *uint8 { return &v }
|
||||
|
||||
func mkCert(t *testing.T, tmpl, parent *x509.Certificate, pub crypto.PublicKey, signer crypto.Signer) *x509.Certificate {
|
||||
t.Helper()
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, parent, pub, signer)
|
||||
if err != nil {
|
||||
t.Fatalf("create cert: %v", err)
|
||||
}
|
||||
c, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse cert: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func certPEM(c *x509.Certificate) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: c.Raw}))
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte {
|
||||
h := sha256.Sum256(b)
|
||||
return h[:]
|
||||
}
|
||||
|
||||
func le16(v int) []byte {
|
||||
b := make([]byte, 2)
|
||||
binary.LittleEndian.PutUint16(b, uint16(v))
|
||||
return b
|
||||
}
|
||||
|
||||
func le24(v int) []byte {
|
||||
return []byte{byte(v), byte(v >> 8), byte(v >> 16)}
|
||||
}
|
||||
|
||||
// joseConcat encodes (r,s) as fixed-width big-endian r||s (the SPDM/JOSE form).
|
||||
func joseConcatLocal(r, s *big.Int, size int) []byte {
|
||||
out := make([]byte, size*2)
|
||||
rB := r.Bytes()
|
||||
sB := s.Bytes()
|
||||
copy(out[size-len(rB):size], rB)
|
||||
copy(out[size*2-len(sB):], sB)
|
||||
return out
|
||||
}
|
||||
|
||||
// buildSPDMRequest builds a GET_MEASUREMENTS (0xE0) request asking for all
|
||||
// blocks with a signature, carrying the requester nonce.
|
||||
func buildSPDMRequest(version byte, nonce [32]byte) []byte {
|
||||
req := []byte{version, 0xE0, 0x01 /*sig requested*/, 0xFF /*all blocks*/}
|
||||
req = append(req, nonce[:]...)
|
||||
req = append(req, 0x00) // SlotIDParam
|
||||
return req
|
||||
}
|
||||
|
||||
type measBlock struct {
|
||||
index uint8
|
||||
value []byte // 48-byte digest
|
||||
}
|
||||
|
||||
// buildSignedSPDMResponse builds a MEASUREMENTS (0x60) response over the
|
||||
// given blocks and signs the DSP0274 transcript (request || responseBody)
|
||||
// with leafPriv. Returns the full response message (body || signature).
|
||||
func buildSignedSPDMResponse(t *testing.T, version byte, blocks []measBlock, respNonce [32]byte, leafPriv *ecdsa.PrivateKey, requestMsg []byte) []byte {
|
||||
t.Helper()
|
||||
var record []byte
|
||||
for _, b := range blocks {
|
||||
dmtf := []byte{0x00} // value type: digest (bit7=0), type 0
|
||||
dmtf = append(dmtf, le16(len(b.value))...)
|
||||
dmtf = append(dmtf, b.value...)
|
||||
blk := []byte{b.index, 0x01 /*DMTF spec*/}
|
||||
blk = append(blk, le16(len(dmtf))...)
|
||||
blk = append(blk, dmtf...)
|
||||
record = append(record, blk...)
|
||||
}
|
||||
body := []byte{version, 0x60, 0x00, 0x00, byte(len(blocks))}
|
||||
body = append(body, le24(len(record))...)
|
||||
body = append(body, record...)
|
||||
body = append(body, respNonce[:]...)
|
||||
body = append(body, le16(0)...) // opaque length 0
|
||||
|
||||
digest := spdmDigestForTest(version, requestMsg, body)
|
||||
r, s, err := ecdsa.Sign(rand.Reader, leafPriv, digest)
|
||||
if err != nil {
|
||||
t.Fatalf("spdm sign: %v", err)
|
||||
}
|
||||
sig := joseConcatLocal(r, s, 48) // P-384 -> 96-byte r||s
|
||||
return append(body, sig...)
|
||||
}
|
||||
|
||||
// spdmDigestForTest independently reproduces the DSP0274 transcript digest
|
||||
// the verifier checks (SHA-384). 1.1: SHA384(req||body). 1.2: SHA384(ctx ||
|
||||
// SHA384(req||body)).
|
||||
func spdmDigestForTest(version byte, requestMsg, body []byte) []byte {
|
||||
h := sha512.New384()
|
||||
h.Write(requestMsg)
|
||||
h.Write(body)
|
||||
th := h.Sum(nil)
|
||||
if version < 0x12 {
|
||||
return th
|
||||
}
|
||||
const prefixUnit = "dmtf-spdm-v1.2.*"
|
||||
const context = "responder-measurements signing"
|
||||
ctx := make([]byte, 100)
|
||||
for i := 0; i < 4; i++ {
|
||||
copy(ctx[i*16:(i+1)*16], prefixUnit)
|
||||
}
|
||||
copy(ctx[100-len(context):], context)
|
||||
h2 := sha512.New384()
|
||||
h2.Write(ctx)
|
||||
h2.Write(th)
|
||||
return h2.Sum(nil)
|
||||
}
|
||||
|
||||
// buildLocalVector assembles a complete, valid ModeLocal vector for the
|
||||
// given SPDM version.
|
||||
func buildLocalVector(t *testing.T, version byte) localVector {
|
||||
t.Helper()
|
||||
now := mustTime(t, testFixedNow)
|
||||
|
||||
// --- device chain: root -> intermediate -> leaf (all P-384) ---
|
||||
rootPriv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
intermPriv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
leafPriv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
|
||||
rootTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "NVIDIA Test Device Identity Root"},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
rootCert := mkCert(t, rootTmpl, rootTmpl, rootPriv.Public(), rootPriv)
|
||||
|
||||
intermTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: "NVIDIA Test GPU Attestation CA"},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
intermCert := mkCert(t, intermTmpl, rootCert, intermPriv.Public(), rootPriv)
|
||||
|
||||
leafTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(testLeafSerial),
|
||||
Subject: pkix.Name{CommonName: "GPU-TEST-0001"},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
leafCert := mkCert(t, leafTmpl, intermCert, leafPriv.Public(), intermPriv)
|
||||
|
||||
rootPool := x509.NewCertPool()
|
||||
rootPool.AddCert(rootCert)
|
||||
|
||||
// --- SPDM request + signed response ---
|
||||
var nonce [32]byte
|
||||
for i := range nonce {
|
||||
nonce[i] = byte(i + 1)
|
||||
}
|
||||
var respNonce [32]byte
|
||||
for i := range respNonce {
|
||||
respNonce[i] = byte(0xA0 + i)
|
||||
}
|
||||
val0 := bytes.Repeat([]byte{0x11}, 48)
|
||||
val1 := bytes.Repeat([]byte{0x22}, 48)
|
||||
req := buildSPDMRequest(version, nonce)
|
||||
resp := buildSignedSPDMResponse(t, version, []measBlock{
|
||||
{index: 0, value: val0},
|
||||
{index: 1, value: val1},
|
||||
}, respNonce, leafPriv, req)
|
||||
|
||||
// --- RIM: index-keyed golden values == the signed measurements ---
|
||||
rimPub, rimPriv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
rimRoots := []nvidia.TrustRoot{{KeyID: "nvidia-rim-1", Public: rimPub}}
|
||||
entries := []nvidia.RIMEntry{
|
||||
{Name: "FW_RT", ValueHex: hex.EncodeToString(val0), Index: u8ptr(0)},
|
||||
{Name: "VBIOS_RT", ValueHex: hex.EncodeToString(val1), Index: u8ptr(1)},
|
||||
}
|
||||
rim := signRIMEd25519(t, rimPriv, "nvidia-rim-1", testArch, testDriver, testVBIOS, entries)
|
||||
|
||||
// --- evidence envelope ---
|
||||
env := map[string]any{
|
||||
"evidence_version": "2.1",
|
||||
"gpu_uuid": "GPU-TEST-0001",
|
||||
"architecture": testArch,
|
||||
"driver_version": testDriver,
|
||||
"vbios_version": testVBIOS,
|
||||
"nonce": hex.EncodeToString(nonce[:]),
|
||||
"cert_chain": []string{certPEM(leafCert), certPEM(intermCert)},
|
||||
"spdm_request": base64.StdEncoding.EncodeToString(req),
|
||||
"spdm_response": base64.StdEncoding.EncodeToString(resp),
|
||||
"spdm_asym_algo": "ecdsa-p384-sha384",
|
||||
"measurements": []map[string]any{
|
||||
{"pcr_index": 0, "name": "FW_RT", "value": hex.EncodeToString(val0)},
|
||||
{"pcr_index": 1, "name": "VBIOS_RT", "value": hex.EncodeToString(val1)},
|
||||
},
|
||||
}
|
||||
evidence, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal envelope: %v", err)
|
||||
}
|
||||
|
||||
return localVector{
|
||||
evidence: evidence,
|
||||
rim: rim,
|
||||
rimRoots: rimRoots,
|
||||
devRoots: rootPool,
|
||||
nonce: nonce,
|
||||
leafCert: leafCert,
|
||||
leafPriv: leafPriv,
|
||||
intermPEM: certPEM(intermCert),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (v localVector) verifier() NVTrust {
|
||||
return NewNVTrust(
|
||||
WithNVTrustDeviceRoots(v.devRoots),
|
||||
WithNVTrustClock(func() time.Time { return v.now }),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// signRIMEd25519 — reproduces the nvidia RIM wire format (canonical body =
|
||||
// json.Marshal of architecture/driver/vbios/entries; signed blob appends
|
||||
// signer_key_id + signature). Entries may carry an index (omitempty).
|
||||
// =============================================================================
|
||||
|
||||
func signRIMEd25519(t *testing.T, priv ed25519.PrivateKey, kid, arch, driver, vbios string, entries []nvidia.RIMEntry) []byte {
|
||||
t.Helper()
|
||||
body := struct {
|
||||
@@ -82,115 +330,353 @@ func signRIMEd25519(t *testing.T, priv ed25519.PrivateKey, kid, arch, driver, vb
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNVTrust_LocalRIM_HappyPath(t *testing.T) {
|
||||
report, rim, roots, _, _ := nvTrustFixture(t)
|
||||
// =============================================================================
|
||||
// ModeLocal happy paths
|
||||
// =============================================================================
|
||||
|
||||
rep, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustRIM(rim), WithNVTrustTrustRoots(roots))
|
||||
func TestNVTrustLocal_HappyPath_SPDM11(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
rep, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots),
|
||||
WithExpectedReportData(v.nonce[:]))
|
||||
if err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
if rep.Kind != KindNVTrust {
|
||||
t.Fatalf("kind = %q, want %q", rep.Kind, KindNVTrust)
|
||||
if rep.Kind != KindNVTrust || rep.Vendor != "nvidia.nvtrust.local" {
|
||||
t.Fatalf("kind/vendor = %q/%q", rep.Kind, rep.Vendor)
|
||||
}
|
||||
if rep.Vendor != "nvidia.nvtrust" {
|
||||
t.Fatalf("vendor = %q", rep.Vendor)
|
||||
// ReportData is the SIGNED requester nonce.
|
||||
if !bytes.Equal(rep.ReportData, v.nonce[:]) {
|
||||
t.Fatalf("report_data = %x want %x", rep.ReportData, v.nonce[:])
|
||||
}
|
||||
if string(rep.ChipID) != "GPU-1234-5678" {
|
||||
t.Fatalf("chip_id = %q", string(rep.ChipID))
|
||||
// ChipID is the trusted leaf serial, NOT the envelope UUID.
|
||||
if !bytes.Equal(rep.ChipID, big.NewInt(testLeafSerial).Bytes()) {
|
||||
t.Fatalf("chip_id = %x want %x", rep.ChipID, big.NewInt(testLeafSerial).Bytes())
|
||||
}
|
||||
// ReportData is the 32-byte nonce (all zero in the fixture).
|
||||
if len(rep.ReportData) != 32 || !bytes.Equal(rep.ReportData, make([]byte, 32)) {
|
||||
t.Fatalf("report_data = %x", rep.ReportData)
|
||||
}
|
||||
// Measurement is the signed RIM measurement root.
|
||||
parsedRIM, err := nvidia.ParseAndVerifyRIM(rim, roots)
|
||||
// Measurement equals the signed-RIM measurement root.
|
||||
parsed, err := nvidia.ParseAndVerifyRIM(v.rim, v.rimRoots)
|
||||
if err != nil {
|
||||
t.Fatalf("re-parse rim: %v", err)
|
||||
t.Fatalf("reparse rim: %v", err)
|
||||
}
|
||||
wantRoot := parsedRIM.MeasurementRoot()
|
||||
wantRoot := parsed.MeasurementRoot()
|
||||
if !bytes.Equal(rep.Measurement, wantRoot[:]) {
|
||||
t.Fatalf("measurement = %x, want %x", rep.Measurement, wantRoot[:])
|
||||
t.Fatalf("measurement = %x want %x", rep.Measurement, wantRoot[:])
|
||||
}
|
||||
if rep.CompositeHash == [32]byte{} {
|
||||
t.Fatal("composite hash must not be zero")
|
||||
}
|
||||
if rep.Extra["nvtrust.driver_version"] != "535.104.05" {
|
||||
t.Fatalf("extra driver = %q", rep.Extra["nvtrust.driver_version"])
|
||||
if rep.Extra["nvtrust.mode"] != "local" {
|
||||
t.Fatalf("extra mode = %q", rep.Extra["nvtrust.mode"])
|
||||
}
|
||||
if rep.Extra["nvtrust.rim_signer"] != "nvidia-rim-1" {
|
||||
t.Fatalf("extra rim_signer = %q", rep.Extra["nvtrust.rim_signer"])
|
||||
if rep.Extra["nvtrust.spdm_asym_algo"] != "ecdsa-p384-sha384" {
|
||||
t.Fatalf("extra algo = %q", rep.Extra["nvtrust.spdm_asym_algo"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_RefusesWithoutRIM(t *testing.T) {
|
||||
report, _, roots, _, _ := nvTrustFixture(t)
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustTrustRoots(roots))
|
||||
func TestNVTrustLocal_HappyPath_SPDM12(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x12)
|
||||
_, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if err != nil {
|
||||
t.Fatalf("verify 1.2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ModeLocal fail-closed / tamper rejection
|
||||
// =============================================================================
|
||||
|
||||
// Dispatch uses the zero value (no pinned device root) -> must refuse.
|
||||
func TestNVTrustLocal_Dispatch_FailClosed(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, v.evidence,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (no insecure mode)", err)
|
||||
t.Fatalf("err = %v, want ErrPolicy (no device root)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_RefusesWithoutTrustRoots(t *testing.T) {
|
||||
report, rim, _, _, _ := nvTrustFixture(t)
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustRIM(rim))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (no insecure mode)", err)
|
||||
func TestNVTrustLocal_RejectsTamperedSPDMSignature(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
// Flip a byte inside the base64 spdm_response so the signature breaks.
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(v.evidence, &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, _ := base64.StdEncoding.DecodeString(env["spdm_response"].(string))
|
||||
resp[len(resp)-1] ^= 0xFF // last byte of the signature
|
||||
env["spdm_response"] = base64.StdEncoding.EncodeToString(resp)
|
||||
tampered, _ := json.Marshal(env)
|
||||
|
||||
_, err := v.verifier().Verify(context.Background(), tampered,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_RejectsTamperedRIMSignature(t *testing.T) {
|
||||
report, rim, roots, _, _ := nvTrustFixture(t)
|
||||
// Flip a measurement value byte in the RIM body — the ed25519
|
||||
// signature no longer covers the manifest. Must be a CHAIN failure
|
||||
// (the golden values are not vendor-authentic), not silently accepted.
|
||||
tampered := bytes.Replace(rim, []byte("deadbeef00112233"), []byte("deadbeef00112234"), 1)
|
||||
if bytes.Equal(tampered, rim) {
|
||||
t.Fatal("fixture did not mutate")
|
||||
func TestNVTrustLocal_RejectsTamperedMeasurementInSignedRecord(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
// Flip a measurement byte in the signed record (not the signature). The
|
||||
// SPDM signature no longer covers the record -> signature failure.
|
||||
var env map[string]any
|
||||
json.Unmarshal(v.evidence, &env)
|
||||
resp, _ := base64.StdEncoding.DecodeString(env["spdm_response"].(string))
|
||||
// The first measurement digest sits well inside the record; flipping any
|
||||
// record byte invalidates the signature.
|
||||
resp[12] ^= 0x01
|
||||
env["spdm_response"] = base64.StdEncoding.EncodeToString(resp)
|
||||
tampered, _ := json.Marshal(env)
|
||||
|
||||
_, err := v.verifier().Verify(context.Background(), tampered,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
|
||||
}
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustRIM(tampered), WithNVTrustTrustRoots(roots))
|
||||
}
|
||||
|
||||
func TestNVTrustLocal_RejectsWrongDeviceRoot(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
// A device-root pool that does NOT contain the chain's root.
|
||||
otherPriv, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
||||
otherTmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(99),
|
||||
Subject: pkix.Name{CommonName: "Unrelated Root"},
|
||||
NotBefore: v.now.Add(-time.Hour),
|
||||
NotAfter: v.now.Add(time.Hour),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
otherRoot := mkCert(t, otherTmpl, otherTmpl, otherPriv.Public(), otherPriv)
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(otherRoot)
|
||||
|
||||
verifier := NewNVTrust(
|
||||
WithNVTrustDeviceRoots(pool),
|
||||
WithNVTrustClock(func() time.Time { return v.now }),
|
||||
)
|
||||
_, err := verifier.Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrChainInvalid) {
|
||||
t.Fatalf("err = %v, want ErrChainInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_RejectsMeasurementMismatch(t *testing.T) {
|
||||
report, _, roots, _, priv := nvTrustFixture(t)
|
||||
// A cryptographically VALID RIM whose golden value differs from what
|
||||
// the GPU reports — must be a POLICY refusal (signed, but the GPU's
|
||||
// actual measurement is not covered).
|
||||
badEntries := []nvidia.RIMEntry{
|
||||
{Name: "FW_RUNTIME", ValueHex: "0000000000000000"},
|
||||
{Name: "VBIOS_RT", ValueHex: "cafebabe44556677"},
|
||||
func TestNVTrustLocal_RejectsRIMMismatch(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
// A cryptographically valid RIM whose index-0 golden differs from the
|
||||
// signed measurement -> POLICY refusal (signed, but does not match).
|
||||
rimPub2, rimPriv2, _ := ed25519.GenerateKey(rand.Reader)
|
||||
roots2 := []nvidia.TrustRoot{{KeyID: "nvidia-rim-2", Public: rimPub2}}
|
||||
bad := []nvidia.RIMEntry{
|
||||
{Name: "FW_RT", ValueHex: hex.EncodeToString(bytes.Repeat([]byte{0xEE}, 48)), Index: u8ptr(0)},
|
||||
{Name: "VBIOS_RT", ValueHex: hex.EncodeToString(bytes.Repeat([]byte{0x22}, 48)), Index: u8ptr(1)},
|
||||
}
|
||||
badRIM := signRIMEd25519(t, priv, "nvidia-rim-1", "Hopper", "535.104.05", "96.00.74.00.01", badEntries)
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustRIM(badRIM), WithNVTrustTrustRoots(roots))
|
||||
badRIM := signRIMEd25519(t, rimPriv2, "nvidia-rim-2", testArch, testDriver, testVBIOS, bad)
|
||||
_, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(badRIM), WithNVTrustTrustRoots(roots2))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_RejectsBadEvidence(t *testing.T) {
|
||||
_, rim, roots, _, _ := nvTrustFixture(t)
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, []byte("{not json"),
|
||||
WithNVTrustRIM(rim), WithNVTrustTrustRoots(roots))
|
||||
func TestNVTrustLocal_RejectsTamperedRIMSignature(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
tampered := bytes.Replace(v.rim,
|
||||
[]byte(hex.EncodeToString(bytes.Repeat([]byte{0x11}, 48))),
|
||||
[]byte(hex.EncodeToString(bytes.Repeat([]byte{0x33}, 48))), 1)
|
||||
if bytes.Equal(tampered, v.rim) {
|
||||
t.Fatal("rim did not mutate")
|
||||
}
|
||||
_, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(tampered), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrChainInvalid) {
|
||||
t.Fatalf("err = %v, want ErrChainInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustLocal_RejectsWrongExpectedNonce(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
wrong := make([]byte, 32)
|
||||
wrong[0] = 0xFF
|
||||
_, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots),
|
||||
WithExpectedReportData(wrong))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (nonce binding)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustLocal_RejectsEnvelopeNonceMismatch(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
// Change the envelope nonce so it no longer equals the SIGNED request
|
||||
// nonce -> ErrPolicy (consistency).
|
||||
var env map[string]any
|
||||
json.Unmarshal(v.evidence, &env)
|
||||
other := make([]byte, 32)
|
||||
other[31] = 0x09
|
||||
env["nonce"] = hex.EncodeToString(other)
|
||||
tampered, _ := json.Marshal(env)
|
||||
_, err := v.verifier().Verify(context.Background(), tampered,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustLocal_RejectsMissingSPDM(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
var env map[string]any
|
||||
json.Unmarshal(v.evidence, &env)
|
||||
delete(env, "spdm_response")
|
||||
delete(env, "attestation_quote")
|
||||
noSPDM, _ := json.Marshal(env)
|
||||
_, err := v.verifier().Verify(context.Background(), noSPDM,
|
||||
WithNVTrustRIM(v.rim), WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrInvalidEvidence) {
|
||||
t.Fatalf("err = %v, want ErrInvalidEvidence", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrust_EnforcesExpectedNonce(t *testing.T) {
|
||||
report, rim, roots, _, _ := nvTrustFixture(t)
|
||||
wrongNonce := make([]byte, 32)
|
||||
wrongNonce[0] = 0xFF
|
||||
_, err := Dispatch(context.Background(), KindNVTrust, report,
|
||||
WithNVTrustRIM(rim), WithNVTrustTrustRoots(roots),
|
||||
WithExpectedReportData(wrongNonce))
|
||||
func TestNVTrustLocal_RefusesWithoutRIM(t *testing.T) {
|
||||
v := buildLocalVector(t, 0x11)
|
||||
_, err := v.verifier().Verify(context.Background(), v.evidence,
|
||||
WithNVTrustTrustRoots(v.rimRoots))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (nonce binding)", err)
|
||||
t.Fatalf("err = %v, want ErrPolicy", err)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ModeNRAS: EAT/JWT verification
|
||||
// =============================================================================
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// buildEATES256 builds an ES256-signed EAT compact JWS over the claims.
|
||||
func buildEATES256(t *testing.T, priv *ecdsa.PrivateKey, kid string, claims map[string]any) string {
|
||||
t.Helper()
|
||||
hdr := map[string]string{"alg": "ES256", "kid": kid}
|
||||
hb, _ := json.Marshal(hdr)
|
||||
cb, _ := json.Marshal(claims)
|
||||
signedInput := b64url(hb) + "." + b64url(cb)
|
||||
sum := sha256Sum([]byte(signedInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, priv, sum)
|
||||
if err != nil {
|
||||
t.Fatalf("eat sign: %v", err)
|
||||
}
|
||||
sig := joseConcatLocal(r, s, 32)
|
||||
return signedInput + "." + b64url(sig)
|
||||
}
|
||||
|
||||
func nrasVerifier(t *testing.T, pub *ecdsa.PublicKey, kid string, now time.Time) NVTrust {
|
||||
return NewNVTrust(
|
||||
WithNVTrustMode(ModeNRAS),
|
||||
WithNVTrustNRASRoots([]nvidia.TrustRoot{{KeyID: kid, Public: pub}}),
|
||||
WithNVTrustClock(func() time.Time { return now }),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_HappyPath(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
var nonce [32]byte
|
||||
for i := range nonce {
|
||||
nonce[i] = byte(i + 1)
|
||||
}
|
||||
measRoot := bytes.Repeat([]byte{0x5A}, 32)
|
||||
token := buildEATES256(t, priv, "nras-1", map[string]any{
|
||||
"eat_nonce": hex.EncodeToString(nonce[:]),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"iat": now.Add(-time.Minute).Unix(),
|
||||
"x-nvidia-overall-att-result": true,
|
||||
"x-nvidia-gpu-measurements-root": hex.EncodeToString(measRoot),
|
||||
})
|
||||
rep, err := nrasVerifier(t, &priv.PublicKey, "nras-1", now).Verify(
|
||||
context.Background(), []byte(token), WithExpectedReportData(nonce[:]))
|
||||
if err != nil {
|
||||
t.Fatalf("nras verify: %v", err)
|
||||
}
|
||||
if rep.Vendor != "nvidia.nvtrust.nras" {
|
||||
t.Fatalf("vendor = %q", rep.Vendor)
|
||||
}
|
||||
if !bytes.Equal(rep.ReportData, nonce[:]) {
|
||||
t.Fatalf("report_data = %x want %x", rep.ReportData, nonce[:])
|
||||
}
|
||||
if !bytes.Equal(rep.Measurement, measRoot) {
|
||||
t.Fatalf("measurement = %x want %x", rep.Measurement, measRoot)
|
||||
}
|
||||
if rep.Extra["nvtrust.nras_result"] != "true" {
|
||||
t.Fatalf("nras_result = %q", rep.Extra["nvtrust.nras_result"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_RejectsBadSignature(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
token := buildEATES256(t, priv, "nras-1", map[string]any{
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
// Corrupt the final signature segment.
|
||||
bad := token[:len(token)-4] + "AAAA"
|
||||
_, err := nrasVerifier(t, &priv.PublicKey, "nras-1", now).Verify(
|
||||
context.Background(), []byte(bad))
|
||||
if !errors.Is(err, ErrSignatureInvalid) {
|
||||
t.Fatalf("err = %v, want ErrSignatureInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_RejectsExpired(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
token := buildEATES256(t, priv, "nras-1", map[string]any{
|
||||
"exp": now.Add(-time.Hour).Unix(),
|
||||
})
|
||||
_, err := nrasVerifier(t, &priv.PublicKey, "nras-1", now).Verify(
|
||||
context.Background(), []byte(token))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (expired)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_RejectsNonceMismatch(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
var nonce [32]byte
|
||||
nonce[0] = 0x01
|
||||
token := buildEATES256(t, priv, "nras-1", map[string]any{
|
||||
"eat_nonce": hex.EncodeToString(nonce[:]),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
})
|
||||
wrong := make([]byte, 32)
|
||||
wrong[0] = 0x02
|
||||
_, err := nrasVerifier(t, &priv.PublicKey, "nras-1", now).Verify(
|
||||
context.Background(), []byte(token), WithExpectedReportData(wrong))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (nonce)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_RejectsNegativeResult(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
priv, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
token := buildEATES256(t, priv, "nras-1", map[string]any{
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"x-nvidia-overall-att-result": false,
|
||||
})
|
||||
_, err := nrasVerifier(t, &priv.PublicKey, "nras-1", now).Verify(
|
||||
context.Background(), []byte(token))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (negative result)", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNVTrustNRAS_RefusesWithoutTrust(t *testing.T) {
|
||||
now := mustTime(t, testFixedNow)
|
||||
v := NewNVTrust(WithNVTrustMode(ModeNRAS), WithNVTrustClock(func() time.Time { return now }))
|
||||
_, err := v.Verify(context.Background(), []byte("x.y.z"))
|
||||
if !errors.Is(err, ErrPolicy) {
|
||||
t.Fatalf("err = %v, want ErrPolicy (no NRAS trust)", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user