wire_e2e_identity/acme/
authz.rs1use base64::Engine;
2use rusty_jwt_tools::prelude::{JwsAlgorithm, Pem};
3
4use crate::acme::{
5 AcmeAccount, AcmeChallenge, AcmeChallengeType, AcmeIdentifier, AcmeJws, RustyAcme, RustyAcmeError, RustyAcmeResult,
6};
7
8impl RustyAcme {
9 pub fn new_authz_request(
12 url: &url::Url,
13 account: &AcmeAccount,
14 alg: JwsAlgorithm,
15 kp: &Pem,
16 previous_nonce: String,
17 ) -> RustyAcmeResult<AcmeJws> {
18 let acct_url = account.acct_url()?;
20
21 let payload = None::<serde_json::Value>;
23 let req = AcmeJws::new(alg, previous_nonce, url, Some(&acct_url), payload, kp)?;
24 Ok(req)
25 }
26
27 pub fn new_authz_response(response: serde_json::Value) -> RustyAcmeResult<AcmeAuthz> {
30 let authz = serde_json::from_value::<AcmeAuthz>(response)?;
31
32 authz.verify()?;
33
34 match authz.status {
35 AuthzStatus::Pending => {}
36 AuthzStatus::Invalid => return Err(AcmeAuthzError::Invalid)?,
37 AuthzStatus::Revoked => return Err(AcmeAuthzError::Revoked)?,
38 AuthzStatus::Deactivated => return Err(AcmeAuthzError::Deactivated)?,
39 AuthzStatus::Expired => return Err(AcmeAuthzError::Expired)?,
40 AuthzStatus::Valid => {
41 return Err(RustyAcmeError::ClientImplementationError(
42 "an authorization is not supposed to be valid at this point. \
43 You should only use this method to parse the response of an authorization creation.",
44 ));
45 }
46 }
47 Ok(authz)
48 }
49}
50
51#[derive(Debug, thiserror::Error)]
52pub enum AcmeAuthzError {
53 #[error("This authorization is expired")]
55 Expired,
56 #[error("This authorization is invalid")]
58 Invalid,
59 #[error("The server revoked this authorization")]
61 Revoked,
62 #[error("The client deactivated this authorization")]
64 Deactivated,
65 #[error("The Challenge tokens must be base64 URL strings")]
67 InvalidBase64Token,
68 #[error("The Challenge token must have at least 128 bits of entropy")]
70 InvalidTokenEntropy,
71 #[error("The Challenge type must match the identifier type")]
73 InvalidChallengeType,
74}
75
76#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct AcmeAuthz {
81 pub status: AuthzStatus,
83 #[serde(skip_serializing_if = "Option::is_none", with = "time::serde::rfc3339::option")]
84 pub expires: Option<time::OffsetDateTime>,
86 pub challenges: [AcmeChallenge; 1],
88 pub identifier: AcmeIdentifier,
90}
91
92impl AcmeAuthz {
93 pub fn verify(&self) -> RustyAcmeResult<()> {
94 let [challenge] = &self.challenges;
95
96 if matches!(
97 (&self.identifier, challenge.typ),
98 (AcmeIdentifier::WireappDevice(_), AcmeChallengeType::WireDpop01)
99 | (AcmeIdentifier::WireappUser(_), AcmeChallengeType::WireOidc01)
100 ) {
101 let now = time::OffsetDateTime::now_utc().unix_timestamp();
102
103 let is_expired = self
104 .expires
105 .map(time::OffsetDateTime::unix_timestamp)
106 .is_some_and(|expires| expires < now);
107 if is_expired {
108 return Err(AcmeAuthzError::Expired)?;
109 }
110
111 let token = base64::prelude::BASE64_URL_SAFE_NO_PAD
114 .decode(&challenge.token)
115 .map_err(|_| AcmeAuthzError::InvalidBase64Token)?;
116
117 const RECOMMENDED_TOKEN_ENTROPY: usize = 128 / 8;
120 if token.len() < RECOMMENDED_TOKEN_ENTROPY {
121 return Err(AcmeAuthzError::InvalidTokenEntropy.into());
122 }
123
124 return Ok(());
125 }
126 Err(AcmeAuthzError::InvalidChallengeType)?
127 }
128}
129
130#[cfg(test)]
131impl Default for AcmeAuthz {
132 fn default() -> Self {
133 Self {
134 status: AuthzStatus::Pending,
135 expires: Some(time::OffsetDateTime::now_utc()),
136 identifier: AcmeIdentifier::new_device(),
137 challenges: [AcmeChallenge::new_device()],
138 }
139 }
140}
141
142#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
144#[serde(rename_all = "lowercase")]
145pub enum AuthzStatus {
146 Pending,
147 Invalid,
148 Valid,
149 Revoked,
150 Deactivated,
151 Expired,
152}
153
154#[cfg(test)]
155mod tests {
156 use serde_json::json;
157 use wasm_bindgen_test::*;
158
159 use super::*;
160
161 wasm_bindgen_test_configure!(run_in_browser);
162
163 mod json {
164 use super::*;
165
166 #[test]
167 #[wasm_bindgen_test]
168 fn can_deserialize_sample_response() {
169 let rfc_sample = json!({
170 "status": "pending",
171 "expires": "2016-01-02T14:09:30Z",
172 "identifier": {
173 "type": "wireapp-user",
174 "value": "www.example.org"
175 },
176 "challenges": [
177 {
178 "type": "http-01",
179 "url": "https://example.com/acme/chall/prV_B7yEyA4",
180 "token": "DGyRejmCefe7v4NfDGDKfA",
181 "target": "https://example.com/target"
182 }
183 ]
184 });
185 assert!(serde_json::from_value::<AcmeAuthz>(rfc_sample).is_ok());
186 }
187 }
188
189 mod verify {
190 use super::*;
191
192 #[test]
193 #[wasm_bindgen_test]
194 fn should_succeed_when_valid() {
195 let tomorrow = time::OffsetDateTime::now_utc() + time::Duration::days(1);
196 let order = AcmeAuthz {
197 expires: Some(tomorrow),
198 ..Default::default()
199 };
200 assert!(order.verify().is_ok());
201 }
202
203 #[test]
204 #[wasm_bindgen_test]
205 fn should_fail_when_expires_in_past() {
206 let yesterday = time::OffsetDateTime::now_utc() - time::Duration::days(1);
207 let order = AcmeAuthz {
208 expires: Some(yesterday),
209 ..Default::default()
210 };
211 assert!(matches!(
212 order.verify().unwrap_err(),
213 RustyAcmeError::AuthzError(AcmeAuthzError::Expired)
214 ));
215 }
216
217 #[test]
218 #[wasm_bindgen_test]
219 fn should_fail_when_challenge_type_mismatches_identifier_type() {
220 let tomorrow = time::OffsetDateTime::now_utc() + time::Duration::days(1);
221 let order = AcmeAuthz {
222 expires: Some(tomorrow),
223 identifier: AcmeIdentifier::new_user(),
224 challenges: [AcmeChallenge::new_device()],
225 ..Default::default()
226 };
227 assert!(matches!(
228 order.verify().unwrap_err(),
229 RustyAcmeError::AuthzError(AcmeAuthzError::InvalidChallengeType)
230 ));
231 let order = AcmeAuthz {
232 expires: Some(tomorrow),
233 identifier: AcmeIdentifier::new_device(),
234 challenges: [AcmeChallenge::new_user()],
235 ..Default::default()
236 };
237 assert!(matches!(
238 order.verify().unwrap_err(),
239 RustyAcmeError::AuthzError(AcmeAuthzError::InvalidChallengeType)
240 ));
241 }
242 }
243}