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}