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::{Ciphersuite, ClientId, KeystoreError, 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: Ciphersuite,
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: Ciphersuite,
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) -> &Ciphersuite {
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` (or from the
187    ///   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. See endpoint [definition](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/get_clients__client__nonce)
224    /// * `expiry` - token expiry
225    #[allow(clippy::too_many_arguments)]
226    pub fn create_dpop_token(&self, expiry_secs: u32, backend_nonce: String) -> Result<String> {
227        let expiry = core::time::Duration::from_secs(expiry_secs as u64);
228        let authz = self
229            .device_authz
230            .as_ref()
231            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
232        let challenge = match authz {
233            E2eiAcmeAuthorization::Device { challenge, .. } => challenge,
234            E2eiAcmeAuthorization::User { .. } => return Err(Error::ImplementationError),
235        };
236        Ok(self.new_dpop_token(
237            &self.client_id,
238            self.display_name.as_str(),
239            challenge,
240            backend_nonce,
241            self.handle.as_str(),
242            self.team.clone(),
243            expiry,
244        )?)
245    }
246
247    /// Creates a new challenge request.
248    ///
249    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
250    ///
251    /// # Parameters
252    /// * `access_token` - returned by wire-server from [this endpoint](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/post_clients__cid__access_token)
253    /// * `dpop_challenge` - you found after [Self::new_authz_response]
254    /// * `account` - you got from [Self::new_account_response]
255    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
256    pub fn new_dpop_challenge_request(&self, access_token: String, previous_nonce: String) -> Result<Json> {
257        let authz = self
258            .device_authz
259            .as_ref()
260            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
261        let challenge = match authz {
262            E2eiAcmeAuthorization::Device { challenge, .. } => challenge,
263            E2eiAcmeAuthorization::User { .. } => return Err(Error::ImplementationError),
264        };
265        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
266            "You must first call 'newAccountResponse()'",
267        ))?;
268        let challenge = self.acme_dpop_challenge_request(access_token, challenge, account, previous_nonce)?;
269        let challenge = serde_json::to_vec(&challenge)?;
270        Ok(challenge)
271    }
272
273    /// Parses the response from `POST /acme/{provisioner-name}/challenge/{challenge-id}` for the DPoP challenge
274    ///
275    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
276    ///
277    /// # Parameters
278    /// * `challenge` - http response body
279    pub fn new_dpop_challenge_response(&self, challenge: Json) -> Result<()> {
280        let challenge = serde_json::from_slice(&challenge[..])?;
281        Ok(self.acme_new_challenge_response(challenge)?)
282    }
283
284    /// Creates a new challenge request.
285    ///
286    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
287    ///
288    /// # Parameters
289    /// * `id_token` - you get back from Identity Provider
290    /// * `oidc_challenge` - you found after [Self::new_authz_response]
291    /// * `account` - you got from [Self::new_account_response]
292    /// * `previous_nonce` - `replay-nonce` response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
293    pub fn new_oidc_challenge_request(&mut self, id_token: String, previous_nonce: String) -> Result<Json> {
294        let authz = self
295            .user_authz
296            .as_ref()
297            .ok_or(Error::OutOfOrderEnrollment("You must first call 'newAuthzResponse()'"))?;
298        let challenge = match authz {
299            E2eiAcmeAuthorization::User { challenge, .. } => challenge,
300            E2eiAcmeAuthorization::Device { .. } => return Err(Error::ImplementationError),
301        };
302        let account = self.account.as_ref().ok_or(Error::OutOfOrderEnrollment(
303            "You must first call 'newAccountResponse()'",
304        ))?;
305        let challenge = self.acme_oidc_challenge_request(id_token, challenge, account, previous_nonce)?;
306        let challenge = serde_json::to_vec(&challenge)?;
307
308        self.has_called_new_oidc_challenge_request = true;
309
310        Ok(challenge)
311    }
312
313    /// Parses the response from `POST /acme/{provisioner-name}/challenge/{challenge-id}` for the OIDC challenge
314    ///
315    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
316    ///
317    /// # Parameters
318    /// * `challenge` - http response body
319    pub fn new_oidc_challenge_response(&mut self, challenge: Json) -> Result<()> {
320        let challenge = serde_json::from_slice(&challenge[..])?;
321        self.acme_new_challenge_response(challenge)?;
322
323        if !self.has_called_new_oidc_challenge_request {
324            return Err(Error::OutOfOrderEnrollment(
325                "You must first call 'new_oidc_challenge_request()'",
326            ));
327        }
328
329        Ok(())
330    }
331
332    /// Verifies that the previous challenge has been completed.
333    ///
334    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
335    ///
336    /// # Parameters
337    /// * `order_url` - `location` header from http response you got from [Self::new_order_response]
338    /// * `account` - you got from [Self::new_account_response]
339    /// * `previous_nonce` - `replay-nonce` response header from `POST
340    ///   /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
413    ///   /acme/{provisioner-name}/order/{order-id}/finalize`
414    pub fn certificate_request(&mut self, previous_nonce: String) -> Result<Json> {
415        let account = self.account.take().ok_or(Error::OutOfOrderEnrollment(
416            "You must first call 'newAccountResponse()'",
417        ))?;
418        let finalize = self
419            .finalize
420            .take()
421            .ok_or(Error::OutOfOrderEnrollment("You must first call 'finalizeResponse()'"))?;
422        let certificate = self.acme_x509_certificate_request(finalize, account, previous_nonce)?;
423        let certificate = serde_json::to_vec(&certificate)?;
424        Ok(certificate)
425    }
426
427    pub(crate) async fn certificate_response(
428        &mut self,
429        certificate_chain: String,
430        env: &wire_e2e_identity::prelude::x509::revocation::PkiEnvironment,
431    ) -> Result<Vec<Vec<u8>>> {
432        let order = self.valid_order.take().ok_or(Error::OutOfOrderEnrollment(
433            "You must first call 'checkOrderResponse()'",
434        ))?;
435        let certificates = self.acme_x509_certificate_response(certificate_chain, order, Some(env))?;
436
437        // zeroize the private material
438        self.sign_sk.zeroize();
439        self.delegate.sign_kp.zeroize();
440        self.delegate.acme_kp.zeroize();
441
442        Ok(certificates)
443    }
444
445    pub(crate) async fn stash(self, backend: &MlsCryptoProvider) -> Result<EnrollmentHandle> {
446        // should be enough to prevent collisions
447        const HANDLE_SIZE: usize = 32;
448
449        let content = serde_json::to_vec(&self)?;
450        let handle = backend
451            .crypto()
452            .random_vec(HANDLE_SIZE)
453            .map_err(MlsError::wrap("generating random vector of bytes"))?;
454        backend
455            .key_store()
456            .save_e2ei_enrollment(&handle, &content)
457            .await
458            .map_err(KeystoreError::wrap("saving e2ei enrollment"))?;
459        Ok(handle)
460    }
461
462    pub(crate) async fn stash_pop(backend: &MlsCryptoProvider, handle: EnrollmentHandle) -> Result<Self> {
463        let content = backend
464            .key_store()
465            .pop_e2ei_enrollment(&handle)
466            .await
467            .map_err(KeystoreError::wrap("popping e2ei enrollment"))?;
468        Ok(serde_json::from_slice(&content)?)
469    }
470}