From 017c9a85f990dbf8a0912fa236d399d9a2a6c246 Mon Sep 17 00:00:00 2001 From: oiupoyt Date: Tue, 22 Sep 2026 22:20:13 +0530 Subject: [PATCH] fix(skins): fix authlib-injector routing, add RSA profile signing, and populate userProperties --- Cargo.lock | 193 ++++++++++++-- packages/oneclient_core/Cargo.toml | 1 + packages/oneclient_core/src/game/launch.rs | 16 ++ .../oneclient_core/src/game/skin_server.rs | 240 +++++++++++++++++- 4 files changed, 419 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1c4fc909..65bf2900 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -872,6 +872,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "beul" version = "1.0.0" @@ -1358,6 +1364,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -1600,6 +1612,17 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1636,6 +1659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", ] @@ -1646,7 +1670,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -3462,6 +3486,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "lebe" @@ -3943,6 +3970,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -3979,6 +4022,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -3997,6 +4050,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4480,7 +4534,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oneclient_app" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "anyhow", "arboard", @@ -4530,7 +4584,7 @@ dependencies = [ [[package]] name = "oneclient_auth" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "base64 0.22.1", "chrono", @@ -4554,7 +4608,7 @@ dependencies = [ [[package]] name = "oneclient_cluster" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "bytes 1.11.1", "chrono", @@ -4577,7 +4631,7 @@ dependencies = [ [[package]] name = "oneclient_common" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "directories", "interfrost", @@ -4590,7 +4644,7 @@ dependencies = [ [[package]] name = "oneclient_content" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "async-trait", "async_zip", @@ -4618,7 +4672,7 @@ dependencies = [ [[package]] name = "oneclient_core" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "async-compression", "async-stream", @@ -4658,6 +4712,7 @@ dependencies = [ "regex", "reqwest 0.13.3", "reqwest-websocket", + "rsa", "sanitize-filename", "sentry", "sentry-tracing", @@ -4683,7 +4738,7 @@ dependencies = [ [[package]] name = "oneclient_db" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "async-tempfile", "chrono", @@ -4698,7 +4753,7 @@ dependencies = [ [[package]] name = "oneclient_discord" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "chrono", "discord-rich-presence", @@ -4710,7 +4765,7 @@ dependencies = [ [[package]] name = "oneclient_events" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "thiserror 2.0.18", "tokio", @@ -4720,7 +4775,7 @@ dependencies = [ [[package]] name = "oneclient_java" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "astral-tokio-tar", "async-tempfile", @@ -4743,7 +4798,7 @@ dependencies = [ [[package]] name = "oneclient_macro" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "proc-macro2", "quote", @@ -4752,7 +4807,7 @@ dependencies = [ [[package]] name = "oneclient_mc" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "base64 0.22.1", "chrono", @@ -4774,7 +4829,7 @@ dependencies = [ [[package]] name = "oneclient_net" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "arc-swap", "async-trait", @@ -4796,7 +4851,7 @@ dependencies = [ [[package]] name = "oneclient_polyplus" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "async-tungstenite", "bytes 1.11.1", @@ -4882,7 +4937,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4940,6 +4995,15 @@ name = "pathgraph" version = "0.5.0-rc.4" source = "git+https://github.com/marc2332/freya?branch=fix%2Fskip-basic-render-driver#56c6e694084210a8405a8cf9e50a1e236a64efd3" +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -5047,6 +5111,27 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -5094,7 +5179,7 @@ checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" [[package]] name = "polyio" -version = "2.5.1-c1" +version = "2.5.1-c2" dependencies = [ "astral-tokio-tar", "async-compression", @@ -5386,13 +5471,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] @@ -5407,6 +5502,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -5417,6 +5522,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -5469,7 +5583,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.5", - "rand_chacha", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.18", "v_frame", @@ -5799,6 +5913,27 @@ dependencies = [ "str_indices", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha1 0.10.6", + "signature", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -6326,6 +6461,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -6511,6 +6656,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" diff --git a/packages/oneclient_core/Cargo.toml b/packages/oneclient_core/Cargo.toml index 5763f432..4d290aa2 100644 --- a/packages/oneclient_core/Cargo.toml +++ b/packages/oneclient_core/Cargo.toml @@ -92,6 +92,7 @@ sysinfo.workspace = true trash.workspace = true url.workspace = true uuid.workspace = true +rsa = { version = "0.9", features = ["sha1", "pem"] } [target.'cfg(target_os = "linux")'.dependencies] libc.workspace = true diff --git a/packages/oneclient_core/src/game/launch.rs b/packages/oneclient_core/src/game/launch.rs index 0bf05a09..31197c86 100644 --- a/packages/oneclient_core/src/game/launch.rs +++ b/packages/oneclient_core/src/game/launch.rs @@ -316,6 +316,8 @@ async fn start( // Always clean up legacy jar patches and resource packs to ensure clean vanilla state crate::game::skin_server::cleanup_legacy_skin_injection(&cwd, &client_jar).await; + let mut offline_skin_port = None; + // Attach proper Authlib-Injector offline skin system if running an offline account if account.is_offline() { let account_uuid_str = account.id.to_string(); @@ -325,6 +327,7 @@ async fn start( if let Ok(port) = crate::game::skin_server::ensure_skin_server().await && let Ok(injector_jar) = crate::game::skin_server::prepare_authlib_injector().await { + offline_skin_port = Some(port); let agent_arg = format!( "-javaagent:{}=http://127.0.0.1:{port}", injector_jar.display() @@ -355,6 +358,19 @@ async fn start( profile.resolution.unwrap_or_default(), &java.os_arch, )?; + + if account.is_offline() + && let Some(port) = offline_skin_port + && let Some(user_props) = + crate::game::skin_server::build_user_properties(&account.id.to_string(), port).await + { + for i in 0..mc_args.len() { + if mc_args[i] == "--userProperties" && i + 1 < mc_args.len() && mc_args[i + 1] == "{}" { + mc_args[i + 1] = user_props; + break; + } + } + } arguments::append_profile_game_arguments(&mut mc_args, profile.force_fullscreen, None); if let Some(reason) = run_hook(profile.hook_pre.as_deref(), &cwd).await { diff --git a/packages/oneclient_core/src/game/skin_server.rs b/packages/oneclient_core/src/game/skin_server.rs index 80323497..6bbe7164 100644 --- a/packages/oneclient_core/src/game/skin_server.rs +++ b/packages/oneclient_core/src/game/skin_server.rs @@ -12,6 +12,9 @@ use serde::{Deserialize, Serialize}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; +use rsa::pkcs8::DecodePrivateKey; +use rsa::{Pkcs1v15Sign, RsaPrivateKey}; + type MojangCacheEntry = (Option, Instant); type MojangCacheMap = HashMap; @@ -20,6 +23,26 @@ static SKIN_SERVER_PORT: OnceLock = OnceLock::new(); static REGISTERED_ACCOUNTS: OnceLock>> = OnceLock::new(); static MOJANG_CACHE: OnceLock> = OnceLock::new(); +pub static SKIN_SERVER_PUBLIC_KEY: &str = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ZEWcf6IB84Atm2pnE/N\na19ipdSUpXjMYWaloNhP5ygsMB63i1T55RxaMeVoX/CLY1cFI14+BqgeHGYHOLyo\nVoLz1ylC1QvI99Q1fyl0caTD41K11T5l+EGs5gpepGbQ11tRhtTFNpZg016W83t9\nQMMqpVzSETfK65bu5jOeipvf9TAlXNCpRPZBXe12+kJ5H1t3H9NT2KMUpcYPER7p\nIeIMOgiUwCCBqlXVhoKqjTvplKmyaY/qP3zBdLDQ/+mA1i9SOM8529vi452g+ftP\nkUK+AP52KBb4MnmrwasnH9JdzmS8t2Kl17HYORZ6RpHAPlF4DEC7QDEhvJRLvf1N\nbQIDAQAB\n-----END PUBLIC KEY-----"; + +static SKIN_SERVER_PRIVATE_KEY_PEM: &str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDZkRZx/ogHzgC2\nbamcT81rX2Kl1JSleMxhZqWg2E/nKCwwHreLVPnlHFox5Whf8ItjVwUjXj4GqB4c\nZgc4vKhWgvPXKULVC8j31DV/KXRxpMPjUrXVPmX4QazmCl6kZtDXW1GG1MU2lmDT\nXpbze31AwyqlXNIRN8rrlu7mM56Km9/1MCVc0KlE9kFd7Xb6QnkfW3cf01PYoxSl\nxg8RHukh4gw6CJTAIIGqVdWGgqqNO+mUqbJpj+o/fMF0sND/6YDWL1I4zznb2+Lj\nnaD5+0+RQr4A/nYoFvgyeavBqycf0l3OZLy3YqXXsdg5FnpGkcA+UXgMQLtAMSG8\nlEu9/U1tAgMBAAECggEACCDgDk08ptH++9HYCuN+YLsVE+3/ybjcJe1wVbSPM6sw\nD3IuWFHJ7lHjWsbf4em6Q3FeW0ZrmdMRIO186pU90tGcq5a6jPweO4gdoY0acR5/\nRRBzg66Ln71QaN3NUGYY+lrKjneHkLUIlA0OJbWg5dkE0F3J6WPEvI2MimQ2UaZU\nkqrGKT7QiAhJSlBSAi4dcDXQbSyFRbCm1hw3goL+hpOfFUVymR0TLFqAl8p0uaei\nnGOt4A3g84xx7zHQx4iUVzanX7mGnKDfEHPB1wohvKkn37vGIocAT43kt/AxJS8p\nzMgy1UyyzIwc6vjuDOqLKqTl6dqNvftzucBHTp8NHwKBgQDtUlYCNsyptDwLXHC7\nmIQd4OJIihZxoOwbPx2gzahpaymyeu3/2w6hA2BlqS1/PFpkhe1igOsYkqUWMyNs\nwA9ob4df9CQhPe787q12YL6LahY73A/Qfgn5+6dW8l9pYPyBgwmUd/NMdkQjZILH\nHTxB8eX1F2IsxAKoQ0g8unQ2OwKBgQDqsLiysueH3gL/u19RksQy9PIVi6dnSuiH\nq93zIKVjKcQmAOrQboo9w8wXbwX3z8WGGbxWNbnIEFYTiKzQD2fviserV1d4Xz9E\nOwIDuc8PdBWbgpjSfIDbBqc6AmE34JLyn1AdX9fkhBjquAy370v4ZdHdG5wnpxSg\nw8qQCy7IdwKBgCA5lOo6DLJigeC9DaW7gP0Zo0BcV83YHxdYC6rhIiQmZAQTQywB\nz8u3TKihP0dOp6uMr/43KTUt/HK2QPIsZis1MbmqyhklcsUvl6hCXL1Li3dXW2Jh\nKvOh40ggIyqI++COLYfWfdf9GyV/KW7mHl+J/EK6iR8xAndco3tzigIvAoGAXBRE\nExCwWJVZld59EnND+T4zcRKe9p7kRr6+0TJA0XxEkiiP+IE2Se91Nsh/je/97pRQ\nWX6wynbmXrmkG+m/fLN1jZsyHW85UlrYen+/Zq/D/oSp0wO4RrcAi3j9jb/Vx82L\n0EqXWPgfEpBtpQkFRIsmYNsBVGlwZXcMFaHdlBcCgYAsBi2KNp3/88XdWxODFpEu\nZ5YjqmhGEAInCYTQOquYHFBjVTrV04I2Zr031Z6iETopm/9nLZ15VMjbZyoZWS3B\nAteqz/MfyDOUN3U4xrBr6nxD/HcpNoWeLXyzGnRya8K+/1vz0hWP/e5Q/5KvCGAo\npmSM49/FvJ7y20c1x1fq1A==\n-----END PRIVATE KEY-----"; + +static SIGNING_KEY: OnceLock = OnceLock::new(); + +pub fn sign_sha1_with_rsa(data: &[u8]) -> Option { + use sha1::Digest; + let key = SIGNING_KEY.get_or_init(|| { + RsaPrivateKey::from_pkcs8_pem(SKIN_SERVER_PRIVATE_KEY_PEM) + .expect("valid embedded private key") + }); + let mut hasher = sha1::Sha1::new(); + hasher.update(data); + let digest = hasher.finalize(); + let signing_scheme = Pkcs1v15Sign::new::(); + let sig_bytes = key.sign(signing_scheme, &digest).ok()?; + Some(base64::engine::general_purpose::STANDARD.encode(sig_bytes)) +} + #[derive(Clone, Debug)] pub struct AccountRegistration { pub uuid: String, @@ -130,7 +153,7 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: let method = parts.next().unwrap_or(""); let uri = parts.next().unwrap_or(""); - if method != "GET" && method != "HEAD" { + if method != "GET" && method != "HEAD" && method != "POST" { let response = "HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; stream.write_all(response.as_bytes()).await?; return Ok(()); @@ -140,10 +163,15 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: // 1. Root / Yggdrasil API metadata if path == "/" || path == "/api/yggdrasil" || path == "/api/yggdrasil/" { - let body = format!( - r#"{{"meta":{{"serverName":"OneLauncher-Cracked","implementationName":"onelauncher-skin-server","implementationVersion":"{}"}},"skinDomains":["127.0.0.1","localhost"]}}"#, - env!("CARGO_PKG_VERSION") - ); + let body = serde_json::json!({ + "meta": { + "serverName": "OneLauncher-Cracked", + "implementationName": "onelauncher-skin-server", + "implementationVersion": env!("CARGO_PKG_VERSION") + }, + "skinDomains": ["127.0.0.1", "localhost"], + "signaturePublickey": SKIN_SERVER_PUBLIC_KEY + }).to_string(); let header = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), @@ -153,8 +181,52 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: return Ok(()); } - // 2. Profile request: /session/minecraft/profile/ - if let Some(uuid_raw) = path.strip_prefix("/session/minecraft/profile/") { + // 2. Public keys endpoint (for Minecraft 1.19+ profile signature verification) + if path == "/publickeys" || path == "/minecraftservices/publickeys" { + let key_b64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA2ZEWcf6IB84Atm2pnE/Na19ipdSUpXjMYWaloNhP5ygsMB63i1T55RxaMeVoX/CLY1cFI14+BqgeHGYHOLyoVoLz1ylC1QvI99Q1fyl0caTD41K11T5l+EGs5gpepGbQ11tRhtTFNpZg016W83t9QMMqpVzSETfK65bu5jOeipvf9TAlXNCpRPZBXe12+kJ5H1t3H9NT2KMUpcYPER7pIeIMOgiUwCCBqlXVhoKqjTvplKmyaY/qP3zBdLDQ/+mA1i9SOM8529vi452g+ftPkUK+AP52KBb4MnmrwasnH9JdzmS8t2Kl17HYORZ6RpHAPlF4DEC7QDEhvJRLvf1NbQIDAQAB"; + let body = serde_json::json!({ + "profilePropertyKeys": [{"publicKey": key_b64}], + "playerCertificateKeys": [{"publicKey": key_b64}] + }).to_string(); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // 3. Profiles by names endpoint (bulk profile lookup) + if path == "/api/profiles/minecraft" || path == "/api/profiles/minecraft/" { + let mut profiles = Vec::new(); + if let Some(accounts) = REGISTERED_ACCOUNTS.get() { + let read = accounts.read(); + for reg in read.values() { + if !profiles.iter().any(|p: &serde_json::Value| p["id"] == reg.undashed_uuid) { + profiles.push(serde_json::json!({ + "id": reg.undashed_uuid, + "name": reg.username, + })); + } + } + } + let body = serde_json::to_string(&profiles).unwrap_or_else(|_| "[]".to_string()); + let header = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream.write_all(header.as_bytes()).await?; + return Ok(()); + } + + // 4. Profile request: /sessionserver/session/minecraft/profile/ or /session/minecraft/profile/ + let profile_uuid = path + .strip_prefix("/sessionserver/session/minecraft/profile/") + .or_else(|| path.strip_prefix("/session/minecraft/profile/")); + + if let Some(uuid_raw) = profile_uuid { let uuid_str = uuid_raw.trim_matches('/'); // Check if this is one of our local accounts with a custom skin @@ -185,7 +257,7 @@ async fn handle_connection(mut stream: TcpStream, port: u16) -> Result<(), std:: return Ok(()); } - // 3. Texture request: /textures/.png + // 5. Texture request: /textures/.png if let Some(texture_raw) = path.strip_prefix("/textures/") { let texture_id = texture_raw.trim_matches('/').trim_end_matches(".png"); if let Some(png_bytes) = load_skin_png(texture_id).await { @@ -328,13 +400,71 @@ async fn build_profile_response(uuid_input: &str, port: u16) -> Option { let encoded_textures = base64::engine::general_purpose::STANDARD.encode(texture_payload.as_bytes()); - let profile_json = format!( - r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# - ); + let profile_json = if let Some(sig) = sign_sha1_with_rsa(encoded_textures.as_bytes()) { + format!( + r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}","signature":"{sig}"}}]}}"# + ) + } else { + format!( + r#"{{"id":"{undashed}","name":"{username}","properties":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# + ) + }; Some(profile_json) } +/// Builds the `--userProperties` JSON map for launch arguments with signed textures. +pub async fn build_user_properties(uuid_input: &str, port: u16) -> Option { + let dashed = to_dashed_uuid(uuid_input); + let undashed = to_undashed_uuid(uuid_input); + + let _skin_path = find_skin_path(uuid_input).await?; + + let is_slim = if let Ok(meta_p) = skin_meta_path(&dashed) { + if meta_p.exists() { + polyio::read_to_string(&meta_p) + .await + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|m| m.is_slim) + .unwrap_or(false) + } else { + false + } + } else { + false + }; + + let username = resolve_username(uuid_input).await; + let skin_url = format!("http://127.0.0.1:{port}/textures/{undashed}.png"); + + let model_json = if is_slim { + r#","metadata":{"model":"slim"}"# + } else { + "" + }; + + let timestamp = chrono::Utc::now().timestamp_millis(); + let texture_payload = format!( + r#"{{"timestamp":{timestamp},"profileId":"{undashed}","profileName":"{username}","textures":{{"SKIN":{{"url":"{skin_url}"{model_json}}}}}}}"# + ); + + let encoded_textures = + base64::engine::general_purpose::STANDARD.encode(texture_payload.as_bytes()); + + let prop_json = if let Some(sig) = sign_sha1_with_rsa(encoded_textures.as_bytes()) { + format!( + r#"{{"textures":[{{"name":"textures","value":"{encoded_textures}","signature":"{sig}"}}]}}"# + ) + } else { + format!( + r#"{{"textures":[{{"name":"textures","value":"{encoded_textures}"}}]}}"# + ) + }; + + Some(prop_json) +} + async fn fetch_mojang_profile(uuid_str: &str) -> Option { let undashed = to_undashed_uuid(uuid_str); if undashed.len() != 32 { @@ -360,7 +490,10 @@ async fn fetch_mojang_profile(uuid_str: &str) -> Option { "https://sessionserver.mojang.com/session/minecraft/profile/{undashed}?unsigned=false" ); let result = match client.get(&url).send().await { - Ok(res) if res.status().is_success() => res.text().await.ok(), + Ok(res) if res.status() == reqwest::StatusCode::OK => match res.text().await { + Ok(text) if !text.trim().is_empty() => Some(text), + _ => None, + }, _ => None, }; @@ -565,4 +698,87 @@ mod tests { let meta = tokio::fs::metadata(&jar_path).await.unwrap(); assert!(meta.len() > 100_000); } + + #[test] + fn test_sign_sha1_with_rsa() { + let sig = sign_sha1_with_rsa(b"hello world test"); + assert!(sig.is_some()); + let sig_str = sig.unwrap(); + assert!(!sig_str.is_empty()); + } + + #[tokio::test] + async fn test_skin_server_http_endpoints() { + let port = ensure_skin_server().await.expect("skin server starts"); + let client = reqwest::Client::new(); + + // 1. Test GET / + let res = client + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .expect("GET / succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let root_json: serde_json::Value = res.json().await.expect("valid JSON"); + assert!(root_json.get("signaturePublickey").is_some()); + assert!(root_json.get("skinDomains").is_some()); + + // 2. Test GET /publickeys + let res = client + .get(format!("http://127.0.0.1:{port}/publickeys")) + .send() + .await + .expect("GET /publickeys succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let pk_json: serde_json::Value = res.json().await.expect("valid JSON"); + assert!(pk_json.get("profilePropertyKeys").is_some()); + + // 3. Test GET /sessionserver/session/minecraft/profile/ + // Unknown player must return 204 No Content so other multiplayer players never get user skin! + let res = client + .get(format!("http://127.0.0.1:{port}/sessionserver/session/minecraft/profile/00000000000000000000000000000000")) + .send() + .await + .expect("GET profile succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::NO_CONTENT); + + // 4. Test registered user with a skin + let test_uuid = "12345678-1234-1234-1234-123456789abc"; + let test_undashed = "12345678123412341234123456789abc"; + let test_user = "TestPlayer"; + register_launch_account(test_uuid, test_user); + + if let Ok(dir) = skins_dir() { + let _ = polyio::create_dir_all(&dir).await; + let skin_p = dir.join(format!("{test_uuid}.png")); + // 1x1 png or dummy png + let dummy_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15c4\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"; + let _ = polyio::write(&skin_p, dummy_png).await; + + let res = client + .get(format!("http://127.0.0.1:{port}/sessionserver/session/minecraft/profile/{test_undashed}")) + .send() + .await + .expect("GET registered profile succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + let prof: serde_json::Value = res.json().await.expect("valid profile JSON"); + assert_eq!(prof["name"], test_user); + let props = prof["properties"].as_array().expect("properties array"); + assert!(!props.is_empty()); + assert_eq!(props[0]["name"], "textures"); + assert!(props[0]["value"].as_str().is_some()); + assert!(props[0]["signature"].as_str().is_some()); + + // Test GET /textures/.png + let res = client + .get(format!("http://127.0.0.1:{port}/textures/{test_undashed}.png")) + .send() + .await + .expect("GET texture succeeds"); + assert_eq!(res.status(), reqwest::StatusCode::OK); + assert_eq!(res.headers()["content-type"], "image/png"); + + let _ = polyio::remove_file(&skin_p).await; + } + } }