Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .changelog/6387.internal.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
go/registry/api: Allow at most one runtime SGX role

A stricter node registration rule is behind the future flag,
and therefore not breaking.
4 changes: 4 additions & 0 deletions .changelog/6387.internal.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Add support for per-role quote policies

This enables a more relaxed general policy but a stricter requirements
for nodes that can access the key manager (e.g. compute/observer nodes).
25 changes: 22 additions & 3 deletions go/common/node/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,18 @@ func (m RolesMask) IsSingleRole() bool {
return m != 0 && m&(m-1) == 0 && m&RoleReserved == 0
}

// AtMostOneRuntimeSGXRole returns true when RoleMask has at most one SGX runtime role.
func (m RolesMask) AtMostOneRuntimeSGXRole() bool {
sgxRoles := m & (RoleComputeWorker | RoleObserver | RoleKeyManager)
if sgxRoles.IsEmptyRole() {
return true
}
if sgxRoles.IsSingleRole() {
return true
}
return false
}

func (m RolesMask) String() string {
if m&RoleReserved != 0 {
return "[invalid roles]"
Expand Down Expand Up @@ -332,7 +344,7 @@ func (n *Node) UnmarshalCBOR(data []byte) error {
}

// ValidateBasic performs basic descriptor validity checks.
func (n *Node) ValidateBasic(strictVersion bool) error {
func (n *Node) ValidateBasic(strictVersion bool, isFeatureVersion242 bool) error {
Copy link
Contributor Author

@martintomazic martintomazic Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: There is not such thing as ValidateBasic. Only Validate with clear invariants and tests for the invariants the Validate will check. Moreover, the validation params should probably be passes as part of the validation options struct. Possibly this check could also be part of the VerifyRegisterNodeArgs directly which avoids some additional changes, but feels off there.

v := n.Versioned.V
switch strictVersion {
case true:
Expand Down Expand Up @@ -366,6 +378,13 @@ func (n *Node) ValidateBasic(strictVersion bool) error {
return fmt.Errorf("invalid role specified")
}

// Make sure a node can have at most one runtime SGX role.
if isFeatureVersion242 {
if !n.Roles.AtMostOneRuntimeSGXRole() {
return fmt.Errorf("multiple runtime SGX roles (roles: %s)", n.Roles)
}
}

return nil
}

Expand Down Expand Up @@ -574,7 +593,7 @@ func HashRAK(rak signature.PublicKey) hash.Hash {
}

// Verify verifies the node's TEE capabilities, at the provided timestamp and height.
func (c *CapabilityTEE) Verify(teeCfg *TEEFeatures, ts time.Time, height uint64, constraints []byte, nodeID signature.PublicKey, isFeatureVersion242 bool) error {
func (c *CapabilityTEE) Verify(teeCfg *TEEFeatures, ts time.Time, height uint64, constraints []byte, nodeID signature.PublicKey, nodeRoles RolesMask, isFeatureVersion242 bool) error {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: All this functions should accept validation options structs?

switch c.Hardware {
case TEEHardwareIntelSGX:
// Parse SGX remote attestation.
Expand All @@ -596,7 +615,7 @@ func (c *CapabilityTEE) Verify(teeCfg *TEEFeatures, ts time.Time, height uint64,
}

// Verify SGX remote attestation.
return sa.Verify(teeCfg, ts, height, &sc, c.RAK, c.REK, nodeID)
return sa.Verify(teeCfg, ts, height, &sc, c.RAK, c.REK, nodeID, nodeRoles)
default:
return ErrInvalidTEEHardware
}
Expand Down
42 changes: 37 additions & 5 deletions go/common/node/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,11 @@ func TestReservedRoles(t *testing.T) {
Versioned: cbor.NewVersioned(LatestNodeDescriptorVersion),
Roles: 0xFFFFFFFF,
}
err := n.ValidateBasic(false)
err := n.ValidateBasic(false, false)
require.Error(err, "ValidateBasic should fail for reserved roles")

n.Roles = 0
err = n.ValidateBasic(false)
err = n.ValidateBasic(false, false)
require.Error(err, "ValidateBasic should fail for empty roles")
}

Expand All @@ -139,7 +139,7 @@ func TestNodeDescriptorV2(t *testing.T) {
Versioned: cbor.NewVersioned(2),
Roles: RoleComputeWorker | roleReserved3,
}
require.Error(v1.ValidateBasic(false), "V1 descriptors should not be allowed anymore")
require.Error(v1.ValidateBasic(false, false), "V1 descriptors should not be allowed anymore")

v2 := nodeV2{
Versioned: cbor.NewVersioned(2),
Expand All @@ -156,7 +156,7 @@ func TestNodeDescriptorV2(t *testing.T) {
err := cbor.Unmarshal(raw, &v3)
require.NoError(err, "cbor.Unmarshal")

err = v3.ValidateBasic(false)
err = v3.ValidateBasic(false, false)
require.NoError(err, "ValidateBasic")
require.True(v3.HasRoles(RoleComputeWorker))
require.False(v3.HasRoles(roleReserved3))
Expand All @@ -170,7 +170,7 @@ func TestNodeDescriptorV2(t *testing.T) {
err = cbor.Unmarshal(raw, &v3)
require.NoError(err, "cbor.Unmarshal")

err = v3.ValidateBasic(false)
err = v3.ValidateBasic(false, false)
require.NoError(err, "ValidateBasic")
require.True(v3.HasRoles(RoleComputeWorker))
require.False(v3.HasRoles(roleReserved3))
Expand Down Expand Up @@ -468,6 +468,38 @@ func TestNodeDeserialization(t *testing.T) {
}
}

func TestAtMostOneRuntimeSGXRole(t *testing.T) {
require := require.New(t)

for _, tc := range []struct {
name string
roles RolesMask
want bool
}{
// Valid: no SGX roles.
{"validator only", RoleValidator, true},
{"validator + storage-rpc", RoleValidator | RoleStorageRPC, true},
// Valid: single SGX role.
{"compute only", RoleComputeWorker, true},
{"observer only", RoleObserver, true},
{"key manager only", RoleKeyManager, true},
{"empty", RoleEmpty, true},
// Valid: non-SGX roles are ignored.
{"compute + validator", RoleComputeWorker | RoleValidator, true},
{"key manager + validator", RoleKeyManager | RoleValidator, true},
{"compute + storage-rpc", RoleComputeWorker | RoleStorageRPC, true},
{"compute + validator + storage-rpc", RoleComputeWorker | RoleValidator | RoleStorageRPC, true},
// Invalid: multiple SGX roles.
{"compute + observer", RoleComputeWorker | RoleObserver, false},
{"compute + key manager", RoleComputeWorker | RoleKeyManager, false},
{"compute + key manager + validator", RoleComputeWorker | RoleKeyManager | RoleValidator, false},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(tc.want, tc.roles.AtMostOneRuntimeSGXRole())
})
}
}

func TestNodeSoftwareVersion(t *testing.T) {
require := require.New(t)

Expand Down
75 changes: 65 additions & 10 deletions go/common/node/sgx.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,17 @@ type SGXConstraints struct {
// Enclaves is the allowed MRENCLAVE/MRSIGNER pairs.
Enclaves []sgx.EnclaveIdentity `json:"enclaves,omitempty"`

// Policy is the quote policy.
// Policy is the default quote policy. The default policy must be satisfied
// unless there exists a corresponding per-role policy.
Policy *quote.Policy `json:"policy,omitempty"`

// PerRolePolicy defines additional role specific quote policies, that overwrite
// Policy when node with these roles does an attestation.
//
// A valid entry is for either [RoleComputeWorker] or [RoleObserver]. Single entry
// should not encode multiple roles.
PerRolePolicy map[RolesMask]quote.Policy `json:"per_role_policy,omitempty"`
Comment on lines +39 to +44
Copy link
Contributor Author

@martintomazic martintomazic Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that:

  • We only allow it for observer and compute roles
  • Keymanager runtime kind does not allow it
  • We don't see discriminating observer/compute access, as they both have access to same secrets,

How about simplifying things further and instead have ComputePolicy *quote.Policy that only applies for the compute runtimes for the observer/compute roles.

This should simplify assumptions, tests, comments, validations and avoid the need for new "at most one runtime sgx role" invariant.


// MaxAttestationAge is the maximum attestation age (in blocks).
MaxAttestationAge uint64 `json:"max_attestation_age,omitempty"`
}
Expand Down Expand Up @@ -111,23 +119,63 @@ func (sc *SGXConstraints) ValidateBasic(cfg *TEEFeatures, isFeatureVersion242 bo
return fmt.Errorf("unsupported SGX constraints version: %d", sc.V)
}

if sc.Policy == nil {
return nil
validatePolicy := func(policy *quote.Policy) error {
if policy == nil {
return nil
}

// Check for TDX enablement.
if !cfg.SGX.TDX && policy.PCS != nil && policy.PCS.TDX != nil {
return fmt.Errorf("TDX policy not supported")
}

// Check that policy is compliant with the current feature version.
return policy.Validate(isFeatureVersion242)
}

validatePerRolePolicyEntry := func(role RolesMask, policy quote.Policy) error {
if !role.IsSingleRole() {
return fmt.Errorf("quote policies should have a single role")
}
if role != RoleComputeWorker && role != RoleObserver {
return fmt.Errorf("invalid role: only compute or observer role allowed")
}
if policy.IAS != nil {
return fmt.Errorf("invalid policy: IAS not allowed")
}
return validatePolicy(&policy)
}

// Check for TDX enablement.
if !cfg.SGX.TDX && sc.Policy.PCS != nil && sc.Policy.PCS.TDX != nil {
return fmt.Errorf("TDX policy not supported")
if err := validatePolicy(sc.Policy); err != nil {
return fmt.Errorf("invalid default policy: %w", err)
}

// Check that policy is compliant with the current feature version.
if err := sc.Policy.Validate(isFeatureVersion242); err != nil {
return fmt.Errorf("invalid policy: %w", err)
if !isFeatureVersion242 && sc.PerRolePolicy != nil {
return fmt.Errorf("per role policy should be nil until feature version 24.2")
}

for role, policy := range sc.PerRolePolicy {
if err := validatePerRolePolicyEntry(role, policy); err != nil {
return fmt.Errorf("invalid per role policy entry (role: %s): %w", role, err)
}
}

return nil
}

// PolicyFor returns a matching per-role policy when present, or otherwise falls back to the default policy.
//
// This function expects role mask that has at most one runtime SGX role.
func (sc *SGXConstraints) PolicyFor(roles RolesMask) *quote.Policy {
for role, policy := range sc.PerRolePolicy {
if role&roles == 0 {
continue
}
return &policy
}
return sc.Policy
}

// ContainsEnclave returns true iff the allowed enclave list in SGX constraints contain the given
// enclave identity.
func (sc *SGXConstraints) ContainsEnclave(eid sgx.EnclaveIdentity) bool {
Expand Down Expand Up @@ -223,16 +271,23 @@ func (sa *SGXAttestation) Verify(
rak signature.PublicKey,
rek *x25519.PublicKey,
nodeID signature.PublicKey,
nodeRoles RolesMask,
) error {
if cfg == nil {
cfg = &emptyFeatures
}

// Use defaults from consensus parameters.
// TODO: Handle default constraints overwrite consistently.
// See https://github.com/oasisprotocol/oasis-core/issues/6459.
cfg.SGX.ApplyDefaultConstraints(sc)

// Prior to 24.2 nodeRoles might have multiple roles, but as per-role policies are guaranteed
// to be empty this works fine.
policy := sc.PolicyFor(nodeRoles)

// Verify the quote.
verifiedQuote, err := sa.Quote.Verify(sc.Policy, ts)
verifiedQuote, err := sa.Quote.Verify(policy, ts)
if err != nil {
return err
}
Expand Down
Loading
Loading