Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions crates/crypto/src/threshold_signature/indexed/config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Configuration types for threshold signing.

use std::collections::HashSet;
use std::{collections::HashSet, num::NonZero};

use arbitrary::Arbitrary;
use borsh::{BorshDeserialize, BorshSerialize};
Expand All @@ -10,12 +10,14 @@ use super::{CompressedPublicKey, ThresholdSignatureError};
/// Configuration for a threshold signature authority.
///
/// Defines who can sign (`keys`) and how many must sign (`threshold`).
#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)]
/// The threshold is stored as `NonZero<u8>` to enforce at the type level
/// that it can never be zero.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ThresholdConfig {
/// Public keys of all authorized signers.
keys: Vec<CompressedPublicKey>,
/// Minimum number of signatures required.
threshold: u8,
/// Minimum number of signatures required (always >= 1).
threshold: NonZero<u8>,
}

impl ThresholdConfig {
Expand All @@ -31,11 +33,14 @@ impl ThresholdConfig {
keys: Vec<CompressedPublicKey>,
threshold: u8,
) -> Result<Self, ThresholdSignatureError> {
// Validate threshold is non-zero
let threshold = NonZero::new(threshold).ok_or(ThresholdSignatureError::ZeroThreshold)?;

let mut config = ThresholdConfig {
keys: vec![],
threshold: 0,
threshold,
};
let update = ThresholdConfigUpdate::new(keys, vec![], threshold);
let update = ThresholdConfigUpdate::new(keys, vec![], threshold.get());
config.apply_update(&update)?;
Ok(config)
}
Expand All @@ -45,9 +50,9 @@ impl ThresholdConfig {
&self.keys
}

/// Get the threshold.
/// Get the threshold value.
pub fn threshold(&self) -> u8 {
self.threshold
self.threshold.get()
}

/// Get the number of authorized signers.
Expand Down Expand Up @@ -125,13 +130,32 @@ impl ThresholdConfig {
// Add new members
self.keys.extend_from_slice(update.add_members());

// Update threshold
self.threshold = update.new_threshold();
// Update threshold - safe because validate_update already checked it's non-zero
self.threshold =
NonZero::new(update.new_threshold()).expect("validate_update ensures non-zero");

Ok(())
}
}

impl BorshSerialize for ThresholdConfig {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
self.keys.serialize(writer)?;
self.threshold.get().serialize(writer)
}
}

impl BorshDeserialize for ThresholdConfig {
fn deserialize_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<Self> {
let keys = Vec::<CompressedPublicKey>::deserialize_reader(reader)?;
let threshold_u8 = u8::deserialize_reader(reader)?;
let threshold = NonZero::new(threshold_u8).ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::InvalidData, "threshold cannot be zero")
})?;
Ok(Self { keys, threshold })
}
}

impl std::hash::Hash for CompressedPublicKey {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.serialize().hash(state);
Expand Down
9 changes: 6 additions & 3 deletions crates/crypto/src/threshold_signature/indexed/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ pub enum ThresholdSignatureError {
InsufficientSignatures { provided: usize, required: usize },

/// Invalid public key data.
#[error("invalid public key at index {index}: {reason}")]
InvalidPublicKey { index: usize, reason: String },
#[error("invalid public key{}: {reason}", index.map(|i| format!(" at index {}", i)).unwrap_or_default())]
InvalidPublicKey {
index: Option<usize>,
reason: String,
},

/// Invalid threshold value.
#[error("invalid threshold: {threshold} exceeds total keys {total_keys}")]
Expand Down Expand Up @@ -61,7 +64,7 @@ pub enum ThresholdSignatureError {
impl From<secp256k1::Error> for ThresholdSignatureError {
fn from(e: secp256k1::Error) -> Self {
Self::InvalidPublicKey {
index: 0,
index: None,
reason: e.to_string(),
}
}
Expand Down
16 changes: 12 additions & 4 deletions crates/crypto/src/threshold_signature/indexed/pubkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::ops::Deref;
use arbitrary::Arbitrary;
use borsh::{BorshDeserialize, BorshSerialize};
use secp256k1::{PublicKey, Secp256k1, SecretKey};
#[cfg(feature = "serde")]
#[cfg(all(feature = "serde", not(target_os = "zkvm")))]
use serde::{Deserialize, Serialize};

use super::ThresholdSignatureError;
Expand All @@ -15,6 +15,14 @@ use super::ThresholdSignatureError;
/// This is a thin wrapper around `secp256k1::PublicKey` that adds Borsh
/// serialization support. Unlike `EvenPublicKey`, this type does not
/// enforce even parity - it accepts any valid compressed public key.
///
/// **Why no parity enforcement?** This key is used for ECDSA signature
/// verification (not Schnorr/BIP340). ECDSA signatures work with both
/// even and odd parity keys, unlike Schnorr which requires even parity
/// for x-only public keys.
///
/// Serializes the key as a 33-byte compressed point where the first byte
/// indicates the y-coordinate parity (0x02 for even, 0x03 for odd).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CompressedPublicKey(PublicKey);

Expand All @@ -25,7 +33,7 @@ impl CompressedPublicKey {
pub fn from_slice(data: &[u8]) -> Result<Self, ThresholdSignatureError> {
let pk =
PublicKey::from_slice(data).map_err(|e| ThresholdSignatureError::InvalidPublicKey {
index: 0,
index: None,
reason: e.to_string(),
})?;
Ok(Self(pk))
Expand Down Expand Up @@ -101,7 +109,7 @@ impl BorshDeserialize for CompressedPublicKey {
}
}

#[cfg(feature = "serde")]
#[cfg(all(feature = "serde", not(target_os = "zkvm")))]
impl Serialize for CompressedPublicKey {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
Expand All @@ -113,7 +121,7 @@ impl Serialize for CompressedPublicKey {
}
}

#[cfg(feature = "serde")]
#[cfg(all(feature = "serde", not(target_os = "zkvm")))]
impl<'de> Deserialize<'de> for CompressedPublicKey {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand Down
25 changes: 17 additions & 8 deletions crates/crypto/src/threshold_signature/indexed/signature.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ use super::ThresholdSignatureError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexedSignature {
/// Index of the signer in the ThresholdConfig keys array (0-255).
pub index: u8,
index: u8,
/// 65-byte recoverable ECDSA signature (recovery_id || r || s).
/// Using recoverable format for hardware wallet compatibility (Ledger/Trezor native output).
pub signature: [u8; 65],
signature: [u8; 65],
}

impl IndexedSignature {
Expand All @@ -23,19 +23,28 @@ impl IndexedSignature {
Self { index, signature }
}

/// Get the signer index.
pub fn index(&self) -> u8 {
self.index
}

/// Get the recovery ID (first byte of the signature).
pub fn recovery_id(&self) -> u8 {
self.signature[0]
}

/// Get the r component (bytes 1-32).
pub fn r(&self) -> &[u8; 32] {
self.signature[1..33].try_into().unwrap()
self.signature[1..33]
.try_into()
.expect("signature[1..33] is always 32 bytes")
}

/// Get the s component (bytes 33-64).
pub fn s(&self) -> &[u8; 32] {
self.signature[33..65].try_into().unwrap()
self.signature[33..65]
.try_into()
.expect("signature[33..65] is always 32 bytes")
}

/// Get the compact signature (r || s) without recovery ID.
Expand Down Expand Up @@ -166,9 +175,9 @@ mod tests {
let set = SignatureSet::new(sigs).unwrap();

// Should be sorted
assert_eq!(set.signatures()[0].index, 0);
assert_eq!(set.signatures()[1].index, 1);
assert_eq!(set.signatures()[2].index, 2);
assert_eq!(set.signatures()[0].index(), 0);
assert_eq!(set.signatures()[1].index(), 1);
assert_eq!(set.signatures()[2].index(), 2);
}

#[test]
Expand Down Expand Up @@ -201,7 +210,7 @@ mod tests {

let sig = IndexedSignature::new(5, signature);

assert_eq!(sig.index, 5);
assert_eq!(sig.index(), 5);
assert_eq!(sig.recovery_id(), 27);
assert_eq!(sig.r(), &[0xAA; 32]);
assert_eq!(sig.s(), &[0xBB; 32]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ pub(super) fn verify_ecdsa_signatures(
// Verify each signature
for indexed_sig in signatures.signatures() {
// Check index is in bounds
let index = indexed_sig.index as usize;
let index = indexed_sig.index() as usize;
if index >= config.keys().len() {
return Err(ThresholdSignatureError::SignerIndexOutOfBounds {
index: indexed_sig.index,
index: indexed_sig.index(),
max: config.keys().len(),
});
}
Expand All @@ -46,13 +46,13 @@ pub(super) fn verify_ecdsa_signatures(
let recovered_pubkey = SECP256K1
.recover_ecdsa(&message, &recoverable_sig)
.map_err(|_| ThresholdSignatureError::InvalidSignature {
index: indexed_sig.index,
index: indexed_sig.index(),
})?;

// Verify the recovered key matches the expected key
if &recovered_pubkey != expected_pubkey {
return Err(ThresholdSignatureError::InvalidSignature {
index: indexed_sig.index,
index: indexed_sig.index(),
});
}
}
Expand Down
Loading