From 9d605ced442db861fe01d3fc2dba92b47780162e Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Mon, 24 Nov 2025 20:29:39 +0100 Subject: [PATCH 1/7] go/common/sgx/quote: Add new apply default policy method Observe that already prior to this commit consensus and runtime quote verification with regards to runtime constaints were not symetric. Consensus part in addition to the runtime constrains may also apply default constaints from the consensus parameters. The runtime part on the other hand only applies runtime constraints. Currently, this is not problematic as as default policy as per the consensus parameters is nil, but this may change in the future. Nit: I would change to sc.ApplyDefaultConstraints(cfg.SGX), as normaly the mutated part should be pointer receiver. --- go/common/node/tee.go | 13 +------------ go/common/sgx/quote/quote.go | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/go/common/node/tee.go b/go/common/node/tee.go index 71acb0a84c6..5aff596b640 100644 --- a/go/common/node/tee.go +++ b/go/common/node/tee.go @@ -34,18 +34,7 @@ type TEEFeaturesSGX struct { // ApplyDefaultConstraints applies configured SGX constraint defaults to the given structure. func (fs *TEEFeaturesSGX) ApplyDefaultConstraints(sc *SGXConstraints) { - // Default policy. - if fs.DefaultPolicy != nil { - if sc.Policy == nil { - sc.Policy = "e.Policy{} - } - if sc.Policy.IAS == nil { - sc.Policy.IAS = fs.DefaultPolicy.IAS - } - if sc.Policy.PCS == nil && fs.PCS { - sc.Policy.PCS = fs.DefaultPolicy.PCS - } - } + sc.Policy = sc.Policy.ApplyDefault(fs.DefaultPolicy, fs.PCS) // Default maximum attestation age. if sc.MaxAttestationAge == 0 { diff --git a/go/common/sgx/quote/quote.go b/go/common/sgx/quote/quote.go index 0441987e51d..32ab4d4ad87 100644 --- a/go/common/sgx/quote/quote.go +++ b/go/common/sgx/quote/quote.go @@ -81,3 +81,22 @@ func (p *Policy) Validate(isFeatureVersion242 bool) error { return fmt.Errorf("fmspc whitelist should be empty") } + +func (p *Policy) ApplyDefault(d *Policy, pcsEnabled bool) *Policy { + if d == nil { + return p + } + + if p == nil { + p = &Policy{} + } + + if p.IAS == nil { + p.IAS = d.IAS + } + if p.PCS == nil && pcsEnabled { + p.PCS = d.PCS + } + + return p +} From 2cdd42ad89b01a703b641244d0134ab0f4501569 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Tue, 13 Jan 2026 14:12:51 +0100 Subject: [PATCH 2/7] go/common/node: Fix possible nil dereference --- go/common/node/sgx.go | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/go/common/node/sgx.go b/go/common/node/sgx.go index 9b188cbe5d7..2a946fec776 100644 --- a/go/common/node/sgx.go +++ b/go/common/node/sgx.go @@ -111,14 +111,20 @@ func (sc *SGXConstraints) ValidateBasic(cfg *TEEFeatures, isFeatureVersion242 bo return fmt.Errorf("unsupported SGX constraints version: %d", sc.V) } - // Check for TDX enablement. - if !cfg.SGX.TDX && sc.Policy.PCS != nil && sc.Policy.PCS.TDX != nil { - return fmt.Errorf("TDX policy not supported") + validatePolicy := func(policy *quote.Policy) error { + // Check for TDX enablement. + if !cfg.SGX.TDX && policy.PCS != nil && policy.PCS.TDX != nil { + return fmt.Errorf("TDX policy not supported") + } + if err := policy.Validate(isFeatureVersion242); err != nil { + return fmt.Errorf("invalid policy: %w", err) + } + return nil } - // Check that policy is compliant with the current feature version. + // Check default policy. if sc.Policy != nil { - if err := sc.Policy.Validate(isFeatureVersion242); err != nil { + if err := validatePolicy(sc.Policy); err != nil { return fmt.Errorf("invalid policy: %w", err) } } From df66a06234173389cbb15bdfc95a12c762c7c889 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Mon, 12 Jan 2026 22:43:25 +0100 Subject: [PATCH 3/7] go/common/sgx/pcs: Add new Merge method to QuotePolicy --- go/common/sgx/pcs/policy.go | 83 +++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/go/common/sgx/pcs/policy.go b/go/common/sgx/pcs/policy.go index ba846fadacc..7a9681eb054 100644 --- a/go/common/sgx/pcs/policy.go +++ b/go/common/sgx/pcs/policy.go @@ -2,6 +2,8 @@ package pcs import ( "fmt" + "maps" + "slices" ) // QuotePolicy is the quote validity policy. @@ -28,6 +30,68 @@ type QuotePolicy struct { TDX *TdxQuotePolicy `json:"tdx,omitempty" yaml:"tdx,omitempty"` } +// Merge merges two QuotePolicies into one, taking more restrictive configuration into account. +// +// TODO: +// - What if FMSCPWhitelist has no intersection (same applies for TDXQuotePolicy)? +// - Should we even allow registration of such runtime descriptor? +// - Finish TDX quote policy merge (ugly). +// - Unit tests. +// - Merge should produce independent copies to not accidentally mutate stuff. +func (p *QuotePolicy) Merge(o *QuotePolicy) *QuotePolicy { + if p == nil { + return o + } + + if o == nil { + return p + } + + merged := &QuotePolicy{ + Disabled: p.Disabled || o.Disabled, + TCBValidityPeriod: min(p.TCBValidityPeriod, o.TCBValidityPeriod), + MinTCBEvaluationDataNumber: max(p.MinTCBEvaluationDataNumber, o.MinTCBEvaluationDataNumber), + } + + func() { + if len(p.FMSPCWhitelist) == 0 { + merged.FMSPCWhitelist = o.FMSPCWhitelist + return + } + + if len(o.FMSPCWhitelist) == 0 { + merged.FMSPCWhitelist = p.FMSPCWhitelist + return + } + + intersect := make(map[string]struct{}, len(p.FMSPCWhitelist)) + for _, fmspc := range p.FMSPCWhitelist { + intersect[fmspc] = struct{}{} + } + for _, fmspc := range o.FMSPCWhitelist { + if _, ok := intersect[fmspc]; ok { + merged.FMSPCWhitelist = append(merged.FMSPCWhitelist, fmspc) + } + } + + // Preventing no intersection meaning allow any. + if len(merged.FMSPCWhitelist) == 0 { + merged.Disabled = true + } + + }() + + union := make(map[string]struct{}, len(p.FMSPCBlacklist)+len(o.FMSPCBlacklist)) + for _, fmspc := range append(p.FMSPCBlacklist, o.FMSPCBlacklist...) { + union[fmspc] = struct{}{} + } + merged.FMSPCBlacklist = slices.Collect(maps.Keys(union)) + slices.Sort(merged.FMSPCBlacklist) + + merged.TDX = p.TDX.Merge(o.TDX) + return merged +} + // TdxQuotePolicy is the TDX-specific quote policy. type TdxQuotePolicy struct { // AllowedTdxModules are the allowed TDX modules. Empty to allow ANY Intel-signed module. @@ -56,6 +120,25 @@ func (tp *TdxQuotePolicy) verifyTdxModule(report *TdReport) error { return fmt.Errorf("pcs/quote: TDX module not allowed") } +func (tp *TdxQuotePolicy) Merge(o *TdxQuotePolicy) *TdxQuotePolicy { + if tp == nil || o == nil { + return nil + } + + if len(tp.AllowedTdxModules) == 0 { + return o + } + + if len(o.AllowedTdxModules) == 0 { + return tp + } + + // TODO + // Merge the TDXQuotePolicy. + + return o +} + // TDX_MrSigner_Intel is the TDX module MRSIGNER for Intel (000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000). var TDX_MrSigner_Intel [48]byte // nolint: revive From a6c973609c6ef30e0aa743b015c4c7c0e2f77a49 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Mon, 24 Nov 2025 21:42:03 +0100 Subject: [PATCH 4/7] go: Add support for per-role quote policies --- .changelog/6387.breaking.md | 4 ++ go/common/node/node.go | 4 +- go/common/node/sgx.go | 50 +++++++++++++++++-- go/common/node/tee.go | 7 +++ .../apps/keymanager/secrets/status.go | 6 +-- .../cometbft/apps/scheduler/scheduler.go | 2 +- .../cmd/debug/byzantine/steps_test.go | 7 ++- go/registry/api/api.go | 8 +-- go/runtime/host/sgx/common/common.go | 10 ++++ go/runtime/registry/notifier_policy.go | 8 +-- 10 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 .changelog/6387.breaking.md diff --git a/.changelog/6387.breaking.md b/.changelog/6387.breaking.md new file mode 100644 index 00000000000..b1d440b6547 --- /dev/null +++ b/.changelog/6387.breaking.md @@ -0,0 +1,4 @@ +Support per-role quote policies + +This enables a more relaxed general policy that all attestations must satisy +but a stricter policies for the nodes that can access the key manager. diff --git a/go/common/node/node.go b/go/common/node/node.go index 27bd22bbd8b..5303891627a 100644 --- a/go/common/node/node.go +++ b/go/common/node/node.go @@ -574,7 +574,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, n *Node, isFeatureVersion242 bool) error { switch c.Hardware { case TEEHardwareIntelSGX: // Parse SGX remote attestation. @@ -596,7 +596,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, n) default: return ErrInvalidTEEHardware } diff --git a/go/common/node/sgx.go b/go/common/node/sgx.go index 2a946fec776..14e44046060 100644 --- a/go/common/node/sgx.go +++ b/go/common/node/sgx.go @@ -32,9 +32,12 @@ type SGXConstraints struct { // Enclaves is the allowed MRENCLAVE/MRSIGNER pairs. Enclaves []sgx.EnclaveIdentity `json:"enclaves,omitempty"` - // Policy is the quote policy. + // Policy is the quote policy that all attestations must satisfy. Policy *quote.Policy `json:"policy,omitempty"` + // PerRolePolicy defines additional role specific quote policies. + PerRolePolicy map[RolesMask]*quote.Policy `json:"per_role_policy,omitempty"` + // MaxAttestationAge is the maximum attestation age (in blocks). MaxAttestationAge uint64 `json:"max_attestation_age,omitempty"` } @@ -129,6 +132,22 @@ func (sc *SGXConstraints) ValidateBasic(cfg *TEEFeatures, isFeatureVersion242 bo } } + // Check per-role policies. + if !isFeatureVersion242 && sc.PerRolePolicy != nil { + return fmt.Errorf("per role policy should be empty") + } + for role, policy := range sc.PerRolePolicy { + if !role.IsSingleRole() { + return fmt.Errorf("per-role quote policies should have a single role") + } + if policy == nil { + return fmt.Errorf("per-role policy should not be nil") + } + if err := validatePolicy(policy); err != nil { + return fmt.Errorf("invalid policy (role: %s): %w", role, err) + } + } + return nil } @@ -138,6 +157,25 @@ func (sc *SGXConstraints) ContainsEnclave(eid sgx.EnclaveIdentity) bool { return slices.Contains(sc.Enclaves, eid) } +// EffectivePolicy returns a combined policy. The combined policy may in addition to the default policy, +// (depending on the specified roles) also include additional per-role policies. +func (sc *SGXConstraints) EffectivePolicy(roles RolesMask) *quote.Policy { + effectivePolicy := sc.Policy // TODO: Make this a deep copy or better yet ensure Merge produces it. + if effectivePolicy == nil { + effectivePolicy = "e.Policy{} + } + + for role, policy := range sc.PerRolePolicy { + if role&roles == 0 { + continue + } + // We are ignoring deprecated IAS part, possibly we could error if if set twice. + effectivePolicy.PCS = effectivePolicy.PCS.Merge(policy.PCS) + } + + return effectivePolicy +} + const ( // LatestSGXAttestationVersion is the latest SGX attestation structure version that should be // used for all new descriptors. @@ -219,6 +257,7 @@ func (sa *SGXAttestation) ValidateBasic(cfg *TEEFeatures) error { } // Verify verifies the SGX attestation. +// TODO: Extensive test suite for this function that acts as integration test (consider mocking). func (sa *SGXAttestation) Verify( cfg *TEEFeatures, ts time.Time, @@ -226,7 +265,7 @@ func (sa *SGXAttestation) Verify( sc *SGXConstraints, rak signature.PublicKey, rek *x25519.PublicKey, - nodeID signature.PublicKey, + n *Node, ) error { if cfg == nil { cfg = &emptyFeatures @@ -235,8 +274,9 @@ func (sa *SGXAttestation) Verify( // Use defaults from consensus parameters. cfg.SGX.ApplyDefaultConstraints(sc) - // Verify the quote. - verifiedQuote, err := sa.Quote.Verify(sc.Policy, ts) + // Verify the quote againt the effective policy. + policy := sc.EffectivePolicy(n.Roles) + verifiedQuote, err := sa.Quote.Verify(policy, ts) if err != nil { return err } @@ -260,7 +300,7 @@ func (sa *SGXAttestation) Verify( if cfg.SGX.SignedAttestations { // In case the signed attestation feature is enabled, verify the signature. - return sa.verifyAttestationSignature(sc, rak, rek, verifiedQuote.ReportData, nodeID, height) + return sa.verifyAttestationSignature(sc, rak, rek, verifiedQuote.ReportData, n.ID, height) } return nil diff --git a/go/common/node/tee.go b/go/common/node/tee.go index 5aff596b640..a51c7775747 100644 --- a/go/common/node/tee.go +++ b/go/common/node/tee.go @@ -36,6 +36,13 @@ type TEEFeaturesSGX struct { func (fs *TEEFeaturesSGX) ApplyDefaultConstraints(sc *SGXConstraints) { sc.Policy = sc.Policy.ApplyDefault(fs.DefaultPolicy, fs.PCS) + for role, policy := range sc.PerRolePolicy { + if policy == nil { + continue + } + sc.PerRolePolicy[role] = policy.ApplyDefault(fs.DefaultPolicy, fs.PCS) + } + // Default maximum attestation age. if sc.MaxAttestationAge == 0 { sc.MaxAttestationAge = fs.DefaultMaxAttestationAge diff --git a/go/consensus/cometbft/apps/keymanager/secrets/status.go b/go/consensus/cometbft/apps/keymanager/secrets/status.go index fc978655dcd..cd269597e28 100644 --- a/go/consensus/cometbft/apps/keymanager/secrets/status.go +++ b/go/consensus/cometbft/apps/keymanager/secrets/status.go @@ -112,7 +112,7 @@ nextNode: continue nextNode } - initResponse, err := VerifyExtraInfo(ctx.Logger(), n.ID, kmrt, nodeRt, ts, height, params, isFeatureVersion242) + initResponse, err := VerifyExtraInfo(ctx.Logger(), n, kmrt, nodeRt, ts, height, params, isFeatureVersion242) if err != nil { ctx.Logger().Error("failed to validate ExtraInfo", append(vars, "err", err)...) continue nextNode @@ -226,7 +226,7 @@ nextNode: // blob for a key manager. func VerifyExtraInfo( logger *logging.Logger, - nodeID signature.PublicKey, + n *node.Node, rt *registry.Runtime, nodeRt *node.Runtime, ts time.Time, @@ -234,7 +234,7 @@ func VerifyExtraInfo( params *registry.ConsensusParameters, isFeatureVersion242 bool, ) (*secrets.InitResponse, error) { - if err := registry.VerifyNodeRuntimeEnclaveIDs(logger, nodeID, nodeRt, rt, params.TEEFeatures, ts, height, isFeatureVersion242); err != nil { + if err := registry.VerifyNodeRuntimeEnclaveIDs(logger, n, nodeRt, rt, params.TEEFeatures, ts, height, isFeatureVersion242); err != nil { return nil, err } if nodeRt.ExtraInfo == nil { diff --git a/go/consensus/cometbft/apps/scheduler/scheduler.go b/go/consensus/cometbft/apps/scheduler/scheduler.go index 7f354e152ad..cb450e036b1 100644 --- a/go/consensus/cometbft/apps/scheduler/scheduler.go +++ b/go/consensus/cometbft/apps/scheduler/scheduler.go @@ -412,7 +412,7 @@ func isSuitableExecutorWorker( ctx.Now(), uint64(ctx.LastHeight()), activeDeployment.TEE, - n.node.ID, + n.node, isFeatureVersion242, ); err != nil { ctx.Logger().Warn("failed to verify node TEE attestation", diff --git a/go/oasis-node/cmd/debug/byzantine/steps_test.go b/go/oasis-node/cmd/debug/byzantine/steps_test.go index 38e1afb7645..957c6e8b709 100644 --- a/go/oasis-node/cmd/debug/byzantine/steps_test.go +++ b/go/oasis-node/cmd/debug/byzantine/steps_test.go @@ -7,15 +7,14 @@ import ( "github.com/stretchr/testify/require" "github.com/oasisprotocol/oasis-core/go/common/cbor" - "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/node" "github.com/oasisprotocol/oasis-core/go/common/sgx" "github.com/oasisprotocol/oasis-core/go/common/sgx/ias" ) func TestFakeCapabilitySGX(t *testing.T) { - var nodeID signature.PublicKey - _, fakeCapabilitiesSGX, err := initFakeCapabilitiesSGX(nodeID) + var n node.Node + _, fakeCapabilitiesSGX, err := initFakeCapabilitiesSGX(n.ID) require.NoError(t, err, "initFakeCapabilitiesSGX failed") cs := cbor.Marshal(node.SGXConstraints{ @@ -31,5 +30,5 @@ func TestFakeCapabilitySGX(t *testing.T) { ias.SetSkipVerify() ias.SetAllowDebugEnclaves() - require.NoError(t, fakeCapabilitiesSGX.TEE.Verify(&teeCfg, time.Now(), 1, cs, nodeID, true), "fakeCapabilitiesSGX not valid") + require.NoError(t, fakeCapabilitiesSGX.TEE.Verify(&teeCfg, time.Now(), 1, cs, &n, true), "fakeCapabilitiesSGX not valid") } diff --git a/go/registry/api/api.go b/go/registry/api/api.go index 2811a120fac..e31209f1ae8 100644 --- a/go/registry/api/api.go +++ b/go/registry/api/api.go @@ -619,7 +619,7 @@ func VerifyRegisterNodeArgs( // nolint: gocyclo // both validators and compute nodes and have out of date attestation evidence. Removing // such nodes could lead to consensus not having the proper majority. This is safe as // attestation evidence is independently verified before scheduling committees. - if err := VerifyNodeRuntimeEnclaveIDs(logger, n.ID, rt, regRt, params.TEEFeatures, now, height, isFeatureVersion242); err != nil && !isSanityCheck && !isGenesis { + if err := VerifyNodeRuntimeEnclaveIDs(logger, &n, rt, regRt, params.TEEFeatures, now, height, isFeatureVersion242); err != nil && !isSanityCheck && !isGenesis { return nil, nil, err } @@ -795,7 +795,7 @@ func VerifyRegisterNodeArgs( // nolint: gocyclo // VerifyNodeRuntimeEnclaveIDs verifies TEE-specific attributes of the node's runtime. func VerifyNodeRuntimeEnclaveIDs( logger *logging.Logger, - nodeID signature.PublicKey, + n *node.Node, rt *node.Runtime, regRt *Runtime, teeCfg *node.TEEFeatures, @@ -830,9 +830,9 @@ func VerifyNodeRuntimeEnclaveIDs( continue } - if err := rt.Capabilities.TEE.Verify(teeCfg, ts, height, rtVersionInfo.TEE, nodeID, isFeatureVersion242); err != nil { + if err := rt.Capabilities.TEE.Verify(teeCfg, ts, height, rtVersionInfo.TEE, n, isFeatureVersion242); err != nil { logger.Error("VerifyNodeRuntimeEnclaveIDs: failed to validate attestation", - "node_id", nodeID, + "node_id", n.ID, "runtime_id", rt.ID, "ts", ts, "err", err, diff --git a/go/runtime/host/sgx/common/common.go b/go/runtime/host/sgx/common/common.go index 51568a842b1..6bfcea757cd 100644 --- a/go/runtime/host/sgx/common/common.go +++ b/go/runtime/host/sgx/common/common.go @@ -48,6 +48,16 @@ func GetQuotePolicy( return nil, fmt.Errorf("malformed runtime SGX constraints: %w", err) } + // TODO: We should return effective quote policy here. This function is called when node + // is creating a TCBBundle. Technically attestation will be verified on both consensus and + // runtime site but we want to catch problems early-on to avoid failed transaction. + // + // Crux: host config does not have access to the registration worker (role providers) roles. + // + // Possible solutions: + // 1. Map config to consensus registry roles(modes &| configured identity &| consesus.validator |& storage.public_rpc_enabled). + // 2. Delay this validation and call it from the registration worker just before submitting registration with attestation. + // 3. Somehow pass role provider roles here. return sc.Policy, nil } return fallbackPolicy, nil diff --git a/go/runtime/registry/notifier_policy.go b/go/runtime/registry/notifier_policy.go index 8c166da2f47..960916978ff 100644 --- a/go/runtime/registry/notifier_policy.go +++ b/go/runtime/registry/notifier_policy.go @@ -165,9 +165,11 @@ func (n *KeyManagerNotifier) watchKmPolicyUpdates(ctx context.Context, kmRtID *c // Make sure that we actually have a new quote policy and that the current runtime version // supports quote policy updates. - if !quotePolicyUpdated && sc != nil && sc.Policy != nil { - n.updateKeyManagerQuotePolicy(sc.Policy) - quotePolicyUpdated = true + if !quotePolicyUpdated && sc != nil { // Possible nil vs empty semantic change (check if it matters). + if policy := sc.EffectivePolicy(node.RoleKeyManager); policy != nil { + n.updateKeyManagerQuotePolicy(policy) + quotePolicyUpdated = true + } } select { From 8489f61634604c5a966d7e52322f1fbbec471489 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Tue, 13 Jan 2026 11:13:10 +0100 Subject: [PATCH 5/7] runtime/src: Prototype per-role quote policies --- runtime/src/attestation.rs | 5 +++-- runtime/src/consensus/registry.rs | 13 ++++++++++++- runtime/src/dispatcher.rs | 16 +++++++++++++++- runtime/src/enclave_rpc/session.rs | 1 + runtime/src/policy.rs | 14 ++++++++------ 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/runtime/src/attestation.rs b/runtime/src/attestation.rs index 9098d582b83..3f9ddcc0433 100644 --- a/runtime/src/attestation.rs +++ b/runtime/src/attestation.rs @@ -11,7 +11,7 @@ use crate::{ sgx::Quote, version::Version, }, consensus::{ - registry::{EndorsedCapabilityTEE, SGXAttestation, ATTESTATION_SIGNATURE_CONTEXT}, + registry::{EndorsedCapabilityTEE, RolesMask, SGXAttestation, ATTESTATION_SIGNATURE_CONTEXT}, verifier::Verifier, }, host::Host, @@ -115,7 +115,8 @@ impl Handler { let runtime_id = self.runtime_id; tokio::task::block_in_place(move || { // Obtain current quote policy from (verified) consensus state. - PolicyVerifier::new(consensus_verifier).quote_policy(&runtime_id, Some(version)) + PolicyVerifier::new(consensus_verifier) + .effective_quote_policy(&runtime_id, Some(version), RolesMask::ROLE_EMPTY) })? }; diff --git a/runtime/src/consensus/registry.rs b/runtime/src/consensus/registry.rs index 50f03541731..82e56677ca2 100644 --- a/runtime/src/consensus/registry.rs +++ b/runtime/src/consensus/registry.rs @@ -699,10 +699,14 @@ pub enum SGXConstraints { #[cbor(optional)] enclaves: Vec, - /// The quote policy. + /// Default quote policy. #[cbor(optional)] policy: sgx::QuotePolicy, + /// Additional per-role quote policies. + #[cbor(optional)] + per_role_policy: BTreeMap, + /// The maximum attestation age (in blocks). #[cbor(optional)] max_attestation_age: u64, @@ -741,6 +745,13 @@ impl SGXConstraints { Self::V1 { ref policy, .. } => policy.clone(), } } + + /// Effective SGX quote policy for the given roles. + /// + /// TODO: Implement effective policy and underyling merging. + pub fn effective_policy(&self, roles: RolesMask) -> sgx::QuotePolicy { + self.policy() + } } /// Verified remote attestation. diff --git a/runtime/src/dispatcher.rs b/runtime/src/dispatcher.rs index 3d1baade1b2..a51f523fddc 100644 --- a/runtime/src/dispatcher.rs +++ b/runtime/src/dispatcher.rs @@ -21,6 +21,7 @@ use crate::{ }, consensus::{ beacon::EpochTime, + registry::RolesMask, roothash::{self, ComputeResultsHeader, Header, COMPUTE_RESULTS_HEADER_SIGNATURE_CONTEXT}, state::keymanager::Status as KeyManagerStatus, verifier::Verifier, @@ -988,12 +989,25 @@ impl Dispatcher { // Verify and decode the policy. let runtime_id = state.protocol.get_host_info().runtime_id; + // TODO: Why are we passing the quote policy from the host given that it needs to be fetched + // using the consensus verifier anyways. This could be signal only (empty struct)? It would be breaking + // to change the signature now, but technically we could ignore it. + // + // Alternative, would be to change the host callsite to only dummy policy, ignore it here and fetch other policies here. + // This way we could get rid of effective policy all-together and keep passing slices of policies, but this would change + // many function signatures, including sessions update policy, so not sure we profit at all. + tokio::task::spawn_blocking(move || -> Result<(), Error> { let key_manager = state.policy_verifier.key_manager(&runtime_id)?; let policy = state .policy_verifier - .verify_quote_policy(quote_policy, &key_manager, None)?; + .verify_quote_policy( + quote_policy, + &key_manager, + None, + RolesMask::ROLE_KEY_MANAGER, + )?; // Dispatch the local RPC call. state.rpc_dispatcher.handle_km_quote_policy_update(policy); diff --git a/runtime/src/enclave_rpc/session.rs b/runtime/src/enclave_rpc/session.rs index 97f447a263e..76691908ba3 100644 --- a/runtime/src/enclave_rpc/session.rs +++ b/runtime/src/enclave_rpc/session.rs @@ -245,6 +245,7 @@ impl Session { return Ok(None); } + // TODO: Ensure correctly propagated per-role policy is in the session config. let policy = self .cfg .policy diff --git a/runtime/src/policy.rs b/runtime/src/policy.rs index b126be68532..3aea831ecd5 100644 --- a/runtime/src/policy.rs +++ b/runtime/src/policy.rs @@ -10,7 +10,7 @@ use crate::{ common::{logger::get_logger, namespace::Namespace, sgx::QuotePolicy, version::Version}, consensus::{ keymanager::SignedPolicySGX, - registry::{SGXConstraints, TEEHardware}, + registry::{RolesMask, SGXConstraints, TEEHardware}, state::{ beacon::ImmutableState as BeaconState, keymanager::{ImmutableState as KeyManagerState, Status}, @@ -60,13 +60,14 @@ impl PolicyVerifier { } } - /// Fetch runtime's quote policy from the latest verified consensus layer state. + /// Fetch runtime's effective quote policy from the latest verified consensus layer state. /// /// If the runtime version is not provided, the policy for the active deployment is returned. - pub fn quote_policy( + pub fn effective_quote_policy( &self, runtime_id: &Namespace, version: Option, + roles: RolesMask, ) -> Result { // Fetch quote policy from the consensus layer using the given or the active version. // TODO: Make this async. @@ -95,7 +96,7 @@ impl PolicyVerifier { let sc: SGXConstraints = ad .try_decode_tee() .map_err(|_| PolicyVerifierError::BadTEEConstraints)?; - sc.policy() + sc.effective_policy(roles) } _ => bail!(PolicyVerifierError::HardwareMismatch), }; @@ -103,14 +104,15 @@ impl PolicyVerifier { Ok(policy) } - /// Verify that runtime's quote policy has been published in the consensus layer. + /// Verify that runtime's effective quote policy has been published in the consensus layer. pub fn verify_quote_policy( &self, policy: QuotePolicy, runtime_id: &Namespace, version: Option, + roles: RolesMask, ) -> Result { - let published_policy = self.quote_policy(runtime_id, version)?; + let published_policy = self.effective_quote_policy(runtime_id, version, roles)?; if policy != published_policy { debug!( From 0949ddcc2b4ffb08de063531f9cb41f2984796f0 Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Tue, 13 Jan 2026 11:26:55 +0100 Subject: [PATCH 6/7] Pass roles from the host for the RHP initialization Alternative would be to pass the expected roles during initialization and bypass using host protocol. --- go/runtime/host/protocol/types.go | 2 ++ go/runtime/registry/handler.go | 9 +++++++-- runtime/src/attestation.rs | 11 ++++++++--- runtime/src/consensus/tendermint/verifier/mod.rs | 2 +- runtime/src/host/mod.rs | 9 ++++++--- runtime/src/types.rs | 4 +++- 6 files changed, 27 insertions(+), 10 deletions(-) diff --git a/go/runtime/host/protocol/types.go b/go/runtime/host/protocol/types.go index 2ece8d9f53d..93e52f72adc 100644 --- a/go/runtime/host/protocol/types.go +++ b/go/runtime/host/protocol/types.go @@ -659,4 +659,6 @@ type HostIdentityRequest struct{} type HostIdentityResponse struct { // NodeID is the host node identifier. NodeID signature.PublicKey `json:"node_id"` + // Roles is the host node role mask. + Roles node.RolesMask `json:"roles,omitempty"` } diff --git a/go/runtime/registry/handler.go b/go/runtime/registry/handler.go index 1b059d0633e..7b0cdca50d2 100644 --- a/go/runtime/registry/handler.go +++ b/go/runtime/registry/handler.go @@ -7,6 +7,7 @@ import ( "github.com/oasisprotocol/oasis-core/go/common/cbor" "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/identity" + "github.com/oasisprotocol/oasis-core/go/common/node" consensus "github.com/oasisprotocol/oasis-core/go/consensus/api" "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction" consensusResults "github.com/oasisprotocol/oasis-core/go/consensus/api/transaction/results" @@ -115,7 +116,7 @@ func (h *runtimeHostHandler) Handle(ctx context.Context, rq *protocol.Body) (*pr rsp.HostProveFreshnessResponse, err = h.handleHostProveFreshness(ctx, rq.HostProveFreshnessRequest) case rq.HostIdentityRequest != nil: // Host identity. - rsp.HostIdentityResponse, err = h.handleHostIdentity() + rsp.HostIdentityResponse, err = h.handleHostIdentity(ctx) default: err = fmt.Errorf("method not supported") } @@ -438,13 +439,17 @@ func (h *runtimeHostHandler) handleHostProveFreshness( }, nil } -func (h *runtimeHostHandler) handleHostIdentity() (*protocol.HostIdentityResponse, error) { +func (h *runtimeHostHandler) handleHostIdentity(ctx context.Context) (*protocol.HostIdentityResponse, error) { identity, err := h.env.GetNodeIdentity() if err != nil { return nil, err } + // TODO: Populate roles via environment. Similar problem to common.GetQuotePolicy, i.e. + // we don't have access to role provider and we either have to find a mapping from config to + // the roles we are registering for or find a way to get them. return &protocol.HostIdentityResponse{ NodeID: identity.NodeSigner.Public(), + Roles: node.RoleEmpty, }, nil } diff --git a/runtime/src/attestation.rs b/runtime/src/attestation.rs index 3f9ddcc0433..833ee70b575 100644 --- a/runtime/src/attestation.rs +++ b/runtime/src/attestation.rs @@ -11,7 +11,7 @@ use crate::{ sgx::Quote, version::Version, }, consensus::{ - registry::{EndorsedCapabilityTEE, RolesMask, SGXAttestation, ATTESTATION_SIGNATURE_CONTEXT}, + registry::{EndorsedCapabilityTEE, SGXAttestation, ATTESTATION_SIGNATURE_CONTEXT}, verifier::Verifier, }, host::Host, @@ -113,10 +113,15 @@ impl Handler { let consensus_verifier = self.consensus_verifier.clone(); let version = self.version; let runtime_id = self.runtime_id; + // TODO: Chicken and an egg problem: enclave should verify quote prior + // to being registered, but this means it cannot fetch roles from the consensus, + // meaning it trust the host that it should not? Similar problem is for the + // node_id? + let (_, roles) = self.host.identity().await?; tokio::task::block_in_place(move || { // Obtain current quote policy from (verified) consensus state. PolicyVerifier::new(consensus_verifier) - .effective_quote_policy(&runtime_id, Some(version), RolesMask::ROLE_EMPTY) + .effective_quote_policy(&runtime_id, Some(version), roles) })? }; @@ -138,7 +143,7 @@ impl Handler { ); // Configure the quote and policy on the identity. - let node_id = self.host.identity().await?; + let (node_id, _) = self.host.identity().await?; let verified_quote = self.identity.set_quote(node_id, quote)?; // Sign the report data, latest verified consensus height, REK and host node ID. diff --git a/runtime/src/consensus/tendermint/verifier/mod.rs b/runtime/src/consensus/tendermint/verifier/mod.rs index 2fb65fc057c..0f4ef67135f 100644 --- a/runtime/src/consensus/tendermint/verifier/mod.rs +++ b/runtime/src/consensus/tendermint/verifier/mod.rs @@ -707,7 +707,7 @@ impl Verifier { "trust_root_chain_context" => ?trust_root.chain_context, ); - let host_node_id = + let (host_node_id, _) = block_on(self.protocol.identity()).expect("host should provide a node identity"); let mut cache = Cache::new(host_node_id); diff --git a/runtime/src/host/mod.rs b/runtime/src/host/mod.rs index 3edf8384a66..919917021e9 100644 --- a/runtime/src/host/mod.rs +++ b/runtime/src/host/mod.rs @@ -4,6 +4,7 @@ use thiserror::Error; use crate::{ common::{crypto::signature::PublicKey, namespace::Namespace}, + consensus::registry::RolesMask, enclave_rpc, protocol::Protocol, storage::mkvs::sync, @@ -56,7 +57,7 @@ pub struct TxResult { #[async_trait] pub trait Host: Send + Sync { /// Returns the identity of the host node. - async fn identity(&self) -> Result; + async fn identity(&self) -> Result<(PublicKey, RolesMask), Error>; /// Submit a transaction. async fn submit_tx(&self, data: Vec, opts: SubmitTxOpts) @@ -77,9 +78,11 @@ pub trait Host: Send + Sync { #[async_trait] impl Host for Protocol { - async fn identity(&self) -> Result { + async fn identity(&self) -> Result<(PublicKey, RolesMask), Error> { match self.call_host_async(Body::HostIdentityRequest {}).await? { - Body::HostIdentityResponse { node_id } => Ok(node_id), + Body::HostIdentityResponse { node_id, roles } => { + Ok((node_id, roles.unwrap_or(RolesMask::ROLE_EMPTY))) + } _ => Err(Error::BadResponse), } } diff --git a/runtime/src/types.rs b/runtime/src/types.rs index 1adcf074cc9..61d773da1ca 100644 --- a/runtime/src/types.rs +++ b/runtime/src/types.rs @@ -17,7 +17,7 @@ use crate::{ consensus::{ self, beacon::EpochTime, - registry::EndorsedCapabilityTEE, + registry::{EndorsedCapabilityTEE, RolesMask}, roothash::{self, Block, ComputeResultsHeader, Header}, state::keymanager::Status as KeyManagerStatus, transaction::{Proof, SignedTransaction}, @@ -301,6 +301,8 @@ pub enum Body { HostIdentityRequest {}, HostIdentityResponse { node_id: signature::PublicKey, + #[cbor(optional)] + roles: Option, }, HostSubmitTxRequest { runtime_id: Namespace, From 750e25a1917be2934de9ab69f7f9cd798580cafa Mon Sep 17 00:00:00 2001 From: Martin Tomazic Date: Mon, 24 Nov 2025 21:46:53 +0100 Subject: [PATCH 7/7] go/registry/api: Verify attestation only when needed This verification was redundant, possibly causing confusing logs. --- go/registry/api/api.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go/registry/api/api.go b/go/registry/api/api.go index e31209f1ae8..bdb4ff02694 100644 --- a/go/registry/api/api.go +++ b/go/registry/api/api.go @@ -619,8 +619,10 @@ func VerifyRegisterNodeArgs( // nolint: gocyclo // both validators and compute nodes and have out of date attestation evidence. Removing // such nodes could lead to consensus not having the proper majority. This is safe as // attestation evidence is independently verified before scheduling committees. - if err := VerifyNodeRuntimeEnclaveIDs(logger, &n, rt, regRt, params.TEEFeatures, now, height, isFeatureVersion242); err != nil && !isSanityCheck && !isGenesis { - return nil, nil, err + if !isSanityCheck && !isGenesis { + if err := VerifyNodeRuntimeEnclaveIDs(logger, &n, rt, regRt, params.TEEFeatures, now, height, isFeatureVersion242); err != nil { + return nil, nil, err + } } // Enforce what kinds of runtimes are allowed.