wire_e2e_identity/acme/
authz.rs

1use 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    /// create authorizations
10    /// see [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5)
11    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        // Extract the account URL from previous response which created a new account
19        let acct_url = account.acct_url()?;
20
21        // No payload required for authz
22        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    /// parse the response from `POST /acme/authz/{authz_id}`
28    /// [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5)
29    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    /// This authorization is expired
54    #[error("This authorization is expired")]
55    Expired,
56    /// This authorization is invalid
57    #[error("This authorization is invalid")]
58    Invalid,
59    /// The server revoked this authorization
60    #[error("The server revoked this authorization")]
61    Revoked,
62    /// The client deactivated this authorization
63    #[error("The client deactivated this authorization")]
64    Deactivated,
65    /// The Challenge tokens must be base64 URL strings
66    #[error("The Challenge tokens must be base64 URL strings")]
67    InvalidBase64Token,
68    /// The Challenge token must have at least 128 bits of entropy
69    #[error("The Challenge token must have at least 128 bits of entropy")]
70    InvalidTokenEntropy,
71    /// The Challenge type must match the identifier type
72    #[error("The Challenge type must match the identifier type")]
73    InvalidChallengeType,
74}
75
76/// Result of an authorization creation
77/// see [RFC 8555 Section 7.5](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.5)
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "camelCase")]
80pub struct AcmeAuthz {
81    /// Should be pending for a newly created authorization
82    pub status: AuthzStatus,
83    #[serde(skip_serializing_if = "Option::is_none", with = "time::serde::rfc3339::option")]
84    /// Expiration time as [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339)
85    pub expires: Option<time::OffsetDateTime>,
86    /// Challenges to complete later
87    pub challenges: [AcmeChallenge; 1],
88    /// DNS entry associated with those challenge
89    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            // RFC 8555 security considerations
112            // see https://datatracker.ietf.org/doc/html/rfc8555#section-8.1
113            let token = base64::prelude::BASE64_URL_SAFE_NO_PAD
114                .decode(&challenge.token)
115                .map_err(|_| AcmeAuthzError::InvalidBase64Token)?;
116
117            // token have enough entropy (at least 16 bytes)
118            // see https://datatracker.ietf.org/doc/html/rfc8555#section-11.3
119            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/// see [RFC 8555 Section 7.1.6](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.6)
143#[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}