Skip to content

Commit 3daa23f

Browse files
committed
go/worker/compute: Verify I/O root of a proposal
1 parent 863b134 commit 3daa23f

8 files changed

Lines changed: 152 additions & 3 deletions

File tree

.changelog/6555.trivial.md

Whitespace-only changes.

go/oasis-node/cmd/debug/byzantine/byzantine.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@ const (
5757
ModeExecutorRunaway ExecutorMode = 2
5858
ModeExecutorStraggler ExecutorMode = 3
5959
ModeExecutorFailureIndicating ExecutorMode = 4
60+
ModeExecutorInvalidBatchHash ExecutorMode = 5
6061

6162
modeExecutorHonestString = "executor_honest"
6263
modeExecutorDishonestString = "executor_dishonest"
6364
modeExecutorRunawayString = "executor_runaway"
6465
modeExecutorStragglerString = "executor_straggler"
6566
modeExecutorFailureIndicatingString = "executor_failure_indicating"
67+
modeExecutorInvalidBatchHashString = "executor_invalid_batch_hash"
6668
)
6769

6870
// String returns a string representation of a executor mode.
@@ -78,6 +80,8 @@ func (m ExecutorMode) String() string {
7880
return modeExecutorStragglerString
7981
case ModeExecutorFailureIndicating:
8082
return modeExecutorFailureIndicatingString
83+
case ModeExecutorInvalidBatchHash:
84+
return modeExecutorInvalidBatchHashString
8185
default:
8286
return "[unsupported runtime kind]"
8387
}
@@ -96,8 +100,10 @@ func (m *ExecutorMode) FromString(str string) error {
96100
*m = ModeExecutorStraggler
97101
case modeExecutorFailureIndicatingString:
98102
*m = ModeExecutorFailureIndicating
103+
case modeExecutorInvalidBatchHashString:
104+
*m = ModeExecutorInvalidBatchHash
99105
default:
100-
return fmt.Errorf("invalid executor mode kind: %s", m)
106+
return fmt.Errorf("invalid executor mode kind: %s", str)
101107
}
102108

103109
return nil

go/oasis-node/cmd/debug/byzantine/node.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
beacon "github.com/oasisprotocol/oasis-core/go/beacon/api"
1111
"github.com/oasisprotocol/oasis-core/go/common"
12+
"github.com/oasisprotocol/oasis-core/go/common/crypto/hash"
1213
"github.com/oasisprotocol/oasis-core/go/common/crypto/signature"
1314
"github.com/oasisprotocol/oasis-core/go/common/entity"
1415
"github.com/oasisprotocol/oasis-core/go/common/identity"
@@ -76,7 +77,18 @@ func (b *byzantine) receiveAndScheduleTransactions(ctx context.Context, cbc *com
7677
panic(fmt.Sprintf("executor proposing batch: %+v", err))
7778
}
7879

79-
if mode == ModeExecutorFailureIndicating {
80+
switch mode {
81+
case ModeExecutorInvalidBatchHash:
82+
validBatchHash := cbc.proposal.Header.BatchHash
83+
cbc.proposal.Header.BatchHash = hash.NewFromBytes([]byte("invalid batch hash"))
84+
if err := cbc.proposal.Sign(b.identity.NodeSigner, block.Header.Namespace); err != nil {
85+
return false, fmt.Errorf("failed to re-sign invalid batch hash proposal: %w", err)
86+
}
87+
logger.Debug("invalid batch hash: invalidating batch hash",
88+
"valid_batch_hash", validBatchHash,
89+
"invalid_batch_hash", cbc.proposal.Header.BatchHash,
90+
)
91+
case ModeExecutorFailureIndicating:
8092
// Submit failure indicating commitment and stop.
8193
logger.Debug("executor failure indicating: submitting commitment and stopping")
8294
schedulerID := b.identity.NodeSigner.Public()

go/oasis-test-runner/scenario/e2e/runtime/byzantine.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,35 @@ var (
164164
Index: primarySchedulerIndex,
165165
},
166166
)
167+
ByzantineExecutorSchedulerInvalidBatchHash scenario.Scenario = newByzantineImpl(
168+
"primary-worker/primary-scheduler/invalid-batch-hash",
169+
"executor",
170+
[]log.WatcherHandlerFactory{
171+
// The Byzantine node will publish a proposal with an invalid batch hash.
172+
// Other workers will reject the proposal after determining that the I/O root
173+
// does not match. As a result, the second-ranked proposer will prepare a new
174+
// proposal but will not have enough commitments to finalize it. This will
175+
// trigger the round timeout, leading to discrepancy resolution, which will
176+
// succeed once the remaining backup workers submit their commitments.
177+
oasis.LogAssertTimeouts(),
178+
oasis.LogAssertExecutionDiscrepancyDetected(),
179+
},
180+
oasis.ByzantineDefaultIdentitySeed,
181+
false,
182+
// Byzantine node entity should be slashed once for liveness.
183+
map[staking.SlashReason]uint64{
184+
staking.SlashRuntimeLiveness: 1,
185+
},
186+
[]oasis.Argument{
187+
{Name: byzantine.CfgPrimarySchedulerExpected},
188+
{Name: byzantine.CfgExecutorMode, Values: []string{byzantine.ModeExecutorInvalidBatchHash.String()}},
189+
},
190+
scheduler.ForceElectCommitteeRole{
191+
Kind: scheduler.KindComputeExecutor,
192+
Roles: []scheduler.Role{scheduler.RoleWorker},
193+
Index: primarySchedulerIndex,
194+
},
195+
)
167196
// ByzantineExecutorStraggler is a scenario in which the Byzantine node acts
168197
// as the primary worker, backup scheduler, and a straggler.
169198
ByzantineExecutorStraggler scenario.Scenario = newByzantineImpl(

go/oasis-test-runner/scenario/e2e/runtime/scenario.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,7 @@ func RegisterScenarios() error {
333333
ByzantineExecutorDishonest,
334334
ByzantineExecutorSchedulerRunaway,
335335
ByzantineExecutorSchedulerBogus,
336+
ByzantineExecutorSchedulerInvalidBatchHash,
336337
ByzantineExecutorStraggler,
337338
ByzantineExecutorSchedulerStraggler,
338339
ByzantineExecutorStragglerAllowed,

go/worker/compute/executor/committee/node.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,25 @@ func (n *Node) runtimeExecuteTxBatch(
588588
func (n *Node) startProcessingBatch(ctx context.Context, proposal *commitment.Proposal, rank uint64, batch transaction.RawBatch) {
589589
// This method runs within its own goroutine and is always stopped before the runtime
590590
// worker finishes. Therefore, it is safe to read local round variables (block info, ...).
591+
n.logger.Debug("verifying batch")
592+
593+
ioRoot, err := n.computeIORoot(ctx, proposal.Header.Round, batch)
594+
if err != nil {
595+
n.logger.Error("failed to compute I/O root",
596+
"err", err,
597+
)
598+
// Notify the round worker that the execution failed.
599+
n.processedBatchCh <- nil
600+
return
601+
}
602+
if !ioRoot.Equal(&proposal.Header.BatchHash) {
603+
n.logger.Debug("batch I/O root mismatch")
604+
n.proposals.Reject(proposal, rank)
605+
// Notify the round worker that the execution failed.
606+
n.processedBatchCh <- nil
607+
return
608+
}
609+
591610
n.logger.Debug("processing batch",
592611
"batch_size", len(batch),
593612
)
@@ -624,6 +643,39 @@ func (n *Node) startProcessingBatch(ctx context.Context, proposal *commitment.Pr
624643
}
625644
}
626645

646+
func (n *Node) computeIORoot(ctx context.Context, round uint64, batch transaction.RawBatch) (hash.Hash, error) {
647+
txs := make([]*transaction.Transaction, 0, len(batch))
648+
for idx, tx := range batch {
649+
txs = append(txs, &transaction.Transaction{
650+
Input: tx,
651+
BatchOrder: uint32(idx),
652+
})
653+
}
654+
655+
emptyRoot := storage.Root{
656+
Namespace: n.rt.ID(),
657+
Version: round,
658+
Type: storage.RootTypeIO,
659+
}
660+
emptyRoot.Hash.Empty()
661+
662+
ioTree := transaction.NewTree(nil, emptyRoot)
663+
defer ioTree.Close()
664+
665+
for _, tx := range txs {
666+
if err := ioTree.AddTransaction(ctx, *tx, nil); err != nil {
667+
return hash.Hash{}, fmt.Errorf("failed to add transaction to tree: %w", err)
668+
}
669+
}
670+
671+
_, ioRoot, err := ioTree.Commit(ctx)
672+
if err != nil {
673+
return hash.Hash{}, fmt.Errorf("failed to commit I/O tree: %w", err)
674+
}
675+
676+
return ioRoot, nil
677+
}
678+
627679
func (n *Node) abortBatch(state *StateProcessingBatch) {
628680
n.logger.Warn("aborting processing batch")
629681

go/worker/compute/executor/committee/p2p.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ func (h *committeeMsgHandler) HandleMessage(_ context.Context, _ signature.Publi
9191
"batch_size", len(proposal.Batch),
9292
)
9393

94-
// Add to the queue.
94+
if _, ok := h.n.proposals.Get(proposal.Header.Round, rank); ok {
95+
return nil
96+
}
97+
9598
if err := h.n.proposals.Add(proposal, rank); err != nil {
9699
return err
97100
}

go/worker/compute/executor/committee/proposals.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const maxPendingProposals = 32
1616
type proposalInfo struct {
1717
proposal *commitment.Proposal
1818
rank uint64
19+
invalid bool
1920
}
2021

2122
// proposalQueue is a priority queue of pending proposals, ordered by round and rank.
@@ -61,6 +62,8 @@ func (q *proposalQueue) Best(round uint64, minRank uint64, maxRank uint64, exclu
6162
return true
6263
case pi.rank > maxRank:
6364
return false
65+
case pi.invalid:
66+
return true
6467
default:
6568
if _, skip := exclude[pi.rank]; skip {
6669
return true
@@ -75,6 +78,28 @@ func (q *proposalQueue) Best(round uint64, minRank uint64, maxRank uint64, exclu
7578
return proposal, rank, ok
7679
}
7780

81+
// Get returns a proposal for the given round and rank.
82+
func (q *proposalQueue) Get(round uint64, rank uint64) (*commitment.Proposal, bool) {
83+
q.l.RLock()
84+
defer q.l.RUnlock()
85+
86+
info := proposalInfo{
87+
proposal: &commitment.Proposal{
88+
Header: commitment.ProposalHeader{
89+
Round: round,
90+
},
91+
},
92+
rank: rank,
93+
}
94+
95+
pi, ok := q.q.Get(&info)
96+
if !ok {
97+
return nil, false
98+
}
99+
100+
return pi.proposal, true
101+
}
102+
78103
// Add adds a new pending proposal that MUST HAVE already undergone basic validity checks
79104
// and is therefore considered a valid proposal for the given round, but the node's
80105
// local consensus view may not yet be ready to process the proposal.
@@ -91,6 +116,11 @@ func (q *proposalQueue) Add(proposal *commitment.Proposal, rank uint64) error {
91116
proposal: proposal,
92117
rank: rank,
93118
}
119+
120+
if _, ok := q.q.Get(&info); ok {
121+
return fmt.Errorf("proposal already exists")
122+
}
123+
94124
q.q.ReplaceOrInsert(&info)
95125

96126
// In case of overflows, remove the proposal that is the most in the future.
@@ -105,6 +135,22 @@ func (q *proposalQueue) Add(proposal *commitment.Proposal, rank uint64) error {
105135
return nil
106136
}
107137

138+
// Reject marks a proposal as invalid.
139+
func (q *proposalQueue) Reject(proposal *commitment.Proposal, rank uint64) {
140+
q.l.Lock()
141+
defer q.l.Unlock()
142+
143+
info, ok := q.q.Get(&proposalInfo{
144+
proposal: proposal,
145+
rank: rank,
146+
})
147+
if !ok {
148+
return
149+
}
150+
151+
info.invalid = true
152+
}
153+
108154
// Prune prunes any proposals which are not valid anymore.
109155
func (q *proposalQueue) Prune(round uint64) {
110156
q.l.Lock()

0 commit comments

Comments
 (0)