mirror of
https://github.com/luxfi/mpc.git
synced 2026-09-24 00:26:24 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] + "..."
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user