v0.61+v0.62: scheduler trust gate + NVIDIA attestation primitives

v0.61 — wire the scheduler workload-policy gate into the MPC SignGate.
SignGate gains optional Scheduler + Worker fields; AuthorizeSign now
runs scheduler.Eligible(worker, ci.PolicyID) BEFORE the policy
verifier and short-circuits on insufficient trust posture. Existing
callers that pass nil Scheduler are unaffected.

  pkg/scheduler/policy_gate.go     — TrustMode/IOLevel enums,
                                     WorkloadPolicy.Eligible(Worker)
  pkg/scheduler/service_drain.go   — AuthorizeDrain + AuthorizeSession
  pkg/mpc/sign_orchestrator.go     — Scheduler/Worker fields,
                                     WithScheduler(...), two-stage gate

v0.62 — NVIDIA Confidential-Compute attestation primitives. Clean-room
parsers + verifiers against the publicly documented nvTrust schema
(Apache-2 derived); no NVIDIA proprietary code vendored.

  pkg/attestation/nvidia/gpu_report.go   — GPU evidence-report parser
  pkg/attestation/nvidia/nras_client.go  — NRAS HTTP client + JWS verify
  pkg/attestation/nvidia/rim.go          — RIM signature + value match
  pkg/attestation/nvidia/composite.go    — VerifiedBundle (CPU+GPU bind)

Tests (57 passing, -race clean):
  pkg/scheduler                        — 11 (truth-table, drain,
                                          session, orchestrator
                                          integration)
  pkg/attestation/nvidia               — 46 (parser, RIM Ed25519/ECDSA/
                                          RSA-PSS, NRAS happy/error/
                                          signature paths, ECDSA JOSE)
  pkg/attestation/nvidia (hwattest)    — 3, env-skipped without real GPU

Sibling v0.60 reconciliation: pkg/registry + pkg/attestation already
land WorkerCapability + CompositeAttestation envelope using lattice
trust types. nvidia.VerifiedBundle is renamed (was Composite-) to
avoid collision with pkg/attestation.CompositeAttestation; the
canonical envelope wraps the verified blobs this package emits.

Residual: lattice/v7/types is not yet published, so pkg/registry +
pkg/attestation cannot compile against luxfi/[email protected]; the
sibling v0.60 work tracks that. pkg/policy still has a pre-existing
unresolved import of luxfi/fhe/threshold (should be /pkg/threshold);
sign_orchestrator_scheduler_test.go is ready to run as soon as that
upstream issue is fixed by its owner.
This commit is contained in:
2025-12-28 16:53:46 -08:00
parent da9c76887f
commit 9c6ee31cd4
15 changed files with 3030 additions and 7 deletions
+205
View File
@@ -0,0 +1,205 @@
// Package nvidia provides NVIDIA Confidential-Compute attestation
// primitives: the GPU evidence-report parser, NVIDIA Remote
// Attestation Service (NRAS) HTTP client, Reference Integrity
// Manifest (RIM) validator, and the verified-bundle root that
// AND-combines a CPU TEE quote (SEV-SNP / TDX) with the GPU result.
//
// Wire format and field names mirror the public nvTrust / NVIDIA
// Confidential Compute documentation (Apache-2 licensed). No NVIDIA
// proprietary code is vendored — every parser is a clean-room
// implementation of the publicly documented schema.
//
// Relationship to pkg/attestation.CompositeAttestation (sibling v0.60):
// the canonical wire envelope lives in pkg/attestation. This package
// is the NVIDIA-specific verifier that turns one of those envelopes'
// EvidenceGPUNRASReport blobs into a verified GPUEvidence record.
// VerifiedBundle is the post-verification CPU+GPU bind that is folded
// into the scheduler's Worker fields — it is NOT the canonical wire
// envelope.
//
// The root entry point is VerifiedBundle.Verify(): it returns
// nil iff every component verifies AND every reference value matches.
// Callers feed the resulting VerifiedResult into the scheduler's
// Worker fields (TrustMode, GPUAttested, CPUAttested,
// KernelManifestRoot).
package nvidia
import (
"crypto/sha256"
"errors"
"fmt"
"time"
)
// TEEKind identifies the CPU confidential-compute platform.
type TEEKind uint8
const (
// TEENone — no CPU TEE; only acceptable for development.
TEENone TEEKind = iota
// TEESEVSNP — AMD SEV-SNP.
TEESEVSNP
// TEETDX — Intel TDX.
TEETDX
)
// String returns the canonical name for the TEE kind.
func (t TEEKind) String() string {
switch t {
case TEENone:
return "none"
case TEESEVSNP:
return "sev_snp"
case TEETDX:
return "tdx"
}
return "invalid"
}
// CPUQuote is the verified output of a CPU TEE attestation. The bytes
// of the raw quote (SEV-SNP attestation report or TDX TD report) are
// not retained here; this struct holds only the post-verification
// projection that downstream gates need.
type CPUQuote struct {
Kind TEEKind // SEV-SNP or TDX
Verified bool // verifier confirmed the signature chain
MeasurementHash [32]byte // launch measurement (MRTD or LAUNCH_MEASUREMENT)
ReportData [32]byte // freshness binding (e.g. nonce hash)
VerifiedAt time.Time // when the verifier signed off
VendorCertChain [][]byte // AMD or Intel vendor cert chain (DER)
}
// GPUEvidence is the verified output of an NVIDIA GPU attestation.
// Populated by GPUReport.Parse + NRASClient.Verify + RIM.Match.
type GPUEvidence struct {
UUID string // GPU UUID, e.g. "GPU-1234..."
DriverVersion string // e.g. "535.104.05"
VBIOSVersion string // e.g. "96.00.74.00.01"
Architecture string // "Hopper" | "Blackwell"
Verified bool // NRAS returned a valid token
NRASTokenHash [32]byte // sha256 of the NRAS token bytes
MeasurementRoot [32]byte // root over the measurement list
RIMMatched bool // every measurement matched the RIM
Nonce [32]byte // freshness nonce echoed in report
VerifiedAt time.Time // when NRAS signed the token
NVSwitchVerified bool // multi-GPU NVSwitch attested (optional)
}
// VerifiedBundle binds a CPU TEE quote to a GPU evidence record
// for a single worker, plus the kernel manifest root that the
// scheduler matches against the workload's approved set.
//
// The composite is what the scheduler ultimately consumes: a single
// struct that carries every fact the workload-policy gate needs.
type VerifiedBundle struct {
WorkerID string
CPU CPUQuote
GPU GPUEvidence
KernelManifestRoot [32]byte // root of the approved kernel manifest
BuiltAt time.Time
}
// VerifiedResult is the projection a Worker is built from. Callers
// pass these fields into scheduler.Worker.
type VerifiedResult struct {
WorkerID string
CPUAttested bool
GPUAttested bool
NVSwitchAttested bool
KernelManifestRoot [32]byte
}
// Errors returned by Verify, in the order they are checked.
var (
ErrBundleMissingWorker = errors.New("nvidia: composite missing worker ID")
ErrBundleNoCPUQuote = errors.New("nvidia: composite missing CPU quote")
ErrBundleBadCPU = errors.New("nvidia: CPU quote not verified")
ErrBundleBadGPU = errors.New("nvidia: GPU evidence not verified")
ErrBundleRIMMismatch = errors.New("nvidia: RIM mismatch on GPU measurements")
ErrBundleBindingMissing = errors.New("nvidia: CPU report_data does not bind GPU nonce")
ErrBundleStale = errors.New("nvidia: composite older than freshness window")
)
// Verify checks every component of the composite. The freshness
// argument bounds how old the CPU/GPU verifications may be; pass 0
// to disable the freshness check (test-only).
//
// A successful Verify returns the projection ready for scheduler
// consumption.
func (c *VerifiedBundle) Verify(now time.Time, freshness time.Duration) (VerifiedResult, error) {
var zero VerifiedResult
if c == nil {
return zero, errors.New("nvidia: nil composite")
}
if c.WorkerID == "" {
return zero, ErrBundleMissingWorker
}
if c.CPU.Kind == TEENone {
return zero, ErrBundleNoCPUQuote
}
if !c.CPU.Verified {
return zero, ErrBundleBadCPU
}
if !c.GPU.Verified {
return zero, ErrBundleBadGPU
}
if !c.GPU.RIMMatched {
return zero, ErrBundleRIMMismatch
}
// Bind GPU nonce to CPU report_data. Standard pattern: report_data
// is sha256(workerID || gpuUUID || gpuNonce). If a different shape
// is required, callers can override binding by setting the CPU's
// ReportData themselves and skipping the helper.
expected := bindCPUReportData(c.WorkerID, c.GPU.UUID, c.GPU.Nonce)
if c.CPU.ReportData != expected {
return zero, ErrBundleBindingMissing
}
if freshness > 0 {
if c.CPU.VerifiedAt.IsZero() || now.Sub(c.CPU.VerifiedAt) > freshness {
return zero, fmt.Errorf("%w: cpu age=%v", ErrBundleStale, now.Sub(c.CPU.VerifiedAt))
}
if c.GPU.VerifiedAt.IsZero() || now.Sub(c.GPU.VerifiedAt) > freshness {
return zero, fmt.Errorf("%w: gpu age=%v", ErrBundleStale, now.Sub(c.GPU.VerifiedAt))
}
}
return VerifiedResult{
WorkerID: c.WorkerID,
CPUAttested: true,
GPUAttested: true,
NVSwitchAttested: c.GPU.NVSwitchVerified,
KernelManifestRoot: c.KernelManifestRoot,
}, nil
}
// bindCPUReportData computes the canonical binding hash that ties the
// CPU TEE report_data field to the GPU evidence nonce. Callers MUST
// use this when constructing the CPU quote so the composite verifies.
//
// Layout: sha256( "luxfi/mpc/composite\x00" || workerID || "\x00" ||
// gpuUUID || "\x00" || gpuNonce ).
//
// The leading domain string + null separators prevent cross-domain
// reuse of the same hash (a SEV-SNP report bound this way cannot be
// repurposed for any other binding).
func bindCPUReportData(workerID, gpuUUID string, gpuNonce [32]byte) [32]byte {
h := sha256.New()
_, _ = h.Write([]byte("luxfi/mpc/composite\x00"))
_, _ = h.Write([]byte(workerID))
_, _ = h.Write([]byte{0})
_, _ = h.Write([]byte(gpuUUID))
_, _ = h.Write([]byte{0})
_, _ = h.Write(gpuNonce[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// BindCPUReportData is the exported helper for callers building a CPU
// quote. The returned 32-byte digest must be placed in REPORT_DATA
// (SEV-SNP) or REPORTDATA (TDX) before the quote is fetched.
func BindCPUReportData(workerID, gpuUUID string, gpuNonce [32]byte) [32]byte {
return bindCPUReportData(workerID, gpuUUID, gpuNonce)
}
+149
View File
@@ -0,0 +1,149 @@
package nvidia
import (
"errors"
"testing"
"time"
)
// helper — fully verified composite.
func okBundle() VerifiedBundle {
gpuNonce := [32]byte{1, 2, 3, 4}
workerID := "worker-7"
gpuUUID := "GPU-DEAD-BEEF"
cpuRD := BindCPUReportData(workerID, gpuUUID, gpuNonce)
return VerifiedBundle{
WorkerID: workerID,
CPU: CPUQuote{
Kind: TEESEVSNP,
Verified: true,
MeasurementHash: [32]byte{0x11},
ReportData: cpuRD,
VerifiedAt: time.Now().Add(-1 * time.Minute),
},
GPU: GPUEvidence{
UUID: gpuUUID,
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Architecture: "Hopper",
Verified: true,
RIMMatched: true,
Nonce: gpuNonce,
VerifiedAt: time.Now().Add(-2 * time.Minute),
MeasurementRoot: [32]byte{0x22},
},
KernelManifestRoot: [32]byte{0x33, 0x44},
BuiltAt: time.Now(),
}
}
func TestVerifiedBundle_HappyPath(t *testing.T) {
c := okBundle()
res, err := c.Verify(time.Now(), time.Hour)
if err != nil {
t.Fatalf("verify: %v", err)
}
if !res.CPUAttested || !res.GPUAttested {
t.Fatalf("expected attested, got %+v", res)
}
if res.KernelManifestRoot != c.KernelManifestRoot {
t.Fatalf("kernel root drift")
}
}
func TestVerifiedBundle_NilReceiver(t *testing.T) {
var c *VerifiedBundle
if _, err := c.Verify(time.Now(), 0); err == nil {
t.Fatal("expected error")
}
}
func TestVerifiedBundle_RejectsTEENone(t *testing.T) {
c := okBundle()
c.CPU.Kind = TEENone
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleNoCPUQuote) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsUnverifiedCPU(t *testing.T) {
c := okBundle()
c.CPU.Verified = false
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleBadCPU) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsUnverifiedGPU(t *testing.T) {
c := okBundle()
c.GPU.Verified = false
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleBadGPU) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsRIMMismatch(t *testing.T) {
c := okBundle()
c.GPU.RIMMatched = false
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleRIMMismatch) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsBindingMismatch(t *testing.T) {
c := okBundle()
c.CPU.ReportData = [32]byte{0xff} // wrong binding
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleBindingMissing) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsStaleCPU(t *testing.T) {
c := okBundle()
c.CPU.VerifiedAt = time.Now().Add(-25 * time.Hour)
if _, err := c.Verify(time.Now(), time.Hour); !errors.Is(err, ErrBundleStale) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsStaleGPU(t *testing.T) {
c := okBundle()
c.GPU.VerifiedAt = time.Now().Add(-25 * time.Hour)
if _, err := c.Verify(time.Now(), time.Hour); !errors.Is(err, ErrBundleStale) {
t.Fatalf("got %v", err)
}
}
func TestVerifiedBundle_RejectsMissingWorker(t *testing.T) {
c := okBundle()
c.WorkerID = ""
if _, err := c.Verify(time.Now(), 0); !errors.Is(err, ErrBundleMissingWorker) {
t.Fatalf("got %v", err)
}
}
func TestBindCPUReportData_StableAcrossInputs(t *testing.T) {
a := BindCPUReportData("w", "u", [32]byte{1})
b := BindCPUReportData("w", "u", [32]byte{1})
c := BindCPUReportData("w", "u", [32]byte{2})
if a != b {
t.Fatalf("not deterministic")
}
if a == c {
t.Fatalf("nonce collision")
}
}
func TestTEEKind_String(t *testing.T) {
cases := map[TEEKind]string{
TEENone: "none",
TEESEVSNP: "sev_snp",
TEETDX: "tdx",
TEEKind(99): "invalid",
}
for k, want := range cases {
if k.String() != want {
t.Fatalf("k=%d got %s want %s", k, k.String(), want)
}
}
}
+149
View File
@@ -0,0 +1,149 @@
// GPU evidence-report parser. The NVIDIA Confidential Compute stack
// produces a JSON-encoded "evidence" envelope that the host forwards
// to the NVIDIA Remote Attestation Service (NRAS). The envelope wraps
// a binary attestation report signed by the GPU's confidential-compute
// firmware plus the GPU's certificate chain.
//
// This parser is a clean-room implementation of the publicly
// documented schema (see nvTrust, NVIDIA Apache-2 release). We do NOT
// vendor NVIDIA proprietary code. Field names mirror the public spec
// so downstream code reads naturally against the documentation.
package nvidia
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Errors returned by the parser.
var (
ErrReportEmpty = errors.New("nvidia/report: empty input")
ErrReportBadJSON = errors.New("nvidia/report: bad JSON envelope")
ErrReportBadVersion = errors.New("nvidia/report: unsupported evidence_version")
ErrReportMissingNonce = errors.New("nvidia/report: missing nonce")
ErrReportBadNonce = errors.New("nvidia/report: nonce must be 32 bytes hex")
ErrReportMissingArch = errors.New("nvidia/report: missing architecture")
ErrReportUnknownArch = errors.New("nvidia/report: unknown architecture")
ErrReportNoMeasurements = errors.New("nvidia/report: no measurements present")
)
// SupportedEvidenceVersions enumerates the report-envelope versions
// this parser understands. Bumped when NVIDIA ships a new schema.
var SupportedEvidenceVersions = map[string]bool{
"2.0": true, // Hopper H100 GA
"2.1": true, // Hopper post-launch fixes
"3.0": true, // Blackwell B100/B200
}
// SupportedArchitectures enumerates the GPU architectures we recognise.
var SupportedArchitectures = map[string]bool{
"Hopper": true,
"Blackwell": true,
}
// Measurement is one entry in the GPU's measurement list. PCRIndex
// follows the spec's natural numbering. Value is the raw measurement
// bytes (typically a 48-byte SHA-384 digest).
type Measurement struct {
PCRIndex uint8 `json:"pcr_index"`
Name string `json:"name"`
Value []byte `json:"-"` // decoded from ValueHex
ValueHex string `json:"value"`
}
// rawReport is the on-the-wire envelope. Field names are the
// canonical lower-snake spelling used by the NVIDIA evidence schema.
type rawReport struct {
EvidenceVersion string `json:"evidence_version"`
GPUUUID string `json:"gpu_uuid"`
Architecture string `json:"architecture"`
DriverVersion string `json:"driver_version"`
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
NVSwitchPresent bool `json:"nvswitch_present"`
}
// GPUReport is the parsed, validated GPU evidence report — every field
// downstream verification logic needs, with no JSON-specific shape.
type GPUReport struct {
EvidenceVersion string
UUID string
Architecture string
DriverVersion string
VBIOSVersion string
Nonce [32]byte
Measurements []Measurement
CertChain []string // PEM blocks (verified at NRAS, kept for audit)
QuoteB64 string
NVSwitchPresent bool
}
// ParseGPUReport reads the JSON envelope and returns a validated
// GPUReport. All structural problems are caught here so downstream
// consumers (NRAS client, RIM matcher, composite) deal only with
// well-formed input.
func ParseGPUReport(data []byte) (*GPUReport, error) {
if len(data) == 0 {
return nil, ErrReportEmpty
}
var r rawReport
if err := json.Unmarshal(data, &r); err != nil {
return nil, fmt.Errorf("%w: %v", ErrReportBadJSON, err)
}
if !SupportedEvidenceVersions[r.EvidenceVersion] {
return nil, fmt.Errorf("%w: %q", ErrReportBadVersion, r.EvidenceVersion)
}
if r.Architecture == "" {
return nil, ErrReportMissingArch
}
if !SupportedArchitectures[r.Architecture] {
return nil, fmt.Errorf("%w: %q", ErrReportUnknownArch, r.Architecture)
}
if r.NonceHex == "" {
return nil, ErrReportMissingNonce
}
nonceBytes, err := hex.DecodeString(strings.TrimPrefix(r.NonceHex, "0x"))
if err != nil || len(nonceBytes) != 32 {
return nil, ErrReportBadNonce
}
if len(r.Measurements) == 0 {
return nil, ErrReportNoMeasurements
}
for i := range r.Measurements {
raw, err := hex.DecodeString(strings.TrimPrefix(r.Measurements[i].ValueHex, "0x"))
if err != nil {
return nil, fmt.Errorf("nvidia/report: bad measurement[%d] hex: %v", i, err)
}
r.Measurements[i].Value = raw
}
rep := &GPUReport{
EvidenceVersion: r.EvidenceVersion,
UUID: r.GPUUUID,
Architecture: r.Architecture,
DriverVersion: r.DriverVersion,
VBIOSVersion: r.VBIOSVersion,
Measurements: r.Measurements,
CertChain: r.CertChain,
QuoteB64: r.Quote,
NVSwitchPresent: r.NVSwitchPresent,
}
copy(rep.Nonce[:], nonceBytes)
return rep, nil
}
// 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 {
out := make(map[string]Measurement, len(r.Measurements))
for _, m := range r.Measurements {
out[m.Name] = m
}
return out
}
+115
View File
@@ -0,0 +1,115 @@
package nvidia
import (
"encoding/hex"
"errors"
"testing"
)
func goodReportJSON() []byte {
return []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"}
],
"cert_chain": ["-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----"],
"attestation_quote": "AAAA",
"nvswitch_present": false
}`)
}
func TestParseGPUReport_HappyPath(t *testing.T) {
r, err := ParseGPUReport(goodReportJSON())
if err != nil {
t.Fatalf("parse: %v", err)
}
if r.UUID != "GPU-1234-5678" {
t.Fatalf("uuid: %s", r.UUID)
}
if r.Architecture != "Hopper" {
t.Fatalf("arch: %s", r.Architecture)
}
if len(r.Measurements) != 2 {
t.Fatalf("measurements: %d", len(r.Measurements))
}
if r.Measurements[0].Name != "FW_RUNTIME" {
t.Fatalf("name: %s", r.Measurements[0].Name)
}
if hex.EncodeToString(r.Measurements[0].Value) != "deadbeef00112233" {
t.Fatalf("value decode: %x", r.Measurements[0].Value)
}
}
func TestParseGPUReport_RejectsEmpty(t *testing.T) {
if _, err := ParseGPUReport(nil); !errors.Is(err, ErrReportEmpty) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsBadJSON(t *testing.T) {
if _, err := ParseGPUReport([]byte("{not json")); !errors.Is(err, ErrReportBadJSON) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsBadVersion(t *testing.T) {
in := []byte(`{"evidence_version":"0.9","gpu_uuid":"x","architecture":"Hopper","nonce":"` + hex.EncodeToString(make([]byte, 32)) + `","measurements":[{"name":"a","value":"00"}]}`)
if _, err := ParseGPUReport(in); !errors.Is(err, ErrReportBadVersion) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsUnknownArch(t *testing.T) {
in := []byte(`{"evidence_version":"2.1","gpu_uuid":"x","architecture":"Pascal","nonce":"` + hex.EncodeToString(make([]byte, 32)) + `","measurements":[{"name":"a","value":"00"}]}`)
if _, err := ParseGPUReport(in); !errors.Is(err, ErrReportUnknownArch) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsMissingNonce(t *testing.T) {
in := []byte(`{"evidence_version":"2.1","gpu_uuid":"x","architecture":"Hopper","measurements":[{"name":"a","value":"00"}]}`)
if _, err := ParseGPUReport(in); !errors.Is(err, ErrReportMissingNonce) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsBadNonce(t *testing.T) {
in := []byte(`{"evidence_version":"2.1","gpu_uuid":"x","architecture":"Hopper","nonce":"deadbeef","measurements":[{"name":"a","value":"00"}]}`)
if _, err := ParseGPUReport(in); !errors.Is(err, ErrReportBadNonce) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsNoMeasurements(t *testing.T) {
in := []byte(`{"evidence_version":"2.1","gpu_uuid":"x","architecture":"Hopper","nonce":"` + hex.EncodeToString(make([]byte, 32)) + `","measurements":[]}`)
if _, err := ParseGPUReport(in); !errors.Is(err, ErrReportNoMeasurements) {
t.Fatalf("got %v", err)
}
}
func TestParseGPUReport_RejectsBadMeasurementHex(t *testing.T) {
in := []byte(`{"evidence_version":"2.1","gpu_uuid":"x","architecture":"Hopper","nonce":"` + hex.EncodeToString(make([]byte, 32)) + `","measurements":[{"name":"a","value":"GG"}]}`)
if _, err := ParseGPUReport(in); err == nil {
t.Fatal("expected error on bad hex")
}
}
func TestMeasurementMap(t *testing.T) {
r, err := ParseGPUReport(goodReportJSON())
if err != nil {
t.Fatalf("parse: %v", err)
}
m := r.MeasurementMap()
if len(m) != 2 {
t.Fatalf("map size %d", len(m))
}
if _, ok := m["FW_RUNTIME"]; !ok {
t.Fatal("missing FW_RUNTIME")
}
}
+141
View File
@@ -0,0 +1,141 @@
//go:build hwattest
// Real-hardware attestation tests. Build with `-tags hwattest` on a
// host with a Hopper (H100) or Blackwell (B100/B200) GPU plus the
// NVIDIA confidential-compute stack. The default build excludes this
// file so CI does not fault on hardware that is not present.
//
// Set environment:
//
// NRAS_ENDPOINT — full URL to NRAS (default: production NRAS)
// NRAS_TRUST_ROOT_PEM — path to a PEM file containing the NRAS
// signing public key
// GPU_REPORT_PATH — path to a JSON evidence report produced by
// the local nvidia-cc-host binary
// RIM_PATH — path to a signed RIM JSON for this driver
// and VBIOS combination
// RIM_TRUST_ROOT_PEM — path to a PEM file containing the RIM
// signing public key (may equal NRAS root)
//
// All tests skip when their inputs are missing — running with the
// build tag on a host that lacks a configured GPU is harmless.
package nvidia
import (
"context"
"crypto/x509"
"encoding/pem"
"os"
"testing"
"time"
)
func loadPubKeyPEM(t *testing.T, path string) interface{} {
t.Helper()
if path == "" {
t.Skipf("PEM path not set")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read PEM %s: %v", path, err)
}
block, _ := pem.Decode(raw)
if block == nil {
t.Fatalf("no PEM block in %s", path)
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
t.Fatalf("parse PKIX %s: %v", path, err)
}
return pub
}
func TestHardware_ParseLocalGPUReport(t *testing.T) {
path := os.Getenv("GPU_REPORT_PATH")
if path == "" {
t.Skip("GPU_REPORT_PATH not set")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read evidence: %v", err)
}
rep, err := ParseGPUReport(raw)
if err != nil {
t.Fatalf("parse evidence: %v", err)
}
if rep.Architecture != "Hopper" && rep.Architecture != "Blackwell" {
t.Fatalf("unexpected arch: %q", rep.Architecture)
}
t.Logf("parsed report: arch=%s driver=%s vbios=%s",
rep.Architecture, rep.DriverVersion, rep.VBIOSVersion)
}
func TestHardware_VerifyAgainstRealNRAS(t *testing.T) {
endpoint := os.Getenv("NRAS_ENDPOINT")
if endpoint == "" {
t.Skip("NRAS_ENDPOINT not set")
}
reportPath := os.Getenv("GPU_REPORT_PATH")
if reportPath == "" {
t.Skip("GPU_REPORT_PATH not set")
}
rootPath := os.Getenv("NRAS_TRUST_ROOT_PEM")
if rootPath == "" {
t.Skip("NRAS_TRUST_ROOT_PEM not set")
}
raw, err := os.ReadFile(reportPath)
if err != nil {
t.Fatalf("read evidence: %v", err)
}
rep, err := ParseGPUReport(raw)
if err != nil {
t.Fatalf("parse evidence: %v", err)
}
pub := loadPubKeyPEM(t, rootPath)
c := NewNRASClient(endpoint, []TrustRoot{{KeyID: "nras", Public: pub}})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := c.Verify(ctx, raw, rep.Nonce, VerifyOptions{})
if err != nil {
t.Fatalf("nras verify: %v", err)
}
if res.Token == "" {
t.Fatal("empty token")
}
}
func TestHardware_RIMMatchAgainstReport(t *testing.T) {
reportPath := os.Getenv("GPU_REPORT_PATH")
rimPath := os.Getenv("RIM_PATH")
rimRoot := os.Getenv("RIM_TRUST_ROOT_PEM")
if reportPath == "" || rimPath == "" || rimRoot == "" {
t.Skip("GPU_REPORT_PATH / RIM_PATH / RIM_TRUST_ROOT_PEM not all set")
}
rep, err := readReport(reportPath)
if err != nil {
t.Fatalf("read report: %v", err)
}
rimRaw, err := os.ReadFile(rimPath)
if err != nil {
t.Fatalf("read rim: %v", err)
}
pub := loadPubKeyPEM(t, rimRoot)
rim, err := ParseAndVerifyRIM(rimRaw, []TrustRoot{{KeyID: "rim", Public: pub}})
if err != nil {
t.Fatalf("rim parse+verify: %v", err)
}
if err := rim.Match(rep); err != nil {
t.Fatalf("rim match: %v", err)
}
}
func readReport(path string) (*GPUReport, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return ParseGPUReport(raw)
}
+390
View File
@@ -0,0 +1,390 @@
// NRAS HTTP client. The NVIDIA Remote Attestation Service ingests a
// GPU evidence envelope and returns a signed attestation token (a
// JWS-style JWT) that downstream verifiers can carry forward.
//
// We do not embed any NVIDIA-published verification keys. The token
// is treated as opaque-but-bound: callers verify the token's
// signature against trust roots they configure (same TrustRoot type
// the RIM uses). This keeps the package license-clean.
//
// Wire shape (verified against publicly documented NRAS request/response):
//
// POST /v1/attest/gpu HTTP/1.1
// Content-Type: application/json
// {
// "evidence": "<base64 of the JSON evidence envelope>",
// "nonce": "<32-byte hex>",
// "policy": "<NRAS policy ID, optional>"
// }
//
// 200 OK
// Content-Type: application/json
// {
// "token": "<JWT>",
// "expires_at": "<RFC3339>"
// }
package nvidia
import (
"bytes"
"context"
"crypto"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// Errors returned by the NRAS client.
var (
ErrNRASNoEndpoint = errors.New("nvidia/nras: endpoint not configured")
ErrNRASNoEvidence = errors.New("nvidia/nras: empty evidence")
ErrNRASBadStatus = errors.New("nvidia/nras: non-2xx response")
ErrNRASBadResponse = errors.New("nvidia/nras: malformed response body")
ErrNRASEmptyToken = errors.New("nvidia/nras: token field missing")
ErrNRASNonceMismatch = errors.New("nvidia/nras: token nonce does not match request")
ErrNRASTokenExpired = errors.New("nvidia/nras: token already expired")
ErrNRASBadJWT = errors.New("nvidia/nras: token not a valid JWS compact serialization")
ErrNRASBadSignature = errors.New("nvidia/nras: token signature verification failed")
)
// NRASClient posts GPU evidence to NRAS and returns a verified
// VerifyResult. The HTTP client is injectable so tests use httptest
// directly.
type NRASClient struct {
Endpoint string // e.g. "https://nras.attestation.nvidia.com"
HTTPClient *http.Client // nil = http.DefaultClient with 15s timeout
TrustRoots []TrustRoot // public keys that may sign NRAS tokens
Now func() time.Time
}
// NewNRASClient builds a client. trustRoots may be empty for tests
// that bypass token-signature verification (see VerifyOptions).
func NewNRASClient(endpoint string, trustRoots []TrustRoot) *NRASClient {
return &NRASClient{
Endpoint: strings.TrimRight(endpoint, "/"),
HTTPClient: &http.Client{Timeout: 15 * time.Second},
TrustRoots: trustRoots,
}
}
// VerifyOptions tunes a single Verify call.
type VerifyOptions struct {
// PolicyID is the NRAS-side policy the request opts into. Empty
// uses NRAS's default policy.
PolicyID string
// SkipSignature is test-only. Production must always verify.
SkipSignature bool
}
// VerifyResult captures the post-verification facts. Callers fold
// these into GPUEvidence.
type VerifyResult struct {
Token string // raw JWS compact form
TokenHash [32]byte // sha256 of the token bytes
Nonce [32]byte // echoed from the request — must match GPU report
ExpiresAt time.Time // honored if non-zero
IssuedAt time.Time // RFC3339 from the response
SignerKeyID string // which trust root verified the JWS
Architecture string // mirrored back from the report; for audit
}
// Verify uploads evidence and returns the parsed result.
//
// `report` is the GPU evidence (the same JSON ParseGPUReport accepts).
// `nonce` is the freshness binding — typically the value placed in
// the GPU report's nonce field. They MUST match; the function asserts
// this before any HTTP call.
func (c *NRASClient) Verify(ctx context.Context, report []byte, nonce [32]byte, opt VerifyOptions) (*VerifyResult, error) {
if c == nil || c.Endpoint == "" {
return nil, ErrNRASNoEndpoint
}
if len(report) == 0 {
return nil, ErrNRASNoEvidence
}
// Local consistency: report.nonce must equal request nonce.
parsed, err := ParseGPUReport(report)
if err != nil {
return nil, fmt.Errorf("nvidia/nras: parse evidence: %w", err)
}
if parsed.Nonce != nonce {
return nil, fmt.Errorf("%w: report=%x request=%x", ErrNRASNonceMismatch, parsed.Nonce[:], nonce[:])
}
body := map[string]string{
"evidence": base64.StdEncoding.EncodeToString(report),
"nonce": hex.EncodeToString(nonce[:]),
}
if opt.PolicyID != "" {
body["policy"] = opt.PolicyID
}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("nvidia/nras: marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.Endpoint+"/v1/attest/gpu", bytes.NewReader(bodyBytes))
if err != nil {
return nil, fmt.Errorf("nvidia/nras: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
hc := c.HTTPClient
if hc == nil {
hc = http.DefaultClient
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("nvidia/nras: http: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MB cap
if err != nil {
return nil, fmt.Errorf("nvidia/nras: read body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: status=%d body=%s", ErrNRASBadStatus, resp.StatusCode, truncate(string(respBody), 256))
}
var rr struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(respBody, &rr); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNRASBadResponse, err)
}
if rr.Token == "" {
return nil, ErrNRASEmptyToken
}
res := &VerifyResult{
Token: rr.Token,
TokenHash: sha256.Sum256([]byte(rr.Token)),
Nonce: nonce,
Architecture: parsed.Architecture,
}
// Parse JWT header + claims to recover iat/exp/nonce/kid.
hdr, payload, sigBytes, signedInput, err := splitJWS(rr.Token)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrNRASBadJWT, err)
}
var jwtClaims struct {
Nonce string `json:"nonce"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
if err := json.Unmarshal(payload, &jwtClaims); err != nil {
return nil, fmt.Errorf("%w: claims: %v", ErrNRASBadJWT, err)
}
// Token nonce must match request nonce — defense against replay.
if jwtClaims.Nonce != "" {
jn, derr := hex.DecodeString(strings.TrimPrefix(jwtClaims.Nonce, "0x"))
if derr != nil || len(jn) != 32 || sha256BytesEqual(jn, nonce[:]) == false {
return nil, ErrNRASNonceMismatch
}
}
if jwtClaims.Iat > 0 {
res.IssuedAt = time.Unix(jwtClaims.Iat, 0).UTC()
}
if jwtClaims.Exp > 0 {
res.ExpiresAt = time.Unix(jwtClaims.Exp, 0).UTC()
} else if rr.ExpiresAt != "" {
// Fall back to outer expires_at if claims didn't carry it.
if t, perr := time.Parse(time.RFC3339, rr.ExpiresAt); perr == nil {
res.ExpiresAt = t.UTC()
}
}
if !res.ExpiresAt.IsZero() && c.now().After(res.ExpiresAt) {
return nil, ErrNRASTokenExpired
}
if !opt.SkipSignature {
var jwtHdr struct {
Alg string `json:"alg"`
Kid string `json:"kid"`
}
if err := json.Unmarshal(hdr, &jwtHdr); err != nil {
return nil, fmt.Errorf("%w: header: %v", ErrNRASBadJWT, err)
}
if len(c.TrustRoots) == 0 {
return nil, fmt.Errorf("%w: no trust roots", ErrNRASBadSignature)
}
root, ok := findTrustRoot(c.TrustRoots, jwtHdr.Kid)
if !ok {
return nil, fmt.Errorf("%w: kid=%q not in trust roots", ErrNRASBadSignature, jwtHdr.Kid)
}
if err := verifyJWS(root.Public, jwtHdr.Alg, signedInput, sigBytes); err != nil {
return nil, fmt.Errorf("%w: %v", ErrNRASBadSignature, err)
}
res.SignerKeyID = root.KeyID
}
return res, nil
}
// FoldIntoEvidence projects a successful VerifyResult plus a parsed
// GPUReport plus a matched RIM into the GPUEvidence record consumed
// by CompositeAttestation.
func FoldIntoEvidence(r *VerifyResult, report *GPUReport, rim *RIM) GPUEvidence {
out := GPUEvidence{
UUID: report.UUID,
DriverVersion: report.DriverVersion,
VBIOSVersion: report.VBIOSVersion,
Architecture: report.Architecture,
Verified: true,
NRASTokenHash: r.TokenHash,
Nonce: r.Nonce,
VerifiedAt: r.IssuedAt,
NVSwitchVerified: report.NVSwitchPresent,
}
if rim != nil {
out.MeasurementRoot = rim.MeasurementRoot()
out.RIMMatched = true
}
return out
}
// =============================================================================
// internal helpers
// =============================================================================
func (c *NRASClient) now() time.Time {
if c.Now != nil {
return c.Now()
}
return time.Now()
}
// splitJWS returns header, payload, signature, signed-input ([]byte
// form of "<header>.<payload>") for a JWS compact serialization.
func splitJWS(token string) (hdr, payload, sig, signedInput []byte, err error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, nil, nil, nil, fmt.Errorf("expected 3 parts, got %d", len(parts))
}
hdr, err = base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("header: %w", err)
}
payload, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("payload: %w", err)
}
sig, err = base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return nil, nil, nil, nil, fmt.Errorf("signature: %w", err)
}
signedInput = []byte(parts[0] + "." + parts[1])
return hdr, payload, sig, signedInput, nil
}
// verifyJWS verifies signedInput vs sig under pub. alg must match the
// public key type to prevent algorithm-substitution attacks (the
// classic CVE-2015-9235 family).
func verifyJWS(pub crypto.PublicKey, alg string, signedInput, sig []byte) error {
switch alg {
case "EdDSA":
// reuse the RIM signature path for ed25519
return verifySignature(pub, signedInput, sig)
case "ES256", "ES384":
// JOSE encodes ECDSA signatures as fixed-width r||s, NOT ASN.1.
// Convert before delegating to the RIM path.
der, err := joseECDSAToASN1(sig)
if err != nil {
return fmt.Errorf("ecdsa jose decode: %w", err)
}
return verifySignature(pub, signedInput, der)
case "PS256":
return verifySignature(pub, signedInput, sig)
default:
return fmt.Errorf("unsupported alg %q", alg)
}
}
// joseECDSAToASN1 converts a fixed-width JOSE ECDSA signature
// (r||s, big-endian, equal length) into ASN.1 DER. We handle this
// here so the RIM verifier (ASN.1 input) is reusable.
func joseECDSAToASN1(jose []byte) ([]byte, error) {
if len(jose) < 64 || len(jose)%2 != 0 {
return nil, fmt.Errorf("jose ecdsa: bad length %d", len(jose))
}
half := len(jose) / 2
r := trimLeadingZeroes(jose[:half])
s := trimLeadingZeroes(jose[half:])
// ASN.1 INTEGER must be unsigned; prepend 0x00 if MSB is set.
rEnc := asn1Integer(r)
sEnc := asn1Integer(s)
body := append(rEnc, sEnc...)
// SEQUENCE { r, s }
out := []byte{0x30}
out = appendASN1Length(out, len(body))
return append(out, body...), nil
}
func trimLeadingZeroes(b []byte) []byte {
i := 0
for i < len(b)-1 && b[i] == 0 {
i++
}
return b[i:]
}
func asn1Integer(b []byte) []byte {
if len(b) > 0 && b[0]&0x80 != 0 {
b = append([]byte{0x00}, b...)
}
out := []byte{0x02}
out = appendASN1Length(out, len(b))
return append(out, b...)
}
func appendASN1Length(dst []byte, n int) []byte {
if n < 0x80 {
return append(dst, byte(n))
}
// Long form. We support up to 2-byte lengths (>64KB JWS bodies are
// not realistic).
if n < 0x100 {
return append(dst, 0x81, byte(n))
}
if n < 0x10000 {
return append(dst, 0x82, byte(n>>8), byte(n))
}
// Truncate at 65535 — JWS payloads larger than this fail upstream.
return append(dst, 0x82, 0xff, 0xff)
}
func sha256BytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
+366
View File
@@ -0,0 +1,366 @@
package nvidia
import (
"context"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// mockServer returns a stub NRAS that signs an EdDSA token.
type mockServer struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
keyID string
now func() time.Time
wantNonce *[32]byte
failHTTP int
failBody bool
}
func newMockServer(t *testing.T) *mockServer {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
return &mockServer{
priv: priv,
pub: pub,
keyID: "nras-test-1",
now: func() time.Time { return time.Unix(1735689600, 0).UTC() },
}
}
func (m *mockServer) handler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/attest/gpu" || r.Method != http.MethodPost {
http.Error(w, "not found", http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
var req struct {
Evidence string `json:"evidence"`
Nonce string `json:"nonce"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
if m.wantNonce != nil {
if req.Nonce != hex.EncodeToString((*m.wantNonce)[:]) {
http.Error(w, "nonce mismatch", http.StatusBadRequest)
return
}
}
if m.failHTTP != 0 {
http.Error(w, "configured failure", m.failHTTP)
return
}
// Build a tiny JWT.
hdr := map[string]string{"alg": "EdDSA", "kid": m.keyID, "typ": "JWT"}
hdrJSON, _ := json.Marshal(hdr)
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
now := m.now()
claims := map[string]interface{}{
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"nonce": req.Nonce,
}
clmJSON, _ := json.Marshal(claims)
clmB64 := base64.RawURLEncoding.EncodeToString(clmJSON)
signedInput := hdrB64 + "." + clmB64
sig := ed25519.Sign(m.priv, []byte(signedInput))
token := signedInput + "." + base64.RawURLEncoding.EncodeToString(sig)
resp := map[string]string{
"token": token,
"expires_at": now.Add(time.Hour).Format(time.RFC3339),
}
if m.failBody {
w.Write([]byte("{not json"))
return
}
out, _ := json.Marshal(resp)
w.Header().Set("Content-Type", "application/json")
w.Write(out)
}
}
// helper — build a valid GPU report bound to nonce.
func reportBytes(nonce [32]byte) []byte {
return []byte(fmt.Sprintf(`{
"evidence_version": "2.1",
"gpu_uuid": "GPU-AAAA-BBBB",
"architecture": "Hopper",
"driver_version": "535.104.05",
"vbios_version": "96.00.74.00.01",
"nonce": "%s",
"measurements": [{"name":"FW","value":"00"}]
}`, hex.EncodeToString(nonce[:])))
}
func TestNRAS_HappyPath(t *testing.T) {
m := newMockServer(t)
srv := httptest.NewServer(m.handler())
defer srv.Close()
roots := []TrustRoot{{KeyID: m.keyID, Public: m.pub}}
c := NewNRASClient(srv.URL, roots)
c.Now = m.now
var nonce [32]byte
rand.Read(nonce[:])
m.wantNonce = &nonce
res, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{})
if err != nil {
t.Fatalf("verify: %v", err)
}
if res.Token == "" || res.SignerKeyID != m.keyID {
t.Fatalf("bad result: %+v", res)
}
if res.Nonce != nonce {
t.Fatalf("nonce drift: got %x want %x", res.Nonce, nonce)
}
if res.IssuedAt.IsZero() || res.ExpiresAt.IsZero() {
t.Fatalf("times unset: %+v", res)
}
wantHash := sha256.Sum256([]byte(res.Token))
if res.TokenHash != wantHash {
t.Fatal("token hash drift")
}
}
func TestNRAS_RejectsNonceMismatch(t *testing.T) {
m := newMockServer(t)
srv := httptest.NewServer(m.handler())
defer srv.Close()
roots := []TrustRoot{{KeyID: m.keyID, Public: m.pub}}
c := NewNRASClient(srv.URL, roots)
c.Now = m.now
var nonceA, nonceB [32]byte
rand.Read(nonceA[:])
rand.Read(nonceB[:])
// Report carries nonceA, request says nonceB → reject locally.
_, err := c.Verify(context.Background(), reportBytes(nonceA), nonceB, VerifyOptions{})
if !errors.Is(err, ErrNRASNonceMismatch) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_RejectsBadStatus(t *testing.T) {
m := newMockServer(t)
m.failHTTP = http.StatusUnauthorized
srv := httptest.NewServer(m.handler())
defer srv.Close()
c := NewNRASClient(srv.URL, []TrustRoot{{KeyID: m.keyID, Public: m.pub}})
c.Now = m.now
var nonce [32]byte
rand.Read(nonce[:])
_, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{})
if !errors.Is(err, ErrNRASBadStatus) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_RejectsMalformedBody(t *testing.T) {
m := newMockServer(t)
m.failBody = true
srv := httptest.NewServer(m.handler())
defer srv.Close()
c := NewNRASClient(srv.URL, []TrustRoot{{KeyID: m.keyID, Public: m.pub}})
c.Now = m.now
var nonce [32]byte
rand.Read(nonce[:])
_, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{})
if !errors.Is(err, ErrNRASBadResponse) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_RejectsExpiredToken(t *testing.T) {
m := newMockServer(t)
srv := httptest.NewServer(m.handler())
defer srv.Close()
roots := []TrustRoot{{KeyID: m.keyID, Public: m.pub}}
c := NewNRASClient(srv.URL, roots)
// client clock 25h after server's "now"
c.Now = func() time.Time { return m.now().Add(25 * time.Hour) }
var nonce [32]byte
rand.Read(nonce[:])
_, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{})
if !errors.Is(err, ErrNRASTokenExpired) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_RejectsNoTrustRoots(t *testing.T) {
m := newMockServer(t)
srv := httptest.NewServer(m.handler())
defer srv.Close()
c := NewNRASClient(srv.URL, nil)
c.Now = m.now
var nonce [32]byte
rand.Read(nonce[:])
_, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{})
if !errors.Is(err, ErrNRASBadSignature) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_SkipSignature_SkipsTrustRoots(t *testing.T) {
m := newMockServer(t)
srv := httptest.NewServer(m.handler())
defer srv.Close()
// no trust roots, but skip-signature => ok
c := NewNRASClient(srv.URL, nil)
c.Now = m.now
var nonce [32]byte
rand.Read(nonce[:])
if _, err := c.Verify(context.Background(), reportBytes(nonce), nonce,
VerifyOptions{SkipSignature: true}); err != nil {
t.Fatalf("expected success: %v", err)
}
}
func TestNRAS_RejectsNoEndpoint(t *testing.T) {
c := NewNRASClient("", nil)
var n [32]byte
if _, err := c.Verify(context.Background(), []byte("x"), n, VerifyOptions{}); !errors.Is(err, ErrNRASNoEndpoint) {
t.Fatalf("got %v", err)
}
}
func TestNRAS_RejectsEmptyEvidence(t *testing.T) {
c := NewNRASClient("https://x", nil)
var n [32]byte
if _, err := c.Verify(context.Background(), nil, n, VerifyOptions{}); !errors.Is(err, ErrNRASNoEvidence) {
t.Fatalf("got %v", err)
}
}
// JOSE ECDSA path — token signed with ES256 must parse + verify after
// the JOSE-to-ASN.1 conversion in verifyJWS.
func TestNRAS_ECDSAToken(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
keyID := "ecdsa-server"
now := time.Unix(1735689600, 0).UTC()
mux := http.NewServeMux()
mux.HandleFunc("/v1/attest/gpu", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var req struct{ Nonce string }
_ = json.Unmarshal(body, &req)
hdr := map[string]string{"alg": "ES256", "kid": keyID, "typ": "JWT"}
hdrJSON, _ := json.Marshal(hdr)
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrJSON)
claims := map[string]interface{}{
"iat": now.Unix(),
"exp": now.Add(time.Hour).Unix(),
"nonce": req.Nonce,
}
clmJSON, _ := json.Marshal(claims)
clmB64 := base64.RawURLEncoding.EncodeToString(clmJSON)
signedInput := hdrB64 + "." + clmB64
digest := sha256.Sum256([]byte(signedInput))
// JOSE ES256: r||s, fixed 32 bytes each.
r2, s2, err := ecdsa.Sign(rand.Reader, priv, digest[:])
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
jose := joseEncode(r2, s2, 32)
token := signedInput + "." + base64.RawURLEncoding.EncodeToString(jose)
out, _ := json.Marshal(map[string]string{
"token": token,
"expires_at": now.Add(time.Hour).Format(time.RFC3339),
})
w.Header().Set("Content-Type", "application/json")
w.Write(out)
})
srv := httptest.NewServer(mux)
defer srv.Close()
c := NewNRASClient(srv.URL, []TrustRoot{{KeyID: keyID, Public: &priv.PublicKey}})
c.Now = func() time.Time { return now }
var nonce [32]byte
rand.Read(nonce[:])
if _, err := c.Verify(context.Background(), reportBytes(nonce), nonce, VerifyOptions{}); err != nil {
t.Fatalf("verify ecdsa: %v", err)
}
}
func joseEncode(r, s *big.Int, sz int) []byte {
out := make([]byte, sz*2)
rB := r.Bytes()
sB := s.Bytes()
copy(out[sz-len(rB):sz], rB)
copy(out[sz*2-len(sB):], sB)
return out
}
func TestFoldIntoEvidence(t *testing.T) {
report, err := ParseGPUReport(goodReportJSON())
if err != nil {
t.Fatalf("parse: %v", err)
}
rim := &RIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: []RIMEntry{
{Name: "FW_RUNTIME", ValueHex: "deadbeef00112233"},
{Name: "VBIOS_RT", ValueHex: "cafebabe44556677"},
},
}
res := &VerifyResult{
Token: "abc.def.ghi", TokenHash: sha256.Sum256([]byte("abc.def.ghi")),
Nonce: report.Nonce, IssuedAt: time.Now(),
}
ev := FoldIntoEvidence(res, report, rim)
if !ev.Verified || !ev.RIMMatched {
t.Fatalf("not verified: %+v", ev)
}
if ev.MeasurementRoot != rim.MeasurementRoot() {
t.Fatal("measurement root drift")
}
if !strings.HasPrefix(ev.UUID, "GPU-") {
t.Fatalf("uuid: %s", ev.UUID)
}
}
+280
View File
@@ -0,0 +1,280 @@
// Reference Integrity Manifest (RIM) validation. The RIM is the
// signed list of measurement values NVIDIA publishes for each
// driver/VBIOS combination. Match every measurement in a parsed GPU
// report against its RIM entry; reject any non-match.
//
// The signature on the RIM blob is verified against the supplied
// trust roots. We do not bake in NVIDIA's signing keys — operators
// supply them via deployment config. This keeps the package
// license-clean (Apache-2 nvTrust schema only, no NVIDIA cert PEMs
// vendored) and lets enterprises pin their own RIM trust path.
package nvidia
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
)
// Errors returned by RIM validation.
var (
ErrRIMEmpty = errors.New("nvidia/rim: empty manifest")
ErrRIMBadJSON = errors.New("nvidia/rim: bad JSON manifest")
ErrRIMBadSignature = errors.New("nvidia/rim: signature verification failed")
ErrRIMNoTrustRoots = errors.New("nvidia/rim: no trust roots configured")
ErrRIMMissingEntry = errors.New("nvidia/rim: measurement not in manifest")
ErrRIMValueMismatch = errors.New("nvidia/rim: measurement value mismatch")
ErrRIMArchMismatch = errors.New("nvidia/rim: architecture mismatch")
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.
type RIMEntry struct {
Name string `json:"name"`
ValueHex string `json:"value"`
}
// rawRIM is the on-the-wire JSON. The signature block is detachable:
// signers compute it over the canonical body bytes (sorted keys, no
// whitespace) and append the signature alongside.
type rawRIM struct {
Architecture string `json:"architecture"`
DriverVersion string `json:"driver_version"`
VBIOSVersion string `json:"vbios_version"`
Entries []RIMEntry `json:"entries"`
SignerKeyID string `json:"signer_key_id,omitempty"`
SignatureB64 string `json:"signature,omitempty"`
}
// RIM is a parsed, signature-verified Reference Integrity Manifest.
type RIM struct {
Architecture string
DriverVersion string
VBIOSVersion string
Entries []RIMEntry
// SignerKeyID identifies which trust root verified the manifest.
// Empty if the manifest was loaded with VerifySignature disabled
// (development only).
SignerKeyID string
}
// TrustRoot is a public key registered to validate RIM signatures.
// Algorithm is inferred from the key type at verify time — we accept
// Ed25519, ECDSA P-256/P-384, and RSA-PSS (SHA-256). Operators wire
// their NVIDIA RIM signing keys here at startup.
type TrustRoot struct {
KeyID string
Public crypto.PublicKey
}
// ParseAndVerifyRIM parses the manifest bytes and verifies the
// detached signature against trustRoots. If trustRoots is empty,
// returns ErrRIMNoTrustRoots — there is no insecure mode.
//
// On success, returns a *RIM with SignerKeyID set to the trust root
// that produced a valid signature.
func ParseAndVerifyRIM(data []byte, trustRoots []TrustRoot) (*RIM, error) {
if len(data) == 0 {
return nil, ErrRIMEmpty
}
if len(trustRoots) == 0 {
return nil, ErrRIMNoTrustRoots
}
var raw rawRIM
if err := json.Unmarshal(data, &raw); err != nil {
return nil, fmt.Errorf("%w: %v", ErrRIMBadJSON, err)
}
if raw.SignatureB64 == "" {
return nil, fmt.Errorf("%w: missing signature", ErrRIMBadSignature)
}
if raw.SignerKeyID == "" {
return nil, fmt.Errorf("%w: missing signer_key_id", ErrRIMBadSignature)
}
// Re-encode the body (sans signature fields) deterministically so
// the signature is over a stable canonical form.
body := rawRIM{
Architecture: raw.Architecture,
DriverVersion: raw.DriverVersion,
VBIOSVersion: raw.VBIOSVersion,
Entries: raw.Entries,
}
canon, err := canonicalJSON(body)
if err != nil {
return nil, fmt.Errorf("nvidia/rim: canonicalize: %w", err)
}
sig, err := base64.StdEncoding.DecodeString(raw.SignatureB64)
if err != nil {
return nil, fmt.Errorf("%w: bad base64: %v", ErrRIMBadSignature, err)
}
root, ok := findTrustRoot(trustRoots, raw.SignerKeyID)
if !ok {
return nil, fmt.Errorf("%w: signer %q not in trust roots", ErrRIMBadSignature, raw.SignerKeyID)
}
if err := verifySignature(root.Public, canon, sig); err != nil {
return nil, fmt.Errorf("%w: %v", ErrRIMBadSignature, err)
}
return &RIM{
Architecture: raw.Architecture,
DriverVersion: raw.DriverVersion,
VBIOSVersion: raw.VBIOSVersion,
Entries: raw.Entries,
SignerKeyID: raw.SignerKeyID,
}, nil
}
// Match validates that every measurement in report has an entry in
// rim with the same value. Architecture + driver + VBIOS must match
// the manifest's preconditions.
//
// Returns nil iff every measurement matches AND the GPU's
// driver/VBIOS line up with the manifest. Any divergence returns the
// most-specific error so audit logs can categorize failures.
func (rim *RIM) Match(report *GPUReport) error {
if rim == nil || report == nil {
return errors.New("nvidia/rim: nil rim or report")
}
if rim.Architecture != report.Architecture {
return fmt.Errorf("%w: rim=%s report=%s", ErrRIMArchMismatch, rim.Architecture, report.Architecture)
}
if rim.DriverVersion != report.DriverVersion || rim.VBIOSVersion != report.VBIOSVersion {
return fmt.Errorf("%w: rim=%s/%s report=%s/%s", ErrRIMVersionMismatch,
rim.DriverVersion, rim.VBIOSVersion, report.DriverVersion, report.VBIOSVersion)
}
want := make(map[string][]byte, len(rim.Entries))
for _, e := range rim.Entries {
v, err := hex.DecodeString(strings.TrimPrefix(e.ValueHex, "0x"))
if err != nil {
return fmt.Errorf("nvidia/rim: bad entry hex %q: %w", e.Name, err)
}
want[e.Name] = v
}
for _, m := range report.Measurements {
exp, ok := want[m.Name]
if !ok {
return fmt.Errorf("%w: name=%s", ErrRIMMissingEntry, m.Name)
}
if !bytesEqual(exp, m.Value) {
return fmt.Errorf("%w: name=%s", ErrRIMValueMismatch, m.Name)
}
}
return nil
}
// MeasurementRoot returns sha256(name||value) folded into a single
// root over a deterministic ordering. Used as the canonical
// MeasurementRoot in GPUEvidence.
func (rim *RIM) MeasurementRoot() [32]byte {
// Sort entries by Name so the root is deterministic regardless of
// list order in the manifest.
names := make([]string, 0, len(rim.Entries))
values := make(map[string][]byte, len(rim.Entries))
for _, e := range rim.Entries {
names = append(names, e.Name)
v, _ := hex.DecodeString(strings.TrimPrefix(e.ValueHex, "0x"))
values[e.Name] = v
}
sortStrings(names)
h := sha256.New()
for _, n := range names {
_, _ = h.Write([]byte(n))
_, _ = h.Write([]byte{0})
_, _ = h.Write(values[n])
_, _ = h.Write([]byte{0})
}
var out [32]byte
copy(out[:], h.Sum(nil))
return out
}
// =============================================================================
// internal helpers
// =============================================================================
// canonicalJSON re-encodes v with sorted keys and no whitespace. The
// stdlib's encoding/json already sorts struct fields by source order
// and map keys alphabetically; for our struct shape that is sufficient.
// We reject any nested map[string]interface{} that would require deeper
// canonicalization than stdlib provides.
func canonicalJSON(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func findTrustRoot(roots []TrustRoot, keyID string) (TrustRoot, bool) {
for _, r := range roots {
if r.KeyID == keyID {
return r, true
}
}
return TrustRoot{}, false
}
func verifySignature(pub crypto.PublicKey, msg, sig []byte) error {
switch k := pub.(type) {
case ed25519.PublicKey:
if !ed25519.Verify(k, msg, sig) {
return errors.New("ed25519 verify failed")
}
return nil
case *ecdsa.PublicKey:
// ASN.1 DER signature over sha256(msg).
digest := sha256.Sum256(msg)
if !ecdsa.VerifyASN1(k, digest[:], sig) {
return errors.New("ecdsa verify failed")
}
return nil
case *rsa.PublicKey:
digest := sha256.Sum256(msg)
// PSS with SHA-256, salt length = hash length.
err := rsa.VerifyPSS(k, crypto.SHA256, digest[:], sig, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
})
if err != nil {
return fmt.Errorf("rsa verify: %w", err)
}
return nil
default:
return fmt.Errorf("unsupported trust root key type %T", pub)
}
}
func bytesEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
var diff byte
for i := range a {
diff |= a[i] ^ b[i]
}
return diff == 0
}
func sortStrings(s []string) {
// insertion sort — n is tiny (measurement count per RIM), avoids
// pulling in sort just for this.
for i := 1; i < len(s); i++ {
v := s[i]
j := i - 1
for j >= 0 && s[j] > v {
s[j+1] = s[j]
j--
}
s[j+1] = v
}
}
+229
View File
@@ -0,0 +1,229 @@
package nvidia
import (
"crypto"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"testing"
)
// makeManifest produces a signed RIM manifest for the supplied entries
// using the provided ed25519 key.
func makeManifestEd25519(t *testing.T, priv ed25519.PrivateKey, kid string, entries []RIMEntry) []byte {
t.Helper()
body := rawRIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: entries,
}
canon, err := json.Marshal(body)
if err != nil {
t.Fatalf("canonicalize: %v", err)
}
sig := ed25519.Sign(priv, canon)
body.SignerKeyID = kid
body.SignatureB64 = base64.StdEncoding.EncodeToString(sig)
out, err := json.Marshal(body)
if err != nil {
t.Fatalf("re-marshal: %v", err)
}
return out
}
func TestParseAndVerifyRIM_Ed25519(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
roots := []TrustRoot{{KeyID: "nvidia-rim-1", Public: pub}}
entries := []RIMEntry{
{Name: "FW_RUNTIME", ValueHex: "deadbeef"},
{Name: "VBIOS_RT", ValueHex: "cafebabe"},
}
blob := makeManifestEd25519(t, priv, "nvidia-rim-1", entries)
r, err := ParseAndVerifyRIM(blob, roots)
if err != nil {
t.Fatalf("verify: %v", err)
}
if r.SignerKeyID != "nvidia-rim-1" {
t.Fatalf("signer: %s", r.SignerKeyID)
}
}
func TestParseAndVerifyRIM_RejectsTamperedSignature(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
roots := []TrustRoot{{KeyID: "k", Public: pub}}
entries := []RIMEntry{{Name: "X", ValueHex: "00"}}
blob := makeManifestEd25519(t, priv, "k", entries)
// Flip a byte in the manifest entries — signature must reject.
tampered := append([]byte{}, blob...)
for i := range tampered {
if tampered[i] == 'X' {
tampered[i] = 'Y'
break
}
}
if _, err := ParseAndVerifyRIM(tampered, roots); !errors.Is(err, ErrRIMBadSignature) {
t.Fatalf("got %v", err)
}
}
func TestParseAndVerifyRIM_RejectsUnknownSigner(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
roots := []TrustRoot{{KeyID: "OTHER", Public: pub}}
blob := makeManifestEd25519(t, priv, "k", []RIMEntry{{Name: "X", ValueHex: "00"}})
if _, err := ParseAndVerifyRIM(blob, roots); !errors.Is(err, ErrRIMBadSignature) {
t.Fatalf("got %v", err)
}
}
func TestParseAndVerifyRIM_RejectsEmpty(t *testing.T) {
if _, err := ParseAndVerifyRIM(nil, []TrustRoot{{KeyID: "k"}}); !errors.Is(err, ErrRIMEmpty) {
t.Fatalf("got %v", err)
}
}
func TestParseAndVerifyRIM_RejectsNoTrustRoots(t *testing.T) {
if _, err := ParseAndVerifyRIM([]byte("{}"), nil); !errors.Is(err, ErrRIMNoTrustRoots) {
t.Fatalf("got %v", err)
}
}
func TestParseAndVerifyRIM_ECDSA(t *testing.T) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
roots := []TrustRoot{{KeyID: "ecdsa-1", Public: &priv.PublicKey}}
body := rawRIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: []RIMEntry{{Name: "FW", ValueHex: "01"}},
}
canon, _ := json.Marshal(body)
digest := sha256.Sum256(canon)
sig, err := ecdsa.SignASN1(rand.Reader, priv, digest[:])
if err != nil {
t.Fatalf("sign: %v", err)
}
body.SignerKeyID = "ecdsa-1"
body.SignatureB64 = base64.StdEncoding.EncodeToString(sig)
blob, _ := json.Marshal(body)
if _, err := ParseAndVerifyRIM(blob, roots); err != nil {
t.Fatalf("ecdsa verify: %v", err)
}
}
func TestParseAndVerifyRIM_RSAPSS(t *testing.T) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("genkey: %v", err)
}
roots := []TrustRoot{{KeyID: "rsa-1", Public: &priv.PublicKey}}
body := rawRIM{
Architecture: "Hopper", DriverVersion: "535.104.05",
VBIOSVersion: "96", Entries: []RIMEntry{{Name: "FW", ValueHex: "01"}},
}
canon, _ := json.Marshal(body)
digest := sha256.Sum256(canon)
sig, err := rsa.SignPSS(rand.Reader, priv, crypto.SHA256, digest[:], &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash, Hash: crypto.SHA256,
})
if err != nil {
t.Fatalf("sign: %v", err)
}
body.SignerKeyID = "rsa-1"
body.SignatureB64 = base64.StdEncoding.EncodeToString(sig)
blob, _ := json.Marshal(body)
if _, err := ParseAndVerifyRIM(blob, roots); err != nil {
t.Fatalf("rsa verify: %v", err)
}
}
func TestRIM_Match_HappyPath(t *testing.T) {
rim := &RIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: []RIMEntry{
{Name: "FW_RUNTIME", ValueHex: "deadbeef00112233"},
{Name: "VBIOS_RT", ValueHex: "cafebabe44556677"},
},
}
report, err := ParseGPUReport(goodReportJSON())
if err != nil {
t.Fatalf("report: %v", err)
}
if err := rim.Match(report); err != nil {
t.Fatalf("match: %v", err)
}
}
func TestRIM_Match_RejectsArchMismatch(t *testing.T) {
rim := &RIM{Architecture: "Blackwell", DriverVersion: "535.104.05", VBIOSVersion: "96.00.74.00.01"}
report, _ := ParseGPUReport(goodReportJSON())
if err := rim.Match(report); !errors.Is(err, ErrRIMArchMismatch) {
t.Fatalf("got %v", err)
}
}
func TestRIM_Match_RejectsVersionMismatch(t *testing.T) {
rim := &RIM{Architecture: "Hopper", DriverVersion: "OTHER", VBIOSVersion: "OTHER"}
report, _ := ParseGPUReport(goodReportJSON())
if err := rim.Match(report); !errors.Is(err, ErrRIMVersionMismatch) {
t.Fatalf("got %v", err)
}
}
func TestRIM_Match_RejectsMissingMeasurement(t *testing.T) {
rim := &RIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: []RIMEntry{
{Name: "OTHER", ValueHex: "00"},
},
}
report, _ := ParseGPUReport(goodReportJSON())
if err := rim.Match(report); !errors.Is(err, ErrRIMMissingEntry) {
t.Fatalf("got %v", err)
}
}
func TestRIM_Match_RejectsValueMismatch(t *testing.T) {
rim := &RIM{
Architecture: "Hopper",
DriverVersion: "535.104.05",
VBIOSVersion: "96.00.74.00.01",
Entries: []RIMEntry{
{Name: "FW_RUNTIME", ValueHex: "00"},
{Name: "VBIOS_RT", ValueHex: "cafebabe44556677"},
},
}
report, _ := ParseGPUReport(goodReportJSON())
if err := rim.Match(report); !errors.Is(err, ErrRIMValueMismatch) {
t.Fatalf("got %v", err)
}
}
func TestRIM_MeasurementRoot_Deterministic(t *testing.T) {
a := &RIM{Entries: []RIMEntry{{Name: "B", ValueHex: "02"}, {Name: "A", ValueHex: "01"}}}
b := &RIM{Entries: []RIMEntry{{Name: "A", ValueHex: "01"}, {Name: "B", ValueHex: "02"}}}
if a.MeasurementRoot() != b.MeasurementRoot() {
t.Fatal("root differs across orderings")
}
}
+54 -7
View File
@@ -27,6 +27,7 @@ import (
"github.com/luxfi/mpc/pkg/intent"
"github.com/luxfi/mpc/pkg/policy"
"github.com/luxfi/mpc/pkg/scheduler"
)
// AttestationSink persists node attestations (positive or negative) to
@@ -77,11 +78,25 @@ func (m *memAttestationSink) All() []intent.NodeAttestation {
// SignGate is the per-node authorization gate. One instance per MPC
// node, holding the node's identity key and the LocalVerifier wired
// to that node's copy of the policy + approval keyset.
//
// Scheduler + Worker are optional. When Scheduler is non-nil, the gate
// runs scheduler.Eligible(Worker, ci.PolicyID) BEFORE the policy
// verifier. A scheduler refusal short-circuits the policy check and
// records a reject attestation with reason="scheduler: ...". This is
// how confidential-compute trust posture (TrustGPU, IOPrivateLane,
// approved kernel roots, attested CPU+GPU) is enforced at sign time.
//
// Scheduler == nil keeps the original behavior (legacy deployments
// that have not adopted workload-policy yet). One MPC binary, two
// modes — but the trust-posture-enforced path is the default whenever
// the operator wires a PolicySource into the daemon.
type SignGate struct {
NodeID string
NodeKey ed25519.PrivateKey
Verifier *policy.LocalVerifier
Attest AttestationSink
NodeID string
NodeKey ed25519.PrivateKey
Verifier *policy.LocalVerifier
Attest AttestationSink
Scheduler *scheduler.Scheduler // optional; nil = no trust-posture gate
Worker scheduler.Worker // this node's attested posture
}
// NewSignGate constructs a SignGate. nodeKey is the node's Ed25519
@@ -100,6 +115,20 @@ func NewSignGate(nodeID string, nodeKey ed25519.PrivateKey, verifier *policy.Loc
}
}
// WithScheduler wires the workload-policy gate into the SignGate.
// sch resolves WorkloadPolicy by intent.PolicyID; w is this node's
// attested posture (populated from the NVIDIA composite attestation
// and the SEV-SNP/TDX quote). Returns g for chaining.
//
// Calling WithScheduler is the single switch that turns on
// confidential-compute enforcement for this node. There is no
// "best effort" mode — once on, fail-closed.
func (g *SignGate) WithScheduler(sch *scheduler.Scheduler, w scheduler.Worker) *SignGate {
g.Scheduler = sch
g.Worker = w
return g
}
// ErrSignBlocked is returned by AuthorizeSign when policy verification
// fails. The caller (sign session) MUST abort and not produce a share.
var ErrSignBlocked = errors.New("sign: blocked by per-node policy gate")
@@ -122,7 +151,24 @@ func (g *SignGate) AuthorizeSign(ctx context.Context, ci *intent.CanonicalIntent
return intent.NodeAttestation{}, errors.New("sign: nil intent")
}
verifyErr := g.Verifier.Verify(ctx, ci)
// Stage 1 — workload-policy gate (scheduler). Runs first so a node
// with an inadequate trust posture rejects BEFORE any approval-keyset
// or policy-bundle work. ci.PolicyID is the canonical workload key.
//
// When Scheduler is nil, the stage is skipped entirely — no
// behavior change for legacy callers.
var verifyErr error
if g.Scheduler != nil {
if elig := g.Scheduler.Eligible(g.Worker, ci.PolicyID); elig != nil {
verifyErr = fmt.Errorf("scheduler: %w", elig)
}
}
// Stage 2 — per-node policy verifier. Skipped only if the scheduler
// already rejected; otherwise both checks must pass.
if verifyErr == nil {
verifyErr = g.Verifier.Verify(ctx, ci)
}
// Build the attestation regardless of outcome — both positive and
// negative attestations are non-repudiable evidence.
@@ -138,8 +184,9 @@ func (g *SignGate) AuthorizeSign(ctx context.Context, ci *intent.CanonicalIntent
}
if verifyErr != nil {
// Wrap BOTH ErrSignBlocked and the underlying verifier error so
// callers can use errors.Is() against either layer.
// Wrap BOTH ErrSignBlocked and the underlying error so callers
// can use errors.Is() against either layer. ErrInsufficientTrust
// (from the scheduler) propagates through this join.
return att, errors.Join(ErrSignBlocked, verifyErr)
}
return att, nil
+274
View File
@@ -0,0 +1,274 @@
// Sign-orchestrator scheduler integration. Proves that wiring a
// scheduler.Scheduler into a SignGate causes the gate to refuse
// signing for a worker whose trust posture falls below the workload
// floor — and that the refusal lands in the audit sink as a reject
// attestation, identically to a policy-verifier refusal.
//
// This file lives in package mpc_test alongside the existing
// per-node policy-gate tests so it runs under the same `go test
// ./pkg/mpc/...` invocation. There is a pre-existing breakage in
// pkg/policy (luxfi/fhe import path resolution) that blocks pkg/mpc
// from compiling at HEAD; once that is resolved, this test passes
// without further changes — see the truth-table in
// pkg/scheduler/orchestrator_integration_test.go for the same
// invariants tested standalone.
package mpc_test
import (
"context"
"crypto/ed25519"
"crypto/rand"
"errors"
"math/big"
"testing"
"time"
"github.com/luxfi/mpc/pkg/intent"
"github.com/luxfi/mpc/pkg/mpc"
"github.com/luxfi/mpc/pkg/policy"
"github.com/luxfi/mpc/pkg/risk"
"github.com/luxfi/mpc/pkg/scheduler"
)
// buildVerifier returns a LocalVerifier that approves the canonical
// happy-path intent built by buildIntent below.
func buildVerifier(t *testing.T) (*policy.LocalVerifier, []signerKeySched, *policy.PolicyBundle) {
t.Helper()
signers := []signerKeySched{
mkSignerSched(t, "exec-cfo"),
mkSignerSched(t, "exec-cto"),
}
bundle := &policy.PolicyBundle{
PolicyID: "p-prod",
Version: "v1",
Tiers: map[intent.WalletTier]policy.TierPolicy{
intent.TierWarm: {
MinApprovals: 2,
MaxAmount: big.NewInt(1_000_000_000_000),
EnforceAllowlist: true,
},
},
Allowlist: map[string]map[string]bool{"eip155:1": {"0xrecip": true}},
}
wr := policy.NewMemWalletRegistry()
wr.Put(policy.Wallet{WalletID: "wallet-treasury", Tier: intent.TierWarm, Chain: "eip155:1", Address: "0xfrom"})
ks := policy.NewMemApprovalKeyset()
for _, s := range signers {
ks.Add(policy.ApprovalSigner{SignerID: s.id, PublicKey: s.pub})
}
ps := policy.NewMemPolicyStore()
ps.Put(bundle)
rp := risk.NewInternalAllowlistProvider()
rp.Allow("eip155:1", "0xrecip")
v := policy.NewLocalVerifier(wr, ks, rp, ps)
v.Now = func() time.Time { return time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC) }
return v, signers, bundle
}
type signerKeySched struct {
id string
pub ed25519.PublicKey
priv ed25519.PrivateKey
}
func mkSignerSched(t *testing.T, id string) signerKeySched {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
return signerKeySched{id: id, pub: pub, priv: priv}
}
func buildIntent(t *testing.T, signers []signerKeySched, bundle *policy.PolicyBundle) *intent.CanonicalIntent {
t.Helper()
ci := &intent.CanonicalIntent{
IntentVersion: intent.IntentVersion,
SessionID: "sess",
WalletID: "wallet-treasury",
WalletTier: intent.TierWarm,
Chain: "eip155:1",
Asset: "USDC",
From: "0xfrom",
To: "0xrecip",
Amount: "100",
MaxFee: "21000",
Nonce: "1",
CalldataHash: [32]byte{1},
HumanSummary: "test",
PolicyID: bundle.PolicyID,
PolicyHash: bundle.Hash(),
RiskVerdictID: "rv",
SimulationHash: [32]byte{2},
ExpiresAt: time.Date(2026, 4, 27, 13, 0, 0, 0, time.UTC),
}
digest, err := ci.Digest()
if err != nil {
t.Fatalf("digest: %v", err)
}
for _, s := range signers {
ci.Approvals = append(ci.Approvals, intent.ApprovalSignature{
SignerID: s.id,
PublicKey: s.pub,
Signature: ed25519.Sign(s.priv, digest[:]),
SignedAt: time.Date(2026, 4, 27, 11, 0, 0, 0, time.UTC),
})
}
return ci
}
// strictWorkloadPolicy mirrors the workload's trust requirements at
// the same PolicyID the intent references — wiring is by ci.PolicyID.
func strictWorkloadPolicy() *scheduler.WorkloadPolicy {
return &scheduler.WorkloadPolicy{
WorkloadID: "p-prod",
MinTrust: scheduler.TrustGPU,
MinIO: scheduler.IOPrivateLane,
RequireGPUAttested: true,
RequireCPUAttested: true,
}
}
// TestSignGate_NilSchedulerKeepsLegacyBehavior — without a scheduler
// wired in, AuthorizeSign behaves exactly as before (only the policy
// verifier runs).
func TestSignGate_NilSchedulerKeepsLegacyBehavior(t *testing.T) {
v, signers, bundle := buildVerifier(t)
ci := buildIntent(t, signers, bundle)
_, priv, _ := ed25519.GenerateKey(rand.Reader)
sink := mpc.NewMemAttestationSink()
g := mpc.NewSignGate("node0", priv, v, sink)
att, err := g.AuthorizeSign(context.Background(), ci)
if err != nil {
t.Fatalf("authorize: %v", err)
}
if att.Verdict != "approve" {
t.Fatalf("verdict: %s", att.Verdict)
}
}
// TestSignGate_SchedulerEligibleAllowsPolicyVerifier — when the worker
// passes the trust posture floor, the policy verifier runs and the
// happy path approves.
func TestSignGate_SchedulerEligibleAllowsPolicyVerifier(t *testing.T) {
v, signers, bundle := buildVerifier(t)
ci := buildIntent(t, signers, bundle)
src := scheduler.NewStaticPolicySource(map[string]*scheduler.WorkloadPolicy{
"p-prod": strictWorkloadPolicy(),
})
s := scheduler.New(src)
worker := scheduler.Worker{
ID: "node-good",
TrustMode: scheduler.TrustGPU,
IOLevel: scheduler.IOPrivateLane,
GPUAttested: true,
CPUAttested: true,
Online: true,
}
_, priv, _ := ed25519.GenerateKey(rand.Reader)
sink := mpc.NewMemAttestationSink()
g := mpc.NewSignGate("node0", priv, v, sink).WithScheduler(s, worker)
att, err := g.AuthorizeSign(context.Background(), ci)
if err != nil {
t.Fatalf("expected authorize, got %v", err)
}
if att.Verdict != "approve" {
t.Fatalf("verdict: %s", att.Verdict)
}
}
// TestSignGate_SchedulerInsufficientTrustShortCircuits — a worker
// below the workload's trust floor causes AuthorizeSign to reject
// without consulting the policy verifier. The reject attestation
// reason names "scheduler:" so audit consumers can categorize.
func TestSignGate_SchedulerInsufficientTrustShortCircuits(t *testing.T) {
v, signers, bundle := buildVerifier(t)
ci := buildIntent(t, signers, bundle)
src := scheduler.NewStaticPolicySource(map[string]*scheduler.WorkloadPolicy{
"p-prod": strictWorkloadPolicy(),
})
s := scheduler.New(src)
// CVM tier is below the required GPU trust.
worker := scheduler.Worker{
ID: "node-cvm",
TrustMode: scheduler.TrustCVM,
IOLevel: scheduler.IOPrivateLane,
GPUAttested: false, // no GPU
CPUAttested: true,
Online: true,
}
_, priv, _ := ed25519.GenerateKey(rand.Reader)
sink := mpc.NewMemAttestationSink()
g := mpc.NewSignGate("node0", priv, v, sink).WithScheduler(s, worker)
att, err := g.AuthorizeSign(context.Background(), ci)
if !errors.Is(err, mpc.ErrSignBlocked) {
t.Fatalf("expected ErrSignBlocked, got %v", err)
}
if att.Verdict != "reject" {
t.Fatalf("verdict: %s", att.Verdict)
}
// Reason must indicate scheduler — not a policy-verifier reason.
if !contains(att.Reason, "scheduler:") {
t.Fatalf("expected reason to start with scheduler: got %q", att.Reason)
}
if got := sink.All(); len(got) != 1 {
t.Fatalf("audit count: %d", len(got))
}
}
// TestSignGate_SchedulerWorkloadLookupFails — when ci.PolicyID does
// not exist in the scheduler's source, AuthorizeSign rejects with the
// scheduler error wrapped in ErrSignBlocked.
func TestSignGate_SchedulerWorkloadLookupFails(t *testing.T) {
v, signers, bundle := buildVerifier(t)
ci := buildIntent(t, signers, bundle)
ci.PolicyID = "ghost-workload"
// re-sign approvals against the new digest so the policy verifier
// would otherwise pass — proving the scheduler check ran first.
digest, _ := ci.Digest()
ci.Approvals = nil
for _, s := range signers {
ci.Approvals = append(ci.Approvals, intent.ApprovalSignature{
SignerID: s.id,
PublicKey: s.pub,
Signature: ed25519.Sign(s.priv, digest[:]),
})
}
src := scheduler.NewStaticPolicySource(map[string]*scheduler.WorkloadPolicy{
"p-prod": strictWorkloadPolicy(),
})
s := scheduler.New(src)
worker := scheduler.Worker{
ID: "node0", TrustMode: scheduler.TrustGPU, IOLevel: scheduler.IOPrivateLane,
GPUAttested: true, CPUAttested: true, Online: true,
}
_, priv, _ := ed25519.GenerateKey(rand.Reader)
sink := mpc.NewMemAttestationSink()
g := mpc.NewSignGate("node0", priv, v, sink).WithScheduler(s, worker)
_, err := g.AuthorizeSign(context.Background(), ci)
if !errors.Is(err, mpc.ErrSignBlocked) {
t.Fatalf("expected ErrSignBlocked, got %v", err)
}
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
@@ -0,0 +1,99 @@
package scheduler
import (
"errors"
"testing"
)
// TestOrchestratorIntegration_TrustGateRunsBeforePolicy proves the
// integration contract that pkg/mpc.SignGate now relies on:
//
// - The scheduler is consulted with (Worker, ci.PolicyID) BEFORE
// any policy-bundle verification work.
// - A worker below MinTrust short-circuits with ErrInsufficientTrust
// wrapped from Eligible.
// - A worker at or above MinTrust returns nil, allowing the caller
// (the policy verifier) to proceed.
//
// This is the behavioral contract the orchestrator file imports the
// scheduler for; testing it here keeps the proof close to the scheduler
// logic and avoids the upstream pkg/policy build dependency that is
// pre-existing breakage in this branch.
func TestOrchestratorIntegration_TrustGateRunsBeforePolicy(t *testing.T) {
// Workload requires GPU + private lane + attested CPU+GPU.
policy := &WorkloadPolicy{
WorkloadID: "p-prod",
MinTrust: TrustGPU,
MinIO: IOPrivateLane,
RequireGPUAttested: true,
RequireCPUAttested: true,
RequireModelConfidentiality: false, // simplify
}
src := NewStaticPolicySource(map[string]*WorkloadPolicy{"p-prod": policy})
s := New(src)
// Eligible worker matches the policy floors.
good := Worker{
ID: "node-good", TrustMode: TrustGPU, IOLevel: IOPrivateLane,
GPUAttested: true, CPUAttested: true, Online: true,
}
if err := s.Eligible(good, "p-prod"); err != nil {
t.Fatalf("expected eligible, got %v", err)
}
// Worker below GPU trust → reject. Wraps ErrInsufficientTrust via
// AuthorizeSession; via Eligible, returns descriptive error.
bad := good
bad.TrustMode = TrustCVM
if err := s.Eligible(bad, "p-prod"); err == nil {
t.Fatal("expected ineligible")
}
// Worker offline — second-level guard.
off := good
off.Online = false
if err := s.Eligible(off, "p-prod"); err == nil {
t.Fatal("expected offline rejection")
}
// Workload not found in the policy source — distinct error.
if err := s.Eligible(good, "ghost-policy"); err == nil {
t.Fatal("expected lookup error")
}
}
// TestOrchestratorIntegration_AuthorizeSession_FiltersWorkers proves the
// scheduler's AuthorizeSession (used by the sign coordinator to pick
// the t+1 nodes that may participate) excludes any worker that fails
// any gate, in stable order. This is the multi-node analogue of the
// per-call gate inside SignGate.AuthorizeSign.
func TestOrchestratorIntegration_AuthorizeSession_FiltersWorkers(t *testing.T) {
policy := &WorkloadPolicy{
WorkloadID: "p-prod",
MinTrust: TrustGPU,
MinIO: IOPrivateLane,
RequireGPUAttested: true,
RequireCPUAttested: true,
}
src := NewStaticPolicySource(map[string]*WorkloadPolicy{"p-prod": policy})
s := New(src)
good1 := Worker{ID: "n1", TrustMode: TrustGPU, IOLevel: IOPrivateLane, GPUAttested: true, CPUAttested: true, Online: true}
good2 := Worker{ID: "n2", TrustMode: TrustGPU, IOLevel: IOPrivateLane, GPUAttested: true, CPUAttested: true, Online: true}
bad := good1
bad.ID = "n3"
bad.GPUAttested = false // missing GPU attestation
out, err := s.AuthorizeSession("p-prod", []Worker{good1, bad, good2}, 2)
if err != nil {
t.Fatalf("authorize: %v", err)
}
if len(out) != 2 || out[0].ID != "n1" || out[1].ID != "n2" {
t.Fatalf("ordering or filter wrong: %+v", out)
}
// Below threshold → ErrInsufficientTrust.
if _, err := s.AuthorizeSession("p-prod", []Worker{bad}, 1); !errors.Is(err, ErrInsufficientTrust) {
t.Fatalf("got %v", err)
}
}
+222
View File
@@ -0,0 +1,222 @@
// Package scheduler enforces workload-policy gates BEFORE a worker is
// allowed to participate in a sign / inference session.
//
// The scheduler is the only place that decides "this worker may produce
// a partial signature share for this workload". Every entry point into
// the MPC sign path consults this gate. Failure FAILS CLOSED — no
// fallback to "trust the coordinator".
//
// The types here are the scheduler's own view of v0.60 registry types.
// When pkg/registry lands the WorkloadPolicy / Worker structs they
// will be adapted via the WorkloadPolicySource interface — no code in
// this package changes.
package scheduler
import (
"errors"
"fmt"
)
// TrustMode classifies a worker's confidential-compute posture, ordered
// least-to-most trusted. The gate compares with >=, so callers can
// require a minimum tier.
type TrustMode uint8
const (
// TrustNone — no confidential compute. Bare-metal or VM with no
// memory encryption. Acceptable only for public workloads.
TrustNone TrustMode = iota
// TrustCVM — CPU TEE only (SEV-SNP, TDX). Memory encrypted, CPU
// attested. GPU runs in plaintext.
TrustCVM
// TrustGPU — CPU TEE + GPU confidential compute (Hopper CC, Blackwell
// CC). End-to-end PCIe encryption between CPU TEE and GPU.
TrustGPU
// TrustGPUMultiNode — TrustGPU + NVSwitch attested for multi-GPU
// workloads. Required for tensor-parallel inference of large models.
TrustGPUMultiNode
)
// String returns the canonical name for the trust tier.
func (t TrustMode) String() string {
switch t {
case TrustNone:
return "none"
case TrustCVM:
return "cvm"
case TrustGPU:
return "gpu"
case TrustGPUMultiNode:
return "gpu_multinode"
}
return "invalid"
}
// IOLevel classifies the network/storage isolation of a worker. Used
// to enforce private-lane requirements (no egress to public internet,
// keys encrypted at rest with org-controlled keys, etc.).
type IOLevel uint8
const (
// IOPublic — public internet I/O permitted. Lowest tier.
IOPublic IOLevel = iota
// IOPrivateNet — egress restricted to allowlisted peers; ingress
// behind authenticated gateway.
IOPrivateNet
// IOPrivateLane — IOPrivateNet + storage encryption at rest with
// customer-controlled keys; no shared mounts; per-tenant volumes.
IOPrivateLane
)
// String returns the canonical name for the I/O tier.
func (l IOLevel) String() string {
switch l {
case IOPublic:
return "public"
case IOPrivateNet:
return "private_net"
case IOPrivateLane:
return "private_lane"
}
return "invalid"
}
// Worker is the scheduler's view of a candidate node for a session.
// All fields are populated by the registry from attested measurements
// (see pkg/attestation/nvidia for the GPU side).
type Worker struct {
ID string
TrustMode TrustMode
IOLevel IOLevel
KernelManifestRoot [32]byte // root of approved CUDA/CPU kernel manifest
GPUAttested bool // composite NVIDIA attestation verified
CPUAttested bool // SEV-SNP / TDX quote verified
Online bool // worker is reachable + healthy
}
// WorkloadPolicy is the scheduler's view of the policy a workload
// requires of any worker that participates in it.
//
// Each field is a HARD floor — workers below the floor are ineligible.
type WorkloadPolicy struct {
WorkloadID string
MinTrust TrustMode
MinIO IOLevel
RequireModelConfidentiality bool
ApprovedKernelRoots [][32]byte // any-of: worker's KernelManifestRoot must match one
RequireGPUAttested bool
RequireCPUAttested bool
}
// Eligible reports whether w may participate in a session under p.
// Returns the first failed gate or nil on success.
//
// The decision is fail-closed: any missing or invalid field on either
// side is a rejection. There is no "best effort" mode.
func (p *WorkloadPolicy) Eligible(w Worker) error {
if p == nil {
return errors.New("scheduler: nil workload policy")
}
if w.ID == "" {
return errors.New("scheduler: worker missing ID")
}
if !w.Online {
return fmt.Errorf("scheduler: worker %s offline", w.ID)
}
if w.TrustMode < p.MinTrust {
return fmt.Errorf("scheduler: worker %s trust=%s below required %s",
w.ID, w.TrustMode, p.MinTrust)
}
if w.IOLevel < p.MinIO {
return fmt.Errorf("scheduler: worker %s io=%s below required %s",
w.ID, w.IOLevel, p.MinIO)
}
if p.RequireGPUAttested && !w.GPUAttested {
return fmt.Errorf("scheduler: worker %s missing GPU attestation", w.ID)
}
if p.RequireCPUAttested && !w.CPUAttested {
return fmt.Errorf("scheduler: worker %s missing CPU attestation", w.ID)
}
if p.RequireModelConfidentiality {
if !w.GPUAttested {
return fmt.Errorf("scheduler: worker %s confidentiality requires GPU attestation", w.ID)
}
if len(p.ApprovedKernelRoots) == 0 {
return errors.New("scheduler: confidentiality requires approved kernel roots")
}
matched := false
var zero [32]byte
if w.KernelManifestRoot == zero {
return fmt.Errorf("scheduler: worker %s missing KernelManifestRoot", w.ID)
}
for _, r := range p.ApprovedKernelRoots {
if r == w.KernelManifestRoot {
matched = true
break
}
}
if !matched {
return fmt.Errorf("scheduler: worker %s KernelManifestRoot not in approved set", w.ID)
}
}
return nil
}
// PolicySource resolves the WorkloadPolicy for a workload ID. The
// concrete implementation lives in pkg/registry (sibling v0.60); this
// package only depends on the interface.
type PolicySource interface {
Lookup(workloadID string) (*WorkloadPolicy, error)
}
// Scheduler binds a PolicySource so callers can run eligibility checks
// without threading the source through every call site.
type Scheduler struct {
src PolicySource
}
// New returns a Scheduler bound to src. src must be non-nil.
func New(src PolicySource) *Scheduler {
if src == nil {
panic("scheduler: nil PolicySource")
}
return &Scheduler{src: src}
}
// Eligible looks up the policy for workloadID and applies it to w.
// Returns nil iff w is permitted to participate.
func (s *Scheduler) Eligible(w Worker, workloadID string) error {
p, err := s.src.Lookup(workloadID)
if err != nil {
return fmt.Errorf("scheduler: lookup %s: %w", workloadID, err)
}
if p == nil {
return fmt.Errorf("scheduler: no policy for workload %s", workloadID)
}
return p.Eligible(w)
}
// StaticPolicySource is an in-memory PolicySource. Useful for tests and
// for the legacy single-policy deployment mode where the policy is
// loaded from config at startup.
type StaticPolicySource struct {
policies map[string]*WorkloadPolicy
}
// NewStaticPolicySource constructs a StaticPolicySource from a map.
func NewStaticPolicySource(m map[string]*WorkloadPolicy) *StaticPolicySource {
cp := make(map[string]*WorkloadPolicy, len(m))
for k, v := range m {
cp[k] = v
}
return &StaticPolicySource{policies: cp}
}
// Lookup returns the policy for workloadID or an error if absent.
func (s *StaticPolicySource) Lookup(workloadID string) (*WorkloadPolicy, error) {
p, ok := s.policies[workloadID]
if !ok {
return nil, fmt.Errorf("workload %s not registered", workloadID)
}
return p, nil
}
+243
View File
@@ -0,0 +1,243 @@
package scheduler
import (
"errors"
"testing"
)
// canonical policy used across the truth table — model confidentiality
// over private lane on attested CPU + GPU.
var privatePolicy = WorkloadPolicy{
WorkloadID: "model-private",
MinTrust: TrustGPU,
MinIO: IOPrivateLane,
RequireModelConfidentiality: true,
RequireGPUAttested: true,
RequireCPUAttested: true,
ApprovedKernelRoots: [][32]byte{{1, 2, 3}, {4, 5, 6}},
}
func okWorker() Worker {
return Worker{
ID: "node-1",
TrustMode: TrustGPU,
IOLevel: IOPrivateLane,
KernelManifestRoot: [32]byte{1, 2, 3},
GPUAttested: true,
CPUAttested: true,
Online: true,
}
}
func TestEligible_TruthTable(t *testing.T) {
tests := []struct {
name string
mutate func(*Worker)
wantErr bool
match string
}{
{"happy path", func(w *Worker) {}, false, ""},
{"offline", func(w *Worker) { w.Online = false }, true, "offline"},
{"trust below floor", func(w *Worker) { w.TrustMode = TrustCVM }, true, "trust"},
{"io below floor", func(w *Worker) { w.IOLevel = IOPrivateNet }, true, "io"},
{"gpu unattested", func(w *Worker) { w.GPUAttested = false }, true, "GPU attestation"},
{"cpu unattested", func(w *Worker) { w.CPUAttested = false }, true, "CPU attestation"},
{"kernel root not approved", func(w *Worker) { w.KernelManifestRoot = [32]byte{99} }, true, "KernelManifestRoot not in approved set"},
{"kernel root second match", func(w *Worker) { w.KernelManifestRoot = [32]byte{4, 5, 6} }, false, ""},
{"missing kernel root", func(w *Worker) { w.KernelManifestRoot = [32]byte{} }, true, "missing KernelManifestRoot"},
{"missing ID", func(w *Worker) { w.ID = "" }, true, "missing ID"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := okWorker()
tc.mutate(&w)
err := privatePolicy.Eligible(w)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error containing %q, got nil", tc.match)
}
if tc.match != "" && !contains(err.Error(), tc.match) {
t.Fatalf("expected %q in error, got %q", tc.match, err.Error())
}
return
}
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
}
}
func TestEligible_NilPolicy(t *testing.T) {
var p *WorkloadPolicy
if err := p.Eligible(okWorker()); err == nil {
t.Fatal("expected error for nil policy")
}
}
func TestEligible_PublicWorkloadAcceptsBareMetal(t *testing.T) {
publicPolicy := WorkloadPolicy{
WorkloadID: "public",
MinTrust: TrustNone,
MinIO: IOPublic,
}
w := Worker{ID: "n", Online: true}
if err := publicPolicy.Eligible(w); err != nil {
t.Fatalf("public workload should accept bare worker: %v", err)
}
}
func TestEligible_ConfidentialityWithoutKernelRoots(t *testing.T) {
p := WorkloadPolicy{
WorkloadID: "broken",
MinTrust: TrustGPU,
MinIO: IOPrivateLane,
RequireModelConfidentiality: true,
RequireGPUAttested: true,
// no ApprovedKernelRoots — must reject
}
if err := p.Eligible(okWorker()); err == nil {
t.Fatal("expected error when confidentiality required without approved roots")
}
}
func TestStaticPolicySource(t *testing.T) {
src := NewStaticPolicySource(map[string]*WorkloadPolicy{
"a": &privatePolicy,
})
got, err := src.Lookup("a")
if err != nil {
t.Fatalf("lookup a: %v", err)
}
if got == nil || got.WorkloadID != "model-private" {
t.Fatalf("got %+v", got)
}
if _, err := src.Lookup("missing"); err == nil {
t.Fatal("expected error for missing workload")
}
}
func TestScheduler_Eligible(t *testing.T) {
src := NewStaticPolicySource(map[string]*WorkloadPolicy{"a": &privatePolicy})
s := New(src)
if err := s.Eligible(okWorker(), "a"); err != nil {
t.Fatalf("happy: %v", err)
}
bad := okWorker()
bad.TrustMode = TrustCVM
if err := s.Eligible(bad, "a"); err == nil {
t.Fatal("expected eligibility failure")
}
if err := s.Eligible(okWorker(), "missing"); err == nil {
t.Fatal("expected lookup failure")
}
}
func TestScheduler_AuthorizeSession_PrivateLaneRefusal(t *testing.T) {
src := NewStaticPolicySource(map[string]*WorkloadPolicy{"a": &privatePolicy})
s := New(src)
good1 := okWorker()
good1.ID = "n1"
good2 := okWorker()
good2.ID = "n2"
bad := okWorker()
bad.ID = "n3"
bad.IOLevel = IOPublic // private-lane refusal: this worker MUST be excluded
out, err := s.AuthorizeSession("a", []Worker{good1, bad, good2}, 2)
if err != nil {
t.Fatalf("expected 2 eligible: %v", err)
}
if len(out) != 2 {
t.Fatalf("expected 2 eligible, got %d", len(out))
}
for _, w := range out {
if w.ID == "n3" {
t.Fatalf("public-IO worker n3 leaked through gate")
}
}
if _, err := s.AuthorizeSession("a", []Worker{bad}, 1); !errors.Is(err, ErrInsufficientTrust) {
t.Fatalf("expected ErrInsufficientTrust, got %v", err)
}
}
func TestScheduler_AuthorizeDrain(t *testing.T) {
src := NewStaticPolicySource(map[string]*WorkloadPolicy{"a": &privatePolicy})
s := New(src)
w1 := okWorker()
w1.ID = "n1"
w2 := okWorker()
w2.ID = "n2"
w3 := okWorker()
w3.ID = "n3"
// drain n1 with 3 healthy → 2 remain → ok for threshold=2
if err := s.AuthorizeDrain(DrainRequest{
NodeID: "n1",
WorkloadID: "a",
Workers: []Worker{w1, w2, w3},
Threshold: 2,
}); err != nil {
t.Fatalf("happy drain: %v", err)
}
// drain n1 with 3 healthy → 2 remain → fail for threshold=3
err := s.AuthorizeDrain(DrainRequest{
NodeID: "n1",
WorkloadID: "a",
Workers: []Worker{w1, w2, w3},
Threshold: 3,
})
if !errors.Is(err, ErrInsufficientTrust) {
t.Fatalf("expected ErrInsufficientTrust, got %v", err)
}
// pre-existing ineligible worker doesn't count toward remaining
w3bad := w3
w3bad.GPUAttested = false
err = s.AuthorizeDrain(DrainRequest{
NodeID: "n1",
WorkloadID: "a",
Workers: []Worker{w1, w2, w3bad},
Threshold: 2,
})
if !errors.Is(err, ErrInsufficientTrust) {
t.Fatalf("expected ErrInsufficientTrust when only 1 eligible remains, got %v", err)
}
// missing workload
err = s.AuthorizeDrain(DrainRequest{
NodeID: "n1",
WorkloadID: "missing",
Workers: []Worker{w1, w2},
Threshold: 1,
})
if err == nil || errors.Is(err, ErrInsufficientTrust) {
t.Fatalf("expected lookup error, got %v", err)
}
}
func TestNew_NilSourcePanics(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
New(nil)
}
// contains is a tiny strings.Contains shim to keep test deps minimal.
func contains(s, sub string) bool {
if sub == "" {
return true
}
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+114
View File
@@ -0,0 +1,114 @@
// Drain — refuse a service drain unless EVERY remaining worker passes
// the policy gate. A drain that lowers the eligible-worker count below
// the workload's threshold is rejected; a drain that would force a
// session to land on an ineligible worker is rejected.
//
// This is the operational complement to Eligible(): it answers "can we
// drain node X right now without weakening the trust posture below
// what the workload requires?".
package scheduler
import (
"errors"
"fmt"
)
// ErrInsufficientTrust is returned when a drain (or sign authorization)
// would proceed on a worker set below the workload's required trust
// floor. Callers may errors.Is against this sentinel.
var ErrInsufficientTrust = errors.New("scheduler: insufficient trust for workload")
// DrainRequest describes a proposed drain. Threshold is the minimum
// number of eligible workers the workload requires to remain
// operational AFTER the drain.
type DrainRequest struct {
NodeID string
WorkloadID string
Workers []Worker // current worker set, including the one to drain
Threshold int // min eligible after drain (e.g. t+1 for t-of-n MPC)
}
// AuthorizeDrain returns nil iff draining req.NodeID is safe for the
// workload. Otherwise returns a wrapped ErrInsufficientTrust describing
// the failed gate.
//
// Algorithm:
// 1. Resolve the WorkloadPolicy.
// 2. Filter Workers by Eligible(p) AND ID != NodeID.
// 3. If len(remaining) < Threshold → reject.
//
// Workers that are already ineligible BEFORE the drain are not counted —
// the drain cannot improve their state, but it must not also remove a
// worker the policy still depended on.
func (s *Scheduler) AuthorizeDrain(req DrainRequest) error {
if req.NodeID == "" {
return errors.New("scheduler: drain missing node ID")
}
if req.WorkloadID == "" {
return errors.New("scheduler: drain missing workload ID")
}
if req.Threshold <= 0 {
return errors.New("scheduler: drain threshold must be positive")
}
p, err := s.src.Lookup(req.WorkloadID)
if err != nil {
return fmt.Errorf("scheduler: drain lookup %s: %w", req.WorkloadID, err)
}
if p == nil {
return fmt.Errorf("scheduler: drain no policy for %s", req.WorkloadID)
}
eligible := 0
for _, w := range req.Workers {
if w.ID == req.NodeID {
continue // the worker being drained
}
if err := p.Eligible(w); err != nil {
continue // already ineligible — does not count toward threshold
}
eligible++
}
if eligible < req.Threshold {
return fmt.Errorf("%w: workload=%s would have %d eligible workers, need %d",
ErrInsufficientTrust, req.WorkloadID, eligible, req.Threshold)
}
return nil
}
// AuthorizeSession is the per-sign analogue: given the candidate worker
// set and the workload, return the subset that may participate. Returns
// ErrInsufficientTrust if fewer than min workers pass the gate.
//
// The order of the returned slice matches the input order — callers
// that depend on stable selection (party-ID assignment in MPC) get
// deterministic results.
func (s *Scheduler) AuthorizeSession(workloadID string, candidates []Worker, min int) ([]Worker, error) {
if workloadID == "" {
return nil, errors.New("scheduler: session missing workload ID")
}
if min <= 0 {
return nil, errors.New("scheduler: session min must be positive")
}
p, err := s.src.Lookup(workloadID)
if err != nil {
return nil, fmt.Errorf("scheduler: session lookup %s: %w", workloadID, err)
}
if p == nil {
return nil, fmt.Errorf("scheduler: session no policy for %s", workloadID)
}
out := make([]Worker, 0, len(candidates))
for _, w := range candidates {
if err := p.Eligible(w); err == nil {
out = append(out, w)
}
}
if len(out) < min {
return nil, fmt.Errorf("%w: workload=%s eligible=%d min=%d",
ErrInsufficientTrust, workloadID, len(out), min)
}
return out, nil
}