diff --git a/.changelog/5551.feature.md b/.changelog/5551.feature.md index 2b3ccef6dbb..83c6735da98 100644 --- a/.changelog/5551.feature.md +++ b/.changelog/5551.feature.md @@ -1 +1 @@ -go/keymanager/churp: Add create and update methods +go/keymanager/churp: Allow key managers to create/update scheme diff --git a/.changelog/5568.feature.md b/.changelog/5568.feature.md new file mode 100644 index 00000000000..389370577e3 --- /dev/null +++ b/.changelog/5568.feature.md @@ -0,0 +1 @@ +go/keymanager/churp: Allow nodes to apply for a new committee diff --git a/go/consensus/cometbft/apps/keymanager/churp/ext.go b/go/consensus/cometbft/apps/keymanager/churp/ext.go index 08308e72e92..4e7796e3aeb 100644 --- a/go/consensus/cometbft/apps/keymanager/churp/ext.go +++ b/go/consensus/cometbft/apps/keymanager/churp/ext.go @@ -53,6 +53,12 @@ func (ext *churpExt) ExecuteTx(ctx *tmapi.Context, tx *transaction.Transaction) return api.ErrInvalidArgument } return ext.update(ctx, &cfg) + case churp.MethodApply: + var reg churp.SignedApplicationRequest + if err := cbor.Unmarshal(tx.Body, ®); err != nil { + return api.ErrInvalidArgument + } + return ext.apply(ctx, ®) default: panic(fmt.Sprintf("keymanager: churp: invalid method: %s", tx.Method)) } diff --git a/go/consensus/cometbft/apps/keymanager/churp/txs.go b/go/consensus/cometbft/apps/keymanager/churp/txs.go index 50ac753bca7..93c034fcc44 100644 --- a/go/consensus/cometbft/apps/keymanager/churp/txs.go +++ b/go/consensus/cometbft/apps/keymanager/churp/txs.go @@ -4,9 +4,12 @@ import ( "fmt" beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" + "github.com/oasisprotocol/oasis-core/go/common/node" tmapi "github.com/oasisprotocol/oasis-core/go/consensus/cometbft/api" churpState "github.com/oasisprotocol/oasis-core/go/consensus/cometbft/apps/keymanager/churp/state" "github.com/oasisprotocol/oasis-core/go/consensus/cometbft/apps/keymanager/common" + registryState "github.com/oasisprotocol/oasis-core/go/consensus/cometbft/apps/registry/state" "github.com/oasisprotocol/oasis-core/go/keymanager/churp" ) @@ -192,6 +195,114 @@ func (ext *churpExt) update(ctx *tmapi.Context, req *churp.UpdateRequest) error return nil } +func (ext *churpExt) apply(ctx *tmapi.Context, req *churp.SignedApplicationRequest) error { + // Prepare states. + state := churpState.NewMutableState(ctx.State()) + regState := registryState.NewMutableState(ctx.State()) + + // Ensure that the runtime exists and is a key manager. + kmRt, err := common.KeyManagerRuntime(ctx, req.Application.RuntimeID) + if err != nil { + return err + } + + // Get the existing status. + status, err := state.Status(ctx, req.Application.RuntimeID, req.Application.ID) + if err != nil { + return fmt.Errorf("keymanager: churp: non-existing ID: %d", req.Application.ID) + } + + // Allow applications one epoch before the next handoff. + now, err := ext.state.GetCurrentEpoch(ctx) + if err != nil { + return err + } + + switch status.NextHandoff { + case churp.HandoffsDisabled: + return fmt.Errorf("keymanager: churp: handoffs disabled") + case now + 1: + default: + return fmt.Errorf("keymanager: churp: submissions closed") + } + + if status.Round != req.Application.Round { + return fmt.Errorf("keymanager: churp: invalid round: got %d, expected %d", req.Application.Round, status.Round) + } + + // Allow only one application per round, to ensure the node's + // verification matrix (commitment) doesn't change. + nodeID := ctx.TxSigner() + if _, ok := status.Applications[nodeID]; ok { + return fmt.Errorf("keymanager: churp: application already submitted") + } + + // Verify the node. + n, err := regState.Node(ctx, nodeID) + if err != nil { + return err + } + if n.IsExpired(uint64(now)) { + return fmt.Errorf("keymanager: churp: node registration expired") + } + if !n.HasRoles(node.RoleKeyManager) { + return fmt.Errorf("keymanager: churp: node not key manager") + } + + // Verify RAK signature. + nodeRt, err := common.NodeRuntime(n, kmRt.ID) + if err != nil { + return err + } + rak, err := common.RuntimeAttestationKey(nodeRt, kmRt) + if err != nil { + return fmt.Errorf("keymanager: churp: failed to fetch node's rak: %w", err) + } + if err = req.VerifyRAK(rak); err != nil { + return fmt.Errorf("keymanager: churp: invalid signature: %w", err) + } + + if ctx.IsCheckOnly() { + return nil + } + + // Charge gas for this operation. + kmParams, err := state.ConsensusParameters(ctx) + if err != nil { + return err + } + if err = ctx.Gas().UseGas(1, churp.GasOpApply, kmParams.GasCosts); err != nil { + return err + } + + // Return early if simulating since this is just estimating gas. + if ctx.IsSimulation() { + return nil + } + + // Ok, as far as we can tell the application is valid, apply it. + if status.Applications == nil { + status.Applications = make(map[signature.PublicKey]churp.Application) + } + status.Applications[nodeID] = churp.Application{ + Checksum: req.Application.Checksum, + Reconstructed: false, + } + + if err := state.SetStatus(ctx, status); err != nil { + ctx.Logger().Error("keymanager: churp: failed to set status", + "err", err, + ) + return fmt.Errorf("keymanager: churp: failed to set status: %w", err) + } + + ctx.EmitEvent(tmapi.NewEventBuilder(ext.appName).TypedAttribute(&churp.UpdateEvent{ + Status: status, + })) + + return nil +} + func (ext *churpExt) computeNextHandoff(ctx *tmapi.Context) (beacon.EpochTime, error) { // The next handoff will start at the beginning of the next epoch, // meaning that nodes need to send their applications until the end diff --git a/go/consensus/cometbft/apps/keymanager/churp/txs_test.go b/go/consensus/cometbft/apps/keymanager/churp/txs_test.go index 4392776e797..78cb9f85e49 100644 --- a/go/consensus/cometbft/apps/keymanager/churp/txs_test.go +++ b/go/consensus/cometbft/apps/keymanager/churp/txs_test.go @@ -10,6 +10,7 @@ import ( beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" memorySigner "github.com/oasisprotocol/oasis-core/go/common/crypto/signature/signers/memory" "github.com/oasisprotocol/oasis-core/go/common/entity" @@ -47,6 +48,8 @@ type TxTestSuite struct { ctx *abciAPI.Context txCtx *abciAPI.Context + cfg *abciAPI.MockApplicationStateConfig + state *churpState.MutableState nodes []*testNode @@ -57,8 +60,8 @@ type TxTestSuite struct { func (s *TxTestSuite) SetupTest() { // Prepare extension. - cfg := abciAPI.MockApplicationStateConfig{} - appState := abciAPI.NewMockApplicationState(&cfg) + s.cfg = &abciAPI.MockApplicationStateConfig{} + appState := abciAPI.NewMockApplicationState(s.cfg) s.ext = churpExt{ state: appState, } @@ -134,6 +137,7 @@ func (s *TxTestSuite) SetupTest() { ID: s.nodes[i].signer.Public(), }, EntityID: s.entity.signer.Public(), + Roles: node.RoleKeyManager, } for _, rt := range s.keymanagerRuntimes { @@ -200,17 +204,17 @@ func (s *TxTestSuite) TestCreate() { }) s.Run("happy path - handoffs disabled", func() { + identity := churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + } policy := churp.SignedPolicySGX{ Policy: churp.PolicySGX{ - ID: 0, - RuntimeID: s.keymanagerRuntimes[0].ID, + Identity: identity, }, } req := churp.CreateRequest{ - Identity: churp.Identity{ - ID: 0, - RuntimeID: s.keymanagerRuntimes[0].ID, - }, + Identity: identity, GroupID: churp.EccNistP384, Threshold: 1, HandoffInterval: 0, @@ -226,8 +230,8 @@ func (s *TxTestSuite) TestCreate() { // Verify state. status, err := s.state.Status(s.txCtx, s.keymanagerRuntimes[0].ID, 0) require.NoError(s.T(), err) - require.Equal(s.T(), uint8(0), status.Identity.ID) - require.Equal(s.T(), s.keymanagerRuntimes[0].ID, status.Identity.RuntimeID) + require.Equal(s.T(), uint8(0), status.ID) + require.Equal(s.T(), s.keymanagerRuntimes[0].ID, status.RuntimeID) require.Equal(s.T(), churp.EccNistP384, status.GroupID) require.Equal(s.T(), uint8(1), status.Threshold) require.Equal(s.T(), uint64(0), status.Round) @@ -240,17 +244,17 @@ func (s *TxTestSuite) TestCreate() { }) s.Run("happy path - handoffs enabled", func() { + identity := churp.Identity{ + ID: 1, + RuntimeID: s.keymanagerRuntimes[0].ID, + } policy := churp.SignedPolicySGX{ Policy: churp.PolicySGX{ - ID: 1, - RuntimeID: s.keymanagerRuntimes[0].ID, + Identity: identity, }, } req := churp.CreateRequest{ - Identity: churp.Identity{ - ID: 1, - RuntimeID: s.keymanagerRuntimes[0].ID, - }, + Identity: identity, GroupID: churp.EccNistP384, Threshold: 1, HandoffInterval: 10, @@ -280,19 +284,19 @@ func (s *TxTestSuite) TestCreate() { func (s *TxTestSuite) TestUpdate() { // Prepare one instance in advance. + identity := churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + } createReq := churp.CreateRequest{ - Identity: churp.Identity{ - ID: 0, - RuntimeID: s.keymanagerRuntimes[0].ID, - }, + Identity: identity, GroupID: churp.EccNistP384, Threshold: 1, HandoffInterval: 0, Policy: churp.SignedPolicySGX{ Policy: churp.PolicySGX{ - Serial: 0, - ID: 0, - RuntimeID: s.keymanagerRuntimes[0].ID, + Identity: identity, + Serial: 0, }, }, } @@ -414,3 +418,160 @@ func (s *TxTestSuite) TestUpdate() { require.Equal(s.T(), beacon.EpochTime(0), status.HandoffInterval) }) } + +func (s *TxTestSuite) TestApply() { + // Prepare one instance in advance. + identity := churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + } + createReq := churp.CreateRequest{ + Identity: identity, + GroupID: churp.EccNistP384, + Threshold: 1, + HandoffInterval: 0, + Policy: churp.SignedPolicySGX{ + Policy: churp.PolicySGX{ + Identity: identity, + Serial: 0, + }, + }, + } + err := s.ext.create(s.txCtx, &createReq) + require.NoError(s.T(), err) + + // Create event should be emitted. + events := s.txCtx.GetEvents() + require.Len(s.T(), events, 1) + + s.Run("not key manager runtime", func() { + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + RuntimeID: s.computeRuntimes[0].ID, + }, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "runtime is not a key manager") + }) + + s.Run("non-existing instance", func() { + // Wrong ID. + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 1, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "non-existing ID") + + // Wrong runtime ID. + req = churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[1].ID, + }, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "non-existing ID") + }) + + s.Run("handoffs disabled", func() { + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "handoffs disabled") + }) + + // Enable handoffs. + handoffInterval := beacon.EpochTime(1) + updateReq := churp.UpdateRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + HandoffInterval: &handoffInterval, + } + err = s.ext.update(s.txCtx, &updateReq) + require.NoError(s.T(), err) + + s.Run("submissions closed", func() { + s.cfg.CurrentEpoch = 1 + + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "submissions closed") + + s.cfg.CurrentEpoch = 0 + }) + + s.Run("invalid round", func() { + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + Round: 100, + }, + } + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "invalid round") + }) + + // A request with invalid signature. + req := churp.SignedApplicationRequest{ + Application: churp.ApplicationRequest{ + Identity: churp.Identity{ + ID: 0, + RuntimeID: s.keymanagerRuntimes[0].ID, + }, + Round: 0, + Checksum: hash.Hash{1, 2, 3}, + }, + Signature: signature.RawSignature{}, + } + + // A valid tx signer. + s.txCtx.SetTxSigner(s.nodes[0].signer.Public()) + + s.Run("invalid RAK signature", func() { + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "RAK signature verification failed") + }) + + // Sign the request. + rak := s.nodes[0].rak + rawSigBytes, err := rak.ContextSign(churp.ApplicationRequestSignatureContext, cbor.Marshal(req.Application)) + require.NoError(s.T(), err) + copy(req.Signature[:], rawSigBytes) + + s.Run("happy path", func() { + err = s.ext.apply(s.txCtx, &req) + require.NoError(s.T(), err) + }) + + s.Run("duplicate submission", func() { + err = s.ext.apply(s.txCtx, &req) + require.ErrorContains(s.T(), err, "application already submitted") + }) +} diff --git a/go/keymanager/churp/api.go b/go/keymanager/churp/api.go index 21fd8bf5b22..e52e7291cb9 100644 --- a/go/keymanager/churp/api.go +++ b/go/keymanager/churp/api.go @@ -20,10 +20,15 @@ var ( // MethodUpdate is the method name for CHURP updates. MethodUpdate = transaction.NewMethodName(ModuleName, "Update", UpdateRequest{}) + // MethodApply is the method name for a node submitting an application + // to form a new committee. + MethodApply = transaction.NewMethodName(ModuleName, "Apply", ApplicationRequest{}) + // Methods is the list of all methods supported by the CHURP extension. Methods = []transaction.MethodName{ MethodCreate, MethodUpdate, + MethodApply, } ) @@ -32,12 +37,15 @@ const ( GasOpCreate transaction.Op = "create" // GasOpUpdate is the gas operation identifier for update costs. GasOpUpdate transaction.Op = "update" + // GasOpApply is the gas operation identifier for application costs. + GasOpApply transaction.Op = "apply" ) // DefaultGasCosts are the "default" gas costs for operations. var DefaultGasCosts = transaction.Costs{ GasOpCreate: 1000, GasOpUpdate: 1000, + GasOpApply: 1000, } // DefaultConsensusParameters are the "default" consensus parameters. @@ -54,3 +62,8 @@ func NewCreateTx(nonce uint64, fee *transaction.Fee, req *CreateRequest) *transa func NewUpdateTx(nonce uint64, fee *transaction.Fee, req *UpdateRequest) *transaction.Transaction { return transaction.NewTransaction(nonce, fee, MethodUpdate, req) } + +// NewApplyTx creates a new apply transaction. +func NewApplyTx(nonce uint64, fee *transaction.Fee, req *SignedApplicationRequest) *transaction.Transaction { + return transaction.NewTransaction(nonce, fee, MethodApply, req) +} diff --git a/go/keymanager/churp/policy.go b/go/keymanager/churp/policy.go index fb48be57afd..de5746d41be 100644 --- a/go/keymanager/churp/policy.go +++ b/go/keymanager/churp/policy.go @@ -3,7 +3,6 @@ package churp import ( "fmt" - "github.com/oasisprotocol/oasis-core/go/common" "github.com/oasisprotocol/oasis-core/go/common/cbor" "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" "github.com/oasisprotocol/oasis-core/go/common/sgx" @@ -15,14 +14,10 @@ var PolicySGXSignatureContext = signature.NewContext("oasis-core/keymanager/chur // PolicySGX represents an SGX access control policy used to authenticate // key manager enclaves during handoffs. type PolicySGX struct { - // Serial is the monotonically increasing policy serial number. - Serial uint32 `json:"serial"` - - // ID is the identifier of the CHURP instance. - ID uint8 `json:"id"` + Identity - // RuntimeID is the runtime identifier of the key manager. - RuntimeID common.Namespace `json:"runtime_id"` + // Serial is the monotonically increasing policy serial number. + Serial uint32 `json:"serial,omitempty"` // MayShare is the vector of enclave identities from which a share can be // obtained during handouts. diff --git a/go/keymanager/churp/requests.go b/go/keymanager/churp/requests.go index 30b10b5880b..a3d7c9d365c 100644 --- a/go/keymanager/churp/requests.go +++ b/go/keymanager/churp/requests.go @@ -4,8 +4,16 @@ import ( "fmt" beacon "github.com/oasisprotocol/oasis-core/go/beacon/api" + + "github.com/oasisprotocol/oasis-core/go/common/cbor" + "github.com/oasisprotocol/oasis-core/go/common/crypto/hash" + "github.com/oasisprotocol/oasis-core/go/common/crypto/signature" ) +// ApplicationRequestSignatureContext is the signature context used to sign +// application requests with runtime signing key (RAK). +var ApplicationRequestSignatureContext = signature.NewContext("oasis-core/keymanager/churp: application request") + // CreateRequest contains the initial configuration. type CreateRequest struct { Identity @@ -75,3 +83,32 @@ func (c *UpdateRequest) ValidateBasic() error { return nil } + +// ApplicationRequest contains node's application to form a new committee. +type ApplicationRequest struct { + // Identity of the CHRUP scheme. + Identity + + // Round is the round for which the node would like to register. + Round uint64 `json:"round,omitempty"` + + // Checksum is the hash of the verification matrix. + Checksum hash.Hash `json:"checksum,omitempty"` +} + +// SignedApplicationRequest is an application request signed by the key manager +// enclave using its runtime attestation key (RAK). +type SignedApplicationRequest struct { + Application ApplicationRequest `json:"application,omitempty"` + + // Signature is the RAK signature of the application request. + Signature signature.RawSignature `json:"signature,omitempty"` +} + +// VerifyRAK verifies the runtime attestation key (RAK) signature. +func (r *SignedApplicationRequest) VerifyRAK(rak *signature.PublicKey) error { + if !rak.Verify(ApplicationRequestSignatureContext, cbor.Marshal(r.Application), r.Signature[:]) { + return fmt.Errorf("RAK signature verification failed") + } + return nil +} diff --git a/go/keymanager/churp/status.go b/go/keymanager/churp/status.go index 8e6035776c8..765a95126f8 100644 --- a/go/keymanager/churp/status.go +++ b/go/keymanager/churp/status.go @@ -25,7 +25,7 @@ type ConsensusParameters struct { // Identity uniquely identifies a CHURP instance. type Identity struct { - // ID is a unique identifier within the key manager runtime. + // ID is a unique CHURP identifier within the key manager runtime. ID uint8 `json:"id,omitempty"` // RuntimeID is the identifier of the key manager runtime. @@ -38,7 +38,7 @@ type Status struct { // GroupID is the identifier of a group used for verifiable secret sharing // and key derivation. - GroupID uint8 `json:"group,omitempty"` + GroupID uint8 `json:"group_id"` // Threshold is the minimum number of distinct shares required // to reconstruct a key. @@ -89,6 +89,11 @@ type Status struct { Checksum *hash.Hash `json:"checksum,omitempty"` } +// HandoffsDisabled returns true if and only if handoffs are disabled. +func (s *Status) HandoffsDisabled() bool { + return s.HandoffInterval == HandoffsDisabled +} + // Application represents a node's application to form a new committee. type Application struct { // Checksum is the hash of the random verification matrix.