//! 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}; use serde::{Serialize, Deserialize}; use base64::Engine; /// 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 } #[derive(Serialize, Deserialize, Clone)] /// 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 b64_msg = base64::engine::general_purpose::STANDARD.encode(encoded_msg_bytes); let b64_msg_bytes = b64_msg.as_bytes(); let signature = ed_privkey.sign(b64_msg_bytes).to_vec(); ed_privkey.verify(b64_msg_bytes, &Signature::from_slice(&signature)?)?; debug!("signature valid via clientside check"); let body = RequestV1 { version: 1, host_id: host_id.to_string(), counter, message: b64_msg, 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()) } } } }