Skip to main content

steel_core/server/
service_keys.rs

1use std::{sync::Arc, time::Duration};
2
3use arc_swap::ArcSwapOption;
4use base64::{Engine as _, prelude::BASE64_STANDARD};
5use serde::Deserialize;
6use steel_crypto::{CryptError, public_key_from_bytes, signature::ProfileKeyValidator};
7use thiserror::Error;
8use tokio::{sync::oneshot, time::sleep};
9use tokio_util::sync::CancellationToken;
10
11const DEFAULT_SERVICES_SERVER: &str = "https://api.minecraftservices.com/publickeys";
12pub(super) const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
13const READ_TIMEOUT: Duration = Duration::from_secs(5);
14const DAILY_REFRESH_INTERVAL: Duration = Duration::from_hours(24);
15const BASE_FAILURE_INTERVAL: Duration = Duration::from_mins(5);
16const MAX_BACKOFF_EXPONENT: u32 = 6;
17
18#[derive(Debug, Error)]
19pub(super) enum ServiceKeyError {
20    #[error("invalid services key endpoint '{endpoint}': {reason}")]
21    InvalidEndpoint { endpoint: String, reason: String },
22    #[error("failed to build services key HTTP client: {0}")]
23    Client(#[source] reqwest::Error),
24    #[error("services key request failed: {0}")]
25    Request(#[from] reqwest::Error),
26    #[error("services key response contains invalid base64: {0}")]
27    Base64(#[from] base64::DecodeError),
28    #[error("services key response contains an invalid public key: {0}")]
29    PublicKey(#[from] CryptError),
30}
31
32#[derive(Deserialize)]
33#[serde(rename_all = "camelCase")]
34struct ServiceKeyResponse {
35    profile_property_keys: Option<Vec<ServiceKeyData>>,
36    player_certificate_keys: Option<Vec<ServiceKeyData>>,
37}
38
39#[derive(Deserialize)]
40#[serde(rename_all = "camelCase")]
41struct ServiceKeyData {
42    public_key: String,
43}
44
45/// Cached Mojang service keys used to validate player-key certificates.
46pub(super) struct ServiceKeyStore {
47    client: reqwest::Client,
48    endpoint: reqwest::Url,
49    profile_key_validator: ArcSwapOption<ProfileKeyValidator>,
50}
51
52impl ServiceKeyStore {
53    pub(super) fn new(endpoint: Option<&str>) -> Result<Self, ServiceKeyError> {
54        let endpoint = endpoint.unwrap_or(DEFAULT_SERVICES_SERVER);
55        let parsed_endpoint =
56            reqwest::Url::parse(endpoint).map_err(|error| ServiceKeyError::InvalidEndpoint {
57                endpoint: endpoint.to_owned(),
58                reason: error.to_string(),
59            })?;
60        if !matches!(parsed_endpoint.scheme(), "http" | "https") {
61            return Err(ServiceKeyError::InvalidEndpoint {
62                endpoint: endpoint.to_owned(),
63                reason: "expected http or https".to_owned(),
64            });
65        }
66        let client = reqwest::Client::builder()
67            .connect_timeout(CONNECT_TIMEOUT)
68            .read_timeout(READ_TIMEOUT)
69            .build()
70            .map_err(ServiceKeyError::Client)?;
71
72        Ok(Self {
73            client,
74            endpoint: parsed_endpoint,
75            profile_key_validator: ArcSwapOption::empty(),
76        })
77    }
78
79    pub(super) fn profile_key_validator(&self) -> Option<Arc<ProfileKeyValidator>> {
80        self.profile_key_validator.load_full()
81    }
82
83    /// Starts the initial fetch and returns a signal completed after its first attempt.
84    pub(super) fn start(
85        self: &Arc<Self>,
86        cancel_token: CancellationToken,
87    ) -> oneshot::Receiver<()> {
88        let (ready_tx, ready_rx) = oneshot::channel();
89        let store = Arc::clone(self);
90        drop(tokio::spawn(async move {
91            let mut has_successful_snapshot = match store.refresh().await {
92                Ok(()) => true,
93                Err(error) => {
94                    log::warn!("Failed to load Minecraft services public keys: {error}");
95                    false
96                }
97            };
98            let _ = ready_tx.send(());
99
100            let mut failure_count = 0;
101            loop {
102                let delay = if has_successful_snapshot {
103                    DAILY_REFRESH_INTERVAL
104                } else {
105                    failure_delay(failure_count)
106                };
107                tokio::select! {
108                    () = cancel_token.cancelled() => return,
109                    () = sleep(delay) => {}
110                }
111
112                match store.refresh().await {
113                    Ok(()) => {
114                        has_successful_snapshot = true;
115                        failure_count = 0;
116                    }
117                    Err(error) => {
118                        log::warn!("Failed to refresh Minecraft services public keys: {error}");
119                        if !has_successful_snapshot {
120                            failure_count = failure_count.saturating_add(1);
121                        }
122                    }
123                }
124            }
125        }));
126        ready_rx
127    }
128
129    async fn refresh(&self) -> Result<(), ServiceKeyError> {
130        let response = self
131            .client
132            .get(self.endpoint.clone())
133            .send()
134            .await?
135            .error_for_status()?
136            .json::<ServiceKeyResponse>()
137            .await?;
138        let validator = profile_key_validator(response)?;
139        self.profile_key_validator.store(validator.map(Arc::new));
140        Ok(())
141    }
142}
143
144fn profile_key_validator(
145    response: ServiceKeyResponse,
146) -> Result<Option<ProfileKeyValidator>, ServiceKeyError> {
147    // Authlib rejects the complete snapshot when either service-key list is malformed.
148    parse_keys(response.profile_property_keys)?;
149    let keys = parse_keys(response.player_certificate_keys)?;
150    Ok(ProfileKeyValidator::new(keys))
151}
152
153fn parse_keys(
154    keys: Option<Vec<ServiceKeyData>>,
155) -> Result<Vec<rsa::RsaPublicKey>, ServiceKeyError> {
156    keys.unwrap_or_default()
157        .into_iter()
158        .map(|key| {
159            let der = BASE64_STANDARD.decode(key.public_key)?;
160            public_key_from_bytes(&der).map_err(ServiceKeyError::from)
161        })
162        .collect()
163}
164
165fn failure_delay(failure_count: u32) -> Duration {
166    let exponent = failure_count.min(MAX_BACKOFF_EXPONENT);
167    BASE_FAILURE_INTERVAL.saturating_mul(1 << exponent)
168}
169
170#[cfg(test)]
171mod tests {
172    use base64::{Engine as _, prelude::BASE64_STANDARD};
173    use steel_crypto::{generate_key_pair, public_key_to_bytes};
174
175    use super::{
176        BASE_FAILURE_INTERVAL, MAX_BACKOFF_EXPONENT, ServiceKeyData, ServiceKeyResponse,
177        failure_delay, profile_key_validator,
178    };
179
180    #[test]
181    fn parses_player_certificate_keys() {
182        let (_, public_key) = generate_key_pair().expect("test RSA key should generate");
183        let der = public_key_to_bytes(&public_key).expect("test RSA key should encode");
184        let response = serde_json::from_value::<ServiceKeyResponse>(serde_json::json!({
185            "profilePropertyKeys": [],
186            "playerCertificateKeys": [{ "publicKey": BASE64_STANDARD.encode(der) }],
187        }))
188        .expect("Minecraft services response should deserialize");
189
190        assert!(
191            profile_key_validator(response)
192                .expect("valid response should parse")
193                .is_some()
194        );
195    }
196
197    #[test]
198    fn empty_player_certificate_keys_disable_validation() {
199        let response = ServiceKeyResponse {
200            profile_property_keys: None,
201            player_certificate_keys: None,
202        };
203
204        assert!(
205            profile_key_validator(response)
206                .expect("missing keys should be accepted")
207                .is_none()
208        );
209    }
210
211    #[test]
212    fn malformed_player_certificate_key_rejects_snapshot() {
213        let response = ServiceKeyResponse {
214            profile_property_keys: None,
215            player_certificate_keys: Some(vec![ServiceKeyData {
216                public_key: "not base64".to_owned(),
217            }]),
218        };
219
220        assert!(profile_key_validator(response).is_err());
221    }
222
223    #[test]
224    fn malformed_profile_property_key_rejects_snapshot() {
225        let (_, public_key) = generate_key_pair().expect("test RSA key should generate");
226        let der = public_key_to_bytes(&public_key).expect("test RSA key should encode");
227        let response = ServiceKeyResponse {
228            profile_property_keys: Some(vec![ServiceKeyData {
229                public_key: "not base64".to_owned(),
230            }]),
231            player_certificate_keys: Some(vec![ServiceKeyData {
232                public_key: BASE64_STANDARD.encode(der),
233            }]),
234        };
235
236        assert!(profile_key_validator(response).is_err());
237    }
238
239    #[test]
240    fn initial_failures_use_authlib_backoff_cap() {
241        assert_eq!(failure_delay(0), BASE_FAILURE_INTERVAL);
242        assert_eq!(failure_delay(1), BASE_FAILURE_INTERVAL * 2);
243        assert_eq!(
244            failure_delay(MAX_BACKOFF_EXPONENT + 1),
245            BASE_FAILURE_INTERVAL * (1 << MAX_BACKOFF_EXPONENT)
246        );
247    }
248}