trifid/dnapi-rs/src/client_blocking.rs

291 lines
10 KiB
Rust

//! Client structs to handle communication with the Defined Networking API. This is the blocking client API - if you want async instead, set no-default-features and enable the async 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, trace};
use reqwest::StatusCode;
use serde::{Deserialize, Serialize};
use std::error::Error;
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::blocking::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::blocking::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 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)?)
.header("Content-Type", "application/json")
.body(req_json)
.send()?;
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()?;
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,
};
debug!("parsing public keys");
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 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,
)?;
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 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,
)?;
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());
}
if result.counter <= creds.counter {
error!(
"counter in request {} should be less than counter in response {}",
creds.counter, result.counter
);
return Err("received older config than what we already had".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 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");
debug!("signed with key: {:x?}", ed_privkey.verifying_key().as_bytes());
let body = RequestV1 {
version: 1,
host_id: host_id.to_string(),
counter,
message: b64_msg,
signature,
};
let post_body = serde_json::to_string(&body)?;
trace!("sending dnclient request {}", post_body);
let resp = self
.http_client
.post(self.server_url.join(ENDPOINT_V1)?)
.body(post_body)
.header("Content-Type", "application/json")
.send()?;
match resp.status() {
StatusCode::OK => Ok(resp.bytes()?.to_vec()),
StatusCode::FORBIDDEN => Err("Forbidden".into()),
_ => {
error!(
"dnclient endpoint returned bad status code {}",
resp.status()
);
Err("dnclient endpoint returned error".into())
}
}
}
}