Skip to content

Commit ec7202a

Browse files
authored
Fix authenticated signer when re-proposing a fast block. (#3920)
## Motivation When re-proposing a validated block from an earlier round there is a validated block certificate, signed by a quorum of validators, attesting the correctness of the outcome and the validity of the oracle responses—_and_ that the `authenticated_signer` is actually the one who originally submitted the block proposal to the validators! This last part I missed in the special case where we re-propose an earlier proposal by a super owner from the fast round! In that case, there is no validated block certificate, which is why we disallow oracles. But we erroneously compare the `authenticated_signer` to the owner _re-proposing_ the block, rather than the original super owner. Of course comparing it to the super owner is not enough: The regular owner must not be able to do something in the super owner's name without permission. So we need to also verify the super owner's signature (and super ownership!) again. Super owners by design take greater responsibility for a chain's liveness than regular owners, and in the case of re-proposing a _fast_ block, the super owner's signature needs to play a similar role to the validators' signatures when re-proposing a _regular_ block. ## Proposal Properly distinguish the _three_ cases of a block proposal: * The current proposer is the one who originally created this block. They are the authenticated signer. * This is a retry of an earlier proposal by a super owner in the _fast_ round: The super owner is the authenticated signer, but their signature must be included in the proposal and verified again. * This is a retry of an earlier proposal in regular round: The original proposer is the authenticated signer, but we don't need to verify their signature again; the validators' signatures of the earlier round's certificate already proves that the proposal is valid (in addition to proving that the included oracle responses are, too). ## Test Plan `test_re_propose_fast_block` was added. ## Release Plan - Nothing to do / These changes follow the usual release cycle. ## Links - [reviewer checklist](https://github.com/linera-io/linera-protocol/blob/main/CONTRIBUTING.md#reviewer-checklist)
1 parent ad89e29 commit ec7202a

File tree

11 files changed

+311
-72
lines changed

11 files changed

+311
-72
lines changed

linera-chain/src/data_types.rs

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,21 @@ pub struct MessageBundle {
204204
pub messages: Vec<PostedMessage>,
205205
}
206206

207+
#[derive(Clone, Debug, Serialize, Deserialize)]
208+
#[cfg_attr(with_testing, derive(Eq, PartialEq))]
209+
/// An earlier proposal that is being retried.
210+
pub enum OriginalProposal {
211+
/// A proposal in the fast round.
212+
Fast {
213+
public_key: AccountPublicKey,
214+
signature: AccountSignature,
215+
},
216+
/// A validated block certificate from an earlier round.
217+
Regular {
218+
certificate: LiteCertificate<'static>,
219+
},
220+
}
221+
207222
/// An authenticated proposal for a new block.
208223
// TODO(#456): the signature of the block owner is currently lost but it would be useful
209224
// to have it for auditing purposes.
@@ -214,7 +229,7 @@ pub struct BlockProposal {
214229
pub public_key: AccountPublicKey,
215230
pub signature: AccountSignature,
216231
#[debug(skip_if = Option::is_none)]
217-
pub validated_block_certificate: Option<LiteCertificate<'static>>,
232+
pub original_proposal: Option<OriginalProposal>,
218233
}
219234

220235
/// A message together with kind, authentication and grant information.
@@ -502,17 +517,42 @@ impl BlockProposal {
502517
content,
503518
public_key,
504519
signature,
505-
validated_block_certificate: None,
520+
original_proposal: None,
521+
})
522+
}
523+
524+
pub async fn new_retry_fast(
525+
owner: AccountOwner,
526+
round: Round,
527+
old_proposal: BlockProposal,
528+
signer: &(impl Signer + ?Sized),
529+
) -> Result<Self, Box<dyn Error>> {
530+
let content = ProposalContent {
531+
round,
532+
block: old_proposal.content.block,
533+
outcome: None,
534+
};
535+
let signature = signer.sign(&owner, &CryptoHash::new(&content)).await?;
536+
let public_key = signer.get_public_key(&owner).await?;
537+
538+
Ok(Self {
539+
content,
540+
public_key,
541+
signature,
542+
original_proposal: Some(OriginalProposal::Fast {
543+
public_key: old_proposal.public_key,
544+
signature: old_proposal.signature,
545+
}),
506546
})
507547
}
508548

509-
pub async fn new_retry(
549+
pub async fn new_retry_regular(
510550
owner: AccountOwner,
511551
round: Round,
512552
validated_block_certificate: ValidatedBlockCertificate,
513553
signer: &(impl Signer + ?Sized),
514554
) -> Result<Self, Box<dyn Error>> {
515-
let lite_cert = validated_block_certificate.lite_certificate().cloned();
555+
let certificate = validated_block_certificate.lite_certificate().cloned();
516556
let block = validated_block_certificate.into_inner().into_inner();
517557
let (block, outcome) = block.into_proposal();
518558
let content = ProposalContent {
@@ -527,7 +567,7 @@ impl BlockProposal {
527567
content,
528568
public_key,
529569
signature,
530-
validated_block_certificate: Some(lite_cert),
570+
original_proposal: Some(OriginalProposal::Regular { certificate }),
531571
})
532572
}
533573

@@ -558,17 +598,19 @@ impl BlockProposal {
558598
/// Checks that the public key matches the owner and that the optional certificate matches
559599
/// the outcome.
560600
pub fn check_invariants(&self) -> Result<(), &'static str> {
561-
match (&self.validated_block_certificate, &self.content.outcome) {
562-
(None, None) => {}
563-
(None, Some(_)) | (Some(_), None) => {
601+
match (&self.original_proposal, &self.content.outcome) {
602+
(None, None) | (Some(OriginalProposal::Fast { .. }), None) => {}
603+
(None, Some(_))
604+
| (Some(OriginalProposal::Fast { .. }), Some(_))
605+
| (Some(OriginalProposal::Regular { .. }), None) => {
564606
return Err("Must contain a validation certificate if and only if \
565607
it contains the execution outcome from a previous round");
566608
}
567-
(Some(lite_certificate), Some(outcome)) => {
609+
(Some(OriginalProposal::Regular { certificate }), Some(outcome)) => {
568610
let block = outcome.clone().with(self.content.block.clone());
569611
let value = ValidatedBlock::new(block);
570612
ensure!(
571-
lite_certificate.check_value(&value),
613+
certificate.check_value(&value),
572614
"Lite certificate must match the given block and execution outcome"
573615
);
574616
}

linera-chain/src/manager.rs

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ use serde::{Deserialize, Serialize};
9292

9393
use crate::{
9494
block::{Block, ConfirmedBlock, Timeout, ValidatedBlock},
95-
data_types::{BlockProposal, LiteVote, ProposedBlock, Vote},
95+
data_types::{BlockProposal, LiteVote, OriginalProposal, ProposedBlock, Vote},
9696
types::{TimeoutCertificate, ValidatedBlockCertificate},
9797
ChainError,
9898
};
@@ -343,10 +343,13 @@ where
343343
// if there is a validated block certificate from a later round.
344344
if let Some(vote) = self.confirmed_vote() {
345345
ensure!(
346-
if let Some(validated_cert) = proposal.validated_block_certificate.as_ref() {
347-
vote.round <= validated_cert.round
348-
} else {
349-
vote.round.is_fast() && vote.value().matches_proposed_block(new_block)
346+
match proposal.original_proposal.as_ref() {
347+
None => false,
348+
Some(OriginalProposal::Regular { certificate }) =>
349+
vote.round <= certificate.round,
350+
Some(OriginalProposal::Fast { .. }) => {
351+
vote.round.is_fast() && vote.value().matches_proposed_block(new_block)
352+
}
350353
},
351354
ChainError::HasIncompatibleConfirmedVote(new_block.height, vote.round)
352355
);
@@ -451,22 +454,44 @@ where
451454
) -> Result<Option<ValidatedOrConfirmedVote>, ChainError> {
452455
let round = proposal.content.round;
453456

454-
// If the validated block certificate is more recent, update our locking block.
455-
if let Some(lite_cert) = &proposal.validated_block_certificate {
456-
if self
457-
.locking_block
458-
.get()
459-
.as_ref()
460-
.is_none_or(|locking| locking.round() < lite_cert.round)
461-
{
462-
let value = ValidatedBlock::new(block.clone());
463-
if let Some(certificate) = lite_cert.clone().with_value(value) {
464-
self.update_locking(LockingBlock::Regular(certificate), blobs.clone())?;
457+
match &proposal.original_proposal {
458+
// If the validated block certificate is more recent, update our locking block.
459+
Some(OriginalProposal::Regular { certificate }) => {
460+
if self
461+
.locking_block
462+
.get()
463+
.as_ref()
464+
.is_none_or(|locking| locking.round() < certificate.round)
465+
{
466+
let value = ValidatedBlock::new(block.clone());
467+
if let Some(certificate) = certificate.clone().with_value(value) {
468+
self.update_locking(LockingBlock::Regular(certificate), blobs.clone())?;
469+
}
470+
}
471+
}
472+
// If this contains a proposal from the fast round, we consider that a locking block.
473+
// It is useful for clients synchronizing with us, so they can re-propose it.
474+
Some(OriginalProposal::Fast {
475+
public_key,
476+
signature,
477+
}) => {
478+
if self.locking_block.get().is_none() {
479+
let original_proposal = BlockProposal {
480+
public_key: *public_key,
481+
signature: *signature,
482+
..proposal.clone()
483+
};
484+
self.update_locking(LockingBlock::Fast(original_proposal), blobs.clone())?;
485+
}
486+
}
487+
// If this proposal itself is from the fast round, it is also a locking block: We
488+
// will vote to confirm it, so it is locked.
489+
None => {
490+
if round.is_fast() && self.locking_block.get().is_none() {
491+
// The fast block also counts as locking.
492+
self.update_locking(LockingBlock::Fast(proposal.clone()), blobs.clone())?;
465493
}
466494
}
467-
} else if round.is_fast() && self.locking_block.get().is_none() {
468-
// The fast block also counts as locking.
469-
self.update_locking(LockingBlock::Fast(proposal.clone()), blobs.clone())?;
470495
}
471496

472497
// We record the proposed block, in case it affects the current round number.

linera-core/src/chain_worker/state/attempted_changes.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ use linera_base::{
1313
identifiers::{AccountOwner, ChainId},
1414
};
1515
use linera_chain::{
16-
data_types::{BlockExecutionOutcome, BlockProposal, MessageBundle, ProposalContent},
16+
data_types::{
17+
BlockExecutionOutcome, BlockProposal, MessageBundle, OriginalProposal, ProposalContent,
18+
},
1719
manager,
1820
types::{ConfirmedBlockCertificate, TimeoutCertificate, ValidatedBlockCertificate},
1921
ChainExecutionContext, ChainStateView, ExecutionResultExt as _,
@@ -130,7 +132,7 @@ where
130132
outcome: _,
131133
},
132134
public_key,
133-
validated_block_certificate,
135+
original_proposal,
134136
signature: _,
135137
} = proposal;
136138

@@ -146,11 +148,12 @@ where
146148
// TODO(#3203): Allow multiple pending proposals on permissionless chains.
147149
chain.pending_proposed_blobs.clear();
148150
}
151+
let validated = matches!(original_proposal, Some(OriginalProposal::Regular { .. }));
149152
chain
150153
.pending_proposed_blobs
151154
.try_load_entry_mut(&owner)
152155
.await?
153-
.update(*round, validated_block_certificate.is_some(), maybe_blobs)
156+
.update(*round, validated, maybe_blobs)
154157
.await?;
155158
self.save().await?;
156159
return Err(WorkerError::BlobsNotFound(missing_blob_ids));

linera-core/src/chain_worker/state/temporary_changes.rs

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
//! Operations that don't persist any changes to the chain state.
55
66
use linera_base::{
7-
data_types::{ApplicationDescription, ArithmeticError, Blob, Timestamp},
7+
data_types::{ApplicationDescription, ArithmeticError, Blob, Round, Timestamp},
88
ensure,
99
identifiers::{AccountOwner, ApplicationId},
1010
};
1111
use linera_chain::{
1212
data_types::{
13-
BlockExecutionOutcome, BlockProposal, IncomingBundle, MessageAction, ProposalContent,
14-
ProposedBlock,
13+
BlockExecutionOutcome, BlockProposal, IncomingBundle, MessageAction, OriginalProposal,
14+
ProposalContent, ProposedBlock,
1515
},
1616
manager,
1717
types::Block,
@@ -161,7 +161,7 @@ where
161161
let BlockProposal {
162162
content,
163163
public_key,
164-
validated_block_certificate,
164+
original_proposal,
165165
signature: _,
166166
} = proposal;
167167
let block = &content.block;
@@ -178,12 +178,47 @@ where
178178
chain.manager.verify_owner(proposal),
179179
WorkerError::InvalidOwner
180180
);
181-
if let Some(lite_certificate) = validated_block_certificate {
182-
// Verify that this block has been validated by a quorum before.
183-
lite_certificate.check(committee)?;
184-
} else if let Some(signer) = block.authenticated_signer {
185-
// Check the authentication of the operations in the new block.
186-
ensure!(signer == owner, WorkerError::InvalidSigner(signer));
181+
match original_proposal {
182+
None => {
183+
if let Some(signer) = block.authenticated_signer {
184+
// Check the authentication of the operations in the new block.
185+
ensure!(signer == owner, WorkerError::InvalidSigner(owner));
186+
}
187+
}
188+
Some(OriginalProposal::Regular { certificate }) => {
189+
// Verify that this block has been validated by a quorum before.
190+
certificate.check(committee)?;
191+
}
192+
Some(OriginalProposal::Fast {
193+
public_key,
194+
signature,
195+
}) => {
196+
let super_owner = AccountOwner::from(*public_key);
197+
ensure!(
198+
chain
199+
.manager
200+
.ownership
201+
.get()
202+
.super_owners
203+
.contains(&super_owner),
204+
WorkerError::InvalidOwner
205+
);
206+
if let Some(signer) = block.authenticated_signer {
207+
// Check the authentication of the operations in the new block.
208+
ensure!(signer == super_owner, WorkerError::InvalidSigner(signer));
209+
}
210+
let old_proposal = BlockProposal {
211+
content: ProposalContent {
212+
block: proposal.content.block.clone(),
213+
round: Round::Fast,
214+
outcome: None,
215+
},
216+
public_key: *public_key,
217+
signature: *signature,
218+
original_proposal: None,
219+
};
220+
old_proposal.check_signature()?;
221+
}
187222
}
188223
// Check if the chain is ready for this new block proposal.
189224
chain.tip_state.get().verify_block_chaining(block)?;

linera-core/src/client/mod.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2744,12 +2744,12 @@ impl<Env: Environment> ChainClient<Env> {
27442744
let proposal = if let Some(locking) = info.manager.requested_locking {
27452745
Box::new(match *locking {
27462746
LockingBlock::Regular(cert) => {
2747-
BlockProposal::new_retry(owner, round, cert, self.signer())
2747+
BlockProposal::new_retry_regular(owner, round, cert, self.signer())
27482748
.await
27492749
.map_err(ChainClientError::signer_failure)?
27502750
}
27512751
LockingBlock::Fast(proposal) => {
2752-
BlockProposal::new_initial(owner, round, proposal.content.block, self.signer())
2752+
BlockProposal::new_retry_fast(owner, round, proposal, self.signer())
27532753
.await
27542754
.map_err(ChainClientError::signer_failure)?
27552755
}

0 commit comments

Comments
 (0)