core_crypto/e2e_identity/enrollment/
mod.rs

1mod crypto;
2#[cfg(test)]
3pub mod test_utils;
4
5use core_crypto_keystore::CryptoKeystoreMls as _;
6use mls_crypto_provider::MlsCryptoProvider;
7use openmls_traits::{OpenMlsCryptoProvider as _, random::OpenMlsRand as _};
8use wire_e2e_identity::{RustyE2eIdentity, prelude::E2eiAcmeAuthorization};
9use zeroize::Zeroize as _;
10
11use crate::{
12    KeystoreError, MlsError,
13    prelude::{ClientId, MlsCiphersuite},
14};
15
16use super::{EnrollmentHandle, Error, Json, Result, crypto::E2eiSignatureKeypair, id::QualifiedE2eiClientId, types};
17
18/// Wire end to end identity solution for fetching a x509 certificate which identifies a client.
19#[derive(Debug, serde::Serialize, serde::Deserialize)]
20pub struct E2eiEnrollment {
21    delegate: RustyE2eIdentity,
22    pub(crate) sign_sk: E2eiSignatureKeypair,
23    pub(super) client_id: String,
24    pub(super) display_name: String,
25    pub(super) handle: String,
26    pub(super) team: Option<String>,
27    expiry: core::time::Duration,
28    directory: Option<types::E2eiAcmeDirectory>,
29    account: Option<wire_e2e_identity::prelude::E2eiAcmeAccount>,
30    user_authz: Option<E2eiAcmeAuthorization>,
31    device_authz: Option<E2eiAcmeAuthorization>,
32    valid_order: Option<wire_e2e_identity::prelude::E2eiAcmeOrder>,
33    finalize: Option<wire_e2e_identity::prelude::E2eiAcmeFinalize>,
34    pub(super) ciphersuite: MlsCiphersuite,
35    has_called_new_oidc_challenge_request: bool,
36}
37
38impl std::ops::Deref for E2eiEnrollment {
39    type Target = RustyE2eIdentity;
40
41    fn deref(&self) -> &Self::Target {
42        &self.delegate
43    }
44}
45
46impl E2eiEnrollment {
47    /// Builds an instance holding private key material. This instance has to be used in the whole
48    /// enrollment process then dropped to clear secret key material.
49    ///
50    /// # Parameters
51    /// * `client_id` - client identifier e.g. `b7ac11a4-8f01-4527-af88-1c30885a7931:6add501bacd1d90e@example.com`
52    /// * `display_name` - human readable name displayed in the application e.g. `Smith, Alice M (QA)`
53    /// * `handle` - user handle e.g. `alice.smith.qa@example.com`
54    /// * `expiry_sec` - generated x509 certificate expiry in seconds
55    #[allow(clippy::too_many_arguments)]
56    pub fn try_new(
57        client_id: ClientId,
58        display_name: String,
59        handle: String,
60        team: Option<String>,
61        expiry_sec: u32,
62        backend: &MlsCryptoProvider,
63        ciphersuite: MlsCiphersuite,
64        sign_keypair: Option<E2eiSignatureKeypair>,
65        has_called_new_oidc_challenge_request: bool,
66    ) -> Result<Self> {
67        let alg = ciphersuite.try_into()?;
68        let sign_sk = sign_keypair
69            .map(Ok)
70            .unwrap_or_else(|| Self::new_sign_key(ciphersuite, backend))?;
71
72        let client_id = QualifiedE2eiClientId::try_from(client_id.as_slice())?;
73        let client_id = String::try_from(client_id)?;
74        let expiry = core::time::Duration::from_secs(u64::from(expiry_sec));
75        let delegate = RustyE2eIdentity::try_new(alg, sign_sk.clone()).map_err(Error::from)?;
76        Ok(Self {
77            delegate,
78            sign_sk,
79            client_id,
80            display_name,
81            handle,
82            team,
83            expiry,
84            directory: None,
85            account: None,
86            user_authz: None,
87            device_authz: None,
88            valid_order: None,
89            finalize: None,
90            ciphersuite,
91            has_called_new_oidc_challenge_request,
92        })
93    }
94
95    pub(crate) fn ciphersuite(&self) -> &MlsCiphersuite {
96        &self.ciphersuite
97    }
98
99    /// Parses the response from `GET /acme/{provisioner-name}/directory`.
100    /// Use this [types::E2eiAcmeDirectory] in the next step to fetch the first nonce from the acme server. Use
101    /// [types::E2eiAcmeDirectory.new_nonce].
102    ///
103    /// See [RFC 8555 Section 7.1.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1)
104    ///
105    /// # Parameters
106    /// * `directory` - http response body
107    pub fn directory_response(&mut self, directory: Json) -> Result<types::E2eiAcmeDirectory> {
108        let directory = serde_json::from_slice(&directory[..])?;
109        let directory: types::E2eiAcmeDirectory = self.acme_directory_response(directory)?.into();
110        self.directory = Some(directory.clone());
111        Ok(directory)
112    }
113
114    /// For creating a new acme account. This returns a signed JWS-alike request body to send to
115    /// `POST /acme/{provisioner-name}/new-account`.
116    ///
117    /// See [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3).
118    ///
119    /// # Parameters
120    /// * `directory` - you got from [Self::directory_response]
121    /// * `previous_nonce` - you got from calling `HEAD {directory.new_nonce}`
122    pub fn new_account_request(&self, previous_nonce: String) -> Result<Json> {
123        let directory = self
124            .directory
125            .as_ref()
126            .ok_or(Error::OutOfOrderEnrollment("You must first call 'directoryResponse()'"))?;
127        let account = self.acme_new_account_request(&directory.try_into()?, previous_nonce)?;
128        let account = serde_json::to_vec(&account)?;
129        Ok(account)
130    }
131
132    /// Parses the response from `POST /acme/{provisioner-name}/new-account`.
133    ///
134    /// See [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3).
135    ///
136    /// # Parameters
137    /// * `account` - http response body
138    pub fn new_account_response(&mut self, account: Json) -> Result<()> {
139        let account = serde_json::from_slice(&account[..])?;
140        let account = self.acme_new_account_response(account)?;
141        self.account = Some(account);
142        Ok(())
143    }
144
145    /// Creates a new acme order for the handle (userId + display name) and the clientId.
146    ///
147    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
148    ///
149    /// # Parameters
150    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/new-account`
151    pub fn new_order_request(&self, previous_nonce: String) -> Result<Json> {
152        let directory = self
153            .directory
154            .as_ref()
155            .ok_or(Error::OutOfOrderEnrollment("You must first call 'directoryResponse()'"))?;
156        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
157            "You must first call 'newAccountResponse()'",
158        ))?;
159        let order = self.acme_new_order_request(
160            &self.display_name,
161            &self.client_id,
162            &self.handle,
163            self.expiry,
164            &directory.try_into()?,
165            account,
166            previous_nonce,
167        )?;
168        let order = serde_json::to_vec(&order)?;
169        Ok(order)
170    }
171
172    /// Parses the response from `POST /acme/{provisioner-name}/new-order`.
173    ///
174    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
175    ///
176    /// # Parameters
177    /// * `new_order` - http response body
178    pub fn new_order_response(&self, order: Json) -> Result<types::E2eiNewAcmeOrder> {
179        let order = serde_json::from_slice(&order[..])?;
180        self.acme_new_order_response(order)?.try_into()
181    }
182
183    /// Creates a new authorization request.
184    ///
185    /// See [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5).
186    ///
187    /// # Parameters
188    /// * `url` - one of the URL in new order's authorizations (from [Self::new_order_response])
189    /// * `account` - you got from [Self::new_account_response]
190    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/new-order`
191    ///   (or from the previous to this method if you are creating the second authorization)
192    pub fn new_authz_request(&self, url: String, previous_nonce: String) -> Result<Json> {
193        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
194            "You must first call 'newAccountResponse()'",
195        ))?;
196        let authz = self.acme_new_authz_request(&url.parse()?, account, previous_nonce)?;
197        let authz = serde_json::to_vec(&authz)?;
198        Ok(authz)
199    }
200
201    /// Parses the response from `POST /acme/{provisioner-name}/authz/{authz-id}`
202    ///
203    /// See [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5).
204    ///
205    /// # Parameters
206    /// * `new_authz` - http response body
207    pub fn new_authz_response(&mut self, authz: Json) -> Result<types::E2eiNewAcmeAuthz> {
208        let authz = serde_json::from_slice(&authz[..])?;
209        let authz = self.acme_new_authz_response(authz)?;
210        match &authz {
211            E2eiAcmeAuthorization::User { .. } => self.user_authz = Some(authz.clone()),
212            E2eiAcmeAuthorization::Device { .. } => self.device_authz = Some(authz.clone()),
213        };
214        authz.try_into()
215    }
216
217    /// Generates a new client Dpop JWT token. It demonstrates proof of possession of the nonces
218    /// (from wire-server & acme server) and will be verified by the acme server when verifying the
219    /// challenge (in order to deliver a certificate).
220    ///
221    /// Then send it to
222    /// [`POST /clients/{id}/access-token`](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/post_clients__cid__access_token)
223    /// on wire-server.
224    ///
225    /// # Parameters
226    /// * `expiry_secs` - of the client Dpop JWT. This should be equal to the grace period set in Team Management
227    /// * `backend_nonce` - you get by calling `GET /clients/token/nonce` on wire-server.
228    ///   See endpoint [definition](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/get_clients__client__nonce)
229    /// * `expiry` - token expiry
230    #[allow(clippy::too_many_arguments)]
231    pub fn create_dpop_token(&self, expiry_secs: u32, backend_nonce: String) -> Result<String> {
232        let expiry = core::time::Duration::from_secs(expiry_secs as u64);
233        let authz = self
234            .device_authz
235            .as_ref()
236            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
237        let challenge = match authz {
238            E2eiAcmeAuthorization::Device { challenge, .. } => challenge,
239            E2eiAcmeAuthorization::User { .. } => return Err(Error::ImplementationError),
240        };
241        Ok(self.new_dpop_token(
242            &self.client_id,
243            self.display_name.as_str(),
244            challenge,
245            backend_nonce,
246            self.handle.as_str(),
247            self.team.clone(),
248            expiry,
249        )?)
250    }
251
252    /// Creates a new challenge request.
253    ///
254    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
255    ///
256    /// # Parameters
257    /// * `access_token` - returned by wire-server from [this endpoint](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/post_clients__cid__access_token)
258    /// * `dpop_challenge` - you found after [Self::new_authz_response]
259    /// * `account` - you got from [Self::new_account_response]
260    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
261    pub fn new_dpop_challenge_request(&self, access_token: String, previous_nonce: String) -> Result<Json> {
262        let authz = self
263            .device_authz
264            .as_ref()
265            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
266        let challenge = match authz {
267            E2eiAcmeAuthorization::Device { challenge, .. } => challenge,
268            E2eiAcmeAuthorization::User { .. } => return Err(Error::ImplementationError),
269        };
270        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
271            "You must first call 'newAccountResponse()'",
272        ))?;
273        let challenge = self.acme_dpop_challenge_request(access_token, challenge, account, previous_nonce)?;
274        let challenge = serde_json::to_vec(&challenge)?;
275        Ok(challenge)
276    }
277
278    /// Parses the response from `POST /acme/{provisioner-name}/challenge/{challenge-id}` for the DPoP challenge
279    ///
280    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
281    ///
282    /// # Parameters
283    /// * `challenge` - http response body
284    pub fn new_dpop_challenge_response(&self, challenge: Json) -> Result<()> {
285        let challenge = serde_json::from_slice(&challenge[..])?;
286        Ok(self.acme_new_challenge_response(challenge)?)
287    }
288
289    /// Creates a new challenge request.
290    ///
291    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
292    ///
293    /// # Parameters
294    /// * `id_token` - you get back from Identity Provider
295    /// * `oidc_challenge` - you found after [Self::new_authz_response]
296    /// * `account` - you got from [Self::new_account_response]
297    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
298    pub fn new_oidc_challenge_request(&mut self, id_token: String, previous_nonce: String) -> Result<Json> {
299        let authz = self
300            .user_authz
301            .as_ref()
302            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
303        let challenge = match authz {
304            E2eiAcmeAuthorization::User { challenge, .. } => challenge,
305            E2eiAcmeAuthorization::Device { .. } => return Err(Error::ImplementationError),
306        };
307        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
308            "You must first call 'newAccountResponse()'",
309        ))?;
310        let challenge = self.acme_oidc_challenge_request(id_token, challenge, account, previous_nonce)?;
311        let challenge = serde_json::to_vec(&challenge)?;
312
313        self.has_called_new_oidc_challenge_request = true;
314
315        Ok(challenge)
316    }
317
318    /// Parses the response from `POST /acme/{provisioner-name}/challenge/{challenge-id}` for the OIDC challenge
319    ///
320    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
321    ///
322    /// # Parameters
323    /// * `challenge` - http response body
324    pub async fn new_oidc_challenge_response(&mut self, challenge: Json) -> Result<()> {
325        let challenge = serde_json::from_slice(&challenge[..])?;
326        self.acme_new_challenge_response(challenge)?;
327
328        if !self.has_called_new_oidc_challenge_request {
329            return Err(Error::OutOfOrderEnrollment(
330                "You must first call 'new_oidc_challenge_request()'",
331            ));
332        }
333
334        Ok(())
335    }
336
337    /// Verifies that the previous challenge has been completed.
338    ///
339    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
340    ///
341    /// # Parameters
342    /// * `order_url` - `location` header from http response you got from [Self::new_order_response]
343    /// * `account` - you got from [Self::new_account_response]
344    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/challenge/{challenge-id}`
345    pub fn check_order_request(&self, order_url: String, previous_nonce: String) -> Result<Json> {
346        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
347            "You must first call 'newAccountResponse()'",
348        ))?;
349        let order = self.acme_check_order_request(order_url.parse()?, account, previous_nonce)?;
350        let order = serde_json::to_vec(&order)?;
351        Ok(order)
352    }
353
354    /// Parses the response from `POST /acme/{provisioner-name}/order/{order-id}`.
355    ///
356    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
357    ///
358    /// # Parameters
359    /// * `order` - http response body
360    ///
361    /// # Returns
362    /// The finalize url to use with [Self::finalize_request]
363    pub fn check_order_response(&mut self, order: Json) -> Result<String> {
364        let order = serde_json::from_slice(&order[..])?;
365        let valid_order = self.acme_check_order_response(order)?;
366        let finalize_url = valid_order.finalize_url.to_string();
367        self.valid_order = Some(valid_order);
368        Ok(finalize_url)
369    }
370
371    /// Final step before fetching the certificate.
372    ///
373    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
374    ///
375    /// # Parameters
376    /// * `domains` - you want to generate a certificate for e.g. `["wire.com"]`
377    /// * `order` - you got from [Self::check_order_response]
378    /// * `account` - you got from [Self::new_account_response]
379    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/order/{order-id}`
380    pub fn finalize_request(&mut self, previous_nonce: String) -> Result<Json> {
381        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
382            "You must first call 'newAccountResponse()'",
383        ))?;
384        let order = self.valid_order.as_ref().ok_or(Error::OutOfOrderEnrollment(
385            "You must first call 'checkOrderResponse()'",
386        ))?;
387        let finalize = self.acme_finalize_request(order, account, previous_nonce)?;
388        let finalize = serde_json::to_vec(&finalize)?;
389        Ok(finalize)
390    }
391
392    /// Parses the response from `POST /acme/{provisioner-name}/order/{order-id}/finalize`.
393    ///
394    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
395    ///
396    /// # Parameters
397    /// * `finalize` - http response body
398    ///
399    /// # Returns
400    /// The certificate url to use with [Self::certificate_request]
401    pub fn finalize_response(&mut self, finalize: Json) -> Result<String> {
402        let finalize = serde_json::from_slice(&finalize[..])?;
403        let finalize = self.acme_finalize_response(finalize)?;
404        let certificate_url = finalize.certificate_url.to_string();
405        self.finalize = Some(finalize);
406        Ok(certificate_url)
407    }
408
409    /// Creates a request for finally fetching the x509 certificate.
410    ///
411    /// See [RFC 8555 Section 7.4.2](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2).
412    ///
413    /// # Parameters
414    /// * `finalize` - you got from [Self::finalize_response]
415    /// * `account` - you got from [Self::new_account_response]
416    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/order/{order-id}/finalize`
417    pub fn certificate_request(&mut self, previous_nonce: String) -> Result<Json> {
418        let account = self.account.take().ok_or(Error::OutOfOrderEnrollment(
419            "You must first call 'newAccountResponse()'",
420        ))?;
421        let finalize = self
422            .finalize
423            .take()
424            .ok_or(Error::OutOfOrderEnrollment("You must first call 'finalizeResponse()'"))?;
425        let certificate = self.acme_x509_certificate_request(finalize, account, previous_nonce)?;
426        let certificate = serde_json::to_vec(&certificate)?;
427        Ok(certificate)
428    }
429
430    pub(crate) async fn certificate_response(
431        &mut self,
432        certificate_chain: String,
433        env: &wire_e2e_identity::prelude::x509::revocation::PkiEnvironment,
434    ) -> Result<Vec<Vec<u8>>> {
435        let order = self.valid_order.take().ok_or(Error::OutOfOrderEnrollment(
436            "You must first call 'checkOrderResponse()'",
437        ))?;
438        let certificates = self.acme_x509_certificate_response(certificate_chain, order, Some(env))?;
439
440        // zeroize the private material
441        self.sign_sk.zeroize();
442        self.delegate.sign_kp.zeroize();
443        self.delegate.acme_kp.zeroize();
444
445        Ok(certificates)
446    }
447
448    pub(crate) async fn stash(self, backend: &MlsCryptoProvider) -> Result<EnrollmentHandle> {
449        // should be enough to prevent collisions
450        const HANDLE_SIZE: usize = 32;
451
452        let content = serde_json::to_vec(&self)?;
453        let handle = backend
454            .crypto()
455            .random_vec(HANDLE_SIZE)
456            .map_err(MlsError::wrap("generating random vector of bytes"))?;
457        backend
458            .key_store()
459            .save_e2ei_enrollment(&handle, &content)
460            .await
461            .map_err(KeystoreError::wrap("saving e2ei enrollment"))?;
462        Ok(handle)
463    }
464
465    pub(crate) async fn stash_pop(backend: &MlsCryptoProvider, handle: EnrollmentHandle) -> Result<Self> {
466        let content = backend
467            .key_store()
468            .pop_e2ei_enrollment(&handle)
469            .await
470            .map_err(KeystoreError::wrap("popping e2ei enrollment"))?;
471        Ok(serde_json::from_slice(&content)?)
472    }
473}