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