diff --git a/Cargo.lock b/Cargo.lock index c2565bf0..38b0cc7e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,8 +496,11 @@ dependencies = [ "base64 0.22.0", "clap", "config", + "duration-str", "ed25519-dalek", "hex", + "mockall", + "parity-scale-codec", "post-rs", "rand", "reqwest", @@ -975,6 +978,19 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" +[[package]] +name = "duration-str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8bb6a301a95ba86fa0ebaf71d49ae4838c51f8b84cb88ed140dfb66452bb3c4" +dependencies = [ + "nom", + "rust_decimal", + "serde", + "thiserror", + "time", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -2976,6 +2992,16 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rust_decimal" +version = "1.34.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39449a79f45e8da28c57c341891b69a183044b29518bb8f86dbac9df60bb7df" +dependencies = [ + "arrayvec", + "num-traits 0.2.18", +] + [[package]] name = "rustc-demangle" version = "0.1.23" diff --git a/certifier/Cargo.toml b/certifier/Cargo.toml index 9167edc3..1780d8f5 100644 --- a/certifier/Cargo.toml +++ b/certifier/Cargo.toml @@ -27,6 +27,12 @@ serde_json = "1.0.115" base64 = "0.22.0" axum-prometheus = "0.6.1" tower = { version = "0.4.13", features = ["limit"] } +duration-str = { version = "0.7.1", default-features = false, features = [ + "serde", + "time", +] } +parity-scale-codec = { version = "3.6.9", features = ["derive", "serde"] } +mockall = "0.12.1" [dev-dependencies] reqwest = { version = "0.12.3", features = ["json"] } diff --git a/certifier/README.md b/certifier/README.md index a2fb3554..5ba4539f 100644 --- a/certifier/README.md +++ b/certifier/README.md @@ -28,6 +28,7 @@ The config structure is defined [here](src/configuration.rs). An example config: ```yaml listen: "127.0.0.1:8080" signing_key: +certificate_expiration: 2w post_cfg: k1: 26 k2: 37 @@ -48,6 +49,10 @@ randomx_mode: Fast Each field can also be provided as env variable prefixed with CERTIFIER. For example, `CERTIFIER_SIGNING_KEY`. +##### Expiring certificates +The certificates don't expire by default. To create certificates that expire after certain time duration, +set `certificate_expiration` field in the config. It understands units supported by the [duration_str](https://docs.rs/duration-str/0.7.1/duration_str/index.html) crate (i.e "1d", "2w"). + ##### Concurrency limit It's important to configure the maximum number of requests that will be processed in parallel. The POST verification is heavy on CPU and hence a value higher than the number of CPU cores might lead to drop in performance and increase latency. @@ -74,4 +79,4 @@ Run `certifier generate-keys` to obtain randomly generated new keys. ``` ## Log level -The log level can be controlled via `RUST_LOG` enviroment variable. It can be set to [error, warn, info, debug, trace, off]. \ No newline at end of file +The log level can be controlled via `RUST_LOG` enviroment variable. It can be set to [error, warn, info, debug, trace, off]. diff --git a/certifier/src/certifier.rs b/certifier/src/certifier.rs index fe35a4ba..6b132385 100644 --- a/certifier/src/certifier.rs +++ b/certifier/src/certifier.rs @@ -1,17 +1,20 @@ use std::sync::Arc; +use std::time::{Duration, SystemTime}; use axum::http::StatusCode; use axum::{extract::State, Json}; use axum::{routing::post, Router}; -use ed25519_dalek::{Signer, SigningKey}; +use ed25519_dalek::{Signature, Signer, SigningKey}; +use parity_scale_codec::{Compact, Decode, Encode}; use post::config::{InitConfig, ProofConfig}; use post::pow::randomx::PoW; -use post::verification::{Mode, Verifier}; +use post::verification::Mode; use serde::{Deserialize, Serialize}; use serde_with::{base64::Base64, serde_as}; use tracing::instrument; use crate::configuration::RandomXMode; +use crate::time::unix_timestamp; #[derive(Debug, Deserialize, Serialize)] pub struct CertifyRequest { @@ -20,53 +23,117 @@ pub struct CertifyRequest { } #[serde_as] -#[derive(Debug, Serialize)] -struct CertifyResponse { +#[derive(Debug, Serialize, Deserialize)] +pub struct CertifyResponse { + /// The certificate as scale-encoded `Certificate` struct #[serde_as(as = "Base64")] - signature: Vec, + pub certificate: Vec, + /// Signature of the certificate #[serde_as(as = "Base64")] - pub_key: Vec, + pub signature: Vec, + /// The public key of the certifier that signed the certificate + #[serde_as(as = "Base64")] + pub pub_key: Vec, +} + +#[derive(Debug, Decode, Encode)] +pub struct Certificate { + // ID of the node being certified + pub pub_key: Vec, + /// Unix timestamp + pub expiration: Option>, } #[instrument(skip(state))] async fn certify( - State(state): State>, + State(state): State>, Json(req): Json, ) -> Result, (StatusCode, String)> { tracing::debug!("certifying"); - let pub_key = req.metadata.node_id; - let my_id = state.signer.verifying_key().to_bytes(); let s = state.clone(); + let result = tokio::task::spawn_blocking(move || s.certify(&req.proof, &req.metadata)) + .await + .map_err(|e| { + tracing::error!("internal error verifying proof: {e:?}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "error verifying proof".into(), + ) + })?; - let result = tokio::task::spawn_blocking(move || { - s.verifier - .verify(&req.proof, &req.metadata, &s.cfg, &s.init_cfg, Mode::All) - }) - .await - .map_err(|e| { - tracing::error!("internal error verifying proof: {e:?}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "error verifying proof".into(), - ) - })?; - - result.map_err(|e| (StatusCode::FORBIDDEN, format!("invalid proof: {e:?}")))?; - - // Sign the nodeID - let response = CertifyResponse { - signature: state.signer.sign(&pub_key).to_vec(), - pub_key: my_id.to_vec(), - }; - Ok(Json(response)) + match result { + Ok(result) => { + let response = CertifyResponse { + certificate: result.0.to_vec(), + signature: result.1.to_vec(), + pub_key: state.signer.verifying_key().to_bytes().to_vec(), + }; + Ok(Json(response)) + } + Err(e) => { + return Err((StatusCode::FORBIDDEN, format!("invalid proof: {e:?}"))); + } + } } -struct AppState { - verifier: Verifier, +#[mockall::automock] +trait Verifier { + fn verify( + &self, + proof: &post::prove::Proof<'static>, + metadata: &post::metadata::ProofMetadata, + ) -> Result<(), String>; +} + +struct PostVerifier { + verifier: post::verification::Verifier, cfg: ProofConfig, init_cfg: InitConfig, +} + +impl Verifier for PostVerifier { + fn verify( + &self, + proof: &post::prove::Proof<'_>, + metadata: &post::metadata::ProofMetadata, + ) -> Result<(), String> { + self.verifier + .verify(proof, metadata, &self.cfg, &self.init_cfg, Mode::All) + .map_err(|e| format!("{e:?}")) + } +} + +struct Certifier { + verifier: Arc, signer: SigningKey, + expiry: Option, +} + +impl Certifier { + pub fn certify( + &self, + proof: &post::prove::Proof<'static>, + metadata: &post::metadata::ProofMetadata, + ) -> Result<(Vec, Signature), String> { + self.verifier.verify(proof, metadata)?; + + let cert = self.create_certificate(&metadata.node_id); + let cert_encoded = cert.encode(); + let signature = self.signer.sign(&cert_encoded); + + Ok((cert_encoded.to_vec(), signature)) + } + + fn create_certificate(&self, id: &[u8; 32]) -> Certificate { + let expiration = self + .expiry + .map(|exp| unix_timestamp(SystemTime::now() + exp)); + Certificate { + pub_key: id.to_vec(), + expiration: expiration.map(Compact), + } + } } pub fn new( @@ -74,17 +141,122 @@ pub fn new( init_cfg: InitConfig, signer: SigningKey, randomx_mode: RandomXMode, + expiry: Option, ) -> Router { - let state = AppState { - verifier: Verifier::new(Box::new( + let verifier = Arc::new(PostVerifier { + verifier: post::verification::Verifier::new(Box::new( PoW::new(randomx_mode.into()).expect("creating RandomX PoW verifier"), )), cfg, init_cfg, + }); + let certifier = Certifier { + verifier, signer, + expiry, }; Router::new() .route("/certify", post(certify)) - .with_state(Arc::new(state)) + .with_state(Arc::new(certifier)) +} + +#[cfg(test)] +mod tests { + use std::{ + sync::Arc, + time::{Duration, SystemTime}, + }; + + use crate::time::unix_timestamp; + + use super::{Certificate, Certifier, MockVerifier}; + use ed25519_dalek::SigningKey; + use parity_scale_codec::Decode; + use post::{metadata::ProofMetadata, prove::Proof}; + + #[test] + fn certify_invalid_post() { + let mut verifier = MockVerifier::new(); + verifier + .expect_verify() + .returning(|_, _| Err("invalid".to_string())); + + let certifier = Certifier { + verifier: Arc::new(verifier), + signer: SigningKey::generate(&mut rand::rngs::OsRng), + expiry: None, + }; + + let proof = Proof { + nonce: 0, + indices: std::borrow::Cow::Owned(vec![1, 2, 3]), + pow: 0, + }; + + let metadata = ProofMetadata { + node_id: [7; 32], + commitment_atx_id: [0u8; 32], + challenge: [0; 32], + num_units: 1, + }; + + certifier + .certify(&proof, &metadata) + .expect_err("certification should fail"); + } + + #[test] + fn ceritify_valid_post() { + let mut verifier = MockVerifier::new(); + verifier.expect_verify().returning(|_, _| Ok(())); + let certifier = Certifier { + verifier: Arc::new(verifier), + signer: SigningKey::generate(&mut rand::rngs::OsRng), + expiry: None, + }; + + let proof = Proof { + nonce: 0, + indices: std::borrow::Cow::Owned(vec![1, 2, 3]), + pow: 0, + }; + + let metadata = ProofMetadata { + node_id: [7; 32], + commitment_atx_id: [0u8; 32], + challenge: [0; 32], + num_units: 1, + }; + + let (encoded, signature) = certifier + .certify(&proof, &metadata) + .expect("certification should succeed"); + + certifier + .signer + .verify(&encoded, &signature) + .expect("signature should be valid"); + + let cert = Certificate::decode(&mut encoded.as_slice()) + .expect("decoding certificate should succeed"); + assert!(cert.expiration.is_none()); + } + + #[test] + fn create_cert_with_expiry() { + let expiry = Duration::from_secs(60 * 60); + let certifier = Certifier { + verifier: Arc::new(MockVerifier::new()), + signer: SigningKey::generate(&mut rand::rngs::OsRng), + expiry: Some(expiry), + }; + + let started = SystemTime::now(); + let cert = certifier.create_certificate(&[7u8; 32]); + + let expiration = cert.expiration.unwrap().0; + assert!(expiration >= unix_timestamp(started + expiry)); + assert!(expiration <= unix_timestamp(SystemTime::now() + expiry)); + } } diff --git a/certifier/src/configuration.rs b/certifier/src/configuration.rs index 284c6d70..6b17f0ac 100644 --- a/certifier/src/configuration.rs +++ b/certifier/src/configuration.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::{path::Path, time::Duration}; use ed25519_dalek::SecretKey; use post::pow::randomx::RandomXFlag; @@ -54,6 +54,13 @@ pub struct Config { #[serde(default)] pub randomx_mode: RandomXMode, + #[serde( + default, + deserialize_with = "duration_str::deserialize_option_duration" + )] + /// The time after which the certificates expire. + pub certificate_expiration: Option, + /// Address to expose metrics on. /// Metrics are disabled if not configured. pub metrics: Option, diff --git a/certifier/src/lib.rs b/certifier/src/lib.rs index 4b79f41b..89909989 100644 --- a/certifier/src/lib.rs +++ b/certifier/src/lib.rs @@ -1,2 +1,3 @@ pub mod certifier; pub mod configuration; +pub mod time; diff --git a/certifier/src/main.rs b/certifier/src/main.rs index e777fb6d..1e3f3463 100644 --- a/certifier/src/main.rs +++ b/certifier/src/main.rs @@ -81,12 +81,18 @@ async fn main() -> Result<(), Box> { "max concurrent requests: {}", config.max_concurrent_requests ); + if let Some(expiry) = config.certificate_expiration { + info!("generated certificates will expire after {expiry:?}"); + } else { + info!("generated certificates won't expire"); + } let mut app = certifier::certifier::new( config.post_cfg, config.init_cfg, signer, config.randomx_mode, + config.certificate_expiration, ) .layer(ConcurrencyLimitLayer::new(config.max_concurrent_requests)); diff --git a/certifier/src/time.rs b/certifier/src/time.rs new file mode 100644 index 00000000..76720eec --- /dev/null +++ b/certifier/src/time.rs @@ -0,0 +1,7 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn unix_timestamp(t: SystemTime) -> u64 { + t.duration_since(UNIX_EPOCH) + .expect("system time is before unix epoch") + .as_secs() +} diff --git a/certifier/tests/test_certify.rs b/certifier/tests/test_certify.rs index 48a5c861..9f463876 100644 --- a/certifier/tests/test_certify.rs +++ b/certifier/tests/test_certify.rs @@ -1,39 +1,41 @@ -use std::{future::IntoFuture, net::SocketAddr, str::FromStr, sync::atomic::AtomicBool}; +use std::{ + future::IntoFuture, + net::SocketAddr, + str::FromStr, + sync::atomic::AtomicBool, + time::{Duration, SystemTime}, +}; -use certifier::{certifier::CertifyRequest, configuration::RandomXMode}; +use certifier::{ + certifier::{Certificate, CertifyRequest}, + configuration::RandomXMode, + time::unix_timestamp, +}; use ed25519_dalek::SigningKey; +use parity_scale_codec::Decode; use post::{ config::{Cores, InitConfig, ProofConfig, ScryptParams}, initialize::{CpuInitializer, Initialize}, metadata::ProofMetadata, pow::randomx::RandomXFlag, - prove::{self, generate_proof}, + prove::{self, generate_proof, Proof}, }; use reqwest::StatusCode; use tokio::net::TcpListener; -#[tokio::test] -async fn test_certificate_post_proof() { +fn gen_proof( + cfg: ProofConfig, + init_cfg: InitConfig, + id: [u8; 32], +) -> (Proof<'static>, ProofMetadata) { // Initialize some data let challenge = b"hello world, challenge me!!!!!!!"; let datadir = tempfile::tempdir().unwrap(); - let cfg = ProofConfig { - k1: 20, - k2: 10, - pow_difficulty: [0xFF; 32], - }; - let init_cfg = InitConfig { - min_num_units: 1, - max_num_units: 1000, - labels_per_unit: 200, - scrypt: ScryptParams::new(2, 1, 1), - }; - let metadata = CpuInitializer::new(init_cfg.scrypt) .initialize( datadir.path(), - &[0u8; 32], + &id, &[0u8; 32], init_cfg.labels_per_unit, 2, @@ -58,9 +60,25 @@ async fn test_certificate_post_proof() { .unwrap(); let metadata = ProofMetadata::new(metadata, *challenge); + (proof, metadata) +} + +#[tokio::test] +async fn test_certificate_post_proof() { + let cfg = ProofConfig { + k1: 20, + k2: 10, + pow_difficulty: [0xFF; 32], + }; + let init_cfg = InitConfig { + min_num_units: 1, + max_num_units: 1000, + labels_per_unit: 200, + scrypt: ScryptParams::new(2, 1, 1), + }; // Spawn the certifier service let signer = SigningKey::generate(&mut rand::rngs::OsRng); - let app = certifier::certifier::new(cfg, init_cfg, signer, RandomXMode::Light); + let app = certifier::certifier::new(cfg, init_cfg, signer.clone(), RandomXMode::Light, None); let listener = TcpListener::bind(SocketAddr::from_str("127.0.0.1:0").unwrap()) .await .unwrap(); @@ -70,8 +88,19 @@ async fn test_certificate_post_proof() { let client = reqwest::Client::new(); + let node_id = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, + 12, 13, 14, 15, 16, + ]; + let (proof, metadata) = gen_proof(cfg, init_cfg, node_id); + // Certify with a valid proof let req = CertifyRequest { proof, metadata }; + + // save as json to file + let json = serde_json::to_string(&req).unwrap(); + std::fs::write("certify_request.json", json).unwrap(); + let response = client .post(format!("http://{addr}/certify")) .json(&req) @@ -80,6 +109,14 @@ async fn test_certificate_post_proof() { .unwrap(); assert!(response.status().is_success()); + // verify the certificate + let data = response.bytes().await.unwrap(); + let cert_resp = serde_json::from_slice::(&data).unwrap(); + let cert = Certificate::decode(&mut cert_resp.certificate.as_slice()).unwrap(); + assert!(cert.expiration.is_none()); + let signature = ed25519_dalek::Signature::from_slice(&cert_resp.signature).unwrap(); + assert!(signer.verify(&cert_resp.certificate, &signature).is_ok()); + // Try to certify with an invalid proof let mut invalid_req = req; invalid_req.metadata.num_units = 8; @@ -91,3 +128,60 @@ async fn test_certificate_post_proof() { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } + +#[tokio::test] +async fn test_certificate_post_proof_with_expiration() { + let cfg = ProofConfig { + k1: 20, + k2: 10, + pow_difficulty: [0xFF; 32], + }; + let init_cfg = InitConfig { + min_num_units: 1, + max_num_units: 1000, + labels_per_unit: 200, + scrypt: ScryptParams::new(2, 1, 1), + }; + // Spawn the certifier service + let signer = SigningKey::generate(&mut rand::rngs::OsRng); + let expiry = Duration::from_secs(60 * 60); + let app = certifier::certifier::new( + cfg, + init_cfg, + signer.clone(), + RandomXMode::Light, + Some(expiry), + ); + let listener = TcpListener::bind(SocketAddr::from_str("127.0.0.1:0").unwrap()) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + let server = axum::serve(listener, app.into_make_service()); + tokio::spawn(server.into_future()); + + let client = reqwest::Client::new(); + + let node_id = [0u8; 32]; + let (proof, metadata) = gen_proof(cfg, init_cfg, node_id); + + // Certify with a valid proof + let req_time = SystemTime::now(); + let req = CertifyRequest { proof, metadata }; + let response = client + .post(format!("http://{addr}/certify")) + .json(&req) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + + // verify the certificate + let data = response.bytes().await.unwrap(); + let cert_resp = serde_json::from_slice::(&data).unwrap(); + let cert = Certificate::decode(&mut cert_resp.certificate.as_slice()).unwrap(); + assert!(cert.expiration.unwrap().0 >= unix_timestamp(req_time + expiry)); + assert!(cert.expiration.unwrap().0 <= unix_timestamp(SystemTime::now() + expiry)); + + let signature = ed25519_dalek::Signature::from_slice(&cert_resp.signature).unwrap(); + assert!(signer.verify(&cert_resp.certificate, &signature).is_ok()); +}