dnapi_rs/
client_async.rs

1//! 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.
2
3use crate::credentials::{ed25519_public_keys_from_pem, Credentials};
4use crate::crypto::{new_keys, nonce};
5use crate::message::{
6    CheckForUpdateResponseWrapper, DoUpdateRequest, DoUpdateResponse, EnrollRequest,
7    EnrollResponse, RequestV1, RequestWrapper, SignedResponseWrapper, CHECK_FOR_UPDATE, DO_UPDATE,
8    ENDPOINT_V1, ENROLL_ENDPOINT,
9};
10use base64::Engine;
11use chrono::Local;
12use log::{debug, error};
13use reqwest::StatusCode;
14use serde::{Deserialize, Serialize};
15use std::error::Error;
16use reqwest::header::HeaderValue;
17use trifid_pki::cert::serialize_ed25519_public;
18use trifid_pki::ed25519_dalek::{Signature, Signer, SigningKey, Verifier};
19use url::Url;
20
21/// A type alias to abstract return types
22pub type NebulaConfig = Vec<u8>;
23
24/// A type alias to abstract DH private keys
25pub type DHPrivateKeyPEM = Vec<u8>;
26
27/// A combination of persistent data and HTTP client used for communicating with the API.
28pub struct Client {
29    http_client: reqwest::Client,
30    server_url: Url,
31}
32
33#[derive(Serialize, Deserialize, Clone)]
34/// A struct containing organization metadata returned as a result of enrollment
35pub struct EnrollMeta {
36    /// The server organization ID this node is now a member of
37    pub organization_id: String,
38    /// The server organization name this node is now a member of
39    pub organization_name: String,
40}
41
42impl Client {
43    /// Create a new `Client` configured with the given User-Agent and API base.
44    /// # Errors
45    /// This function will return an error if the reqwest Client could not be created.
46    pub fn new(user_agent: String, api_base: Url) -> Result<Self, Box<dyn Error>> {
47        let client = reqwest::Client::builder().user_agent(user_agent).build()?;
48        Ok(Self {
49            http_client: client,
50            server_url: api_base,
51        })
52    }
53
54    /// Issues an enrollment request against the REST API using the given enrollment code, passing along a
55    /// locally generated DH X25519 Nebula key to be signed by the CA, and an Ed25519 key for future API
56    /// authentication. On success it returns the Nebula config generated by the server, a Nebula private key PEM,
57    /// credentials to be used for future DN API requests, and an object containing organization information.
58    /// # Errors
59    /// This function will return an error in any of the following situations:
60    /// - the `server_url` is invalid
61    /// - the HTTP request fails
62    /// - the HTTP response is missing X-Request-ID
63    /// - X-Request-ID isn't valid UTF-8
64    /// - the server returns an error
65    /// - the server returns invalid JSON
66    /// - the `trusted_keys` field is invalid
67    pub async fn enroll(
68        &self,
69        code: &str,
70    ) -> Result<(NebulaConfig, DHPrivateKeyPEM, Credentials, EnrollMeta), Box<dyn Error>> {
71        debug!(
72            "making enrollment request to API {{server: {}, code: {}}}",
73            self.server_url, code
74        );
75
76        let (dh_pubkey_pem, dh_privkey_pem, ed_pubkey, ed_privkey) = new_keys();
77
78        let req_json = serde_json::to_string(&EnrollRequest {
79            code: code.to_string(),
80            dh_pubkey: dh_pubkey_pem,
81            ed_pubkey: serialize_ed25519_public(ed_pubkey.as_bytes()),
82            timestamp: Local::now().format("%Y-%m-%dT%H:%M:%S.%f%:z").to_string(),
83        })?;
84
85        let resp = self
86            .http_client
87            .post(self.server_url.join(ENROLL_ENDPOINT)?)
88            .body(req_json)
89            .header("Content-Type", "application/json")
90            .send()
91            .await?;
92
93        let empty_hval;
94        #[allow(clippy::unwrap_used)] {
95            empty_hval = HeaderValue::from_str("").unwrap();
96        };
97
98        let req_id = resp
99            .headers()
100            .get("X-Request-ID")
101            .unwrap_or(&empty_hval)
102            .to_str()?;
103        debug!("enrollment request complete {{req_id: {}}}", req_id);
104
105        let resp: EnrollResponse = resp.json().await?;
106
107        let r = match resp {
108            EnrollResponse::Success { data } => data,
109            EnrollResponse::Error { errors } => {
110                error!("unexpected error during enrollment: {}", errors[0].message);
111                return Err(errors[0].message.clone().into());
112            }
113        };
114
115        let meta = EnrollMeta {
116            organization_id: r.organization.id,
117            organization_name: r.organization.name,
118        };
119
120        let trusted_keys = ed25519_public_keys_from_pem(&r.trusted_keys)?;
121
122        let creds = Credentials {
123            host_id: r.host_id,
124            ed_privkey,
125            counter: r.counter,
126            trusted_keys,
127        };
128
129        Ok((r.config, dh_privkey_pem, creds, meta))
130    }
131
132    /// Send a signed message to the `DNClient` API to learn if there is a new configuration available.
133    /// # Errors
134    /// This function returns an error if the dnclient request fails, or the server returns invalid data.
135    pub async fn check_for_update(&self, creds: &Credentials) -> Result<bool, Box<dyn Error>> {
136        let body = self
137            .post_dnclient(
138                CHECK_FOR_UPDATE,
139                &[],
140                &creds.host_id,
141                creds.counter,
142                &creds.ed_privkey,
143            )
144            .await?;
145
146        let result: CheckForUpdateResponseWrapper = serde_json::from_slice(&body)?;
147
148        Ok(result.data.update_available)
149    }
150
151    /// Send a signed message to the `DNClient` API to fetch the new configuration update. During this call a new
152    /// DH X25519 keypair is generated for the new Nebula certificate as well as a new Ed25519 keypair for `DNClient` API
153    /// communication. On success it returns the new config, a Nebula private key PEM to be inserted into the config
154    /// and new `DNClient` API credentials
155    /// # Errors
156    /// This function returns an error in any of the following scenarios:
157    /// - if the message could not be serialized
158    /// - if the request fails
159    /// - if the response could not be deserialized
160    /// - if the signature is invalid
161    /// - if the keys are invalid
162    pub async fn do_update(
163        &self,
164        creds: &Credentials,
165    ) -> Result<(NebulaConfig, DHPrivateKeyPEM, Credentials), Box<dyn Error>> {
166        let (dh_pubkey_pem, dh_privkey_pem, ed_pubkey, ed_privkey) = new_keys();
167
168        let update_keys = DoUpdateRequest {
169            ed_pubkey_pem: serialize_ed25519_public(ed_pubkey.as_bytes()),
170            dh_pubkey_pem,
171            nonce: nonce().to_vec(),
172        };
173
174        let update_keys_blob = serde_json::to_vec(&update_keys)?;
175
176        let resp = self
177            .post_dnclient(
178                DO_UPDATE,
179                &update_keys_blob,
180                &creds.host_id,
181                creds.counter,
182                &creds.ed_privkey,
183            )
184            .await?;
185
186        let result_wrapper: SignedResponseWrapper = serde_json::from_slice(&resp)?;
187
188        let mut valid = false;
189
190        for ca_pubkey in &creds.trusted_keys {
191            if ca_pubkey
192                .verify(
193                    &result_wrapper.data.message,
194                    &Signature::from_slice(&result_wrapper.data.signature)?,
195                )
196                .is_ok()
197            {
198                valid = true;
199                break;
200            }
201        }
202
203        if !valid {
204            return Err("Failed to verify signed API result".into());
205        }
206
207        debug!("deserializing result");
208
209        let result: DoUpdateResponse = serde_json::from_slice(&result_wrapper.data.message)?;
210
211        if result.nonce != update_keys.nonce {
212            error!(
213                "nonce mismatch between request {:x?} and response {:x?}",
214                result.nonce, update_keys.nonce
215            );
216            return Err("nonce mismatch between request and response".into());
217        }
218
219        let trusted_keys = ed25519_public_keys_from_pem(&result.trusted_keys)?;
220
221        let new_creds = Credentials {
222            host_id: creds.host_id.clone(),
223            ed_privkey,
224            counter: result.counter,
225            trusted_keys,
226        };
227
228        Ok((result.config, dh_privkey_pem, new_creds))
229    }
230
231    /// Wraps and signs the given `req_type` and value, and then makes the API call.
232    /// On success, returns the response body.
233    /// # Errors
234    /// This function will return an error if:
235    /// - serialization in any step fails
236    /// - if the `server_url` is invalid
237    /// - if the request could not be sent
238    pub async fn post_dnclient(
239        &self,
240        req_type: &str,
241        value: &[u8],
242        host_id: &str,
243        counter: u32,
244        ed_privkey: &SigningKey,
245    ) -> Result<Vec<u8>, Box<dyn Error>> {
246        let encoded_msg = serde_json::to_string(&RequestWrapper {
247            message_type: req_type.to_string(),
248            value: value.to_vec(),
249            timestamp: Local::now().format("%Y-%m-%dT%H:%M:%S.%f%:z").to_string(),
250        })?;
251        let encoded_msg_bytes = encoded_msg.into_bytes();
252        let b64_msg = base64::engine::general_purpose::STANDARD.encode(encoded_msg_bytes);
253        let b64_msg_bytes = b64_msg.as_bytes();
254        let signature = ed_privkey.sign(b64_msg_bytes).to_vec();
255
256        ed_privkey.verify(b64_msg_bytes, &Signature::from_slice(&signature)?)?;
257        debug!("signature valid via clientside check");
258
259        let body = RequestV1 {
260            version: 1,
261            host_id: host_id.to_string(),
262            counter,
263            message: b64_msg,
264            signature,
265        };
266
267        let post_body = serde_json::to_string(&body)?;
268
269        let resp = self
270            .http_client
271            .post(self.server_url.join(ENDPOINT_V1)?)
272            .body(post_body)
273            .header("Content-Type", "application/json")
274            .send()
275            .await?;
276
277        match resp.status() {
278            StatusCode::OK => Ok(resp.bytes().await?.to_vec()),
279            StatusCode::FORBIDDEN => Err("Forbidden".into()),
280            _ => {
281                error!(
282                    "dnclient endpoint returned bad status code {}",
283                    resp.status()
284                );
285                Err("dnclient endpoint returned error".into())
286            }
287        }
288    }
289}