レッスン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 通りの呼び方で使う(ConsensusBridgetrait / 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.rs と signing_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.rs に pub 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 を満たす。