apply_confirmed_block is public and unguarded: make certificate verification a precondition in the type
Surfaced while auditing the safety proof (#6685, layer 0).
The gap
manager::proof::commit::TipAdvancesOnlyOnValidCertificate states that a correct validator's tip advances only for a block carried by a ConfirmedBlockCertificate that has passed check against the committee of the block's epoch. That is true today, but nothing enforces it:
pub async fn apply_confirmed_block(
&mut self,
block: &ConfirmedBlock, // the certified *value* — no signatures
local_time: Timestamp,
tracked: Option<&ChainIdSet>,
) -> Result<BTreeSet<StreamId>, ChainError>It is pub, on ChainStateView, which linera-chain re-exports — and linera-chain is published. It takes the certified value and no Committee, so it cannot verify anything: the entire guarantee rests on callers doing the check first. The lemma's enumeration ("reached only through process_confirmed_block, whose every non-early-return path first evaluates certificate.check(&committee)?") holds by current usage, not by visibility.
pub(crate) is not available — linera-core calls it from ChainWorkerState::execute_contiguous_block.
Proposed fix: a minted token
Make check return unforgeable evidence, and require it:
/// Evidence that a certificate's signatures were checked against a committee.
pub struct Checked(CryptoHash); // private field: only `check` can mint one
impl ConfirmedBlockCertificate {
pub fn check(&self, committee: &Committee) -> Result<Checked, ChainError>;
}
impl ChainStateView<C> {
pub async fn apply_confirmed_block(&mut self, block: &ConfirmedBlock, checked: Checked, …) {
ensure!(checked.0 == block.inner().hash(), …);
…
}
}Properties:
- Unforgeable outside the crate — the field is private, so only
checkproduces one. - Bound to the block, not merely to "some certificate was checked somewhere": the hash comparison rules out checking certificate A and applying block B.
- Cheap — one hash comparison. Re-verifying the certificate inside
apply_confirmed_blockwould be the obvious alternative but pays N secp256k1 verifications twice per block on the hot path.
It threads cleanly: mint in process_confirmed_block right after the existing certificate.check(&committee)?, pass down through execute_contiguous_block and execute_block_with_checkpoint_restore, both of which already hold the certificate.
Cost
ConfirmedBlockCertificate::checkchanges return type fromResult<(), _>toResult<Checked, _>— a small break for in-workspace callers.- Three
apply_confirmed_blockcall sites inlinera-chain's unit tests need to mint a token; a#[cfg(with_testing)]constructor covers that.
Alternatives considered
- Take the certificate plus the committee and verify inside — correct, but duplicates signature verification on the hot path.
- Rename to
apply_verified_confirmed_block— zero cost, but documentation by another name; it does not stop anything. - Leave it, keep the documented fragility note — where things stand now. Defensible: only
linera-corecalls it, and the note is in the spec. The argument for fixing it is thatlinera-chainis published, so the footgun is reachable from outside the workspace, and the proof would then rest on the type rather than on a grep.
Links
- #6685 — the audit that surfaced it; the fragility note lives in
TipAdvancesOnlyOnValidCertificate. - #6674 — the specification.
Source: linera-io/linera-protocol