use alloc::vec::Vec;
use crate::curve::{CurvePoint, CurveScalar};
use crate::error::Error;
use crate::reshare::DealerCommitment;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CheckpointAnchor {
pub height: u64,
pub block_hash: [u8; 32],
pub timestamp: u64,
}
impl CheckpointAnchor {
pub fn new(height: u64, block_hash: [u8; 32], timestamp: u64) -> Self {
Self {
height,
block_hash,
timestamp,
}
}
pub fn to_bytes(&self) -> [u8; 48] {
let mut buf = [0u8; 48];
buf[0..8].copy_from_slice(&self.height.to_le_bytes());
buf[8..40].copy_from_slice(&self.block_hash);
buf[40..48].copy_from_slice(&self.timestamp.to_le_bytes());
buf
}
pub fn is_recent(&self, current_height: u64, max_age_blocks: u64) -> bool {
current_height.saturating_sub(self.height) <= max_age_blocks
}
}
pub const LIVENESS_SIG_DOMAIN: &[u8] = b"frostito/liveness-sig/v1";
pub const CONTRIBUTION_SIG_MSG_DOMAIN: &[u8] = b"frostito/contribution-sig/v2";
#[derive(Clone, Debug)]
pub struct LivenessProof {
pub anchor: CheckpointAnchor,
pub ligerito_proof: Vec<u8>,
pub state_root: [u8; 32],
}
impl LivenessProof {
pub fn new(anchor: CheckpointAnchor, ligerito_proof: Vec<u8>, state_root: [u8; 32]) -> Self {
Self {
anchor,
ligerito_proof,
state_root,
}
}
pub fn byte_size(&self) -> usize {
48 + 4 + self.ligerito_proof.len() + 32
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(self.byte_size());
buf.extend_from_slice(&self.anchor.to_bytes());
buf.extend_from_slice(&(self.ligerito_proof.len() as u32).to_le_bytes());
buf.extend_from_slice(&self.ligerito_proof);
buf.extend_from_slice(&self.state_root);
buf
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() < 48 + 4 + 32 {
return Err(Error::InvalidCommitment);
}
let height = u64::from_le_bytes(bytes[0..8].try_into().unwrap());
let block_hash: [u8; 32] = bytes[8..40].try_into().unwrap();
let timestamp = u64::from_le_bytes(bytes[40..48].try_into().unwrap());
let anchor = CheckpointAnchor {
height,
block_hash,
timestamp,
};
let proof_len = u32::from_le_bytes(bytes[48..52].try_into().unwrap()) as usize;
if bytes.len() < 52 + proof_len + 32 {
return Err(Error::InvalidCommitment);
}
let ligerito_proof = bytes[52..52 + proof_len].to_vec();
let state_root: [u8; 32] = bytes[52 + proof_len..52 + proof_len + 32]
.try_into()
.unwrap();
Ok(Self {
anchor,
ligerito_proof,
state_root,
})
}
}
#[derive(Clone, Debug)]
pub struct DealerContribution<P: CurvePoint> {
pub commitment: DealerCommitment<P>,
pub liveness: LivenessProof,
pub signature: ContributionSignature<P>,
}
#[derive(Clone)]
pub struct ContributionSignature<P: CurvePoint> {
pub r: P,
pub s: P::Scalar,
}
impl<P: CurvePoint> ContributionSignature<P> {
pub fn new(r: P, s: P::Scalar) -> Self {
Self { r, s }
}
#[inline]
pub fn byte_size() -> usize {
P::COMPRESSED_SIZE + 32
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(Self::byte_size());
buf.extend_from_slice(self.r.compress().as_ref());
buf.extend_from_slice(&self.s.to_bytes());
buf
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self, Error> {
if bytes.len() != Self::byte_size() {
return Err(Error::InvalidCommitment);
}
let n = P::COMPRESSED_SIZE;
let r = P::decompress(&bytes[0..n]).ok_or(Error::InvalidCommitment)?;
let s = P::Scalar::from_canonical_bytes(&bytes[n..n + 32].try_into().unwrap())
.ok_or(Error::InvalidResponse)?;
Ok(Self { r, s })
}
}
impl<P: CurvePoint> core::fmt::Debug for ContributionSignature<P> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ContributionSignature")
.field("r", &hex_short(self.r.compress().as_ref()))
.field("s", &"[SCALAR]")
.finish()
}
}
fn hex_short(bytes: &[u8]) -> alloc::string::String {
use alloc::format;
if bytes.len() <= 8 {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
} else {
format!(
"{}...{}",
bytes[0..4]
.iter()
.map(|b| format!("{:02x}", b))
.collect::<alloc::string::String>(),
bytes[bytes.len() - 4..]
.iter()
.map(|b| format!("{:02x}", b))
.collect::<alloc::string::String>()
)
}
}
impl<P: CurvePoint> DealerContribution<P> {
pub fn new(
commitment: DealerCommitment<P>,
liveness: LivenessProof,
signature: ContributionSignature<P>,
) -> Self {
Self {
commitment,
liveness,
signature,
}
}
pub fn dealer_index(&self) -> u32 {
self.commitment.dealer_index
}
pub fn signing_message(
commitment: &DealerCommitment<P>,
liveness: &LivenessProof,
context: &[u8],
) -> [u8; 64] {
use sha2::{Digest, Sha512};
fn field(hasher: &mut sha2::Sha512, bytes: &[u8]) {
use sha2::Digest;
hasher.update((bytes.len() as u64).to_le_bytes());
hasher.update(bytes);
}
let mut hasher = Sha512::new();
field(&mut hasher, CONTRIBUTION_SIG_MSG_DOMAIN);
field(&mut hasher, &commitment.to_bytes());
field(&mut hasher, &liveness.to_bytes());
field(&mut hasher, context);
hasher.finalize().into()
}
pub fn sign<R: rand_core::RngCore + rand_core::CryptoRng>(
commitment: DealerCommitment<P>,
liveness: LivenessProof,
secret_key: &P::Scalar,
context: &[u8],
rng: &mut R,
) -> Self {
let message = Self::signing_message(&commitment, &liveness, context);
let k = P::Scalar::random(rng);
let r_point = P::generator().mul_scalar(&k);
let public_key = P::generator().mul_scalar(secret_key);
let e = Self::challenge_hash(&r_point, &public_key, &message);
let s = k.add(&e.mul(secret_key));
let signature = ContributionSignature::new(r_point, s);
Self {
commitment,
liveness,
signature,
}
}
pub fn verify_signature(&self, public_key: &P, context: &[u8]) -> bool {
let message = Self::signing_message(&self.commitment, &self.liveness, context);
let e = Self::challenge_hash(&self.signature.r, public_key, &message);
let lhs = P::generator().mul_scalar(&self.signature.s);
let rhs = self.signature.r.add(&public_key.mul_scalar(&e));
lhs == rhs
}
pub fn challenge_hash(r: &P, public_key: &P, message: &[u8; 64]) -> P::Scalar {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(LIVENESS_SIG_DOMAIN);
hasher.update(r.compress());
hasher.update(public_key.compress());
hasher.update(message);
let hash: [u8; 64] = hasher.finalize().into();
P::Scalar::from_bytes_wide(&hash)
}
}
pub trait LivenessVerifier {
fn verify_ligerito_proof(
&self,
anchor: &CheckpointAnchor,
proof: &[u8],
state_root: &[u8; 32],
) -> bool;
fn current_anchor(&self) -> CheckpointAnchor;
fn max_checkpoint_age(&self) -> u64;
}
pub struct ContributionVerifier<'a, P: CurvePoint, V: LivenessVerifier> {
verifier: &'a V,
context: &'a [u8],
_marker: core::marker::PhantomData<P>,
}
impl<'a, P: CurvePoint, V: LivenessVerifier> ContributionVerifier<'a, P, V> {
pub fn new(verifier: &'a V, context: &'a [u8]) -> Self {
Self {
verifier,
context,
_marker: core::marker::PhantomData,
}
}
pub fn verify(
&self,
contribution: &DealerContribution<P>,
public_key: &P,
) -> Result<(), ContributionError> {
let current = self.verifier.current_anchor();
if !contribution
.liveness
.anchor
.is_recent(current.height, self.verifier.max_checkpoint_age())
{
return Err(ContributionError::CheckpointTooOld);
}
if !contribution.verify_signature(public_key, self.context) {
return Err(ContributionError::InvalidSignature);
}
if !self.verifier.verify_ligerito_proof(
&contribution.liveness.anchor,
&contribution.liveness.ligerito_proof,
&contribution.liveness.state_root,
) {
return Err(ContributionError::InvalidLigerito);
}
Ok(())
}
pub fn verify_keyed(
&self,
contribution: &DealerContribution<P>,
public_keys: &[(u32, P)],
) -> Result<(), ContributionError> {
let pk = public_keys
.iter()
.find(|(i, _)| *i == contribution.dealer_index())
.map(|(_, k)| k)
.ok_or(ContributionError::IndexMismatch)?;
self.verify(contribution, pk)
}
pub fn verify_batch(
&self,
contributions: &[DealerContribution<P>],
public_keys: &[(u32, P)],
) -> Vec<usize> {
contributions
.iter()
.enumerate()
.filter_map(|(i, contrib)| self.verify_keyed(contrib, public_keys).ok().map(|_| i))
.collect()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ContributionError {
CheckpointTooOld,
InvalidSignature,
InvalidLigerito,
IndexMismatch,
}
#[cfg(all(test, feature = "ristretto255"))]
mod tests {
use super::*;
use crate::reshare::Dealer;
use curve25519_dalek::{ristretto::RistrettoPoint, scalar::Scalar};
use rand::rngs::OsRng;
struct MockVerifier {
current_height: u64,
max_age: u64,
}
impl LivenessVerifier for MockVerifier {
fn verify_ligerito_proof(
&self,
_anchor: &CheckpointAnchor,
proof: &[u8],
_state_root: &[u8; 32],
) -> bool {
!proof.is_empty()
}
fn current_anchor(&self) -> CheckpointAnchor {
CheckpointAnchor::new(self.current_height, [0u8; 32], 0)
}
fn max_checkpoint_age(&self) -> u64 {
self.max_age
}
}
#[test]
fn contribution_message_is_injective_across_the_commitment_boundary() {
let mut rng = OsRng;
let g = <RistrettoPoint as CurvePoint>::generator();
let p1 = g.mul_scalar(&Scalar::random(&mut rng));
let p2 = g.mul_scalar(&Scalar::random(&mut rng));
let x = g.mul_scalar(&Scalar::random(&mut rng));
let x_bytes = CurvePoint::compress(&x);
let commitment_a = DealerCommitment::<RistrettoPoint> {
dealer_index: 7,
coefficients: vec![p1, p2],
};
let height_a = u64::from_le_bytes(x_bytes[0..8].try_into().unwrap());
let mut block_hash_a = [0u8; 32];
block_hash_a[0..24].copy_from_slice(&x_bytes[8..32]);
block_hash_a[24..32].copy_from_slice(&[0xA1; 8]);
let timestamp_a = 0x0102_0304_0506_0708u64;
let mut proof_a = [0x5Au8; 32];
proof_a[28..32].copy_from_slice(&0u32.to_le_bytes());
let state_root = [0xC3u8; 32];
let liveness_a = LivenessProof::new(
CheckpointAnchor::new(height_a, block_hash_a, timestamp_a),
proof_a.to_vec(),
state_root,
);
let commitment_b = DealerCommitment::<RistrettoPoint> {
dealer_index: 7,
coefficients: vec![p1, p2, x],
};
let tail = &liveness_a.to_bytes()[32..];
let liveness_b = LivenessProof::from_bytes(tail).expect("tail parses as a liveness proof");
let context = b"osst-epoch-42";
let old = |c: &DealerCommitment<RistrettoPoint>, l: &LivenessProof| {
use sha2::{Digest, Sha512};
let mut h = Sha512::new();
h.update(b"OSST-CONTRIBUTION-V1");
h.update(c.to_bytes());
h.update(l.to_bytes());
h.update(context);
let out: [u8; 64] = h.finalize().into();
out
};
assert_ne!(
commitment_a.to_bytes(),
commitment_b.to_bytes(),
"the two commitments must genuinely differ"
);
assert_eq!(
old(&commitment_a, &liveness_a),
old(&commitment_b, &liveness_b),
"the pre-0.5.0 encoding really did collide here"
);
assert_ne!(
DealerContribution::<RistrettoPoint>::signing_message(
&commitment_a,
&liveness_a,
context
),
DealerContribution::<RistrettoPoint>::signing_message(
&commitment_b,
&liveness_b,
context
),
"length prefixes must separate the two triples"
);
}
#[test]
fn contribution_message_separates_the_context_field() {
let mut rng = OsRng;
let dealer: Dealer<RistrettoPoint> =
Dealer::new(1, Scalar::random(&mut rng), 3, &mut rng).expect("index is 1-indexed by construction");
let commitment = dealer.commitment().clone();
let liveness = LivenessProof::new(
CheckpointAnchor::new(100, [1u8; 32], 1234567890),
vec![1, 2, 3, 4],
[2u8; 32],
);
assert_ne!(
DealerContribution::<RistrettoPoint>::signing_message(&commitment, &liveness, b"ab"),
DealerContribution::<RistrettoPoint>::signing_message(&commitment, &liveness, b"abc"),
);
}
#[test]
fn test_contribution_sign_verify() {
let mut rng = OsRng;
let secret = Scalar::random(&mut rng);
let public: RistrettoPoint = RistrettoPoint::generator().mul_scalar(&secret);
let dealer: Dealer<RistrettoPoint> = Dealer::new(1, Scalar::random(&mut rng), 3, &mut rng).expect("index is 1-indexed by construction");
let commitment = dealer.commitment().clone();
let anchor = CheckpointAnchor::new(100, [1u8; 32], 1234567890);
let liveness = LivenessProof::new(anchor, vec![1, 2, 3, 4], [2u8; 32]);
let context = b"test-epoch-42";
let contribution =
DealerContribution::sign(commitment, liveness, &secret, context, &mut rng);
assert!(contribution.verify_signature(&public, context));
assert!(!contribution.verify_signature(&public, b"wrong-context"));
let wrong_public: RistrettoPoint =
RistrettoPoint::generator().mul_scalar(&Scalar::random(&mut rng));
assert!(!contribution.verify_signature(&wrong_public, context));
}
#[test]
fn test_contribution_verifier() {
let mut rng = OsRng;
let verifier = MockVerifier {
current_height: 100,
max_age: 10,
};
let secret = Scalar::random(&mut rng);
let public: RistrettoPoint = RistrettoPoint::generator().mul_scalar(&secret);
let dealer: Dealer<RistrettoPoint> = Dealer::new(1, Scalar::random(&mut rng), 3, &mut rng).expect("index is 1-indexed by construction");
let commitment = dealer.commitment().clone();
let anchor = CheckpointAnchor::new(95, [1u8; 32], 0);
let liveness = LivenessProof::new(anchor, vec![1, 2, 3], [0u8; 32]);
let context = b"epoch-1";
let contribution =
DealerContribution::sign(commitment.clone(), liveness, &secret, context, &mut rng);
let cv = ContributionVerifier::<RistrettoPoint, _>::new(&verifier, context);
assert!(cv.verify(&contribution, &public).is_ok());
let old_anchor = CheckpointAnchor::new(50, [1u8; 32], 0);
let old_liveness = LivenessProof::new(old_anchor, vec![1, 2, 3], [0u8; 32]);
let old_contribution =
DealerContribution::sign(commitment, old_liveness, &secret, context, &mut rng);
assert_eq!(
cv.verify(&old_contribution, &public),
Err(ContributionError::CheckpointTooOld)
);
}
#[test]
fn test_checkpoint_serialization() {
let anchor = CheckpointAnchor::new(12345, [0xab; 32], 1700000000);
let bytes = anchor.to_bytes();
assert_eq!(bytes.len(), 48);
assert_eq!(u64::from_le_bytes(bytes[0..8].try_into().unwrap()), 12345);
}
#[test]
fn test_liveness_proof_serialization() {
let anchor = CheckpointAnchor::new(100, [1u8; 32], 123);
let proof = LivenessProof::new(anchor.clone(), vec![1, 2, 3, 4, 5], [2u8; 32]);
let bytes = proof.to_bytes();
let recovered = LivenessProof::from_bytes(&bytes).unwrap();
assert_eq!(recovered.anchor, anchor);
assert_eq!(recovered.ligerito_proof, vec![1, 2, 3, 4, 5]);
assert_eq!(recovered.state_root, [2u8; 32]);
}
}