steel_core/server/
service_keys.rs1use 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
45pub(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 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 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}