use crate::manifest::{self, Manifest}; use base64::{engine::general_purpose::STANDARD, Engine}; use ed25519_dalek::{Signature, VerifyingKey}; use reqwest::header::ACCEPT_ENCODING; use reqwest::{blocking::Client, redirect::Policy}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ fmt, io::{self, Read}, thread, time::Duration, }; const AERONAUTICS_MANIFEST: &str = "https://shacraft.ru/api/launcher/v2/profiles/aeronautics/signed-manifest"; const AERONAUTICS_ONLINE: &str = "https://shacraft.ru/api/online/aoc"; const MANIFEST_PUBLIC_KEY: &str = "2S3FRdZj4Xw5nJpZ3IhqVITBg3nTH9AtGSo1Ew9+qVQ="; const MANIFEST_KEY_ID: &str = "2026-09-06"; const MAX_ENVELOPE_BYTES: usize = 2 * 1024 * 1024; const MANIFEST_ATTEMPTS: u32 = 3; /// Display-only status: never used to select executable files or versions. #[derive(Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ServerStatus { pub online: Option, pub max: Option, pub reachable: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SignedManifest { schema_version: u32, key_id: String, payload: String, signature: String, } #[derive(Debug)] pub enum RemoteError { UnknownProfile, Network(reqwest::Error), Status(reqwest::StatusCode), TooLarge, Read(io::Error), InvalidSignature, ProfileMismatch, InvalidManifest(manifest::ManifestError), } impl fmt::Display for RemoteError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::UnknownProfile => formatter.write_str("Unknown ShaCraft profile"), Self::Network(error) => write!(formatter, "Cannot load ShaCraft manifest: {error}"), Self::Status(status) => write!(formatter, "ShaCraft manifest request failed: {status}"), Self::TooLarge => formatter.write_str("ShaCraft manifest is too large"), Self::Read(error) => write!(formatter, "Cannot read ShaCraft manifest: {error}"), Self::InvalidSignature => formatter.write_str("ShaCraft manifest signature is invalid"), Self::ProfileMismatch => { formatter.write_str("Signed manifest does not match the requested profile") } Self::InvalidManifest(error) => { write!(formatter, "ShaCraft manifest is invalid: {error}") } } } } pub struct VerifiedSnapshot { pub manifest: Manifest, pub digest: String, } pub fn fetch_manifest(profile_id: &str) -> Result { Ok(fetch_snapshot(profile_id)?.manifest) } pub fn fetch_snapshot(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_MANIFEST, _ => return Err(RemoteError::UnknownProfile), }; let client = Client::builder() .https_only(true) .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)) .redirect(Policy::none()) .build() .map_err(RemoteError::Network)?; let source = fetch_manifest_bytes(&client, url)?; let public_key_bytes = STANDARD .decode(MANIFEST_PUBLIC_KEY) .expect("embedded public key must be valid"); let public_key = VerifyingKey::from_bytes( &public_key_bytes .try_into() .expect("embedded public key must be 32 bytes"), ) .expect("embedded public key must be valid"); verify_snapshot(&source, profile_id, &public_key) } fn fetch_manifest_bytes(client: &Client, url: &str) -> Result, RemoteError> { for attempt in 1..=MANIFEST_ATTEMPTS { let request = || { let response = client .get(url) .header(ACCEPT_ENCODING, "identity") .send() .map_err(RemoteError::Network)?; if !response.status().is_success() { return Err(RemoteError::Status(response.status())); } if response .content_length() .is_some_and(|size| size > MAX_ENVELOPE_BYTES as u64) { return Err(RemoteError::TooLarge); } read_envelope(response) }; match request() { Err(RemoteError::Network(_) | RemoteError::Read(_)) if attempt < MANIFEST_ATTEMPTS => { thread::sleep(Duration::from_millis(250 * attempt as u64)); } result => return result, } } unreachable!("the last attempt always returns") } pub fn fetch_server_status(profile_id: &str) -> Result { let url = match profile_id { "aeronautics" => AERONAUTICS_ONLINE, _ => return Err(RemoteError::UnknownProfile), }; let client = Client::builder() .https_only(true) .timeout(Duration::from_secs(10)) .redirect(Policy::none()) .build() .map_err(RemoteError::Network)?; let response = client.get(url).send().map_err(RemoteError::Network)?; if !response.status().is_success() { return Err(RemoteError::Status(response.status())); } response .json::() .map_err(RemoteError::Network) } fn read_envelope(source: impl Read) -> Result, RemoteError> { let mut bytes = Vec::new(); source .take(MAX_ENVELOPE_BYTES as u64 + 1) .read_to_end(&mut bytes) .map_err(RemoteError::Read)?; if bytes.len() > MAX_ENVELOPE_BYTES { return Err(RemoteError::TooLarge); } Ok(bytes) } #[cfg(test)] fn verify_envelope( source: &[u8], profile_id: &str, public_key: &VerifyingKey, ) -> Result { Ok(verify_snapshot(source, profile_id, public_key)?.manifest) } fn verify_snapshot( source: &[u8], profile_id: &str, public_key: &VerifyingKey, ) -> Result { if source.len() > MAX_ENVELOPE_BYTES { return Err(RemoteError::TooLarge); } let envelope = serde_json::from_slice::(source) .map_err(|_| RemoteError::InvalidSignature)?; if envelope.schema_version != 1 || envelope.key_id != MANIFEST_KEY_ID { return Err(RemoteError::InvalidSignature); } let payload = STANDARD .decode(envelope.payload) .map_err(|_| RemoteError::InvalidSignature)?; let signature_bytes = STANDARD .decode(envelope.signature) .map_err(|_| RemoteError::InvalidSignature)?; let signature = Signature::from_slice(&signature_bytes).map_err(|_| RemoteError::InvalidSignature)?; public_key .verify_strict(&payload, &signature) .map_err(|_| RemoteError::InvalidSignature)?; let digest = format!("{:x}", Sha256::digest(&payload)); let payload = String::from_utf8(payload).map_err(|_| RemoteError::InvalidSignature)?; let manifest = manifest::validate_json(&payload).map_err(RemoteError::InvalidManifest)?; if manifest.id != profile_id { return Err(RemoteError::ProfileMismatch); } Ok(VerifiedSnapshot { manifest, digest }) } #[cfg(test)] mod tests { use super::*; use ed25519_dalek::{Signer, SigningKey}; use serde_json::{json, Value}; #[test] #[ignore = "read-only check of the production signed manifest; requires network"] fn live_validates_production_aeronautics_manifest() { let manifest = fetch_manifest("aeronautics").unwrap(); assert_eq!(manifest.id, "aeronautics"); assert!(!manifest.files.is_empty()); } fn signed_fixture() -> (Value, VerifyingKey) { let key = SigningKey::from_bytes(&[17; 32]); let payload = serde_json::to_vec(&json!({ "schemaVersion": 1, "id": "aeronautics", "displayName": "Aeronautics", "minecraft": {"version": "1.21.1", "loader": {"kind": "neoforge", "version": "21.1.248"}, "javaMajor": 21}, "files": [] })).unwrap(); let envelope = json!({ "schemaVersion": 1, "keyId": MANIFEST_KEY_ID, "payload": STANDARD.encode(&payload), "signature": STANDARD.encode(key.sign(&payload).to_bytes()) }); (envelope, key.verifying_key()) } #[test] fn accepts_valid_signature_and_binds_requested_profile() { let (envelope, key) = signed_fixture(); let bytes = serde_json::to_vec(&envelope).unwrap(); assert_eq!( verify_envelope(&bytes, "aeronautics", &key).unwrap().id, "aeronautics" ); assert!(matches!( verify_envelope(&bytes, "another-profile", &key), Err(RemoteError::ProfileMismatch) )); } #[test] fn rejects_modified_payload_signature_key_and_schema() { let (original, key) = signed_fixture(); for (field, value) in [ ("payload", json!(STANDARD.encode(b"{}"))), ("signature", json!(STANDARD.encode([0; 64]))), ("keyId", json!("unknown")), ("schemaVersion", json!(2)), ] { let mut envelope = original.clone(); envelope[field] = value; assert!( matches!( verify_envelope(&serde_json::to_vec(&envelope).unwrap(), "aeronautics", &key), Err(RemoteError::InvalidSignature) ), "{field}" ); } let other_key = SigningKey::from_bytes(&[18; 32]).verifying_key(); assert!(matches!( verify_envelope( &serde_json::to_vec(&original).unwrap(), "aeronautics", &other_key ), Err(RemoteError::InvalidSignature) )); } #[test] fn bounds_stream_without_content_length() { assert!(matches!( read_envelope(io::repeat(b'x')), Err(RemoteError::TooLarge) )); } #[test] fn retries_truncated_manifest_transfers_and_requests_identity_encoding() { use std::{ io::{Read, Write}, net::TcpListener, }; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}/manifest", listener.local_addr().unwrap()); let server = std::thread::spawn(move || { for body in [ b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\nConnection: close\r\n\r\nbad".as_slice(), b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}".as_slice(), ] { let (mut stream, _) = listener.accept().unwrap(); stream .set_read_timeout(Some(Duration::from_secs(2))) .unwrap(); let mut request = [0_u8; 4096]; let length = stream.read(&mut request).unwrap(); assert!(String::from_utf8_lossy(&request[..length]) .to_ascii_lowercase() .contains("accept-encoding: identity")); stream.write_all(body).unwrap(); } }); let client = Client::builder() .timeout(Duration::from_secs(2)) .build() .unwrap(); assert_eq!(fetch_manifest_bytes(&client, &url).unwrap(), b"{}"); server.join().unwrap(); } }