wire_e2e_identity/acme/
account.rs

1use rusty_jwt_tools::prelude::*;
2
3use crate::acme::prelude::*;
4
5impl RustyAcme {
6    /// 5. Create a new acme account see [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3)
7    pub fn new_account_request(
8        directory: &AcmeDirectory,
9        alg: JwsAlgorithm,
10        kp: &Pem,
11        previous_nonce: String,
12    ) -> RustyAcmeResult<AcmeJws> {
13        const DEFAULT_CONTACT: &str = "anonymous@anonymous.invalid";
14
15        // explicitly set an invalid email so that if someday it is required to set one we do not
16        // set it by accident
17        let contact = vec![DEFAULT_CONTACT.to_string()];
18        let payload = AcmeAccountRequest {
19            terms_of_service_agreed: Some(true),
20            contact,
21            only_return_existing: Some(false),
22        };
23        let req = AcmeJws::new(alg, previous_nonce, &directory.new_account, None, Some(payload), kp)?;
24        Ok(req)
25    }
26
27    /// 6. parse the response from `POST /acme/new-account` see [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3)
28    pub fn new_account_response(response: serde_json::Value) -> RustyAcmeResult<AcmeAccount> {
29        let account = serde_json::from_value::<AcmeAccount>(response)
30            .map_err(|_| RustyAcmeError::SmallstepImplementationError("Invalid account response"))?;
31        account.verify()?;
32        Ok(account)
33    }
34}
35
36#[derive(Debug, thiserror::Error)]
37pub enum AcmeAccountError {
38    /// step-ca flagged this order as invalid
39    #[error("Created account is not valid")]
40    Invalid,
41    /// step-ca revoked this account
42    #[error("Account was revoked by the server")]
43    Revoked,
44    /// A client deactivated this account
45    #[error("A client deactivated this account")]
46    Deactivated,
47}
48
49/// For creating an account
50/// see https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3
51#[derive(Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
52#[cfg_attr(test, derive(Clone))]
53#[serde(rename_all = "camelCase")]
54struct AcmeAccountRequest {
55    /// Including this field in a newAccount request, with a value of true, indicates the client's
56    /// agreement with the terms of service. This field cannot be updated by the client
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub terms_of_service_agreed: Option<bool>,
59    /// An array of URLs that the server can use to contact the client for issues related to this
60    /// account. For example, the server may wish to notify the client about server-initiated
61    /// revocation or certificate expiration
62    pub contact: Vec<String>,
63    /// If this field is present with the value "true", then the server MUST NOT create a new
64    /// account if one does not already exist. This allows a client to look up an account URL
65    /// based on an account key.
66    /// see [Section 7.3.1](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3.1) for more details.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub only_return_existing: Option<bool>,
69}
70
71/// Account creation response
72/// see [RFC 8555 Section 7.3](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.3)
73#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct AcmeAccount {
76    pub status: AcmeAccountStatus,
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub orders: Option<url::Url>,
79}
80
81impl AcmeAccount {
82    /// Infers the account url used in almost all [AcmeJws] kid.
83    /// To do so, trims the last segment from the 'orders' URL
84    pub fn acct_url(&self) -> RustyAcmeResult<url::Url> {
85        let orders = self
86            .orders
87            .as_ref()
88            .ok_or(RustyAcmeError::SmallstepImplementationError(
89                "Account should have 'orders' url",
90            ))?;
91        let mut orders = orders.clone();
92        if orders.path_segments().and_then(|mut paths| paths.next_back()) == Some("orders") {
93            orders
94                .path_segments_mut()
95                .map_err(|_| RustyAcmeError::ImplementationError)?
96                .pop();
97            Ok(orders)
98        } else {
99            Err(RustyAcmeError::SmallstepImplementationError(
100                "Invalid 'orders' URL in account",
101            ))
102        }
103    }
104
105    /// Verifies the account status and the presence of an 'orders' URL
106    fn verify(&self) -> RustyAcmeResult<()> {
107        self.orders
108            .as_ref()
109            .ok_or(RustyAcmeError::SmallstepImplementationError(
110                "Newly created account should have 'orders' url",
111            ))?;
112        match self.status {
113            AcmeAccountStatus::Valid => Ok(()),
114            AcmeAccountStatus::Deactivated => Err(AcmeAccountError::Deactivated)?,
115            AcmeAccountStatus::Revoked => Err(AcmeAccountError::Revoked)?,
116        }
117    }
118}
119
120#[cfg(test)]
121impl Default for AcmeAccount {
122    fn default() -> Self {
123        Self {
124            status: AcmeAccountStatus::Valid,
125            orders: Some(
126                "https://acme-server/acme/account/muYiJmuJRn9u2L0tdI5bu11T7QqqPR1u/orders"
127                    .parse()
128                    .unwrap(),
129            ),
130        }
131    }
132}
133
134/// see [RFC 8555 Section 7.1.6](https://www.rfc-editor.org/rfc/rfc8555.html#section-7.1.6)
135#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum AcmeAccountStatus {
138    Valid,
139    Deactivated,
140    Revoked,
141}
142
143#[cfg(test)]
144pub mod tests {
145    use serde_json::json;
146    use wasm_bindgen_test::*;
147
148    use super::*;
149
150    wasm_bindgen_test_configure!(run_in_browser);
151
152    mod json {
153        use super::*;
154
155        #[test]
156        #[wasm_bindgen_test]
157        fn can_deserialize_rfc_sample_request() {
158            let rfc_sample = json!({
159                "termsOfServiceAgreed": true,
160                "contact": [
161                  "mailto:cert-admin@example.org",
162                  "mailto:admin@example.org"
163                ]
164            });
165            assert!(serde_json::from_value::<AcmeAccountRequest>(rfc_sample).is_ok());
166        }
167
168        #[test]
169        #[wasm_bindgen_test]
170        fn can_deserialize_rfc_sample_response() {
171            let rfc_sample = json!({
172                "status": "valid",
173                "contact": [
174                    "mailto:cert-admin@example.org",
175                    "mailto:admin@example.org"
176                ],
177                "orders": "https://example.com/acme/acct/evOfKhNU60wg/orders"
178            });
179            assert!(serde_json::from_value::<AcmeAccount>(rfc_sample).is_ok());
180        }
181    }
182
183    mod verify {
184        use super::*;
185
186        #[test]
187        #[wasm_bindgen_test]
188        fn should_succeed_when_status_valid() {
189            let account = AcmeAccount {
190                status: AcmeAccountStatus::Valid,
191                ..Default::default()
192            };
193            assert!(account.verify().is_ok());
194        }
195
196        #[test]
197        #[wasm_bindgen_test]
198        fn should_fail_when_status_deactivated() {
199            let account = AcmeAccount {
200                status: AcmeAccountStatus::Deactivated,
201                ..Default::default()
202            };
203            assert!(matches!(
204                account.verify().unwrap_err(),
205                RustyAcmeError::AccountError(AcmeAccountError::Deactivated)
206            ));
207        }
208
209        #[test]
210        #[wasm_bindgen_test]
211        fn should_fail_when_status_revoked() {
212            let account = AcmeAccount {
213                status: AcmeAccountStatus::Revoked,
214                ..Default::default()
215            };
216            assert!(matches!(
217                account.verify().unwrap_err(),
218                RustyAcmeError::AccountError(AcmeAccountError::Revoked)
219            ));
220        }
221
222        #[test]
223        #[wasm_bindgen_test]
224        fn should_fail_when_orders_absent() {
225            let account = AcmeAccount {
226                orders: None,
227                ..Default::default()
228            };
229            assert!(matches!(
230                account.verify().unwrap_err(),
231                RustyAcmeError::SmallstepImplementationError("Newly created account should have 'orders' url")
232            ));
233        }
234    }
235
236    mod acct_url {
237        use super::*;
238
239        #[test]
240        #[wasm_bindgen_test]
241        fn should_trim_last_orders_segment() {
242            let base = "https://acme-server/acme/wire-acme/account/muYiJmuJRn9u2L0tdI5bu11T7QqqPR1u";
243            let orders_url = format!("{base}/orders");
244            let account = AcmeAccount {
245                orders: Some(orders_url.parse().unwrap()),
246                ..Default::default()
247            };
248            assert_eq!(account.acct_url().unwrap().as_str(), base);
249        }
250
251        #[test]
252        #[wasm_bindgen_test]
253        fn should_fail_when_orders_absent() {
254            let account = AcmeAccount {
255                orders: None,
256                ..Default::default()
257            };
258            assert!(matches!(
259                account.acct_url().unwrap_err(),
260                RustyAcmeError::SmallstepImplementationError("Account should have 'orders' url")
261            ));
262        }
263
264        #[test]
265        #[wasm_bindgen_test]
266        fn should_fail_when_orders_url_doesnt_end_with_orders() {
267            let base = "https://acme-server/acme/wire-acme/account/muYiJmuJRn9u2L0tdI5bu11T7QqqPR1u";
268            let orders_url = format!("{base}/error");
269            let account = AcmeAccount {
270                orders: Some(orders_url.parse().unwrap()),
271                ..Default::default()
272            };
273            assert!(matches!(
274                account.acct_url().unwrap_err(),
275                RustyAcmeError::SmallstepImplementationError("Invalid 'orders' URL in account")
276            ));
277        }
278    }
279}