Phase 2: add Server (#124)

* ongoing work

* partial server implementation

* preliminary version of all functions

* feature complete, test working

* improve documentation and tests
This commit is contained in:
Conrado Gouvea 2024-02-12 06:33:15 -03:00 committed by GitHub
parent 0091b9c24f
commit 0fe11c9b22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 2082 additions and 106 deletions

1470
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,16 @@ members = [
"participant",
"trusted-dealer",
"dkg",
"coordinator", "tests"
"coordinator",
"tests",
"server",
]
default-members = ["participant",
default-members = [
"participant",
"trusted-dealer",
"dkg",
"coordinator", "tests"]
"coordinator",
"tests",
"server"
]
resolver = "2"

26
server/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "server"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = "0.7.3"
clap = { version = "4.4.14", features = ["derive"] }
eyre = "0.6.11"
rand = "0.8"
reddsa = { git = "https://github.com/ZcashFoundation/reddsa.git", rev = "81c649c412e5b6ba56d491d2857f91fbd28adbc7", features = [
"frost",
"serde",
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0.68"
tokio = { version = "1.0", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.6.1", features = ["v4", "fast-rng", "serde"] }
[dev-dependencies]
axum-test = "14.2.2"
reqwest = { version = "0.11.23", features = ["json"] }

35
server/README.md Executable file
View File

@ -0,0 +1,35 @@
# FROST Server
This is a HTTP server that allow clients (Coordinator and Participants) to
run FROST without needing to directly connect to one another.
## Status ⚠
This is a prototype which is NOT SECURE since messages are not encrypted nor
authenticated. DO NOT USE this for anything other than testing.
## Usage
NOTE: This is for demo purposes only and should not be used in production.
You will need to have [Rust and Cargo](https://doc.rust-lang.org/cargo/getting-started/installation.html) installed.
To run:
1. Clone the repo. Run `git clone https://github.com/ZcashFoundation/frost-zcash-demo.git`
2. Run `cargo install`
3. Run `cargo run --bin server`
You can specify the IP and port to bind to using `--ip` and `--port`, e.g.
`cargo run --bin server -- --ip 127.0.0.1 --port 2744`.
## TODO
- Add specific error codes
- Remove frost-specific types (when data is encrypted)
- Session timeouts
- Encryption/authentication
- DoS protections and other production-ready requirements
-

13
server/src/args.rs Normal file
View File

@ -0,0 +1,13 @@
use clap::Parser;
#[derive(Parser, Debug, Default)]
#[command(author, version, about, long_about = None)]
pub struct Args {
/// IP to bind to
#[arg(short, long, default_value = "0.0.0.0")]
pub ip: String,
/// Port to bind to
#[arg(short, long, default_value_t = 2744)]
pub port: u16,
}

239
server/src/functions.rs Normal file
View File

@ -0,0 +1,239 @@
use std::collections::BTreeSet;
use axum::{extract::State, http::StatusCode, Json};
use eyre::eyre;
use uuid::Uuid;
use crate::{
state::{Session, SessionState, SharedState},
types::*,
AppError,
};
/// Implement the create_new_session API.
pub(crate) async fn create_new_session(
State(state): State<SharedState>,
Json(args): Json<CreateNewSessionArgs>,
) -> Result<Json<CreateNewSessionOutput>, AppError> {
// Create new session object.
let id = Uuid::new_v4();
let session = Session {
identifiers: args.identifiers.iter().cloned().collect(),
state: SessionState::WaitingForCommitments {
commitments: Default::default(),
},
};
// Save session into global state.
state.write().unwrap().sessions.insert(id, session);
let user = CreateNewSessionOutput { session_id: id };
Ok(Json(user))
}
/// Implement the send_commitments API
// TODO: get identifier from channel rather from arguments
pub(crate) async fn send_commitments(
State(state): State<SharedState>,
Json(args): Json<SendCommitmentsArgs>,
) -> Result<(), AppError> {
// Get the mutex lock to read and write from the state
let mut state_lock = state.write().unwrap();
let session = state_lock
.sessions
.get_mut(&args.session_id)
.ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &mut session.state {
SessionState::WaitingForCommitments { commitments } => {
if !session.identifiers.contains(&args.identifier) {
return Err(AppError(StatusCode::NOT_FOUND, eyre!("invalid identifier")));
}
// Add commitment to map.
// Currently ignores the possibility of overwriting previous values
// (it seems better to ignore overwrites, which could be caused by
// poor networking connectivity leading to retries)
commitments.insert(args.identifier, args.commitments);
// If complete, advance to next state
if commitments.keys().cloned().collect::<BTreeSet<_>>() == session.identifiers {
session.state = SessionState::CommitmentsReady {
commitments: commitments.clone(),
}
}
}
_ => {
return Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
));
}
}
Ok(())
}
/// Implement the get_commitments API
pub(crate) async fn get_commitments(
State(state): State<SharedState>,
Json(args): Json<GetCommitmentsArgs>,
) -> Result<Json<GetCommitmentsOutput>, AppError> {
let state_lock = state.read().unwrap();
let session = state_lock.sessions.get(&args.session_id).ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &session.state {
SessionState::CommitmentsReady { commitments } => Ok(Json(GetCommitmentsOutput {
commitments: commitments.clone(),
})),
_ => Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
)),
}
}
/// Implement the send_signing_package API
pub(crate) async fn send_signing_package(
State(state): State<SharedState>,
Json(args): Json<SendSigningPackageArgs>,
) -> Result<(), AppError> {
let mut state_lock = state.write().unwrap();
let session = state_lock
.sessions
.get_mut(&args.session_id)
.ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &mut session.state {
SessionState::CommitmentsReady { .. } => {
session.state = SessionState::WaitingForSignatureShares {
signing_package: args.signing_package,
signature_shares: Default::default(),
randomizer: args.randomizer,
};
}
_ => {
return Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
));
}
}
Ok(())
}
/// Implement the get_signing_package API
pub(crate) async fn get_signing_package(
State(state): State<SharedState>,
Json(args): Json<GetSigningPackageArgs>,
) -> Result<Json<GetSigningPackageOutput>, AppError> {
let state_lock = state.read().unwrap();
let session = state_lock.sessions.get(&args.session_id).ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &session.state {
SessionState::WaitingForSignatureShares {
signing_package,
signature_shares: _,
randomizer,
} => Ok(Json(GetSigningPackageOutput {
signing_package: signing_package.clone(),
randomizer: *randomizer,
})),
_ => Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
)),
}
}
/// Implement the send_signature_share API
// TODO: get identifier from channel rather from arguments
pub(crate) async fn send_signature_share(
State(state): State<SharedState>,
Json(args): Json<SendSignatureShareArgs>,
) -> Result<(), AppError> {
let mut state_lock = state.write().unwrap();
let session = state_lock
.sessions
.get_mut(&args.session_id)
.ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &mut session.state {
SessionState::WaitingForSignatureShares {
signing_package: _,
signature_shares,
randomizer: _,
} => {
if !session.identifiers.contains(&args.identifier) {
return Err(AppError(StatusCode::NOT_FOUND, eyre!("invalid identifier")));
}
// Currently ignoring the possibility of overwriting previous values
// (it seems better to ignore overwrites, which could be caused by
// poor networking connectivity leading to retries)
signature_shares.insert(args.identifier, args.signature_share);
// If complete, advance to next state
if signature_shares.keys().cloned().collect::<BTreeSet<_>>() == session.identifiers {
session.state = SessionState::SignatureSharesReady {
signature_shares: signature_shares.clone(),
};
}
}
_ => {
return Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
));
}
}
Ok(())
}
/// Implement the get_signature_shares API
pub(crate) async fn get_signature_shares(
State(state): State<SharedState>,
Json(args): Json<GetSignatureSharesArgs>,
) -> Result<Json<GetSignatureSharesOutput>, AppError> {
let state_lock = state.read().unwrap();
let session = state_lock.sessions.get(&args.session_id).ok_or(AppError(
StatusCode::NOT_FOUND,
eyre!("session ID not found"),
))?;
match &session.state {
SessionState::SignatureSharesReady { signature_shares } => {
Ok(Json(GetSignatureSharesOutput {
signature_shares: signature_shares.clone(),
}))
}
_ => Err(AppError(
StatusCode::INTERNAL_SERVER_ERROR,
eyre!("incompatible session state"),
)),
}
}
/// Implement the close_session API.
pub(crate) async fn close_session(
State(state): State<SharedState>,
Json(args): Json<CloseSessionArgs>,
) -> Result<Json<()>, AppError> {
state.write().unwrap().sessions.remove(&args.session_id);
Ok(Json(()))
}

60
server/src/lib.rs Normal file
View File

@ -0,0 +1,60 @@
pub mod args;
mod functions;
mod state;
mod types;
pub use types::*;
use args::Args;
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
routing::post,
Router,
};
/// Create the axum Router for the server.
/// Maps specific endpoints to handler functions.
// TODO: use methods of a single object instead of separate functions?
pub fn router() -> Router {
// Shared state that is passed to each handler by axum
let shared_state = state::SharedState::default();
Router::new()
.route("/create_new_session", post(functions::create_new_session))
.route("/send_commitments", post(functions::send_commitments))
.route("/get_commitments", post(functions::get_commitments))
.route(
"/send_signing_package",
post(functions::send_signing_package),
)
.route("/get_signing_package", post(functions::get_signing_package))
.route(
"/send_signature_share",
post(functions::send_signature_share),
)
.route(
"/get_signature_shares",
post(functions::get_signature_shares),
)
.route("/close_session", post(functions::close_session))
.with_state(shared_state)
}
/// Run the server with the specified arguments.
pub async fn run(args: &Args) -> Result<(), Box<dyn std::error::Error>> {
let app = router();
let addr = format!("{}:{}", args.ip, args.port);
let listener = tokio::net::TcpListener::bind(addr).await?;
Ok(axum::serve(listener, app).await?)
}
/// An error. Wraps a StatusCode which is returned by the server when the
/// error happens during a API call, and a generic eyre::Report.
// TODO: create an enum with specific errors
pub struct AppError(StatusCode, eyre::Report);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.0, format!("{}", self.1)).into_response()
}
}

11
server/src/main.rs Normal file
View File

@ -0,0 +1,11 @@
use clap::Parser;
use server::args::Args;
use server::run;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
// initialize tracing
tracing_subscriber::fmt::init();
run(&args).await
}

66
server/src/state.rs Normal file
View File

@ -0,0 +1,66 @@
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
sync::{Arc, RwLock},
};
use uuid::Uuid;
use reddsa::frost::redpallas as frost;
/// The current state of the server, and the required data for the state.
pub enum SessionState {
/// Waiting for participants to send their commitments.
WaitingForCommitments {
/// Commitments sent by participants so far.
commitments: BTreeMap<frost::Identifier, frost::round1::SigningCommitments>,
},
/// Commitments have been sent by all participants; ready to be fetched by
/// the coordinator. Waiting for coordinator to send the SigningPackage.
CommitmentsReady {
/// All commitments sent by participants.
commitments: BTreeMap<frost::Identifier, frost::round1::SigningCommitments>,
},
/// SigningPackage ready to be fetched by participants. Waiting for
/// participants to send their signature shares.
WaitingForSignatureShares {
/// SigningPackage sent by the coordinator to be sent to participants.
signing_package: frost::SigningPackage,
/// Randomizer sent by coordinator to be sent to participants
/// (Rerandomized FROST only. TODO: make it optional?)
randomizer: frost::round2::Randomizer,
/// Signature shares sent by participants so far.
signature_shares: BTreeMap<frost::Identifier, frost::round2::SignatureShare>,
},
/// SignatureShares have been sent by all participants; ready to be fetched
/// by the coordinator.
SignatureSharesReady {
signature_shares: BTreeMap<frost::Identifier, frost::round2::SignatureShare>,
},
}
impl Default for SessionState {
fn default() -> Self {
SessionState::WaitingForCommitments {
commitments: Default::default(),
}
}
}
/// A particular signing session.
pub struct Session {
/// The set of identifiers for the session.
pub(crate) identifiers: BTreeSet<frost::Identifier>,
/// The session state.
pub(crate) state: SessionState,
}
/// The global state of the server.
#[derive(Default)]
pub struct AppState {
/// Mapping of signing sessions by UUID.
pub(crate) sessions: HashMap<Uuid, Session>,
}
/// Type alias for the global state under a reference-counted RW mutex,
/// which allows reading and writing the state across different handlers.
pub type SharedState = Arc<RwLock<AppState>>;

73
server/src/types.rs Normal file
View File

@ -0,0 +1,73 @@
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use reddsa::frost::redpallas as frost;
#[derive(Serialize, Deserialize)]
pub struct CreateNewSessionArgs {
pub identifiers: Vec<frost::Identifier>,
}
#[derive(Serialize, Deserialize)]
pub struct CreateNewSessionOutput {
pub session_id: Uuid,
}
#[derive(Serialize, Deserialize)]
pub struct SendCommitmentsArgs {
pub session_id: Uuid,
pub identifier: frost::Identifier,
pub commitments: frost::round1::SigningCommitments,
}
#[derive(Serialize, Deserialize)]
pub struct GetCommitmentsArgs {
pub session_id: Uuid,
}
#[derive(Serialize, Deserialize)]
pub struct GetCommitmentsOutput {
pub commitments: BTreeMap<frost::Identifier, frost::round1::SigningCommitments>,
}
#[derive(Serialize, Deserialize)]
pub struct SendSigningPackageArgs {
pub session_id: Uuid,
pub signing_package: frost::SigningPackage,
pub randomizer: frost::round2::Randomizer,
}
#[derive(Serialize, Deserialize)]
pub struct GetSigningPackageArgs {
pub session_id: Uuid,
}
#[derive(Serialize, Deserialize)]
pub struct GetSigningPackageOutput {
pub signing_package: frost::SigningPackage,
pub randomizer: frost::round2::Randomizer,
}
#[derive(Serialize, Deserialize)]
pub struct SendSignatureShareArgs {
pub session_id: Uuid,
pub identifier: frost::Identifier,
pub signature_share: frost::round2::SignatureShare,
}
#[derive(Serialize, Deserialize)]
pub struct GetSignatureSharesArgs {
pub session_id: Uuid,
}
#[derive(Serialize, Deserialize)]
pub struct GetSignatureSharesOutput {
pub signature_shares: BTreeMap<frost::Identifier, frost::round2::SignatureShare>,
}
#[derive(Serialize, Deserialize)]
pub struct CloseSessionArgs {
pub session_id: Uuid,
}

View File

@ -0,0 +1,183 @@
use std::{collections::BTreeMap, time::Duration};
use axum_test::TestServer;
use rand::thread_rng;
use server::{args::Args, router};
use reddsa::frost::redpallas as frost;
/// Test the entire FROST signing flow using axum_test.
/// This is a good example of the overall flow but it's not a good example
/// of the client code, see the next test for that.
///
/// Also note that this simulates multiple clients using loops. In practice,
/// each client will run independently.
#[tokio::test]
async fn test_main_router() -> Result<(), Box<dyn std::error::Error>> {
let mut rng = thread_rng();
let (shares, pubkeys) =
frost::keys::generate_with_dealer(3, 2, frost::keys::IdentifierList::Default, &mut rng)
.unwrap();
let key_packages: BTreeMap<_, _> = shares
.iter()
.map(|(identifier, secret_share)| {
(
*identifier,
frost::keys::KeyPackage::try_from(secret_share.clone()).unwrap(),
)
})
.collect();
let router = router();
let server = TestServer::new(router)?;
let res = server
.post("/create_new_session")
.json(&server::CreateNewSessionArgs {
identifiers: key_packages.keys().copied().collect::<Vec<_>>(),
})
.await;
res.assert_status_ok();
let r: server::CreateNewSessionOutput = res.json();
let session_id = r.session_id;
let mut nonces_map = BTreeMap::<_, _>::new();
for (identifier, key_package) in key_packages.iter() {
let (nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng);
nonces_map.insert(*identifier, nonces);
let res = server
.post("/send_commitments")
.json(&server::SendCommitmentsArgs {
identifier: *identifier,
session_id,
commitments,
})
.await;
res.assert_status_ok();
}
let res = server
.post("/get_commitments")
.json(&server::GetCommitmentsArgs { session_id })
.await;
res.assert_status_ok();
let r: server::GetCommitmentsOutput = res.json();
let commitments = r.commitments;
let message = "Hello, world!".as_bytes();
let signing_package = frost::SigningPackage::new(commitments, message);
let randomized_params =
frost::RandomizedParams::new(pubkeys.verifying_key(), &signing_package, &mut rng)?;
let res = server
.post("/send_signing_package")
.json(&server::SendSigningPackageArgs {
session_id,
signing_package: signing_package.clone(),
randomizer: *randomized_params.randomizer(),
})
.await;
res.assert_status_ok();
for (identifier, key_package) in key_packages.iter() {
let res = server
.post("get_signing_package")
.json(&server::GetSigningPackageArgs { session_id })
.await;
res.assert_status_ok();
let r: server::GetSigningPackageOutput = res.json();
let signature_share = frost::round2::sign(
&r.signing_package,
&nonces_map[identifier],
key_package,
r.randomizer,
)?;
let res = server
.post("/send_signature_share")
.json(&server::SendSignatureShareArgs {
session_id,
identifier: *identifier,
signature_share,
})
.await;
res.assert_status_ok();
}
let res = server
.post("/get_signature_shares")
.json(&server::GetSignatureSharesArgs { session_id })
.await;
res.assert_status_ok();
let r: server::GetSignatureSharesOutput = res.json();
let signature = frost::aggregate(
&signing_package,
&r.signature_shares,
&pubkeys,
&randomized_params,
)?;
randomized_params
.randomized_verifying_key()
.verify(message, &signature)?;
let res = server
.post("/close_session")
.json(&server::CloseSessionArgs { session_id })
.await;
res.assert_status_ok();
println!("{}", res.text());
let _: () = res.json();
Ok(())
}
/// Actually spawn the HTTP server and connect to it using reqwest.
/// A better example on how to write client code.
#[tokio::test]
async fn test_http() -> Result<(), Box<dyn std::error::Error>> {
// Create test values
let mut rng = thread_rng();
let (shares, _pubkeys) =
frost::keys::generate_with_dealer(3, 2, frost::keys::IdentifierList::Default, &mut rng)
.unwrap();
let key_packages: BTreeMap<_, _> = shares
.iter()
.map(|(identifier, secret_share)| {
(
*identifier,
frost::keys::KeyPackage::try_from(secret_share.clone()).unwrap(),
)
})
.collect();
// Spawn server for testing
tokio::spawn(async move {
server::run(&Args {
ip: "127.0.0.1".to_string(),
port: 2744,
})
.await
.unwrap();
});
// Wait for server to start listening
// TODO: this could possibly be not enough, use some retry logic instead
tokio::time::sleep(Duration::from_secs(2)).await;
// Call create_new_session
let client = reqwest::Client::new();
let r = client
.post("http://127.0.0.1:2744/create_new_session")
.json(&server::CreateNewSessionArgs {
identifiers: key_packages.keys().copied().collect::<Vec<_>>(),
})
.send()
.await?
.json::<server::CreateNewSessionOutput>()
.await?;
println!("{}", r.session_id);
Ok(())
}