dnapi-rs 0.2.3

A rust client for the Defined Networking API
Documentation
//! 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 crate::credentials::{ed25519_public_keys_from_pem, Credentials};
use crate::crypto::{new_keys, nonce};
use crate::message::{
    CheckForUpdateResponseWrapper, DoUpdateRequest, DoUpdateResponse, EnrollRequest,
    EnrollResponse, RequestV1, RequestWrapper, SignedResponseWrapper, CHECK_FOR_UPDATE, DO_UPDATE,
    ENDPOINT_V1, ENROLL_ENDPOINT,
};
use base64::Engine;
use chrono::Local;
use log::{debug, error};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::error::Error;
use reqwest::header::HeaderValue;
use trifid_pki::cert::serialize_ed25519_public;
use trifid_pki::ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
use url::Url;

/// A type alias to abstract return types
pub type NebulaConfig = Vec<u8>;

/// A type alias to abstract DH private keys
pub type DHPrivateKeyPEM = Vec<u8>;

/// 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<Self, Box<dyn Error>> {
        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<dyn Error>> {
        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)
            .header("Content-Type", "application/json")
            .send()
            .await?;

        let empty_hval;
        #[allow(clippy::unwrap_used)] {
            empty_hval = HeaderValue::from_str("").unwrap();
        };

        let req_id = resp
            .headers()
            .get("X-Request-ID")
            .unwrap_or(&empty_hval)
            .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<bool, Box<dyn Error>> {
        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<dyn Error>> {
        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());
        }

        debug!("deserializing result");

        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<Vec<u8>, Box<dyn Error>> {
        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)
            .header("Content-Type", "application/json")
            .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())
            }
        }
    }
}