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