diff --git a/Cargo.lock b/Cargo.lock index 546f980c..33daa057 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,6 +2023,7 @@ version = "0.1.0" dependencies = [ "casper-types 4.0.1", "hex", + "kairos-tx", "thiserror", ] @@ -2067,12 +2068,14 @@ dependencies = [ "anyhow", "backoff", "casper-client", + "kairos-server", "nom", "reqwest 0.12.3", "sd-notify", "tempfile", "tokio", "tracing", + "tracing-subscriber", ] [[package]] @@ -2092,6 +2095,7 @@ dependencies = [ "hex", "num-traits", "rasn", + "sha2 0.10.8", ] [[package]] diff --git a/kairos-cli/Cargo.toml b/kairos-cli/Cargo.toml index df53204e..69535981 100644 --- a/kairos-cli/Cargo.toml +++ b/kairos-cli/Cargo.toml @@ -18,7 +18,7 @@ casper-types = { version = "4.0.1", features = ["std"] } # TODO: Change `std` -> clap = { version = "4.5", features = ["derive", "deprecated"] } hex = "0.4" thiserror = "1" -kairos-crypto = { path = "../kairos-crypto" } +kairos-crypto = { path = "../kairos-crypto", features = ["fs"] } [dev-dependencies] assert_cmd = "2" diff --git a/kairos-crypto/Cargo.toml b/kairos-crypto/Cargo.toml index 894086ed..584d0282 100644 --- a/kairos-crypto/Cargo.toml +++ b/kairos-crypto/Cargo.toml @@ -5,14 +5,17 @@ edition.workspace = true license.workspace = true [features] -default = ["crypto-casper"] +default = ["crypto-casper", "tx"] crypto-casper = ["casper-types"] +tx = ["kairos-tx"] +fs = ["casper-types/std"] # TODO: Change `std` -> `std-fs-io` in the future version. [lib] [dependencies] hex = "0.4" thiserror = "1" +kairos-tx = { path = "../kairos-tx", optional = true } # Casper signer implementation. -casper-types = { version = "4", optional = true, features = ["std"] } # TODO: Change `std` -> `std-fs-io` in the future version. +casper-types = { version = "4", optional = true } diff --git a/kairos-crypto/src/error.rs b/kairos-crypto/src/error.rs index 885720af..d911b899 100644 --- a/kairos-crypto/src/error.rs +++ b/kairos-crypto/src/error.rs @@ -17,4 +17,13 @@ pub enum CryptoError { /// Private key is not provided. #[error("private key is not provided")] MissingPrivateKey, + + /// Unable to compute transaction hash - invalid data given. + #[cfg(feature = "tx")] + #[error("unable to hash transaction data: {error}")] + TxHashingError { error: String }, + /// Signing algorithm is not available in `kairos-tx`. + #[cfg(feature = "tx")] + #[error("algorithm not available in tx format")] + InvalidSigningAlgorithm, } diff --git a/kairos-crypto/src/implementations/casper.rs b/kairos-crypto/src/implementations/casper.rs index 9b0fcc57..ebcfa0ca 100644 --- a/kairos-crypto/src/implementations/casper.rs +++ b/kairos-crypto/src/implementations/casper.rs @@ -1,5 +1,7 @@ use casper_types::bytesrepr::{FromBytes, ToBytes}; use casper_types::{crypto, PublicKey, SecretKey, Signature}; + +#[cfg(feature = "fs")] use std::path::Path; use crate::CryptoError; @@ -11,6 +13,7 @@ pub struct Signer { } impl CryptoSigner for Signer { + #[cfg(feature = "fs")] fn from_private_key_file>(file: P) -> Result where Self: Sized, @@ -85,6 +88,50 @@ impl CryptoSigner for Signer { Ok(public_key) } + + #[cfg(feature = "tx")] + fn verify_tx(&self, tx: kairos_tx::asn::Transaction) -> Result<(), CryptoError> { + let tx_hash = tx.payload.hash().map_err(|e| CryptoError::TxHashingError { + error: e.to_string(), + })?; + let signature: Vec = tx.signature.into(); + self.verify(tx_hash, signature)?; + + Ok(()) + } + + #[cfg(feature = "tx")] + fn sign_tx_payload( + &self, + payload: kairos_tx::asn::SigningPayload, + ) -> Result { + // Compute payload signature. + let tx_hash = payload.hash().map_err(|e| CryptoError::TxHashingError { + error: e.to_string(), + })?; + let signature = self.sign(tx_hash)?; + + // Prepare public key. + let public_key = self.to_public_key()?; + + // Prepare algorithm. + let algorithm = match self.public_key { + PublicKey::Ed25519(_) => Ok(kairos_tx::asn::SigningAlgorithm::CasperEd25519), + PublicKey::Secp256k1(_) => Ok(kairos_tx::asn::SigningAlgorithm::CasperSecp256k1), + _ => Err(CryptoError::InvalidSigningAlgorithm), + }?; + + // Build full transaction. + let tx = kairos_tx::asn::Transaction::new( + public_key, + payload, + &tx_hash, + algorithm, + signature.into(), + ); + + Ok(tx) + } } #[cfg(test)] diff --git a/kairos-crypto/src/lib.rs b/kairos-crypto/src/lib.rs index a8f31aeb..f6fc99cb 100644 --- a/kairos-crypto/src/lib.rs +++ b/kairos-crypto/src/lib.rs @@ -1,11 +1,13 @@ pub mod error; pub mod implementations; +#[cfg(feature = "fs")] use std::path::Path; use error::CryptoError; pub trait CryptoSigner { + #[cfg(feature = "fs")] fn from_private_key_file>(file: P) -> Result where Self: Sized; @@ -20,5 +22,13 @@ pub trait CryptoSigner { signature_bytes: U, ) -> Result<(), CryptoError>; + #[cfg(feature = "tx")] + fn verify_tx(&self, tx: kairos_tx::asn::Transaction) -> Result<(), CryptoError>; + #[cfg(feature = "tx")] + fn sign_tx_payload( + &self, + payload: kairos_tx::asn::SigningPayload, + ) -> Result; + fn to_public_key(&self) -> Result, CryptoError>; } diff --git a/kairos-server/.env b/kairos-server/.env index a857ae62..710cba80 100644 --- a/kairos-server/.env +++ b/kairos-server/.env @@ -1 +1 @@ -KAIROS_SERVER_PORT="8000" +KAIROS_SERVER_SOCKET_ADDR="127.0.0.1:7893" diff --git a/kairos-server/src/config.rs b/kairos-server/src/config.rs index a93752b7..711ff4e0 100644 --- a/kairos-server/src/config.rs +++ b/kairos-server/src/config.rs @@ -1,14 +1,15 @@ +use std::net::SocketAddr; use std::{fmt, str::FromStr}; #[derive(Debug)] pub struct ServerConfig { - pub port: u16, + pub socket_addr: SocketAddr, } impl ServerConfig { pub fn from_env() -> Result { - let port = parse_env_as::("KAIROS_SERVER_PORT")?; - Ok(Self { port }) + let socket_addr = parse_env_as::("KAIROS_SERVER_SOCKET_ADDR")?; + Ok(Self { socket_addr }) } } diff --git a/kairos-server/src/lib.rs b/kairos-server/src/lib.rs index ec063145..c8e9dd47 100644 --- a/kairos-server/src/lib.rs +++ b/kairos-server/src/lib.rs @@ -12,6 +12,9 @@ use axum_extra::routing::RouterExt; pub use errors::AppErr; +use crate::config::ServerConfig; +use crate::state::BatchStateManager; + type PublicKey = Vec; type Signature = Vec; @@ -22,3 +25,37 @@ pub fn app_router(state: Arc) -> Router { .typed_post(routes::transfer_handler) .with_state(state) } + +pub async fn run(config: ServerConfig) { + let app = app_router(BatchStateManager::new_empty()); + + let listener = tokio::net::TcpListener::bind(config.socket_addr) + .await + .unwrap(); + tracing::info!("listening on `{}`", listener.local_addr().unwrap()); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .unwrap(); +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("Failed to install Ctrl+C handler"); + }; + + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("Failed to install signal handler") + .recv() + .await; + }; + + tokio::select! { + _ = ctrl_c => {tracing::info!("Received CTRL+C signal, shutting down...")}, + _ = terminate => {tracing::info!("Received shutdown signal, shutting down...")}, + } +} diff --git a/kairos-server/src/main.rs b/kairos-server/src/main.rs index 409ca4d5..107e421d 100644 --- a/kairos-server/src/main.rs +++ b/kairos-server/src/main.rs @@ -1,21 +1,19 @@ -use std::net::SocketAddr; - use dotenvy::dotenv; -use kairos_server::{config::ServerConfig, state::BatchStateManager}; +use kairos_server::config::ServerConfig; #[tokio::main] async fn main() { - tracing_subscriber::fmt::init(); - dotenv().ok(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber"); + // loads the environment from the current directories .env file + // if the .env does not exist in the current directory, + // we still go ahead and try to obtain a server config from the environment + let _ = dotenv(); let config = ServerConfig::from_env() .unwrap_or_else(|e| panic!("Failed to parse server config from environment: {}", e)); - - let app = kairos_server::app_router(BatchStateManager::new_empty()); - - let axum_addr = SocketAddr::from(([127, 0, 0, 1], config.port)); - - tracing::info!("starting http server on `{}`", axum_addr); - let listener = tokio::net::TcpListener::bind(axum_addr).await.unwrap(); - axum::serve(listener, app).await.unwrap(); + kairos_server::run(config).await } diff --git a/kairos-test-utils/Cargo.toml b/kairos-test-utils/Cargo.toml index 2c758f6d..94a3d187 100644 --- a/kairos-test-utils/Cargo.toml +++ b/kairos-test-utils/Cargo.toml @@ -28,5 +28,7 @@ sd-notify = "0.4" tokio = { version = "1", features = [ "full", "tracing", "macros" ] } tempfile = "3" tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] } reqwest = { version = "0.12", features = ["json"] } +kairos-server = { path = "../kairos-server" } diff --git a/kairos-test-utils/src/kairos.rs b/kairos-test-utils/src/kairos.rs index 643eaf83..5c0457c8 100644 --- a/kairos-test-utils/src/kairos.rs +++ b/kairos-test-utils/src/kairos.rs @@ -1,21 +1,10 @@ use backoff::future::retry; use backoff::ExponentialBackoff; use reqwest::Url; -use std::env; use std::io; use std::net::{SocketAddr, TcpListener}; -use std::path::PathBuf; -use std::process::{Child, Command}; use tokio::net::TcpStream; -// A hacky way to get the cargo binary directory path -pub fn bin_dir() -> PathBuf { - let mut path = env::current_exe().unwrap(); - path.pop(); // pop kairos_test_utils-hash - path.pop(); // pop deps - path -} - async fn wait_for_port(address: &SocketAddr) -> Result<(), io::Error> { retry(ExponentialBackoff::default(), || async { Ok(TcpStream::connect(address).await.map(|_| ())?) @@ -25,25 +14,22 @@ async fn wait_for_port(address: &SocketAddr) -> Result<(), io::Error> { pub struct Kairos { pub url: Url, - process_handle: Child, + process_handle: tokio::task::JoinHandle<()>, } impl Kairos { pub async fn run() -> Result { - let port = TcpListener::bind("127.0.0.1:0")? - .local_addr()? - .port() - .to_string(); - let url = Url::parse(format!("http://127.0.0.1:{}", port).as_str()).unwrap(); - let kairos = bin_dir().join("kairos-server"); - let process_handle = Command::new(kairos) - .env("KAIROS_SERVER_PORT", &port) - .spawn() - .expect("Failed to start the kairos-server"); + let socket_addr = TcpListener::bind("127.0.0.1:0")?.local_addr()?; + let port = socket_addr.port().to_string(); + let url = Url::parse(&format!("http://127.0.0.1:{}", port)).unwrap(); + let config = kairos_server::config::ServerConfig { socket_addr }; + + let process_handle = tokio::spawn(async move { + tracing_subscriber::fmt::init(); + kairos_server::run(config).await; + }); - wait_for_port(url.socket_addrs(|| Option::None).unwrap().first().unwrap()) - .await - .unwrap(); + wait_for_port(&socket_addr).await.unwrap(); Ok(Kairos { url, @@ -54,7 +40,7 @@ impl Kairos { impl Drop for Kairos { fn drop(&mut self) { - let _ = self.process_handle.kill(); + self.process_handle.abort() } } diff --git a/kairos-tx/Cargo.toml b/kairos-tx/Cargo.toml index 0b4aacc3..a6faaad9 100644 --- a/kairos-tx/Cargo.toml +++ b/kairos-tx/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true [dependencies] num-traits = "0.2" rasn = { version = "0.12", default-features = false, features = ["macros"] } +sha2 = "0.10" [dev-dependencies] hex = "0.4" diff --git a/kairos-tx/schema.asn b/kairos-tx/schema.asn index 049315ea..0f712f6f 100644 --- a/kairos-tx/schema.asn +++ b/kairos-tx/schema.asn @@ -4,9 +4,28 @@ TxSchema DEFINITIONS AUTOMATIC TAGS ::= BEGIN -- Basic types. PublicKey ::= OCTET STRING + Signature ::= OCTET STRING + PayloadHash ::= OCTET STRING Amount ::= INTEGER (0..18446744073709551615) Nonce ::= INTEGER (0..18446744073709551615) + -- Full, top-level transaction type. + Transaction ::= SEQUENCE { + publicKey PublicKey, + payload SigningPayload, + hash PayloadHash, + algorithm SigningAlgorithm, + signature Signature, + ... + } + + -- Support for multiple signing algorithms. + SigningAlgorithm ::= ENUMERATED { + casperSecp256k1 (0), + casperEd25519 (1), + ... + } + -- Transaction payload for signing. SigningPayload ::= SEQUENCE { nonce Nonce, diff --git a/kairos-tx/src/asn.rs b/kairos-tx/src/asn.rs index 3765ffcd..0e0d32bf 100644 --- a/kairos-tx/src/asn.rs +++ b/kairos-tx/src/asn.rs @@ -9,6 +9,7 @@ pub use rasn::types::{Integer, OctetString}; use num_traits::cast::ToPrimitive; use rasn::types::AsnType; use rasn::{Decode, Encode}; +use sha2::Digest; #[derive(AsnType, Encode, Decode, Debug, Clone)] #[rasn(delegate)] @@ -21,6 +22,12 @@ impl From for Vec { } } +impl From> for PublicKey { + fn from(value: Vec) -> Self { + PublicKey(OctetString::copy_from_slice(&value)) + } +} + impl From<&[u8]> for PublicKey { fn from(value: &[u8]) -> Self { PublicKey(OctetString::copy_from_slice(value)) @@ -33,6 +40,40 @@ impl From<&[u8; N]> for PublicKey { } } +#[derive(AsnType, Encode, Decode, Debug, Clone)] +#[rasn(delegate)] +pub struct Signature(pub(crate) OctetString); + +// Converts an ASN.1 decoded signature into raw byte representation. +impl From for Vec { + fn from(value: Signature) -> Self { + value.0.into() + } +} + +impl From> for Signature { + fn from(value: Vec) -> Self { + Signature(OctetString::copy_from_slice(&value)) + } +} + +#[derive(AsnType, Encode, Decode, Debug, Clone)] +#[rasn(delegate)] +pub struct PayloadHash(pub(crate) OctetString); + +// Converts an ASN.1 decoded payload hash into raw byte representation. +impl From for Vec { + fn from(value: PayloadHash) -> Self { + value.0.into() + } +} + +impl From<&[u8; 32]> for PayloadHash { + fn from(value: &[u8; 32]) -> Self { + PayloadHash(OctetString::copy_from_slice(value)) + } +} + #[derive(AsnType, Encode, Decode, Debug, Clone)] #[rasn(delegate)] pub struct Amount(pub(crate) Integer); @@ -82,6 +123,54 @@ impl From for Nonce { } } +#[derive(AsnType, Encode, Decode, Debug)] +#[non_exhaustive] +pub struct Transaction { + pub public_key: PublicKey, // NOTE: Field name can be different than defined in schema, as only **order** is crucial + pub payload: SigningPayload, + pub hash: PayloadHash, + pub algorithm: SigningAlgorithm, + pub signature: Signature, +} + +impl Transaction { + /// Wraps full transaction data for storing. + /// + /// CAUTION: This method does NOT perform validity checks - please use + /// `kairos-crypto::sign_tx_payload()` to construct it safely. + pub fn new( + public_key: impl Into, + payload: SigningPayload, + hash: impl Into, + algorithm: SigningAlgorithm, + signature: Signature, + ) -> Self { + Self { + public_key: public_key.into(), + payload, + hash: hash.into(), + algorithm, + signature, + } + } + + pub fn der_encode(&self) -> Result, TxError> { + rasn::der::encode(self).map_err(TxError::EncodeError) + } + + pub fn der_decode(value: impl AsRef<[u8]>) -> Result { + rasn::der::decode(value.as_ref()).map_err(TxError::DecodeError) + } +} + +#[derive(AsnType, Encode, Decode, Debug, PartialEq, Copy, Clone)] +#[rasn(enumerated)] +#[non_exhaustive] +pub enum SigningAlgorithm { + CasperSecp256k1 = 0, + CasperEd25519 = 1, +} + #[derive(AsnType, Encode, Decode, Debug)] #[non_exhaustive] pub struct SigningPayload { @@ -130,6 +219,15 @@ impl SigningPayload { pub fn der_decode(value: impl AsRef<[u8]>) -> Result { rasn::der::decode(value.as_ref()).map_err(TxError::DecodeError) } + + // Computes the hash for a transaction. + // Hash is obtained from payload by computing sha256 of DER encoded ASN.1 data. + pub fn hash(&self) -> Result<[u8; 32], TxError> { + let data = self.der_encode()?; + let tx_hash: [u8; 32] = sha2::Sha256::digest(data).into(); + + Ok(tx_hash) + } } #[derive(AsnType, Encode, Decode, Debug)] @@ -206,6 +304,22 @@ impl Withdrawal { } } +impl TryFrom<&[u8]> for Transaction { + type Error = TxError; + + fn try_from(value: &[u8]) -> Result { + Transaction::der_decode(value) + } +} + +impl TryFrom for Vec { + type Error = TxError; + + fn try_from(value: Transaction) -> Result { + value.der_encode() + } +} + impl TryFrom<&[u8]> for SigningPayload { type Error = TxError; diff --git a/nixos/configurations/kairos-host/default.nix b/nixos/configurations/kairos-host/default.nix index b155e2fe..a56a8729 100644 --- a/nixos/configurations/kairos-host/default.nix +++ b/nixos/configurations/kairos-host/default.nix @@ -16,7 +16,7 @@ enableACME = true; locations = { "/api" = { - proxyPass = "http://127.0.0.1:${toString config.services.kairos.port}"; + proxyPass = "http://${config.services.kairos.bindAddress}:${toString config.services.kairos.port}"; }; }; }; diff --git a/nixos/modules/kairos.nix b/nixos/modules/kairos.nix index bd750333..621b377c 100644 --- a/nixos/modules/kairos.nix +++ b/nixos/modules/kairos.nix @@ -19,6 +19,15 @@ in type = types.package; }; + bindAddress = mkOption { + type = types.str; + default = "0.0.0.0"; + example = "0.0.0.0"; + description = mdDoc '' + Port to listen on. + ''; + }; + port = mkOption { type = types.port; default = 60000; @@ -54,7 +63,7 @@ in requires = [ "network-online.target" ]; environment = { RUST_LOG = cfg.logLevel; - KAIROS_SERVER_PORT = builtins.toString cfg.port; + KAIROS_SERVER_SOCKET_ADDR = "${cfg.bindAddress}:${builtins.toString cfg.port}"; }; serviceConfig = mkMerge [ {