dnapi_rs/
client_blocking.rs

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