diff --git a/Cargo.lock b/Cargo.lock index 89d7b68..18ca831 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,13 +86,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.64" +version = "0.1.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cd7fce9ba8c3c042128ce72d8b2ddbf3a05747efb67ea0313c635e10bda47a2" +checksum = "b9ccdd8f2a161be9bd5c023df56f1b2a0bd1d83872ae53b71a84a12c9bf6e842" dependencies = [ "proc-macro2", "quote", - "syn 1.0.107", + "syn 2.0.11", ] [[package]] @@ -2092,7 +2092,7 @@ checksum = "4c614d17805b093df4b147b51339e7e44bf05ef59fba1e45d83500bcfb4d8585" dependencies = [ "proc-macro2", "quote", - "syn 2.0.4", + "syn 2.0.11", ] [[package]] @@ -2388,9 +2388,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.4" +version = "2.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c622ae390c9302e214c31013517c2061ecb2699935882c60a9b37f82f8625ae" +checksum = "21e3787bb71465627110e7d87ed4faaa36c1f61042ee67badb9e2ef173accc40" dependencies = [ "proc-macro2", "quote", diff --git a/dnapi-rs/Cargo.toml b/dnapi-rs/Cargo.toml index c206fe8..8cb1bd3 100644 --- a/dnapi-rs/Cargo.toml +++ b/dnapi-rs/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dnapi-rs" -version = "0.1.1" +version = "0.1.2" edition = "2021" description = "A rust client for the Defined Networking API" license = "AGPL-3.0-or-later" diff --git a/dnapi-rs/src/client_async.rs b/dnapi-rs/src/client_async.rs index e69de29..2aa2099 100644 --- a/dnapi-rs/src/client_async.rs +++ b/dnapi-rs/src/client_async.rs @@ -0,0 +1,212 @@ +//! Client structs to handle communication with the Defined Networking API. This is the async client API - if you want blocking instead, enable the blocking (or default) feature instead. + +use std::error::Error; +use chrono::Local; +use log::{debug, error}; +use reqwest::StatusCode; +use url::Url; +use trifid_pki::cert::serialize_ed25519_public; +use trifid_pki::ed25519_dalek::{Signature, Signer, SigningKey, Verifier}; +use crate::credentials::{Credentials, ed25519_public_keys_from_pem}; +use crate::crypto::{new_keys, nonce}; +use crate::message::{CHECK_FOR_UPDATE, CheckForUpdateResponseWrapper, DO_UPDATE, DoUpdateRequest, DoUpdateResponse, ENDPOINT_V1, ENROLL_ENDPOINT, EnrollRequest, EnrollResponse, RequestV1, RequestWrapper, SignedResponseWrapper}; + +/// A type alias to abstract return types +pub type NebulaConfig = Vec; + +/// A type alias to abstract DH private keys +pub type DHPrivateKeyPEM = Vec; + +/// A combination of persistent data and HTTP client used for communicating with the API. +pub struct Client { + http_client: reqwest::Client, + server_url: Url +} + +/// A struct containing organization metadata returned as a result of enrollment +pub struct EnrollMeta { + /// The server organization ID this node is now a member of + pub organization_id: String, + /// The server organization name this node is now a member of + pub organization_name: String +} + +impl Client { + /// Create a new `Client` configured with the given User-Agent and API base. + /// # Errors + /// This function will return an error if the reqwest Client could not be created. + pub fn new(user_agent: String, api_base: Url) -> Result> { + let client = reqwest::Client::builder().user_agent(user_agent).build()?; + Ok(Self { + http_client: client, + server_url: api_base + }) + } + + /// Issues an enrollment request against the REST API using the given enrollment code, passing along a + /// locally generated DH X25519 Nebula key to be signed by the CA, and an Ed25519 key for future API + /// authentication. On success it returns the Nebula config generated by the server, a Nebula private key PEM, + /// credentials to be used for future DN API requests, and an object containing organization information. + /// # Errors + /// This function will return an error in any of the following situations: + /// - the `server_url` is invalid + /// - the HTTP request fails + /// - the HTTP response is missing X-Request-ID + /// - X-Request-ID isn't valid UTF-8 + /// - the server returns an error + /// - the server returns invalid JSON + /// - the `trusted_keys` field is invalid + pub async fn enroll(&self, code: &str) -> Result<(NebulaConfig, DHPrivateKeyPEM, Credentials, EnrollMeta), Box> { + debug!("making enrollment request to API {{server: {}, code: {}}}", self.server_url, code); + + let (dh_pubkey_pem, dh_privkey_pem, ed_pubkey, ed_privkey) = new_keys(); + + let req_json = serde_json::to_string(&EnrollRequest { + code: code.to_string(), + dh_pubkey: dh_pubkey_pem, + ed_pubkey: serialize_ed25519_public(ed_pubkey.as_bytes()), + timestamp: Local::now().format("%Y-%m-%dT%H:%M:%S.%f%:z").to_string(), + })?; + + let resp = self.http_client.post(self.server_url.join(ENROLL_ENDPOINT)?).body(req_json).send().await?; + + let req_id = resp.headers().get("X-Request-ID").ok_or("Response missing X-Request-ID")?.to_str()?; + debug!("enrollment request complete {{req_id: {}}}", req_id); + + let resp: EnrollResponse = resp.json().await?; + + let r = match resp { + EnrollResponse::Success { data } => data, + EnrollResponse::Error { errors } => { + error!("unexpected error during enrollment: {}", errors[0].message); + return Err(errors[0].message.clone().into()); + } + }; + + let meta = EnrollMeta { + organization_id: r.organization.id, + organization_name: r.organization.name, + }; + + let trusted_keys = ed25519_public_keys_from_pem(&r.trusted_keys)?; + + let creds = Credentials { + host_id: r.host_id, + ed_privkey, + counter: r.counter, + trusted_keys, + }; + + Ok((r.config, dh_privkey_pem, creds, meta)) + } + + /// Send a signed message to the `DNClient` API to learn if there is a new configuration available. + /// # Errors + /// This function returns an error if the dnclient request fails, or the server returns invalid data. + pub async fn check_for_update(&self, creds: &Credentials) -> Result> { + let body = self.post_dnclient(CHECK_FOR_UPDATE, &[], &creds.host_id, creds.counter, &creds.ed_privkey).await?; + + let result: CheckForUpdateResponseWrapper = serde_json::from_slice(&body)?; + + Ok(result.data.update_available) + } + + /// Send a signed message to the `DNClient` API to fetch the new configuration update. During this call a new + /// DH X25519 keypair is generated for the new Nebula certificate as well as a new Ed25519 keypair for `DNClient` API + /// communication. On success it returns the new config, a Nebula private key PEM to be inserted into the config + /// and new `DNClient` API credentials + /// # Errors + /// This function returns an error in any of the following scenarios: + /// - if the message could not be serialized + /// - if the request fails + /// - if the response could not be deserialized + /// - if the signature is invalid + /// - if the keys are invalid + pub async fn do_update(&self, creds: &Credentials) -> Result<(NebulaConfig, DHPrivateKeyPEM, Credentials), Box> { + let (dh_pubkey_pem, dh_privkey_pem, ed_pubkey, ed_privkey) = new_keys(); + + let update_keys = DoUpdateRequest { + ed_pubkey_pem: serialize_ed25519_public(ed_pubkey.as_bytes()), + dh_pubkey_pem, + nonce: nonce().to_vec(), + }; + + let update_keys_blob = serde_json::to_vec(&update_keys)?; + + let resp = self.post_dnclient(DO_UPDATE, &update_keys_blob, &creds.host_id, creds.counter, &creds.ed_privkey).await?; + + let result_wrapper: SignedResponseWrapper = serde_json::from_slice(&resp)?; + + let mut valid = false; + + for ca_pubkey in &creds.trusted_keys { + if ca_pubkey.verify(&result_wrapper.data.message, &Signature::from_slice(&result_wrapper.data.signature)?).is_ok() { + valid = true; + break; + } + } + + if !valid { + return Err("Failed to verify signed API result".into()) + } + + let result: DoUpdateResponse = serde_json::from_slice(&result_wrapper.data.message)?; + + if result.nonce != update_keys.nonce { + error!("nonce mismatch between request {:x?} and response {:x?}", result.nonce, update_keys.nonce); + return Err("nonce mismatch between request and response".into()) + } + + let trusted_keys = ed25519_public_keys_from_pem(&result.trusted_keys)?; + + let new_creds = Credentials { + host_id: creds.host_id.clone(), + ed_privkey, + counter: result.counter, + trusted_keys, + }; + + Ok((result.config, dh_privkey_pem, new_creds)) + } + + /// Wraps and signs the given `req_type` and value, and then makes the API call. + /// On success, returns the response body. + /// # Errors + /// This function will return an error if: + /// - serialization in any step fails + /// - if the `server_url` is invalid + /// - if the request could not be sent + pub async fn post_dnclient(&self, req_type: &str, value: &[u8], host_id: &str, counter: u32, ed_privkey: &SigningKey) -> Result, Box> { + let encoded_msg = serde_json::to_string(&RequestWrapper { + message_type: req_type.to_string(), + value: value.to_vec(), + timestamp: Local::now().format("%Y-%m-%dT%H:%M:%S.%f%:z").to_string(), + })?; + let encoded_msg_bytes = encoded_msg.into_bytes(); + let signature = ed_privkey.sign(&encoded_msg_bytes).to_vec(); + let body = RequestV1 { + version: 1, + host_id: host_id.to_string(), + counter, + message: encoded_msg_bytes, + signature, + }; + + let post_body = serde_json::to_string(&body)?; + + let resp = self.http_client.post(self.server_url.join(ENDPOINT_V1)?).body(post_body).send().await?; + + match resp.status() { + StatusCode::OK => { + Ok(resp.bytes().await?.to_vec()) + }, + StatusCode::FORBIDDEN => { + Err("Forbidden".into()) + }, + _ => { + error!("dnclient endpoint returned bad status code {}", resp.status()); + Err("dnclient endpoint returned error".into()) + } + } + } +} \ No newline at end of file diff --git a/tfclient/Cargo.toml b/tfclient/Cargo.toml index 56fa277..339ec88 100644 --- a/tfclient/Cargo.toml +++ b/tfclient/Cargo.toml @@ -24,7 +24,7 @@ base64 = "0.21.0" chrono = "0.4.24" ipnet = "2.7.1" base64-serde = "0.7.0" -dnapi-rs = { version = "0.1.0", path = "../dnapi-rs" } +dnapi-rs = { version = "0.1.1", path = "../dnapi-rs" } [build-dependencies] serde = { version = "1.0.157", features = ["derive"] } diff --git a/tfclient/src/apiworker.rs b/tfclient/src/apiworker.rs index a5f85ce..6ad2ae9 100644 --- a/tfclient/src/apiworker.rs +++ b/tfclient/src/apiworker.rs @@ -3,12 +3,10 @@ use base64::Engine; use chrono::Local; use log::{error, info, warn}; use url::Url; -use trifid_pki::ca::NebulaCAPool; use trifid_pki::cert::{serialize_ed25519_public, serialize_x25519_public}; use trifid_pki::ed25519_dalek::{SecretKey, SigningKey}; use trifid_pki::rand_core::OsRng; use trifid_pki::x25519_dalek::StaticSecret; -use crate::message::{APIResponse, enroll, EnrollRequest}; use crate::config::{load_cdata, save_cdata, TFClientConfig}; use crate::daemon::ThreadMessageSender; diff --git a/tfclient/src/config.rs b/tfclient/src/config.rs index 9d86a0b..dfe48af 100644 --- a/tfclient/src/config.rs +++ b/tfclient/src/config.rs @@ -5,6 +5,7 @@ use std::fs; use log::{debug, info}; use serde::{Deserialize, Serialize}; +use dnapi_rs::credentials::Credentials; use crate::dirs::{get_cdata_dir, get_cdata_file, get_config_dir, get_config_file}; @@ -20,13 +21,8 @@ pub struct TFClientConfig { #[derive(Serialize, Deserialize, Clone)] pub struct TFClientData { pub host_id: Option, - pub ed_privkey: Option<[u8; 32]>, pub dh_privkey: Option<[u8; 32]>, - pub counter: i32, - pub key_pool: Option, - pub org_id: Option, - pub org_name: Option, - pub config: Option + pub creds: Option } pub fn create_config(instance: &str) -> Result<(), Box> {