wire_e2e_identity/
e2e_identity.rs

1use jwt_simple::prelude::{ES256KeyPair, ES384KeyPair, ES512KeyPair, Ed25519KeyPair, Jwk};
2use rusty_jwt_tools::{
3    jwk::TryIntoJwk,
4    jwk_thumbprint::JwkThumbprint,
5    prelude::{ClientId, Dpop, Handle, HashAlgorithm, Htm, JwsAlgorithm, Pem, RustyJwtTools},
6};
7use zeroize::Zeroize as _;
8
9use crate::{
10    acme::{AcmeChallenge, AcmeDirectory, AcmeIdentifier, RustyAcme},
11    error::E2eIdentityResult,
12    types::{
13        E2eiAcmeAccount, E2eiAcmeAuthorization, E2eiAcmeChallenge, E2eiAcmeFinalize, E2eiAcmeOrder, E2eiNewAcmeOrder,
14        Json,
15    },
16    x509_check::revocation::PkiEnvironment,
17};
18
19#[derive(Debug, serde::Serialize, serde::Deserialize)]
20pub struct RustyE2eIdentity {
21    pub sign_alg: JwsAlgorithm,
22    pub sign_kp: Pem,
23    pub hash_alg: HashAlgorithm,
24    pub acme_kp: Pem,
25    pub acme_jwk: Jwk,
26}
27
28/// Enrollment flow.
29impl RustyE2eIdentity {
30    /// Builds an instance holding private key material. This instance has to be used in the whole
31    /// enrollment process then dropped to clear secret key material.
32    ///
33    /// # Parameters
34    /// * `sign_alg` - Signature algorithm (only Ed25519 for now)
35    /// * `raw_sign_key` - Raw signature key as bytes
36    pub fn try_new(sign_alg: JwsAlgorithm, mut raw_sign_key: Vec<u8>) -> E2eIdentityResult<Self> {
37        let sign_kp = match sign_alg {
38            JwsAlgorithm::Ed25519 => Ed25519KeyPair::from_bytes(&raw_sign_key[..])?.to_pem(),
39            JwsAlgorithm::P256 => ES256KeyPair::from_bytes(&raw_sign_key[..])?.to_pem()?,
40            JwsAlgorithm::P384 => ES384KeyPair::from_bytes(&raw_sign_key[..])?.to_pem()?,
41            JwsAlgorithm::P521 => ES512KeyPair::from_bytes(&raw_sign_key[..])?.to_pem()?,
42        };
43        let (acme_kp, acme_jwk) = match sign_alg {
44            JwsAlgorithm::Ed25519 => {
45                let kp = Ed25519KeyPair::generate();
46                (kp.to_pem().into(), kp.public_key().try_into_jwk()?)
47            }
48            JwsAlgorithm::P256 => {
49                let kp = ES256KeyPair::generate();
50                (kp.to_pem()?.into(), kp.public_key().try_into_jwk()?)
51            }
52            JwsAlgorithm::P384 => {
53                let kp = ES384KeyPair::generate();
54                (kp.to_pem()?.into(), kp.public_key().try_into_jwk()?)
55            }
56            JwsAlgorithm::P521 => {
57                let kp = ES512KeyPair::generate();
58                (kp.to_pem()?.into(), kp.public_key().try_into_jwk()?)
59            }
60        };
61        // drop the private immediately since it already has been copied
62        raw_sign_key.zeroize();
63        Ok(Self {
64            sign_alg,
65            sign_kp: sign_kp.into(),
66            hash_alg: HashAlgorithm::from(sign_alg),
67            acme_kp,
68            acme_jwk,
69        })
70    }
71
72    /// Parses the response from `GET /acme/{provisioner-name}/directory`.
73    /// Use this [AcmeDirectory] in the next step to fetch the first nonce from the acme server. Use
74    /// [AcmeDirectory::new_nonce].
75    ///
76    /// See [RFC 8555 Section 7.1.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.1)
77    ///
78    /// # Parameters
79    /// * `directory` - http response body
80    pub fn acme_directory_response(&self, directory: Json) -> E2eIdentityResult<AcmeDirectory> {
81        let directory = RustyAcme::acme_directory_response(directory)?;
82        Ok(directory)
83    }
84
85    /// For creating a new acme account. This returns a signed JWS-alike request body to send to
86    /// `POST /acme/{provisioner-name}/new-account`.
87    ///
88    /// See [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3).
89    ///
90    /// # Parameters
91    /// * `directory` - you got from [Self::acme_directory_response]
92    /// * `previous_nonce` - you got from calling `HEAD {directory.new_nonce}`
93    pub fn acme_new_account_request(
94        &self,
95        directory: &AcmeDirectory,
96        previous_nonce: String,
97    ) -> E2eIdentityResult<Json> {
98        let acct_req = RustyAcme::new_account_request(directory, self.sign_alg, &self.acme_kp, previous_nonce)?;
99        Ok(serde_json::to_value(acct_req)?)
100    }
101
102    /// Parses the response from `POST /acme/{provisioner-name}/new-account`.
103    ///
104    /// See [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3).
105    ///
106    /// # Parameters
107    /// * `account` - http response body
108    pub fn acme_new_account_response(&self, account: Json) -> E2eIdentityResult<E2eiAcmeAccount> {
109        RustyAcme::new_account_response(account)?.try_into()
110    }
111
112    /// Creates a new acme order for the handle (userId + display name) and the clientId.
113    ///
114    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
115    ///
116    /// # Parameters
117    /// * `display_name` - human readable name displayed in the application e.g. `Smith, Alice M (QA)`
118    /// * `domain` - DNS name of owning backend e.g. `example.com`
119    /// * `client_id` - client identifier with user b64Url encoded & clientId hex encoded e.g.
120    ///   `NDUyMGUyMmY2YjA3NGU3NjkyZjE1NjJjZTAwMmQ2NTQ/6add501bacd1d90e@example.com`
121    /// * `handle` - user handle e.g. `alice.smith.qa@example.com`
122    /// * `expiry` - x509 generated certificate expiry
123    /// * `directory` - you got from [Self::acme_directory_response]
124    /// * `account` - you got from [Self::acme_new_account_response]
125    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/new-account`
126    #[allow(clippy::too_many_arguments)]
127    pub fn acme_new_order_request(
128        &self,
129        display_name: &str,
130        client_id: &str,
131        handle: &str,
132        expiry: core::time::Duration,
133        directory: &AcmeDirectory,
134        account: &E2eiAcmeAccount,
135        previous_nonce: String,
136    ) -> E2eIdentityResult<Json> {
137        let account = account.clone().try_into()?;
138        let client_id = ClientId::try_from_qualified(client_id)?;
139        let order_req = RustyAcme::new_order_request(
140            display_name,
141            client_id,
142            &handle.into(),
143            expiry,
144            directory,
145            &account,
146            self.sign_alg,
147            &self.acme_kp,
148            previous_nonce,
149        )?;
150        Ok(serde_json::to_value(order_req)?)
151    }
152
153    /// Parses the response from `POST /acme/{provisioner-name}/new-order`.
154    ///
155    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
156    ///
157    /// # Parameters
158    /// * `new_order` - http response body
159    pub fn acme_new_order_response(&self, new_order: Json) -> E2eIdentityResult<E2eiNewAcmeOrder> {
160        let new_order = RustyAcme::new_order_response(new_order)?;
161        let json_new_order = serde_json::to_vec(&new_order)?.into();
162        Ok(E2eiNewAcmeOrder {
163            delegate: json_new_order,
164            authorizations: new_order.authorizations,
165        })
166    }
167
168    /// Creates a new authorization request.
169    ///
170    /// See [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5).
171    ///
172    /// # Parameters
173    /// * `url` - one of the URL in new order's authorizations (from [Self::acme_new_order_response])
174    /// * `account` - you got from [Self::acme_new_account_response]
175    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/new-order` (or from the
176    ///   previous to this method if you are creating the second authorization)
177    pub fn acme_new_authz_request(
178        &self,
179        url: &url::Url,
180        account: &E2eiAcmeAccount,
181        previous_nonce: String,
182    ) -> E2eIdentityResult<Json> {
183        let account = account.clone().try_into()?;
184        let authz_req = RustyAcme::new_authz_request(url, &account, self.sign_alg, &self.acme_kp, previous_nonce)?;
185        Ok(serde_json::to_value(authz_req)?)
186    }
187
188    /// Parses the response from `POST /acme/{provisioner-name}/authz/{authz-id}`
189    ///
190    /// You then have to map the challenge from this authorization object. The `client_id_challenge`
191    /// will be the one with the `client_id_host` (you supplied to [Self::acme_new_order_request]) identifier,
192    /// the other will be your `handle_challenge`.
193    ///
194    /// See [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5).
195    ///
196    /// # Parameters
197    /// * `new_authz` - http response body
198    pub fn acme_new_authz_response(&self, new_authz: Json) -> E2eIdentityResult<E2eiAcmeAuthorization> {
199        let authz = serde_json::from_value(new_authz)?;
200        let authz = RustyAcme::new_authz_response(authz)?;
201
202        let [challenge] = authz.challenges;
203        Ok(match authz.identifier {
204            AcmeIdentifier::WireappUser(_) => {
205                let thumbprint = JwkThumbprint::generate(&self.acme_jwk, self.hash_alg)?.kid;
206                let oidc_chall_token = &challenge.token;
207                let keyauth = format!("{oidc_chall_token}.{thumbprint}");
208                E2eiAcmeAuthorization::User {
209                    identifier: authz.identifier.to_json()?,
210                    challenge: challenge.try_into()?,
211                    keyauth,
212                }
213            }
214            AcmeIdentifier::WireappDevice(_) => E2eiAcmeAuthorization::Device {
215                identifier: authz.identifier.to_json()?,
216                challenge: challenge.try_into()?,
217            },
218        })
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    /// * `access_token_url` - backend endpoint where this token will be sent. Should be [this one](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/post_clients__cid__access_token)
231    /// * `client_id` - client identifier with user b64Url encoded & clientId hex encoded e.g.
232    ///   `NDUyMGUyMmY2YjA3NGU3NjkyZjE1NjJjZTAwMmQ2NTQ:6add501bacd1d90e@example.com`
233    /// * `dpop_challenge` - you found after [Self::acme_new_authz_response]
234    /// * `backend_nonce` - you get by calling `GET /clients/token/nonce` on wire-server.
235    /// * `handle` - user handle e.g. `alice.smith.qa@example.com` See endpoint [definition](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/get_clients__client__nonce)
236    /// * `expiry` - token expiry
237    #[allow(clippy::too_many_arguments)]
238    pub fn new_dpop_token(
239        &self,
240        client_id: &str,
241        display_name: &str,
242        dpop_challenge: &E2eiAcmeChallenge,
243        backend_nonce: String,
244        handle: &str,
245        team: Option<String>,
246        expiry: core::time::Duration,
247    ) -> E2eIdentityResult<String> {
248        let dpop_chall: AcmeChallenge = dpop_challenge.clone().try_into()?;
249        let audience = dpop_chall.url;
250        let client_id = ClientId::try_from_qualified(client_id)?;
251        let handle = Handle::from(handle).try_to_qualified(&client_id.domain)?;
252        let dpop = Dpop {
253            htm: Htm::Post,
254            htu: dpop_challenge.target.clone().into(),
255            challenge: dpop_chall.token.into(),
256            handle,
257            team: team.into(),
258            display_name: display_name.to_string(),
259            extra_claims: None,
260        };
261        Ok(RustyJwtTools::generate_dpop_token(
262            dpop,
263            &client_id,
264            backend_nonce.into(),
265            audience,
266            expiry,
267            self.sign_alg,
268            &self.acme_kp,
269        )?)
270    }
271
272    /// Creates a new challenge request.
273    ///
274    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
275    ///
276    /// # Parameters
277    /// * `access_token` - returned by wire-server from [this endpoint](https://staging-nginz-https.zinfra.io/api/swagger-ui/#/default/post_clients__cid__access_token)
278    /// * `dpop_challenge` - you found after [Self::acme_new_authz_response]
279    /// * `account` - you got from [Self::acme_new_account_response]
280    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
281    pub fn acme_dpop_challenge_request(
282        &self,
283        access_token: String,
284        dpop_challenge: &E2eiAcmeChallenge,
285        account: &E2eiAcmeAccount,
286        previous_nonce: String,
287    ) -> E2eIdentityResult<Json> {
288        let account = account.clone().try_into()?;
289        let dpop_challenge: AcmeChallenge = dpop_challenge.clone().try_into()?;
290        let new_challenge_req = RustyAcme::dpop_chall_request(
291            access_token,
292            dpop_challenge,
293            &account,
294            self.sign_alg,
295            &self.acme_kp,
296            previous_nonce,
297        )?;
298        Ok(serde_json::to_value(new_challenge_req)?)
299    }
300
301    /// Creates a new challenge request.
302    ///
303    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
304    ///
305    /// # Parameters
306    /// * `id_token` - returned by Identity Provider
307    /// * `oidc_challenge` - you found after [Self::acme_new_authz_response]
308    /// * `account` - you got from [Self::acme_new_account_response]
309    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/authz/{authz-id}`
310    pub fn acme_oidc_challenge_request(
311        &self,
312        id_token: String,
313        oidc_challenge: &E2eiAcmeChallenge,
314        account: &E2eiAcmeAccount,
315        previous_nonce: String,
316    ) -> E2eIdentityResult<Json> {
317        let account = account.clone().try_into()?;
318        let oidc_chall: AcmeChallenge = oidc_challenge.clone().try_into()?;
319        let new_challenge_req = RustyAcme::oidc_chall_request(
320            id_token,
321            oidc_chall,
322            &account,
323            self.sign_alg,
324            &self.acme_kp,
325            previous_nonce,
326        )?;
327        Ok(serde_json::to_value(new_challenge_req)?)
328    }
329
330    /// Parses the response from `POST /acme/{provisioner-name}/challenge/{challenge-id}`.
331    ///
332    /// See [RFC 8555 Section 7.5.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5.1).
333    ///
334    /// # Parameters
335    /// * `challenge` - http response body
336    pub fn acme_new_challenge_response(&self, challenge: Json) -> E2eIdentityResult<()> {
337        let challenge = serde_json::from_value(challenge)?;
338        RustyAcme::new_chall_response(challenge)?;
339        Ok(())
340    }
341
342    /// Verifies that the previous challenge has been completed.
343    ///
344    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
345    ///
346    /// # Parameters
347    /// * `order_url` - "location" header from http response you got from [Self::acme_new_order_response]
348    /// * `account` - you got from [Self::acme_new_account_response]
349    /// * `previous_nonce` - "replay-nonce" response header from `POST
350    ///   /acme/{provisioner-name}/challenge/{challenge-id}`
351    pub fn acme_check_order_request(
352        &self,
353        order_url: url::Url,
354        account: &E2eiAcmeAccount,
355        previous_nonce: String,
356    ) -> E2eIdentityResult<Json> {
357        let account = account.clone().try_into()?;
358        let check_order_req =
359            RustyAcme::check_order_request(order_url, &account, self.sign_alg, &self.acme_kp, previous_nonce)?;
360        Ok(serde_json::to_value(check_order_req)?)
361    }
362
363    /// Parses the response from `POST /acme/{provisioner-name}/order/{order-id}`.
364    ///
365    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
366    ///
367    /// # Parameters
368    /// * `order` - http response body
369    pub fn acme_check_order_response(&self, order: Json) -> E2eIdentityResult<E2eiAcmeOrder> {
370        RustyAcme::check_order_response(order)?.try_into()
371    }
372
373    /// Final step before fetching the certificate.
374    ///
375    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
376    ///
377    /// # Parameters
378    /// * `domains` - domains you want to generate a certificate for e.g. `["wire.com"]`
379    /// * `order` - you got from [Self::acme_check_order_response]
380    /// * `account` - you got from [Self::acme_new_account_response]
381    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/order/{order-id}`
382    pub fn acme_finalize_request(
383        &self,
384        order: &E2eiAcmeOrder,
385        account: &E2eiAcmeAccount,
386        previous_nonce: String,
387    ) -> E2eIdentityResult<Json> {
388        let order = order.clone().try_into()?;
389        let account = account.clone().try_into()?;
390        let finalize_req = RustyAcme::finalize_req(
391            &order,
392            &account,
393            self.sign_alg,
394            &self.acme_kp,
395            &self.sign_kp,
396            previous_nonce,
397        )?;
398        Ok(serde_json::to_value(finalize_req)?)
399    }
400
401    /// Parses the response from `POST /acme/{provisioner-name}/order/{order-id}/finalize`.
402    ///
403    /// See [RFC 8555 Section 7.4](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4).
404    ///
405    /// # Parameters
406    /// * `finalize` - http response body
407    pub fn acme_finalize_response(&self, finalize: Json) -> E2eIdentityResult<E2eiAcmeFinalize> {
408        RustyAcme::finalize_response(finalize)?.try_into()
409    }
410
411    /// Creates a request for finally fetching the x509 certificate.
412    ///
413    /// See [RFC 8555 Section 7.4.2](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2).
414    ///
415    /// # Parameters
416    /// * `domains` - domains you want to generate a certificate for e.g. `["wire.com"]`
417    /// * `order` - you got from [Self::acme_check_order_response]
418    /// * `account` - you got from [Self::acme_new_account_response]
419    /// * `previous_nonce` - "replay-nonce" response header from `POST /acme/{provisioner-name}/order/{order-id}`
420    pub fn acme_x509_certificate_request(
421        &self,
422        finalize: E2eiAcmeFinalize,
423        account: E2eiAcmeAccount,
424        previous_nonce: String,
425    ) -> E2eIdentityResult<Json> {
426        let finalize = finalize.try_into()?;
427        let account = account.try_into()?;
428        let certificate_req =
429            RustyAcme::certificate_req(finalize, account, self.sign_alg, &self.acme_kp, previous_nonce)?;
430        Ok(serde_json::to_value(certificate_req)?)
431    }
432
433    /// Parses the response from `POST /acme/{provisioner-name}/certificate/{certificate-id}`.
434    ///
435    /// See [RFC 8555 Section 7.4.2](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.4.2)
436    ///
437    /// # Parameters
438    /// * `response` - http string response body
439    pub fn acme_x509_certificate_response(
440        &self,
441        response: String,
442        order: E2eiAcmeOrder,
443        env: Option<&PkiEnvironment>,
444    ) -> E2eIdentityResult<Vec<Vec<u8>>> {
445        let order = order.try_into()?;
446        Ok(RustyAcme::certificate_response(response, order, self.hash_alg, env)?)
447    }
448}