mirror of
https://github.com/serai-dex/serai.git
synced 2025-12-08 20:29:23 +00:00
330 lines
12 KiB
Rust
330 lines
12 KiB
Rust
/*
|
|
A MuSig-based signing protocol executed with the validators' keys.
|
|
|
|
This is used for confirming the results of a DKG on-chain, an operation requiring all validators
|
|
which aren't specified as removed while still satisfying a supermajority.
|
|
|
|
Since we're using the validator's keys, as needed for their being the root of trust, the
|
|
coordinator must perform the signing. This is distinct from all other group-signing operations,
|
|
as they're all done by the processor.
|
|
|
|
The MuSig-aggregation achieves on-chain efficiency and enables a more secure design pattern.
|
|
While we could individually tack votes, that'd require logic to prevent voting multiple times and
|
|
tracking the accumulated votes. MuSig-aggregation simply requires checking the list is sorted and
|
|
the list's weight exceeds the threshold.
|
|
|
|
Instead of maintaining state in memory, a combination of the DB and re-execution are used. This
|
|
is deemed acceptable re: performance as:
|
|
|
|
1) This is only done prior to a DKG being confirmed on Substrate and is assumed infrequent.
|
|
2) This is an O(n) algorithm.
|
|
3) The size of the validator set is bounded by MAX_KEY_SHARES_PER_SET.
|
|
|
|
Accordingly, this should be tolerable.
|
|
|
|
As for safety, it is explicitly unsafe to reuse nonces across signing sessions. This raises
|
|
concerns regarding our re-execution which is dependent on fixed nonces. Safety is derived from
|
|
the nonces being context-bound under a BFT protocol. The flow is as follows:
|
|
|
|
1) Decide the nonce.
|
|
2) Publish the nonces' commitments, receiving everyone elses *and potentially the message to be
|
|
signed*.
|
|
3) Sign and publish the signature share.
|
|
|
|
In order for nonce re-use to occur, the received nonce commitments (or the message to be signed)
|
|
would have to be distinct and sign would have to be called again.
|
|
|
|
Before we act on any received messages, they're ordered and finalized by a BFT algorithm. The
|
|
only way to operate on distinct received messages would be if:
|
|
|
|
1) A logical flaw exists, letting new messages over write prior messages
|
|
2) A reorganization occurred from chain A to chain B, and with it, different messages
|
|
|
|
Reorganizations are not supported, as BFT is assumed by the presence of a BFT algorithm. While
|
|
a significant amount of processes may be byzantine, leading to BFT being broken, that still will
|
|
not trigger a reorganization. The only way to move to a distinct chain, with distinct messages,
|
|
would be by rebuilding the local process (this time following chain B). Upon any complete
|
|
rebuild, we'd re-decide nonces, achieving safety. This does set a bound preventing partial
|
|
rebuilds which is accepted.
|
|
|
|
Additionally, to ensure a rebuilt service isn't flagged as malicious, we have to check the
|
|
commitments generated from the decided nonces are in fact its commitments on-chain (TODO).
|
|
|
|
TODO: We also need to review how we're handling Processor preprocesses and likely implement the
|
|
same on-chain-preprocess-matches-presumed-preprocess check before publishing shares.
|
|
*/
|
|
|
|
use core::ops::Deref;
|
|
use std::collections::HashMap;
|
|
|
|
use zeroize::{Zeroize, Zeroizing};
|
|
|
|
use rand_core::OsRng;
|
|
|
|
use blake2::{Digest, Blake2s256};
|
|
|
|
use ciphersuite::{
|
|
group::{ff::PrimeField, GroupEncoding},
|
|
Ciphersuite, Ristretto,
|
|
};
|
|
use frost::{
|
|
FrostError,
|
|
dkg::{Participant, musig::musig},
|
|
ThresholdKeys,
|
|
sign::*,
|
|
};
|
|
use frost_schnorrkel::Schnorrkel;
|
|
|
|
use scale::Encode;
|
|
|
|
use serai_client::{
|
|
Public,
|
|
validator_sets::primitives::{KeyPair, musig_context, set_keys_message},
|
|
};
|
|
|
|
use serai_db::*;
|
|
|
|
use crate::tributary::TributarySpec;
|
|
|
|
create_db!(
|
|
SigningProtocolDb {
|
|
CachedPreprocesses: (context: &impl Encode) -> [u8; 32]
|
|
}
|
|
);
|
|
|
|
struct SigningProtocol<'a, T: DbTxn, C: Encode> {
|
|
pub(crate) key: &'a Zeroizing<<Ristretto as Ciphersuite>::F>,
|
|
pub(crate) spec: &'a TributarySpec,
|
|
pub(crate) txn: &'a mut T,
|
|
pub(crate) context: C,
|
|
}
|
|
|
|
impl<T: DbTxn, C: Encode> SigningProtocol<'_, T, C> {
|
|
fn preprocess_internal(
|
|
&mut self,
|
|
participants: &[<Ristretto as Ciphersuite>::G],
|
|
) -> (AlgorithmSignMachine<Ristretto, Schnorrkel>, [u8; 64]) {
|
|
// Encrypt the cached preprocess as recovery of it will enable recovering the private key
|
|
// While the DB isn't expected to be arbitrarily readable, it isn't a proper secret store and
|
|
// shouldn't be trusted as one
|
|
let mut encryption_key = {
|
|
let mut encryption_key_preimage =
|
|
Zeroizing::new(b"Cached Preprocess Encryption Key".to_vec());
|
|
encryption_key_preimage.extend(self.context.encode());
|
|
let repr = Zeroizing::new(self.key.to_repr());
|
|
encryption_key_preimage.extend(repr.deref());
|
|
Blake2s256::digest(&encryption_key_preimage)
|
|
};
|
|
let encryption_key_slice: &mut [u8] = encryption_key.as_mut();
|
|
|
|
let algorithm = Schnorrkel::new(b"substrate");
|
|
let keys: ThresholdKeys<Ristretto> =
|
|
musig(&musig_context(self.spec.set()), self.key, participants)
|
|
.expect("signing for a set we aren't in/validator present multiple times")
|
|
.into();
|
|
|
|
if CachedPreprocesses::get(self.txn, &self.context).is_none() {
|
|
let (machine, _) =
|
|
AlgorithmMachine::new(algorithm.clone(), keys.clone()).preprocess(&mut OsRng);
|
|
|
|
let mut cache = machine.cache();
|
|
assert_eq!(cache.0.len(), 32);
|
|
#[allow(clippy::needless_range_loop)]
|
|
for b in 0 .. 32 {
|
|
cache.0[b] ^= encryption_key_slice[b];
|
|
}
|
|
|
|
CachedPreprocesses::set(self.txn, &self.context, &cache.0);
|
|
}
|
|
|
|
let cached = CachedPreprocesses::get(self.txn, &self.context).unwrap();
|
|
let mut cached: Zeroizing<[u8; 32]> = Zeroizing::new(cached);
|
|
#[allow(clippy::needless_range_loop)]
|
|
for b in 0 .. 32 {
|
|
cached[b] ^= encryption_key_slice[b];
|
|
}
|
|
encryption_key_slice.zeroize();
|
|
let (machine, preprocess) =
|
|
AlgorithmSignMachine::from_cache(algorithm, keys, CachedPreprocess(cached));
|
|
|
|
(machine, preprocess.serialize().try_into().unwrap())
|
|
}
|
|
|
|
fn share_internal(
|
|
&mut self,
|
|
participants: &[<Ristretto as Ciphersuite>::G],
|
|
mut serialized_preprocesses: HashMap<Participant, Vec<u8>>,
|
|
msg: &[u8],
|
|
) -> Result<(AlgorithmSignatureMachine<Ristretto, Schnorrkel>, [u8; 32]), Participant> {
|
|
let machine = self.preprocess_internal(participants).0;
|
|
|
|
let mut participants = serialized_preprocesses.keys().copied().collect::<Vec<_>>();
|
|
participants.sort();
|
|
let mut preprocesses = HashMap::new();
|
|
for participant in participants {
|
|
preprocesses.insert(
|
|
participant,
|
|
machine
|
|
.read_preprocess(&mut serialized_preprocesses.remove(&participant).unwrap().as_slice())
|
|
.map_err(|_| participant)?,
|
|
);
|
|
}
|
|
|
|
let (machine, share) = machine.sign(preprocesses, msg).map_err(|e| match e {
|
|
FrostError::InternalError(e) => unreachable!("FrostError::InternalError {e}"),
|
|
FrostError::InvalidParticipant(_, _) |
|
|
FrostError::InvalidSigningSet(_) |
|
|
FrostError::InvalidParticipantQuantity(_, _) |
|
|
FrostError::DuplicatedParticipant(_) |
|
|
FrostError::MissingParticipant(_) => unreachable!("{e:?}"),
|
|
FrostError::InvalidPreprocess(p) | FrostError::InvalidShare(p) => p,
|
|
})?;
|
|
|
|
Ok((machine, share.serialize().try_into().unwrap()))
|
|
}
|
|
|
|
fn complete_internal(
|
|
machine: AlgorithmSignatureMachine<Ristretto, Schnorrkel>,
|
|
shares: HashMap<Participant, Vec<u8>>,
|
|
) -> Result<[u8; 64], Participant> {
|
|
let shares = shares
|
|
.into_iter()
|
|
.map(|(p, share)| {
|
|
machine.read_share(&mut share.as_slice()).map(|share| (p, share)).map_err(|_| p)
|
|
})
|
|
.collect::<Result<HashMap<_, _>, _>>()?;
|
|
let signature = machine.complete(shares).map_err(|e| match e {
|
|
FrostError::InternalError(e) => unreachable!("FrostError::InternalError {e}"),
|
|
FrostError::InvalidParticipant(_, _) |
|
|
FrostError::InvalidSigningSet(_) |
|
|
FrostError::InvalidParticipantQuantity(_, _) |
|
|
FrostError::DuplicatedParticipant(_) |
|
|
FrostError::MissingParticipant(_) => unreachable!("{e:?}"),
|
|
FrostError::InvalidPreprocess(p) | FrostError::InvalidShare(p) => p,
|
|
})?;
|
|
Ok(signature.to_bytes())
|
|
}
|
|
}
|
|
|
|
// Get the keys of the participants, noted by their threshold is, and return a new map indexed by
|
|
// the MuSig is.
|
|
fn threshold_i_map_to_keys_and_musig_i_map(
|
|
spec: &TributarySpec,
|
|
removed: &[<Ristretto as Ciphersuite>::G],
|
|
our_key: &Zeroizing<<Ristretto as Ciphersuite>::F>,
|
|
mut map: HashMap<Participant, Vec<u8>>,
|
|
) -> (Vec<<Ristretto as Ciphersuite>::G>, HashMap<Participant, Vec<u8>>) {
|
|
// Insert our own index so calculations aren't offset
|
|
let our_threshold_i =
|
|
spec.i(removed, <Ristretto as Ciphersuite>::generator() * our_key.deref()).unwrap().start;
|
|
assert!(map.insert(our_threshold_i, vec![]).is_none());
|
|
|
|
let spec_validators = spec.validators();
|
|
let key_from_threshold_i = |threshold_i| {
|
|
for (key, _) in &spec_validators {
|
|
if threshold_i == spec.i(removed, *key).unwrap().start {
|
|
return *key;
|
|
}
|
|
}
|
|
panic!("requested info for threshold i which doesn't exist")
|
|
};
|
|
|
|
let mut sorted = vec![];
|
|
let mut threshold_is = map.keys().copied().collect::<Vec<_>>();
|
|
threshold_is.sort();
|
|
for threshold_i in threshold_is {
|
|
sorted.push((key_from_threshold_i(threshold_i), map.remove(&threshold_i).unwrap()));
|
|
}
|
|
|
|
// Now that signers are sorted, with their shares, create a map with the is needed for MuSig
|
|
let mut participants = vec![];
|
|
let mut map = HashMap::new();
|
|
for (raw_i, (key, share)) in sorted.into_iter().enumerate() {
|
|
let musig_i = u16::try_from(raw_i).unwrap() + 1;
|
|
participants.push(key);
|
|
map.insert(Participant::new(musig_i).unwrap(), share);
|
|
}
|
|
|
|
map.remove(&our_threshold_i).unwrap();
|
|
|
|
(participants, map)
|
|
}
|
|
|
|
type DkgConfirmerSigningProtocol<'a, T> = SigningProtocol<'a, T, (&'static [u8; 12], u32)>;
|
|
|
|
pub(crate) struct DkgConfirmer<'a, T: DbTxn> {
|
|
key: &'a Zeroizing<<Ristretto as Ciphersuite>::F>,
|
|
spec: &'a TributarySpec,
|
|
removed: Vec<<Ristretto as Ciphersuite>::G>,
|
|
txn: &'a mut T,
|
|
attempt: u32,
|
|
}
|
|
|
|
impl<T: DbTxn> DkgConfirmer<'_, T> {
|
|
pub(crate) fn new<'a>(
|
|
key: &'a Zeroizing<<Ristretto as Ciphersuite>::F>,
|
|
spec: &'a TributarySpec,
|
|
txn: &'a mut T,
|
|
attempt: u32,
|
|
) -> Option<DkgConfirmer<'a, T>> {
|
|
// This relies on how confirmations are inlined into the DKG protocol and they accordingly
|
|
// share attempts
|
|
let removed = crate::tributary::removed_as_of_dkg_attempt(txn, spec.genesis(), attempt)?;
|
|
Some(DkgConfirmer { key, spec, removed, txn, attempt })
|
|
}
|
|
fn signing_protocol(&mut self) -> DkgConfirmerSigningProtocol<'_, T> {
|
|
let context = (b"DkgConfirmer", self.attempt);
|
|
SigningProtocol { key: self.key, spec: self.spec, txn: self.txn, context }
|
|
}
|
|
|
|
fn preprocess_internal(&mut self) -> (AlgorithmSignMachine<Ristretto, Schnorrkel>, [u8; 64]) {
|
|
let participants = self.spec.validators().iter().map(|val| val.0).collect::<Vec<_>>();
|
|
self.signing_protocol().preprocess_internal(&participants)
|
|
}
|
|
// Get the preprocess for this confirmation.
|
|
pub(crate) fn preprocess(&mut self) -> [u8; 64] {
|
|
self.preprocess_internal().1
|
|
}
|
|
|
|
fn share_internal(
|
|
&mut self,
|
|
preprocesses: HashMap<Participant, Vec<u8>>,
|
|
key_pair: &KeyPair,
|
|
) -> Result<(AlgorithmSignatureMachine<Ristretto, Schnorrkel>, [u8; 32]), Participant> {
|
|
let participants = self.spec.validators().iter().map(|val| val.0).collect::<Vec<_>>();
|
|
let preprocesses =
|
|
threshold_i_map_to_keys_and_musig_i_map(self.spec, &self.removed, self.key, preprocesses).1;
|
|
let msg = set_keys_message(
|
|
&self.spec.set(),
|
|
&self.removed.iter().map(|key| Public(key.to_bytes())).collect::<Vec<_>>(),
|
|
key_pair,
|
|
);
|
|
self.signing_protocol().share_internal(&participants, preprocesses, &msg)
|
|
}
|
|
// Get the share for this confirmation, if the preprocesses are valid.
|
|
pub(crate) fn share(
|
|
&mut self,
|
|
preprocesses: HashMap<Participant, Vec<u8>>,
|
|
key_pair: &KeyPair,
|
|
) -> Result<[u8; 32], Participant> {
|
|
self.share_internal(preprocesses, key_pair).map(|(_, share)| share)
|
|
}
|
|
|
|
pub(crate) fn complete(
|
|
&mut self,
|
|
preprocesses: HashMap<Participant, Vec<u8>>,
|
|
key_pair: &KeyPair,
|
|
shares: HashMap<Participant, Vec<u8>>,
|
|
) -> Result<[u8; 64], Participant> {
|
|
let shares =
|
|
threshold_i_map_to_keys_and_musig_i_map(self.spec, &self.removed, self.key, shares).1;
|
|
|
|
let machine = self
|
|
.share_internal(preprocesses, key_pair)
|
|
.expect("trying to complete a machine which failed to preprocess")
|
|
.0;
|
|
|
|
DkgConfirmerSigningProtocol::<'_, T>::complete_internal(machine, shares)
|
|
}
|
|
}
|