use alloc::vec::Vec;
use crate::curve::{OsstPoint, OsstScalar};
use crate::error::OsstError;
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
}
}
#[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, OsstError> {
if bytes.len() < 48 + 4 + 32 {
return Err(OsstError::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(OsstError::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: OsstPoint> {
pub commitment: DealerCommitment<P>,
pub liveness: LivenessProof,
pub signature: ContributionSignature<P::Scalar>,
}
#[derive(Clone)]
pub struct ContributionSignature<S: OsstScalar> {
pub r: [u8; 32],
pub s: S,
}
impl<S: OsstScalar> ContributionSignature<S> {
pub fn new(r: [u8; 32], s: S) -> Self {
Self { r, s }
}
pub fn to_bytes(&self) -> [u8; 64] {
let mut buf = [0u8; 64];
buf[0..32].copy_from_slice(&self.r);
buf[32..64].copy_from_slice(&self.s.to_bytes());
buf
}
pub fn from_bytes(bytes: &[u8; 64]) -> Result<Self, OsstError> {
let r: [u8; 32] = bytes[0..32].try_into().unwrap();
let s = S::from_canonical_bytes(&bytes[32..64].try_into().unwrap())
.ok_or(OsstError::InvalidResponse)?;
Ok(Self { r, s })
}
}
impl<S: OsstScalar> core::fmt::Debug for ContributionSignature<S> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("ContributionSignature")
.field("r", &hex_short(&self.r))
.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: OsstPoint> DealerContribution<P> {
pub fn new(
commitment: DealerCommitment<P>,
liveness: LivenessProof,
signature: ContributionSignature<P::Scalar>,
) -> 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};
let mut hasher = Sha512::new();
hasher.update(b"OSST-CONTRIBUTION-V1");
hasher.update(commitment.to_bytes());
hasher.update(liveness.to_bytes());
hasher.update(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 r = r_point.compress();
let e = Self::challenge_hash(&r, &message);
let s = k.add(&e.mul(secret_key));
let signature = ContributionSignature::new(r, 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 r_point = match P::decompress(&self.signature.r) {
Some(p) => p,
None => return false,
};
let e = Self::challenge_hash(&self.signature.r, &message);
let lhs = P::generator().mul_scalar(&self.signature.s);
let rhs = r_point.add(&public_key.mul_scalar(&e));
lhs == rhs
}
fn challenge_hash(r: &[u8; 32], message: &[u8; 64]) -> P::Scalar {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(r);
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: OsstPoint, V: LivenessVerifier> {
verifier: &'a V,
context: &'a [u8],
_marker: core::marker::PhantomData<P>,
}
impl<'a, P: OsstPoint, 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_batch(
&self,
contributions: &[DealerContribution<P>],
public_keys: &[P],
) -> Vec<usize> {
contributions
.iter()
.zip(public_keys.iter())
.enumerate()
.filter_map(|(i, (contrib, pk))| self.verify(contrib, pk).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 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);
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);
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]);
}
}