FABRKNT
Step 1. Consensus — cargo init から single-validator devnet 構築
CL types
レッスン 8 / 16·CONTENT40 分80 XP
コース
Step 1. Consensus — cargo init から single-validator devnet 構築
レッスンの役割
CONTENT
順序
8 / 16

レッスン7 — OpenHlSigningProvider と canonical encoding

問い

vote / proposal に署名するとき、何のバイト列 に署名するのか? そして、なぜ serde::Serialize から derive したバイト列に署名してはいけないのか?

原理(最小モデル)

  • canonical encoding は consensus-critical。 署名対象のバイトレイアウトは chain の spec の一部。serde 由来だと、serde バージョンが違う validator が同じ vote から違うバイト列を作って別物に署名 → fork。自分で 1 バイト単位を制御する。
  • stateful provider が純粋関数を wrap。 sign_vote(vote, &sk) は free function(テストが直接呼ぶ)、OpenHlSigningProvider は鍵を保持して sp.sign_vote(vote) を Malachite に提供する。1 ロジックを 2 通りの呼び方で使う(ConsensusBridge trait / impl 分離と同じ)。
  • Ed25519 が鍵を型分離。 PrivateKey::sign は存在し PublicKey::sign は存在しない — 公開鍵で署名する事故を compiler が拒否。
  • 未使用機能には空バイト署名。 trait surface が要求するが chain が使わない機能(proposal part / vote extension)は、確定的な空データに署名して contract を honor しつつ偽データを作らない。

具体例

vote の signing-bytes(value_id=Val ケース、全 70 バイト):

┌── Height 8B ──┬── Round 8B ──┬Typ┬Tag┬── Value ID 32B ──┬── Address 20B ──┐
0              8              16  17  18                 50                70
  u64 LE         i64 LE        │   │   BlockHash 本体       20-byte Eth addr
                               │   └ 0=Nil / 1=Val
                               └── 0=Prevote / 1=Precommit

どの validator がどの host で走らせても、この 70 バイトは完全に同一に生成される — それが Ed25519 が署名するメッセージ。

失敗例(誤解)

bincode::serialize(v) の結果に署名すればいい」は誤り。既製シリアライズはライブラリ更新でバイトが変わりうる — 同じ struct でも今日と明日で違う。canonical encoding は自分で制御し、chain の wire format spec の一部にする。


ここまでで「何に署名するか・なぜ canonical か」は着地した。ここから signing.rssigning_provider.rs を組み立てる。コードは完全形。

🛑 予測。 OpenHlVote の canonical encoding はどのフィールドを含むべきか? ヒント: 含め忘れたフィールドがあると、意味の違う 2 つの vote が同じ signing-bytes になり、片方の署名がもう片方に通って replay/swap 攻撃が成立する。

ステップで組み立てる

Step 1-3: signing.rs — canonical encoding

//! Canonical encoding + signing for proposals and votes.
//!
//! v0 uses a simple length-prefixed concatenation rather than Protobuf/SSZ.
//! Real production validators will want a stable serialization format
//! (ステップ 2's `openhl-codec` crate is the natural home for that).

use informalsystems_malachitebft_core_types::{NilOrVal, Round, SignedMessage, VoteType};
use informalsystems_malachitebft_signing_ed25519::{PrivateKey, Signature};

use crate::types::{OpenHlProposal, OpenHlVote};

/// Canonical bytes that a vote signature commits to.
#[must_use]
pub fn vote_signing_bytes(v: &OpenHlVote) -> Vec<u8> {
    let mut buf = Vec::with_capacity(128);
    buf.extend_from_slice(&v.height.0.to_le_bytes());
    buf.extend_from_slice(&round_to_i64(v.round).to_le_bytes());
    buf.push(match v.vote_type {
        VoteType::Prevote => 0,
        VoteType::Precommit => 1,
    });
    match v.value_id {
        NilOrVal::Nil => buf.push(0),
        NilOrVal::Val(h) => {
            buf.push(1);
            buf.extend_from_slice(&h.0);
        }
    }
    buf.extend_from_slice(&v.address.0);
    buf
}

/// Canonical bytes that a proposal signature commits to.
#[must_use]
pub fn proposal_signing_bytes(p: &OpenHlProposal) -> Vec<u8> {
    let mut buf = Vec::with_capacity(128);
    buf.extend_from_slice(&p.height.0.to_le_bytes());
    buf.extend_from_slice(&round_to_i64(p.round).to_le_bytes());
    buf.extend_from_slice(&p.value.0.0);
    buf.extend_from_slice(&round_to_i64(p.pol_round).to_le_bytes());
    buf.extend_from_slice(&p.address.0);
    buf
}

要点: little-endian は x86/ARM 慣習。NilOrVal に tag バイト(0=Nil 1B / 1=Val 33B)を付けてパーサが判別可能に。address を含めるのは「誰の vote か」も署名対象だから。proposal の value は NilOrVal でなく無条件 BlockHash(proposal は必ず value を運ぶ)。p.value.0.0 は 2 段 newtype(OpenHlValue(BlockHash([u8;32])))の unwrap。

Step 4-5: sign / verify + VerifierLike shim

#[must_use]
pub fn sign_vote(v: OpenHlVote, sk: &PrivateKey) -> SignedMessage<crate::OpenHlContext, OpenHlVote> {
    let sig = sk.sign(&vote_signing_bytes(&v));
    SignedMessage::new(v, sig)
}

#[must_use]
pub fn sign_proposal(
    p: OpenHlProposal,
    sk: &PrivateKey,
) -> SignedMessage<crate::OpenHlContext, OpenHlProposal> {
    let sig = sk.sign(&proposal_signing_bytes(&p));
    SignedMessage::new(p, sig)
}

/// Verify a vote signature against the public key recorded for `vote.address`.
/// Returns false on bad signature.
#[must_use]
pub fn verify_vote(v: &OpenHlVote, sig: &Signature, public_key: &impl VerifierLike) -> bool {
    public_key.verify_msg(&vote_signing_bytes(v), sig).is_ok()
}

/// Trait shim so consumers can pass `&malachitebft_signing_ed25519::PublicKey`
/// without depending on the underlying `signature` crate's trait surface.
pub trait VerifierLike {
    fn verify_msg(&self, msg: &[u8], sig: &Signature) -> Result<(), VerifyError>;
}

#[derive(Debug)]
pub struct VerifyError;

impl VerifierLike for informalsystems_malachitebft_signing_ed25519::PublicKey {
    fn verify_msg(&self, msg: &[u8], sig: &Signature) -> Result<(), VerifyError> {
        self.verify(msg, sig).map_err(|_| VerifyError)
    }
}

fn round_to_i64(r: Round) -> i64 {
    r.as_i64()
}

sign は所有権を取り canonical bytes に Ed25519 署名 → SignedMessage で wrap。VerifierLike は外部 crate(signature::Verifier)依存を公開 API から隠す shim — 上流差し替え時の breaking change を signing.rs 1 箇所に閉じ込める。

Step 6: signing.rs の 2 テスト

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{OpenHlAddress, OpenHlHeight};
    use openhl_types::BlockHash;
    use rand::rngs::OsRng;

    #[test]
    fn vote_signature_round_trips() {
        let sk = PrivateKey::generate(OsRng);
        let pk = sk.public_key();
        let vote = OpenHlVote {
            height: OpenHlHeight(7),
            round: Round::new(0),
            value_id: NilOrVal::Val(BlockHash([0x42; 32])),
            vote_type: VoteType::Prevote,
            address: OpenHlAddress([0xaa; 20]),
        };
        let signed = sign_vote(vote.clone(), &sk);
        assert!(verify_vote(&vote, &signed.signature, &pk));
    }

    #[test]
    fn vote_signature_is_field_sensitive() {
        let sk = PrivateKey::generate(OsRng);
        let pk = sk.public_key();
        let vote = OpenHlVote {
            height: OpenHlHeight(7),
            round: Round::new(0),
            value_id: NilOrVal::Val(BlockHash([0x42; 32])),
            vote_type: VoteType::Prevote,
            address: OpenHlAddress([0xaa; 20]),
        };
        let signed = sign_vote(vote.clone(), &sk);
        // Mutate value_id; signature should no longer verify.
        let mut tampered = vote;
        tampered.value_id = NilOrVal::Val(BlockHash([0x43; 32]));
        assert!(!verify_vote(&tampered, &signed.signature, &pk));
    }
}

2 つ目が load-bearing — canonical encoding が意味あるフィールドすべてに敏感であることを証明(含め忘れがあれば tampered でも検証が通ってしまい、このテストが落ちる)。

Step 7-8: signing_provider.rs — struct + 8-method impl

//! `SigningProvider` implementation — the trait the Malachite engine plugs in.
//!
//! Holds our private key as state; delegates the actual signing to
//! [`crate::signing`]'s canonical encoding so the wire format and the engine
//! interface stay consistent.

use informalsystems_malachitebft_core_types::{SignedMessage, SigningProvider};
use informalsystems_malachitebft_signing_ed25519::{PrivateKey, PublicKey, Signature};

use crate::context::OpenHlContext;
use crate::signing::{
    proposal_signing_bytes, sign_proposal as sign_proposal_with,
    sign_vote as sign_vote_with, vote_signing_bytes,
};
use crate::types::{OpenHlProposal, OpenHlProposalPart, OpenHlVote};

#[derive(Debug)]
pub struct OpenHlSigningProvider {
    private_key: PrivateKey,
}

impl OpenHlSigningProvider {
    #[must_use]
    pub const fn new(private_key: PrivateKey) -> Self {
        Self { private_key }
    }

    #[must_use]
    pub fn public_key(&self) -> PublicKey {
        self.private_key.public_key()
    }
}

impl SigningProvider<OpenHlContext> for OpenHlSigningProvider {
    fn sign_vote(&self, vote: OpenHlVote) -> SignedMessage<OpenHlContext, OpenHlVote> {
        sign_vote_with(vote, &self.private_key)
    }

    fn verify_signed_vote(
        &self,
        vote: &OpenHlVote,
        signature: &Signature,
        public_key: &PublicKey,
    ) -> bool {
        public_key.verify(&vote_signing_bytes(vote), signature).is_ok()
    }

    fn sign_proposal(
        &self,
        proposal: OpenHlProposal,
    ) -> SignedMessage<OpenHlContext, OpenHlProposal> {
        sign_proposal_with(proposal, &self.private_key)
    }

    fn verify_signed_proposal(
        &self,
        proposal: &OpenHlProposal,
        signature: &Signature,
        public_key: &PublicKey,
    ) -> bool {
        public_key
            .verify(&proposal_signing_bytes(proposal), signature)
            .is_ok()
    }

    fn sign_proposal_part(
        &self,
        part: OpenHlProposalPart,
    ) -> SignedMessage<OpenHlContext, OpenHlProposalPart> {
        // ProposalPart is a unit struct in OpenHL (ValuePayload::ProposalOnly mode);
        // sign empty bytes so the type-level contract is honored but no extra
        // information is committed.
        let sig = self.private_key.sign(&[]);
        SignedMessage::new(part, sig)
    }

    fn verify_signed_proposal_part(
        &self,
        _part: &OpenHlProposalPart,
        signature: &Signature,
        public_key: &PublicKey,
    ) -> bool {
        public_key.verify(&[], signature).is_ok()
    }

    fn sign_vote_extension(&self, ext: ()) -> SignedMessage<OpenHlContext, ()> {
        // Vote extensions are unused at v0 (Context::Extension = ()).
        let sig = self.private_key.sign(&[]);
        SignedMessage::new(ext, sig)
    }

    fn verify_signed_vote_extension(
        &self,
        _ext: &(),
        signature: &Signature,
        public_key: &PublicKey,
    ) -> bool {
        public_key.verify(&[], signature).is_ok()
    }
}

8 method = 4 sign/verify ペア。signing.rs の低レベル関数を as sign_vote_with でリネーム import するのは trait メソッド名(sign_vote)との衝突回避。proposal_part / vote_extension は空バイト署名(unit 型でコミットすべきデータがない、だが valid な署名は出るので trait surface を満たしエンジンが crash しない)。

Step 9: signing_provider.rs の 7 テスト

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{OpenHlAddress, OpenHlHeight, OpenHlValue};
    use informalsystems_malachitebft_core_types::{NilOrVal, Round, VoteType};
    use openhl_types::BlockHash;
    use rand::rngs::OsRng;

    fn provider() -> (OpenHlSigningProvider, PublicKey) {
        let sk = PrivateKey::generate(OsRng);
        let pk = sk.public_key();
        (OpenHlSigningProvider::new(sk), pk)
    }

    fn sample_vote() -> OpenHlVote {
        OpenHlVote {
            height: OpenHlHeight(1),
            round: Round::new(0),
            value_id: NilOrVal::Val(BlockHash([0x42; 32])),
            vote_type: VoteType::Prevote,
            address: OpenHlAddress([0xaa; 20]),
        }
    }

    fn sample_proposal() -> OpenHlProposal {
        OpenHlProposal {
            height: OpenHlHeight(1),
            round: Round::new(0),
            value: OpenHlValue(BlockHash([0x42; 32])),
            pol_round: Round::Nil,
            address: OpenHlAddress([0xaa; 20]),
        }
    }

    #[test]
    fn vote_sign_verify_round_trips() {
        let (sp, pk) = provider();
        let vote = sample_vote();
        let signed = sp.sign_vote(vote.clone());
        assert!(sp.verify_signed_vote(&vote, &signed.signature, &pk));
    }

    #[test]
    fn vote_tamper_detected() {
        let (sp, pk) = provider();
        let vote = sample_vote();
        let signed = sp.sign_vote(vote.clone());
        let mut tampered = vote;
        tampered.value_id = NilOrVal::Val(BlockHash([0x43; 32]));
        assert!(!sp.verify_signed_vote(&tampered, &signed.signature, &pk));
    }

    #[test]
    fn proposal_sign_verify_round_trips() {
        let (sp, pk) = provider();
        let proposal = sample_proposal();
        let signed = sp.sign_proposal(proposal.clone());
        assert!(sp.verify_signed_proposal(&proposal, &signed.signature, &pk));
    }

    #[test]
    fn proposal_tamper_detected() {
        let (sp, pk) = provider();
        let proposal = sample_proposal();
        let signed = sp.sign_proposal(proposal.clone());
        let mut tampered = proposal;
        tampered.value = OpenHlValue(BlockHash([0x99; 32]));
        assert!(!sp.verify_signed_proposal(&tampered, &signed.signature, &pk));
    }

    #[test]
    fn proposal_part_sign_verify_round_trips() {
        let (sp, pk) = provider();
        let part = OpenHlProposalPart;
        let signed = sp.sign_proposal_part(part);
        assert!(sp.verify_signed_proposal_part(&part, &signed.signature, &pk));
    }

    #[test]
    fn vote_extension_sign_verify_round_trips() {
        let (sp, pk) = provider();
        let signed = sp.sign_vote_extension(());
        assert!(sp.verify_signed_vote_extension(&(), &signed.signature, &pk));
    }

    #[test]
    fn signature_from_one_provider_does_not_verify_under_another() {
        let (sp1, _pk1) = provider();
        let (_sp2, pk2) = provider();
        let vote = sample_vote();
        let signed = sp1.sign_vote(vote.clone());
        // Signed by provider 1, verified against provider 2's public key — must fail.
        assert!(!sp1.verify_signed_vote(&vote, &signed.signature, &pk2));
    }
}

最後の signature_from_one_provider_does_not_verify_under_another が load-bearing なセキュリティ保証 — 署名は特定の鍵に紐付き、別の鍵の署名は交換不可能。

Step 10: lib.rs

//! Consensus layer — Malachite BFT.

pub mod bridge;
pub mod context;
pub mod signing;
pub mod signing_provider;
pub mod types;

pub use context::OpenHlContext;

答え合わせ

cd ~/code/openhl-reference && git checkout 9e810a7
diff -u ~/code/my-openhl/crates/consensus/src/signing.rs ./crates/consensus/src/signing.rs
diff -u ~/code/my-openhl/crates/consensus/src/signing_provider.rs ./crates/consensus/src/signing_provider.rs
git checkout main

doc の文言は違って OK。canonical encoding のバイト順、SigningProvider impl の委譲先、テストパターンは厳密一致が必要。9e810a7 には後のレッスンの変更も含まれるので signing 関連ファイルだけ diff する。

合格基準

cargo test -p openhl-consensus

14 テスト pass(レッスン6 の 5 + signing 2 + signing_provider 7)。よくあるミス: lib.rspub mod signing; 追加忘れ / 8 method すべて未実装 / canonical encoding が value_id 等を含めず tamper テストが逆に落ちる。

まとめ(3行)

  • canonical encoding は自分で 1 バイト単位を制御し chain の spec の一部にする(serde derive はバージョン差で fork する)。
  • OpenHlSigningProvider は鍵を状態に持ち、純粋関数 sign_vote に委譲 — テストは関数を直接、エンジンは trait メソッドを使う。
  • 未使用機能(proposal part / vote extension)は空バイト署名で trait surface を満たす。