fix(skins): fix authlib-injector routing, add RSA profile signing, and populate userProperties

This commit is contained in:
katR 2026-09-22 22:20:13 +05:30
parent 700b11a3ce
commit 017c9a85f9
4 changed files with 419 additions and 31 deletions

193
Cargo.lock generated
View file

@ -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"

View file

@ -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

View file

@ -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 {

View file

@ -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<String>, Instant);
type MojangCacheMap = HashMap<String, MojangCacheEntry>;
@ -20,6 +23,26 @@ static SKIN_SERVER_PORT: OnceLock<u16> = OnceLock::new();
static REGISTERED_ACCOUNTS: OnceLock<RwLock<HashMap<String, AccountRegistration>>> = OnceLock::new();
static MOJANG_CACHE: OnceLock<RwLock<MojangCacheMap>> = 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<RsaPrivateKey> = OnceLock::new();
pub fn sign_sha1_with_rsa(data: &[u8]) -> Option<String> {
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::<sha1::Sha1>();
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/<uuid>
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/<uuid> or /session/minecraft/profile/<uuid>
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/<id>.png
// 5. Texture request: /textures/<id>.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<String> {
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<String> {
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::<SkinMetadata>(&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<String> {
let undashed = to_undashed_uuid(uuid_str);
if undashed.len() != 32 {
@ -360,7 +490,10 @@ async fn fetch_mojang_profile(uuid_str: &str) -> Option<String> {
"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_uuid>
// 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/<undashed>.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;
}
}
}