wire_e2e_identity/acme/
authz.rs

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